diff --git a/packages/native/package.json b/packages/native/package.json index 1bb3b009d..42bc47668 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -2,7 +2,7 @@ "name": "@gsd/native", "version": "0.1.0", "description": "Native Rust bindings for GSD \u2014 high-performance native modules via N-API", - "type": "module", + "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { @@ -14,75 +14,75 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "default": "./dist/index.js" }, "./grep": { "types": "./dist/grep/index.d.ts", - "import": "./dist/grep/index.js" + "default": "./dist/grep/index.js" }, "./ps": { "types": "./dist/ps/index.d.ts", - "import": "./dist/ps/index.js" + "default": "./dist/ps/index.js" }, "./glob": { "types": "./dist/glob/index.d.ts", - "import": "./dist/glob/index.js" + "default": "./dist/glob/index.js" }, "./clipboard": { "types": "./dist/clipboard/index.d.ts", - "import": "./dist/clipboard/index.js" + "default": "./dist/clipboard/index.js" }, "./ast": { "types": "./dist/ast/index.d.ts", - "import": "./dist/ast/index.js" + "default": "./dist/ast/index.js" }, "./html": { "types": "./dist/html/index.d.ts", - "import": "./dist/html/index.js" + "default": "./dist/html/index.js" }, "./text": { "types": "./dist/text/index.d.ts", - "import": "./dist/text/index.js" + "default": "./dist/text/index.js" }, "./fd": { "types": "./dist/fd/index.d.ts", - "import": "./dist/fd/index.js" + "default": "./dist/fd/index.js" }, "./image": { "types": "./dist/image/index.d.ts", - "import": "./dist/image/index.js" + "default": "./dist/image/index.js" }, "./xxhash": { "types": "./dist/xxhash/index.d.ts", - "import": "./dist/xxhash/index.js" + "default": "./dist/xxhash/index.js" }, "./diff": { "types": "./dist/diff/index.d.ts", - "import": "./dist/diff/index.js" + "default": "./dist/diff/index.js" }, "./gsd-parser": { "types": "./dist/gsd-parser/index.d.ts", - "import": "./dist/gsd-parser/index.js" + "default": "./dist/gsd-parser/index.js" }, "./highlight": { "types": "./dist/highlight/index.d.ts", - "import": "./dist/highlight/index.js" + "default": "./dist/highlight/index.js" }, "./json-parse": { "types": "./dist/json-parse/index.d.ts", - "import": "./dist/json-parse/index.js" + "default": "./dist/json-parse/index.js" }, "./stream-process": { "types": "./dist/stream-process/index.d.ts", - "import": "./dist/stream-process/index.js" + "default": "./dist/stream-process/index.js" }, "./truncate": { "types": "./dist/truncate/index.d.ts", - "import": "./dist/truncate/index.js" + "default": "./dist/truncate/index.js" }, "./ttsr": { "types": "./dist/ttsr/index.d.ts", - "import": "./dist/ttsr/index.js" + "default": "./dist/ttsr/index.js" } }, "files": [ diff --git a/packages/native/src/__tests__/module-compat.test.mjs b/packages/native/src/__tests__/module-compat.test.mjs new file mode 100644 index 000000000..949fd16d3 --- /dev/null +++ b/packages/native/src/__tests__/module-compat.test.mjs @@ -0,0 +1,91 @@ +/** + * Tests that the @gsd/native package.json is correctly configured + * for Node.js module resolution (ESM/CJS compatibility). + * + * Regression test for #2861: "type": "module" + "import"-only export + * conditions caused crashes on Node.js v24 when the parent package also + * declared "type": "module" and strict ESM resolution was enforced. + */ + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkgPath = path.resolve(__dirname, "..", "..", "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + +describe("@gsd/native module compatibility (#2861)", () => { + test("package.json must not declare type: module (compiled output is CJS-compatible)", () => { + // The compiled output uses createRequire() to load .node addons. + // Declaring "type": "module" forces Node.js to treat .js files as ESM, + // but the package needs "type": "commonjs" to override the parent + // package's "type": "module" and ensure correct CJS semantics. + assert.notEqual( + pkg.type, + "module", + 'package.json must not set "type": "module" — this causes crashes on Node.js v24 ' + + "when the parent package also declares ESM (see #2861)", + ); + }); + + test("package.json should explicitly declare type: commonjs", () => { + // When installed as a dependency under a parent with "type": "module" + // (e.g. gsd-pi), an absent "type" field would inherit the parent's + // ESM setting. Explicit "commonjs" overrides this. + assert.equal( + pkg.type, + "commonjs", + 'package.json must explicitly set "type": "commonjs" to override ' + + "the parent package's ESM declaration", + ); + }); + + test("all export conditions must use 'default' (not 'import'-only)", () => { + // The "import" condition key restricts resolution to ESM import + // statements only. Using "default" ensures the export works for both + // require() and import, which is essential for a CJS package that may + // be consumed from ESM code via Node's CJS interop. + const exportsMap = pkg.exports; + assert.ok(exportsMap, "package.json must have an exports map"); + + for (const [subpath, conditions] of Object.entries(exportsMap)) { + assert.ok( + !conditions.import || conditions.default, + `exports["${subpath}"] uses "import" condition without "default" — ` + + `this breaks CJS consumers and Node.js v24 strict resolution`, + ); + } + }); + + test("native.ts source must not use bare import.meta.url (parse-time error in CJS)", () => { + // When compiled to CJS, import.meta is a *parse-time* syntax error -- + // typeof guards don't help because Node rejects the syntax before + // executing any code. The source must wrap import.meta access in + // an indirect eval so the CJS parser never sees the bare syntax. + const nativeSrc = readFileSync( + path.resolve(__dirname, "..", "native.ts"), + "utf8", + ); + + // Bare import.meta.url (NOT wrapped) would crash at parse time in CJS. + // These regexes match direct usage like fileURLToPath(import.meta.url) + // and createRequire(import.meta.url), but NOT indirect patterns that + // hide import.meta from the CJS parser. + const hasBareImportMetaDirname = /path\.dirname\(.*fileURLToPath\(import\.meta\.url\)\)/.test(nativeSrc); + const hasBareImportMetaRequire = /createRequire\(import\.meta\.url\)/.test(nativeSrc); + + assert.ok( + !hasBareImportMetaDirname, + "native.ts must not use bare import.meta.url in fileURLToPath() -- " + + "this is a parse-time syntax error in CJS; use indirect eval", + ); + assert.ok( + !hasBareImportMetaRequire, + "native.ts must not use bare import.meta.url in createRequire() -- " + + "this is a parse-time syntax error in CJS; use indirect eval", + ); + }); +}); diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index b310cef28..05d4288b1 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -8,14 +8,15 @@ * 3. native/addon/gsd_engine.dev.node (local debug build) */ -import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); +// __dirname and require are available in both execution contexts: +// - CJS (production build via tsc): provided natively by Node +// - ESM (CI test loader): injected by the dist-redirect.mjs preamble +const _dirname = __dirname; +const _require = require; -const addonDir = path.resolve(__dirname, "..", "..", "..", "native", "addon"); +const addonDir = path.resolve(_dirname, "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; /** Map Node.js platform/arch to the npm package suffix */ @@ -36,7 +37,7 @@ function loadNative(): Record { const packageSuffix = platformPackageMap[platformTag]; if (packageSuffix) { try { - _loadedSuccessfully = true; return require(`@gsd-build/engine-${packageSuffix}`) as Record; + _loadedSuccessfully = true; return _require(`@gsd-build/engine-${packageSuffix}`) as Record; } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`@gsd-build/engine-${packageSuffix}: ${message}`); @@ -46,7 +47,7 @@ function loadNative(): Record { // 2. Try local release build (native/addon/gsd_engine.{platform}.node) const releasePath = path.join(addonDir, `gsd_engine.${platformTag}.node`); try { - _loadedSuccessfully = true; return require(releasePath) as Record; + _loadedSuccessfully = true; return _require(releasePath) as Record; } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`${releasePath}: ${message}`); @@ -55,7 +56,7 @@ function loadNative(): Record { // 3. Try local dev build (native/addon/gsd_engine.dev.node) const devPath = path.join(addonDir, "gsd_engine.dev.node"); try { - _loadedSuccessfully = true; return require(devPath) as Record; + _loadedSuccessfully = true; return _require(devPath) as Record; } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`${devPath}: ${message}`); diff --git a/src/resources/extensions/gsd/tests/dist-redirect.mjs b/src/resources/extensions/gsd/tests/dist-redirect.mjs index 8fdf93c5b..2d476430e 100644 --- a/src/resources/extensions/gsd/tests/dist-redirect.mjs +++ b/src/resources/extensions/gsd/tests/dist-redirect.mjs @@ -87,7 +87,26 @@ export function load(url, context, nextLoad) { emitDecoratorMetadata: true, }, }); - return { format: 'module', source: outputText, shortCircuit: true }; + // Inject CJS-compatible globals (__dirname, __filename, require) so that + // workspace packages compiled as ESM can still use them. This avoids the + // need for import.meta.url behind indirect invocation patterns that fail in + // CJS and in dynamically-created scopes. + // Only inject globals that the source file doesn't already declare itself. + const preambleLines = [ + 'import { fileURLToPath as __preamble_fUTP } from "node:url";', + 'import { dirname as __preamble_dn } from "node:path";', + 'import { createRequire as __preamble_cR } from "node:module";', + ]; + if (!outputText.includes('const __filename') && !outputText.includes('let __filename')) { + preambleLines.push('const __filename = __preamble_fUTP(import.meta.url);'); + } + if (!outputText.includes('const __dirname') && !outputText.includes('let __dirname')) { + preambleLines.push('const __dirname = __preamble_dn(__preamble_fUTP(import.meta.url));'); + } + if (!outputText.includes('const require') && !outputText.includes('let require')) { + preambleLines.push('const require = __preamble_cR(import.meta.url);'); + } + return { format: 'module', source: preambleLines.join('\n') + '\n' + outputText, shortCircuit: true }; } return nextLoad(url, context); }