Say what is wrong when node is too old, and run on node 18
A machine with an older node reported `Unexpected token {` and nothing else:
that is its parser meeting the bundle's first import statement, before any
code of ours has a chance to run. Reproduced across versions -- node 10 stops
at `import {`, 12 and 14 at `??` -- so the published bin is now a small ES5
launcher that checks the version, says what is needed and why, and only then
reaches for the bundle. Even the dynamic import is built at run time, since
the versions it exists to talk to cannot parse that syntax either.
The floor is node 18 rather than anything higher, because node 18 is what
several current distributions ship. It needed one change: WebCrypto is a
global from node 19 on, and in 18 lives under `node:crypto`, so the cache-key
hash asks for it in whichever way this build can. Verified from 10 through 24
-- the four old ones print the message, 18 and 24 install and fill, Dawn and
h5wasm included.
4 changed files+72−4
README.mdmodified+8−0View file
@@ -148,6 +148,14 @@ writes `~/.config/turing-surface-cache/key`), or passed as `--key`, though the
148148 environment is preferable: a key on the command line is visible to every user
149149 on the machine through `ps`, while another process's environment is not.
150150
151+Node 18 or newer is required — the cache keys are SHA-256 through WebCrypto,
152+which older node does not have as a global, and node 18 itself has it only
153+under `node:crypto`, which is worth supporting since that is what several
154+current distributions ship. An older node than that cannot even parse the
155+bundle, and would otherwise report a syntax error pointing at a brace, so the
156+published command is a small ES5 launcher that checks the version first and
157+says what to do about it.
158+
151159 One consequence of installing from a URL is worth knowing. npx keys its
152160 install directory on the whole spec string it was given, so a URL that never
153161 changes keeps running whatever it first installed, however often the file
scripts/cli-launcher.cjsadded+44−0View file
@@ -0,0 +1,44 @@
1+#!/usr/bin/env node
2+/**
3+ * The published command's entry point, whose only job is to be parseable by
4+ * whatever node it lands on.
5+ *
6+ * fill.js is a modern bundle: on node 10 its very first `import {` is a syntax
7+ * error, on 12 and 14 it is `??`, and what the user sees is a caret under a
8+ * brace with no indication of what is wrong or what to do. That happens before
9+ * any code runs, so no check inside the bundle can ever report it. This file
10+ * is therefore deliberately ES5 — no arrow functions, no template literals, no
11+ * optional chaining, and the dynamic import hidden inside a Function body,
12+ * since older node cannot parse that either.
13+ *
14+ * It is copied into the tarball as launch.cjs by scripts/pack-cli.mjs.
15+ */
16+'use strict';
17+
18+var MIN_MAJOR = 18;
19+var have = process.versions.node;
20+var major = parseInt(have.split('.')[0], 10);
21+
22+if (!(major >= MIN_MAJOR)) {
23+ process.stderr.write(
24+ 'turing-surface-cache: this is node ' + have + ', and filling the cache needs node ' +
25+ MIN_MAJOR + ' or newer.\n' +
26+ ' The solver reaches the GPU through WebGPU, and the cache keys through\n' +
27+ ' WebCrypto, neither of which older node has.\n' +
28+ ' nodejs.org has current builds; nvm, fnm and asdf install one per user\n' +
29+ ' without touching what the system depends on.\n',
30+ );
31+ process.exit(1);
32+}
33+
34+var path = require('path');
35+var url = require('url');
36+var target = url.pathToFileURL(path.join(__dirname, 'fill.js')).href;
37+
38+// `import(target)` as written syntax would be a parse error on the versions
39+// this file exists to talk to, so it is built at run time instead — by which
40+// point the check above has already sent them away.
41+new Function('specifier', 'return import(specifier);')(target).catch(function (e) {
42+ process.stderr.write(String((e && e.stack) || e) + '\n');
43+ process.exit(1);
44+});
scripts/pack-cli.mjsmodified+8−3View file
@@ -32,9 +32,13 @@ const manifest = {
3232 description: 'Fill the turing-surface-cache shared cache from the command line',
3333 license: pkg.license,
3434 type: 'module',
35- engines: pkg.engines,
36- bin: { 'turing-surface-fill': 'fill.js' },
37- files: ['fill.js'],
35+ // The bundle's own floor, not the repo's: node 18 is the oldest with fetch,
36+ // and the launcher checks for it. Kept in step with cli-launcher.cjs.
37+ engines: { node: '>=18' },
38+ // The bin is the launcher, not the bundle: an old node must reach a sentence
39+ // rather than a syntax error (scripts/cli-launcher.cjs).
40+ bin: { 'turing-surface-fill': 'launch.cjs' },
41+ files: ['launch.cjs', 'fill.js'],
3842 dependencies: { h5wasm: pkg.dependencies.h5wasm },
3943 optionalDependencies: { webgpu: pkg.optionalDependencies.webgpu },
4044 };
@@ -42,6 +46,7 @@ const manifest = {
4246 const stage = await mkdtemp(join(tmpdir(), 'turing-fill-'));
4347 try {
4448 await copyFile(join(root, 'dist-cli/fill.js'), join(stage, 'fill.js'));
49+ await copyFile(join(root, 'scripts/cli-launcher.cjs'), join(stage, 'launch.cjs'));
4550 await writeFile(join(stage, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
4651 const out = execFileSync('npm', ['pack', '--silent', '--pack-destination', stage], {
4752 cwd: stage,
src/cache/spec.tsmodified+12−1View file
@@ -57,8 +57,19 @@ export function canonicalJson(value: unknown): string {
5757 return `{${body}}`;
5858 }
5959
60+/**
61+ * WebCrypto. A global in every browser, and in node from version 19; node 18
62+ * has the same implementation but only under `node:crypto`, and a machine with
63+ * an idle GPU is quite likely to be running whatever its distribution shipped.
64+ */
65+async function subtle(): Promise<SubtleCrypto> {
66+ if (globalThis.crypto?.subtle) return globalThis.crypto.subtle;
67+ if (__NODE_BUILD__) return (await import('node:crypto')).webcrypto.subtle as SubtleCrypto;
68+ throw new Error('WebCrypto is not available');
69+}
70+
6071 export async function sha256Hex(text: string): Promise<string> {
61- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
72+ const digest = await (await subtle()).digest('SHA-256', new TextEncoder().encode(text));
6273 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
6374 }
6475