Config evaluation

The config is TypeScript, evaluated by an engine embedded in the Rune binary. There is no Node process, no tsc, and no toolchain to install. A cold evaluation is a few milliseconds and a warm run reads one cached JSON file.

What works

TypeScript syntax is erased before evaluation. Annotations, interfaces and type aliases have no runtime effect, and constructs that do carry runtime meaning keep it.

rune.config.ts
import { defineConfig, rune } from '@gio-labs/rune';
import { PACKAGES } from './scripts/packages';

const enum Reporter {
  Dot = 'dot',
  Junit = 'junit',
}

const reporter = rune.isCI ? Reporter.Junit : Reporter.Dot;
const threads = rune.env.CPU_COUNT ?? '4';

export default defineConfig({
  scripts: {
    test: { command: `vitest run --reporter=${reporter} --maxWorkers=${threads}` },
    ...Object.fromEntries(
      PACKAGES.map((name) => [`build:${name}`, { command: `tsc --build packages/${name}` }]),
    ),
  },
});

Variables, template strings, spread, Object.fromEntries, functions, enum members and relative imports all behave as expected.

Relative imports

A config may import other files in the repository with ./ and ../ specifiers. Each imported file goes through the same pipeline, recursively.

import { PACKAGES } from './scripts/packages';   // resolves scripts/packages.ts
import { flags } from './scripts/flags.ts';      // explicit extension
import shared from './shared';                   // shared/index.ts

Forward slashes and platform separators resolve to the same file, so a Windows and a Linux machine produce the same result and share one cache entry.

The rune object

What a config is allowed to know about the machine it is read on. It is an export of the package Rune supplies, not a global, so the published types describe it and no declaration file is needed:

import { rune } from '@gio-labs/rune';
PropertyTypeValue
rune.envRecord<string, string | undefined>Read access to the process environment
rune.platform'win32' | 'darwin' | 'linux'The running operating system, named as Node names it
rune.isCIbooleanCI is set to anything other than empty, 0 or false

The import works in any file in the graph, not only the entry config — which is where it is most useful, since shared fragments tend to live in a helper module:

scripts/helpers.ts
import { rune } from '@gio-labs/rune';

export const reporter = rune.isCI ? ' --reporter=github' : '';

Reads are recorded. A variable the config actually read enters the cache key; an unrelated variable changing does not throw away a valid entry.

Assignment throws. The object is frozen, and env is a proxy that rejects writes, so a config cannot change what Rune resolves with by mutating it.

Reading rune without importing it is an error that names the import, so a config written against an older version says what to add.

What does not work

The engine is not Node. Reaching for a Node global or module produces a message that lists what is available:

Error: `process` is not available in a rune config.

rune evaluates this file with an embedded JavaScript engine, not Node.js, so Node globals and modules do not exist here.

what does work:
  - `import { rune } from "@gio-labs/rune"` — then `rune.env`, `rune.platform`, `rune.isCI`
  - relative imports of other files in this repository, such as `./scripts/helpers.ts`
  - `import { defineConfig } from "@gio-labs/rune"` — the one package rune supplies itself
  - `import type` from any npm package — type-only imports are erased before evaluation
Not availableNote
require, process, module, exports, Buffer, global, __dirname, __filenameNode globals
fs, path, os, url, util, child_process, cryptoNode modules
Runtime imports from npm packagesThe specifier is named in the error
Dynamic import()Rejected, see below

Type-only imports from npm packages are fine, because they are erased before anything runs:

import type { Script } from '@gio-labs/rune';

The one package Rune supplies

@gio-labs/rune is answered from inside the binary rather than resolved from node_modules, so the authoring style the types teach loads before a package manager has installed anything.

import { defineConfig } from '@gio-labs/rune';

It exports defineConfig and nothing else, and defineConfig returns its argument unchanged — it exists to carry the types. Importing any other name from it fails rather than handing the config an undefined value. No other bare specifier is supplied.

Dynamic import() is rejected for cache integrity. A computed specifier cannot be found by the static import walk, so it would escape the hashed import graph and serve a stale result.

Errors

A config that throws reports the file and a line in the TypeScript the user wrote, not in the JavaScript the engine ran. Type stripping reprints the file, so the two line numbers differ. Rune builds a source map on the failure path and remaps the frame, which costs nothing on the runs that succeed.

A syntax error names the file and points at the span. A missing relative import names both the missing file and the file that imported it.

Caching

The resolved config is stored at node_modules/.cache/rune/ under a key made from:

  • the entry file's bytes
  • the bytes of every transitively imported relative file
  • the Rune binary version
  • the platform
  • the environment values the config read

The cache is content addressed. There is no expiry and no timestamp comparison, so restoring a file to its original bytes reuses the original entry.

Every cache failure degrades to full evaluation with nothing on stderr. An unreadable entry is a miss. An unwritable cache directory is a miss that still exits 0. A caching layer that can break a build is worse than no caching layer.

Clear it with:

rune cache clear

Budget

The targets the implementation is held to:

StageTarget
Binary startupunder 3 ms
Warm config, cache hitunder 2 ms
Cold config, strip and evaluateunder 30 ms
Spawn overhead for a single scriptclose to zero
Total, warmunder 5 ms