diff --git a/stasis-core/src/hooks.js b/stasis-core/src/hooks.js index 5f7ed126..02650400 100644 --- a/stasis-core/src/hooks.js +++ b/stasis-core/src/hooks.js @@ -33,6 +33,9 @@ const NODEJS_FORMATS = new Set(['module', 'commonjs', 'json', 'module-typescript // the allowlist stays the single trust gate and the message is just UX. const NON_NODE_SOURCE_LANGUAGES = new Set(['solidity', 'php', 'bash', 'rust']) const RESOURCE_FORMATS = new Set(['resource', 'resource:base64']) +// The executable CommonJS formats (plain CJS + TS-CJS). Named like the sets above so +// "which formats are CJS" lives in one place; the require-repair below keys off it. +const CJS_FORMATS = new Set(['commonjs', 'commonjs-typescript']) function refuseNonNodeFormat(format, url) { if (NON_NODE_SOURCE_LANGUAGES.has(format)) { @@ -61,6 +64,100 @@ function refuseNonNodeFormat(format, url) { ) } +// node:59666 workaround (see the load hook). A single-line prelude, injected +// into served CommonJS source, that restores the `require.cache` and +// `require.extensions` the ESM->CJS translator's re-invented require omits. +// `require` here is the module wrapper's own parameter; node:module's CJS +// loader owns the real `Module._cache` / `Module._extensions`, so pointing the +// two properties at those makes a bundle-served module's require match a +// disk-loaded one's shape. Guarded (typeof/try) so a require without the usual +// shape, or a frozen builtins object, degrades to a no-op rather than throwing. +const CJS_REQUIRE_REPAIR = + "try{if(typeof require==='function'&&!require.cache){" + + "const __stasis_m=require('node:module');" + + 'if(__stasis_m&&__stasis_m.Module){' + + 'require.cache=__stasis_m.Module._cache;' + + 'if(!require.extensions)require.extensions=__stasis_m.Module._extensions;}}}catch{}' + +// Sticky scanners for the pieces of a Directive Prologue: a run of whitespace and +// line/block comments, a single string literal, and the run of horizontal space and +// SAME-LINE block comments that may trail a directive before its terminator. Sticky (`y`) +// + lastIndex so the prologue is walked in place, never by re-slicing the source each step. +const WS_COMMENTS = /(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/uy +const STRING_LITERAL = /(['"])(?:[^\\]|\\.)*?\1/uy +// A block comment that CONTAINS a line terminator counts as a line terminator for ASI, so +// the trailing run stops at one (it's handled as a line boundary below); this matches only +// single-line block comments (comment body has no line terminators). +const TRAILING_SAME_LINE = /(?:[ \t]|\/\*(?:[^*\n\r\u2028\u2029]|\*(?!\/))*\*\/)*/uy +// Tokens that CONTINUE an expression across a line break. If the first token after the +// break following a candidate directive string is one of these, the string heads a +// multi-line expression (`'a,b'\n.split(...)`, `'x'\ninstanceof Y`), not a directive +// (ASI does not insert a semicolon before them), so it must NOT be skipped. Punctuators +// are matched by first char; the two keyword operators need a `\b` so `in`/`instanceof` +// don't swallow identifiers like `index`/`instances`. +const EXPR_CONTINUATION = new Set(['.', '[', '(', '`', '+', '-', '*', '/', '%', ',', '?', ':', '=', '<', '>', '&', '|', '^']) +const KEYWORD_CONTINUATION = /(?:instanceof|in)\b/uy +function skipSticky(re, source, i) { + re.lastIndex = i + re.exec(source) + return re.lastIndex +} +// At a line boundary at `pos` (a line terminator or a newline-spanning block comment), does +// the next significant token begin a NEW statement -- so the preceding string is a complete +// directive -- rather than continue the expression? +function newStatementAfterBreak(source, pos) { + const afterBreak = skipSticky(WS_COMMENTS, source, pos) + if (afterBreak >= source.length) return true + if (EXPR_CONTINUATION.has(source[afterBreak])) return false + KEYWORD_CONTINUATION.lastIndex = afterBreak + return !KEYWORD_CONTINUATION.test(source) +} + +// Prepend CJS_REQUIRE_REPAIR to a served CommonJS module's source WITHOUT shifting line +// numbers (the prelude adds no line break) and WITHOUT perturbing the module's Directive +// Prologue -- so a `'use strict'` directive keeps strict mode. The prelude is a `try{...}` +// statement: inserting it ahead of a directive would demote that directive to a plain +// expression (dropping the module to sloppy mode), so we insert AFTER the prologue -- +// shebang, then whitespace, comments, and string-literal directive STATEMENTS. A leading +// ';' makes the insertion self-terminating so it can never fuse `'use strict'try{` into a +// SyntaxError. +// +// A leading string counts as a directive only when a real statement boundary follows it: +// `;`, `}`, a line comment, EOF, or a LINE boundary -- a line terminator OR a block comment +// containing one -- whose next token does not continue the expression. So a module that +// opens with a string EXPRESSION (`'x'.toUpperCase()`, `'a' + b`, `'a,b'` newline-then- +// `.split()`, `'x'` newline-then-`instanceof Y`) is left intact instead of being split +// mid-expression (a SyntaxError, or silently wrong bytes), while a real directive followed +// by a multi-line banner comment still keeps strict mode. Distinguishing the two is the ASI +// problem, so this is a small scan rather than one regex. +function repairCjsRequire(source) { + if (typeof source !== 'string') return source + let i = 0 + if (source.startsWith('#!')) { + const nl = source.indexOf('\n') + i = nl === -1 ? source.length : nl + 1 + } + for (;;) { + const afterWs = skipSticky(WS_COMMENTS, source, i) + STRING_LITERAL.lastIndex = afterWs + if (STRING_LITERAL.exec(source) === null) break // next token isn't a string: end of prologue + const afterStr = STRING_LITERAL.lastIndex + const afterTrail = skipSticky(TRAILING_SAME_LINE, source, afterStr) + const c = source[afterTrail] + let isDirective + if (c === undefined || c === ';' || c === '}') isDirective = true + else if (c === '/' && source[afterTrail + 1] === '/') isDirective = true // trailing line comment + // A `/*` here is a newline-spanning block comment (single-line ones were consumed + // above); like a bare line break it terminates the directive when a new statement follows. + else if (c === '/' && source[afterTrail + 1] === '*') isDirective = newStatementAfterBreak(source, afterTrail) + else if (c === '\n' || c === '\r' || c === '\u2028' || c === '\u2029') isDirective = newStatementAfterBreak(source, afterTrail) + else isDirective = false // same-line continuation (`.`, `+`, `,`, ...): a string expression + if (!isDirective) break + i = afterStr // past the directive; loop for stacked directives + } + return `${source.slice(0, i)};${CJS_REQUIRE_REPAIR}${source.slice(i)}` +} + let state // The shard dir + per-build ed25519 keypair THIS process created (root only). The PRIVATE key // is exported to descendants via env (each capturing child signs its shard with it); the PUBLIC @@ -429,6 +526,20 @@ function load(url, context, nextLoad) { // *-typescript formats are in the allowlist: Node's own translators strip // the types after this hook returns. if (!NODEJS_FORMATS.has(format)) refuseNonNodeFormat(format, url) + // node:59666 workaround. When a load hook supplies `source` for a CommonJS + // module, Node's ESM->CJS translator (loadCJSModule) hands the module a + // *re-invented* require() that sets only `.resolve`/`.main` and omits + // `.cache` and `.extensions` -- unlike the require a disk-loaded CJS module + // gets (makeRequireFunction, which sets all four). Packages that read those + // (import-fresh, and anything walking require.cache / require.extensions) + // then throw `Cannot read properties of undefined` or misbehave -- but only + // under bundle=load, because that's the only mode that serves CJS via a + // source override. Repair the two missing properties from node:module + // before user code runs. Confined to executable CJS formats; ESM has no + // require and resources are not executed. + if (CJS_FORMATS.has(format)) { + return { source: repairCjsRequire(source), format, shortCircuit: true } + } return { source, format, shortCircuit: true } } diff --git a/tests/commonjs.test.js b/tests/commonjs.test.js index 79669a91..71fdf3cb 100644 --- a/tests/commonjs.test.js +++ b/tests/commonjs.test.js @@ -546,3 +546,233 @@ test('run --lock=add keys require.resolve() edges under require conditions, not t.assert.ok(cond.split(', ').includes('require'), `expected require conditions, got '${cond}'`) } })) + +// node:59666. When a load hook supplies `source` for a CommonJS module, Node's +// ESM->CJS translator (loadCJSModule) hands it a *re-invented* require() that sets +// only `.resolve`/`.main` -- `.cache` and `.extensions` are undefined, unlike the +// require a disk-loaded CJS module gets. bundle=load is the only mode that serves CJS +// via a source override, so the deficiency only shows there. The loader repairs the +// two properties from node:module before user code runs. This is the direct guard: +// a bundle-served (off-disk) entry must see require.cache/require.extensions that ARE +// the real Module._cache/_extensions. +test('run --bundle=load gives a bundle-served CJS module a require() with .cache and .extensions (node:59666)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'req-shape', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(tmp, 'index.cjs'), + "const m = require('node:module')\n" + + 'console.log(JSON.stringify([typeof require.cache, typeof require.extensions, ' + + 'require.cache === m.Module._cache, require.extensions === m.Module._extensions]))\n') + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, `${JSON.stringify(['object', 'object', true, true])}\n`) + + // Serve the entry entirely from the bundle: this is the path that gets the + // re-invented require. Before the repair, `typeof require.cache` was 'undefined'. + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, `${JSON.stringify(['object', 'object', true, true])}\n`) +})) + +// The import-fresh@3.3.1 interaction, in its mechanism: a module-invalidation +// helper does `delete require.cache[require.resolve(id)]; require(id)`. Under +// bundle=load this threw `Cannot read properties of undefined (reading '')` +// at `require.cache[...]` before the node:59666 repair, because the entry's +// re-invented require() had no `.cache`. This is the regression guard: the pattern +// must complete (no crash), and the two plain require()s must agree. +// +// counter.cjs is kept ON DISK so require.resolve() is version-independent; only the +// ENTRY is served from the bundle (removed from disk), and the entry is the module +// that receives the re-invented require under test. We deliberately do NOT assert a +// fresh re-evaluation ([1,1,2,3]): a bundle-served module lives in the ESM loader's +// own CJS cache, not Module._cache, so this inline form returns the cached instance +// ([1,1,1,1]) even though the published import-fresh -- via resolve-from + +// parent.require -- does force a fresh eval. Asserting only the stable contract +// keeps the test independent of that Node-internal detail. +test('run --bundle=load survives import-fresh-style require.cache busting (node:59666)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'fresh', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(tmp, 'counter.cjs'), + 'globalThis.__n = (globalThis.__n || 0) + 1\nmodule.exports = globalThis.__n\n') + writeFileSync(join(tmp, 'index.cjs'), + "const fresh = (id) => { delete require.cache[require.resolve(id)]; return require(id) }\n" + + "const a = require('./counter.cjs')\n" + + "const b = require('./counter.cjs')\n" + + "const c = fresh('./counter.cjs')\n" + + "const d = fresh('./counter.cjs')\n" + + 'console.log(JSON.stringify([a, b, c, d]))\n') + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, '[1,1,2,3]\n') // on disk: ordinary Node require, genuine fresh re-eval + + // Serve the entry from the bundle (counter.cjs stays on disk so require.resolve is + // version-independent). Before the repair this crashed at `require.cache[...]`. + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + const parsed = JSON.parse(load.stdout) + t.assert.equal(parsed.length, 4) + t.assert.ok(parsed.every((n) => typeof n === 'number'), `expected 4 numbers, got ${load.stdout}`) + t.assert.equal(parsed[0], parsed[1], 'the two plain require()s return a consistent value') +})) + +// Regression for the node:59666 repair's directive handling: the require-repair +// prelude must be inserted AFTER the module's full Directive Prologue (shebang, +// comments, and string-literal directives), never before or fused onto it. Two +// realistic shapes broke a naive "match a bare leading 'use strict'" approach: +// (a) a license/banner comment BEFORE `'use strict'` -> prelude inserted at +// offset 0 -> the directive is demoted to an expression -> the module +// silently runs in SLOPPY mode under bundle=load but strict on disk; and +// (b) `'use strict'` followed by a comment with no semicolon -> the prelude's +// `try{` fuses onto the directive -> SyntaxError, failing the load. +// The module below is strict on disk (a `with` statement would be a SyntaxError); +// its `f()` assigns to an undeclared name, which throws only in strict mode. We +// assert the served module still throws -> strict mode preserved, no demotion. +test('run --bundle=load preserves a strict-mode directive behind a leading comment (node:59666)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'banner', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(tmp, 'index.cjs'), + '/*! license banner (c) */\n' + + "'use strict'\n" + + 'module.exports = (() => { try { undeclaredStrictProbe = 1; return "sloppy" } catch { return "strict" } })()\n' + + 'console.log(module.exports)\n') + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, 'strict\n') // strict on disk + + // Served from the bundle, the directive must still take effect (not be demoted). + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, 'strict\n', 'strict-mode directive must survive bundle=load (no demotion)') +})) + +test('run --bundle=load handles a "use strict" directive trailed by a comment without crashing (node:59666)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'trail', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + // 'use strict' followed by a line comment, NO semicolon -> naive splicing produced + // `'use strict'try{...` -> SyntaxError. The prologue-aware insertion avoids it. + writeFileSync(join(tmp, 'index.cjs'), + "'use strict'// directive comment, no semicolon\nconsole.log('ok')\n") + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, 'ok\n') + + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, 'ok\n') +})) + +// Regression for over-skip in the node:59666 repair: a module whose FIRST statement +// is an EXPRESSION beginning with a string literal (`'x'.toUpperCase()`, `'a' + b`, +// `'a', b`) must not be mistaken for a `'use strict'`-style directive. Mis-skipping +// the leading string splices the prelude mid-expression, which either fails to parse +// or -- worse -- silently changes the value. The repair only skips a string when a +// statement boundary follows it. This module opens with a string method call and a +// string-concatenation expression; both must survive bundle=load intact. +test('run --bundle=load leaves a leading string EXPRESSION intact (node:59666 over-skip)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'overskip', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(tmp, 'index.cjs'), + "'HELLO'.toLowerCase()\n" + // string-method expression statement (not a directive) + "module.exports = 'a' + 'b'\n" + // string-concat: must stay 'ab', not be split + "console.log(module.exports)\n") + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, 'ab\n') + + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, 'ab\n', 'a leading string expression must not be split by the repair') +})) + +// Regression for a subtler over-skip in the node:59666 repair: a module whose first +// statement is a MULTI-LINE expression beginning with a string -- e.g. `'a,b'` on one +// line, `.split(',')` on the next. JS ASI does NOT insert a semicolon before a leading +// `.` (or `[`, `+`, ...) on the following line, so this is a single expression, not a +// directive. A naive "string followed by a line break = directive" check splices the +// prelude between the string and the `.`, crashing the load. The repair peeks past the +// line break and treats it as a directive only when the next token can't continue the +// expression. +test('run --bundle=load leaves a multi-line leading string expression intact (node:59666 over-skip)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'nlcont', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + // The module's FIRST statement is a bare string whose expression continues on the next + // line via `.split` -- not a directive, so the string must not be skipped/split. + writeFileSync(join(tmp, 'index.cjs'), + "'a,b'\n" + + " .split(',')\n" + + " .forEach((w) => console.log(w))\n") + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, 'a\nb\n') + + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, 'a\nb\n', 'a multi-line leading string expression must not be split by the repair') +})) + +// The keyword-operator tail of the same over-skip class: a leading string whose +// expression continues via the `in` / `instanceof` operator on the next line +// (`'k'\nin obj`). ASI does not break before those keywords, so the string is not a +// directive. The repair peeks past the line break for both punctuator and keyword +// continuations, so the module is left intact rather than split at `in`. +test('run --bundle=load leaves a leading string continued by `in`/`instanceof` intact (node:59666 over-skip)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'kwcont', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(tmp, 'index.cjs'), + "'length'\n" + + 'in globalThis\n' + + "console.log('ok')\n") + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=none', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, 'ok\n') + + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=none', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, 'ok\n', 'a string continued by `in` must not be split by the repair') +})) + +// A `'use strict'` directive trailed by a NEWLINE-SPANNING block comment (a multi-line +// banner) before the next statement. Per spec a block comment containing a line terminator +// acts as a line terminator for ASI, so `'use strict'` is a real directive here and the +// module is strict on disk. The repair must treat that comment as a line boundary (not skip +// past it and land on the next statement, which demoted the directive to sloppy mode). +test('run --bundle=load keeps strict mode across a multi-line block comment after the directive (node:59666)', withTmp((t, tmp) => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'mlbanner', version: '1.0.0', private: true })) + writeFileSync(join(tmp, 'pnpm-workspace.yaml'), 'packages: []\n') + // The block comment's own line terminator is the only break; module.exports follows `*/` + // on the same physical line -- so the trailer must not skip past the comment onto it. + writeFileSync(join(tmp, 'index.cjs'), + "'use strict' /* banner\n spanning lines */ module.exports = " + + "(() => { try { undeclaredStrictProbe = 1; return 'sloppy' } catch { return 'strict' } })()\n" + + 'console.log(module.exports)\n') + + const bundlePath = join(tmp, 'snapshot.br') + const save = run(['run', '--lock=none', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, 'strict\n') + + rmSync(join(tmp, 'index.cjs')) + const load = run(['run', '--lock=none', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.cjs'], { cwd: tmp }) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, 'strict\n', 'a directive before a multi-line block comment must stay strict under bundle=load') +})) diff --git a/tests/fixtures/import-fresh/node_modules/callsites/index.js b/tests/fixtures/import-fresh/node_modules/callsites/index.js new file mode 100644 index 00000000..486c2410 --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/callsites/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const callsites = () => { + const _prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (_, stack) => stack; + const stack = new Error().stack.slice(1); + Error.prepareStackTrace = _prepareStackTrace; + return stack; +}; + +module.exports = callsites; +// TODO: Remove this for the next major release +module.exports.default = callsites; diff --git a/tests/fixtures/import-fresh/node_modules/callsites/package.json b/tests/fixtures/import-fresh/node_modules/callsites/package.json new file mode 100644 index 00000000..8d8ab2d6 --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/callsites/package.json @@ -0,0 +1,6 @@ +{ + "name": "callsites", + "version": "3.1.0", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/import-fresh/node_modules/import-fresh/index.js b/tests/fixtures/import-fresh/node_modules/import-fresh/index.js new file mode 100644 index 00000000..1bed8765 --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/import-fresh/index.js @@ -0,0 +1,34 @@ +'use strict'; +const path = require('path'); +const resolveFrom = require('resolve-from'); +const parentModule = require('parent-module'); + +module.exports = moduleId => { + if (typeof moduleId !== 'string') { + throw new TypeError('Expected a string'); + } + + const parentPath = parentModule(__filename); + + const cwd = parentPath ? path.dirname(parentPath) : __dirname; + const filePath = resolveFrom(cwd, moduleId); + + const oldModule = require.cache[filePath]; + // Delete itself from module parent + if (oldModule && oldModule.parent) { + let i = oldModule.parent.children.length; + + while (i--) { + if (oldModule.parent.children[i].id === filePath) { + oldModule.parent.children.splice(i, 1); + } + } + } + + delete require.cache[filePath]; // Delete module from cache + + const parent = require.cache[parentPath]; // If `filePath` and `parentPath` are the same, cache will already be deleted so we won't get a memory leak in next step + + // In case cache doesn't have parent, fall back to normal require + return parent === undefined || parent.require === undefined ? require(filePath) : parent.require(filePath); +}; diff --git a/tests/fixtures/import-fresh/node_modules/import-fresh/package.json b/tests/fixtures/import-fresh/node_modules/import-fresh/package.json new file mode 100644 index 00000000..bcb98ad9 --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/import-fresh/package.json @@ -0,0 +1,6 @@ +{ + "name": "import-fresh", + "version": "3.3.1", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/import-fresh/node_modules/parent-module/index.js b/tests/fixtures/import-fresh/node_modules/parent-module/index.js new file mode 100644 index 00000000..a26f953a --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/parent-module/index.js @@ -0,0 +1,37 @@ +'use strict'; +const callsites = require('callsites'); + +module.exports = filepath => { + const stacks = callsites(); + + if (!filepath) { + return stacks[2].getFileName(); + } + + let seenVal = false; + + // Skip the first stack as it's this function + stacks.shift(); + + for (const stack of stacks) { + const parentFilepath = stack.getFileName(); + + if (typeof parentFilepath !== 'string') { + continue; + } + + if (parentFilepath === filepath) { + seenVal = true; + continue; + } + + // Skip native modules + if (parentFilepath === 'module.js') { + continue; + } + + if (seenVal && parentFilepath !== filepath) { + return parentFilepath; + } + } +}; diff --git a/tests/fixtures/import-fresh/node_modules/parent-module/package.json b/tests/fixtures/import-fresh/node_modules/parent-module/package.json new file mode 100644 index 00000000..eaa20bfe --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/parent-module/package.json @@ -0,0 +1,6 @@ +{ + "name": "parent-module", + "version": "1.0.1", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/import-fresh/node_modules/resolve-from/index.js b/tests/fixtures/import-fresh/node_modules/resolve-from/index.js new file mode 100644 index 00000000..d092447e --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/resolve-from/index.js @@ -0,0 +1,47 @@ +'use strict'; +const path = require('path'); +const Module = require('module'); +const fs = require('fs'); + +const resolveFrom = (fromDir, moduleId, silent) => { + if (typeof fromDir !== 'string') { + throw new TypeError(`Expected \`fromDir\` to be of type \`string\`, got \`${typeof fromDir}\``); + } + + if (typeof moduleId !== 'string') { + throw new TypeError(`Expected \`moduleId\` to be of type \`string\`, got \`${typeof moduleId}\``); + } + + try { + fromDir = fs.realpathSync(fromDir); + } catch (err) { + if (err.code === 'ENOENT') { + fromDir = path.resolve(fromDir); + } else if (silent) { + return null; + } else { + throw err; + } + } + + const fromFile = path.join(fromDir, 'noop.js'); + + const resolveFileName = () => Module._resolveFilename(moduleId, { + id: fromFile, + filename: fromFile, + paths: Module._nodeModulePaths(fromDir) + }); + + if (silent) { + try { + return resolveFileName(); + } catch (err) { + return null; + } + } + + return resolveFileName(); +}; + +module.exports = (fromDir, moduleId) => resolveFrom(fromDir, moduleId); +module.exports.silent = (fromDir, moduleId) => resolveFrom(fromDir, moduleId, true); diff --git a/tests/fixtures/import-fresh/node_modules/resolve-from/package.json b/tests/fixtures/import-fresh/node_modules/resolve-from/package.json new file mode 100644 index 00000000..761478b0 --- /dev/null +++ b/tests/fixtures/import-fresh/node_modules/resolve-from/package.json @@ -0,0 +1,6 @@ +{ + "name": "resolve-from", + "version": "4.0.0", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/import-fresh/package.json b/tests/fixtures/import-fresh/package.json new file mode 100644 index 00000000..865c754a --- /dev/null +++ b/tests/fixtures/import-fresh/package.json @@ -0,0 +1,8 @@ +{ + "name": "stasis-fixture-import-fresh", + "version": "0.0.0", + "private": true, + "dependencies": { + "import-fresh": "3.3.1" + } +} diff --git a/tests/fixtures/import-fresh/pnpm-workspace.yaml b/tests/fixtures/import-fresh/pnpm-workspace.yaml new file mode 100644 index 00000000..3334c0e4 --- /dev/null +++ b/tests/fixtures/import-fresh/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: [] diff --git a/tests/fixtures/import-fresh/src/counter.cjs b/tests/fixtures/import-fresh/src/counter.cjs new file mode 100644 index 00000000..f627f1b9 --- /dev/null +++ b/tests/fixtures/import-fresh/src/counter.cjs @@ -0,0 +1,4 @@ +// Bumps a global on every evaluation, so a genuine fresh (uncached) re-require +// returns a higher number while a cached require repeats the last one. +globalThis.__stasisImportFreshCounter = (globalThis.__stasisImportFreshCounter || 0) + 1 +module.exports = globalThis.__stasisImportFreshCounter diff --git a/tests/fixtures/import-fresh/src/entry.cjs b/tests/fixtures/import-fresh/src/entry.cjs new file mode 100644 index 00000000..a190fb62 --- /dev/null +++ b/tests/fixtures/import-fresh/src/entry.cjs @@ -0,0 +1,8 @@ +const importFresh = require('import-fresh') + +const a = require('./counter.cjs') // eval #1 -> 1 +const b = require('./counter.cjs') // cached -> 1 +const c = importFresh('./counter.cjs') // fresh -> 2 +const d = importFresh('./counter.cjs') // fresh -> 3 + +console.log(JSON.stringify([a, b, c, d])) diff --git a/tests/import-fresh.test.js b/tests/import-fresh.test.js new file mode 100644 index 00000000..0755b6cf --- /dev/null +++ b/tests/import-fresh.test.js @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import { spawnSync } from 'node:child_process' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { brotliDecompressSync } from 'node:zlib' + +// End-to-end regression for the node:59666 fix, using the REAL import-fresh@3.3.1 +// package (vendored under the fixture's node_modules, with its resolve-from / +// parent-module / callsites deps). import-fresh invalidates Node's module cache with +// `delete require.cache[require.resolve(id)]` and re-require()s -- which threw +// `Cannot read properties of undefined` under bundle=load before the loader restored +// the `require.cache` that Node's ESM->CJS translator omits for loader-supplied CJS. + +const here = dirname(fileURLToPath(import.meta.url)) +const cli = join(here, '..', 'stasis', 'bin', 'stasis.js') +const fixture = join(here, 'fixtures', 'import-fresh') + +const { + EXODUS_STASIS_LOCK: _l, + EXODUS_STASIS_SCOPE: _s, + EXODUS_STASIS_BUNDLE: _b, + EXODUS_STASIS_BUNDLE_FILE: _bf, + EXODUS_STASIS_DEBUG: _d, + ...cleanEnv +} = process.env + +const run = (args, opts = {}) => + spawnSync(process.execPath, [cli, ...args], { encoding: 'utf-8', env: cleanEnv, ...opts }) + +const withTmp = (fn) => (t) => { + const dir = mkdtempSync(join(tmpdir(), 'stasis-import-fresh-')) + try { + return fn(t, dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +// Can THIS Node resolve a bundle-served module's require from a bundle with node_modules +// removed? import-fresh reaches its target via resolve-from + parent-module (callsites / +// Error-stack introspection). Under a bundle-served module's re-invented require, Node +// 24.15-26.2 changed the stack/off-disk-resolution behavior resolve-from depends on, so +// parent-module returns the wrong caller directory and the re-require can't be found. +// 24.14 and 26.3+ resolve it. This is the same limitation the CJS suite probes for +// require.resolve; probe the real behavior once rather than hardcode a version range. +const RESOLVE_RESOLVE_FROM_BUNDLE = (() => { + const dir = mkdtempSync(join(tmpdir(), 'stasis-import-fresh-probe-')) + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', private: true })) + writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(dir, 'index.mjs'), "import './r.cjs'\n") + writeFileSync(join(dir, 'r.cjs'), "require.resolve('pd')\nconsole.log('ok')\n") // resolve-only, never loaded + const pd = join(dir, 'node_modules', 'pd') + mkdirSync(pd, { recursive: true }) + writeFileSync(join(pd, 'package.json'), JSON.stringify({ name: 'pd', version: '1.0.0', main: 'index.js' })) + writeFileSync(join(pd, 'index.js'), 'module.exports = 1\n') + const bundlePath = join(dir, 'p.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: dir }) + if (save.status !== 0) return false + rmSync(join(dir, 'node_modules'), { recursive: true, force: true }) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: dir }) + return load.status === 0 + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + +test('run --bundle=load runs the real import-fresh package from a bundle (node:59666)', withTmp((t, tmp) => { + cpSync(fixture, tmp, { recursive: true }) + const bundlePath = join(tmp, 'snapshot.br') + + // Capture: on disk this is ordinary Node, so import-fresh genuinely re-evaluates + // counter.cjs -> [1,1,2,3] (two cached requires, then two fresh ones). + const save = run( + ['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'src/entry.cjs'], + { cwd: tmp } + ) + t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`) + t.assert.equal(save.stdout, '[1,1,2,3]\n') + t.assert.ok(existsSync(bundlePath)) + + // import-fresh and its dependency graph are captured in the bundle. + const decoded = JSON.parse(brotliDecompressSync(readFileSync(bundlePath)).toString('utf-8')) + const names = new Set(Object.values(decoded.modules).map((m) => m.name)) + for (const pkg of ['import-fresh', 'resolve-from', 'parent-module', 'callsites']) { + t.assert.ok(names.has(pkg), `bundle should capture ${pkg}; got ${[...names].join(', ')}`) + } + + // Bundle-only load: import-fresh (and its deps) are served from the bundle, so their + // re-invented require() is the one the loader repairs -- the require.cache access no + // longer throws (the node:59666 fix). Reaching the fresh-required target additionally + // relies on resolve-from + parent-module, which Node 24.15-26.2 can't satisfy from a + // bundle-served module (see RESOLVE_RESOLVE_FROM_BUNDLE); skip the end-to-end load + // there. The capture + bundle-contents assertions above run on every version, and the + // hermetic require.cache tests in commonjs.test.js cover the repair itself unconditionally. + if (!RESOLVE_RESOLVE_FROM_BUNDLE) { + t.diagnostic(`skipping bundle-only import-fresh load on ${process.version} (Node loader limitation)`) + return + } + rmSync(join(tmp, 'node_modules'), { recursive: true, force: true }) + + const load = run( + ['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'src/entry.cjs'], + { cwd: tmp } + ) + t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`) + t.assert.equal(load.stdout, '[1,1,2,3]\n') +}))