diff --git a/docs/versioning-and-exports.md b/docs/versioning-and-exports.md index 31955a9..d0af54c 100644 --- a/docs/versioning-and-exports.md +++ b/docs/versioning-and-exports.md @@ -20,6 +20,8 @@ CJS: const { parse } = require("@cldmv/jsonv/2021"); ``` +`require()` is synchronous and returns the same exports as `import` (existing `await require(...)` code keeps working). It loads the ESM build through Node's `require(esm)`, so it needs Node.js ^20.19.0 or >=22.12.0; on older Node.js, use `import()`. + Root alias: ```js import { parse } from "@cldmv/jsonv"; // latest year diff --git a/package.json b/package.json index eac629c..44cc587 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,11 @@ "build:plugin": "node scripts/build-plugin.mjs", "build:types": "tsc --project .configs/tsconfig.types.json", "build:ci": "npm run lint --if-present && npm run format:check --if-present && npm run build", - "test": "node tests/run-vitest.mjs", + "test": "node tests/run-vitest.mjs && npm run test:cjs", + "test:cjs": "npm run build && node --test tests/cjs/entry.test.cjs", "test:watch": "vitest --config .configs/vitest.config.mjs", "test:types": "tsc --project .configs/tsconfig.build.json --noEmit", - "coverage": "node tests/run-vitest.mjs --coverage-quiet", + "coverage": "node tests/run-vitest.mjs --coverage-quiet && npm run test:cjs", "ci:coverage": "npm run coverage", "lint": "eslint --config .configs/eslint.config.v9.mjs src tests", "lint:v8": "set ESLINT_USE_FLAT_CONFIG=true&& eslint --config .configs/eslint.config.mjs src tests", diff --git a/scripts/build-cjs.mjs b/scripts/build-cjs.mjs index 8627c7c..2a45275 100644 --- a/scripts/build-cjs.mjs +++ b/scripts/build-cjs.mjs @@ -14,11 +14,15 @@ */ /** - * Build CJS wrappers for CommonJS compatibility - * Creates .cjs files that load the ESM modules + * Build CJS wrappers for CommonJS compatibility. + * + * Each .cjs file is a thin wrapper that loads its ESM counterpart through Node's + * synchronous require(esm), so `require("@cldmv/jsonv")` returns the same module + * namespace object that `import` gives. The ESM graph therefore must not use + * top-level await (require(esm) rejects it with ERR_REQUIRE_ASYNC_MODULE). */ -import { mkdirSync, writeFileSync, existsSync, readdirSync } from "fs"; +import { mkdirSync, writeFileSync, readdirSync, rmSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -27,127 +31,83 @@ const __dirname = dirname(__filename); const rootDir = join(__dirname, ".."); const distDir = join(rootDir, "dist"); const cjsDir = join(distDir, "cjs"); - -console.log("\nšŸ“¦ Building CJS wrappers...\n"); - -// Ensure CJS directory exists -if (!existsSync(cjsDir)) { - mkdirSync(cjsDir, { recursive: true }); -} - -// Create CJS loader that dynamically imports ESM -const loaderContent = `/** - * CommonJS loader for @cldmv/jsonv - * Dynamically imports ESM modules - */ - -module.exports = (async () => { - const esm = await import('../index.mjs'); - return esm; -})(); -`; - -const loaderFile = join(cjsDir, "loader.cjs"); -writeFileSync(loaderFile, loaderContent, "utf8"); -console.log(`āœ“ Created CJS loader → ${loaderFile}`); - -// Create main CJS entry point -const indexContent = `/** - * CommonJS entry point for @cldmv/jsonv - */ - -const loader = require('./loader.cjs'); - -module.exports = loader; -`; - -const indexFile = join(cjsDir, "index.cjs"); -writeFileSync(indexFile, indexContent, "utf8"); -console.log(`āœ“ Created CJS index → ${indexFile}`); - -// Prepare CJS types directory -const cjsTypesDir = join(distDir, "types", "cjs"); -if (!existsSync(cjsTypesDir)) { - mkdirSync(cjsTypesDir, { recursive: true }); -} - -// Create CJS wrappers for year modules const cjsYearsDir = join(cjsDir, "years"); +const cjsTypesDir = join(distDir, "types", "cjs"); const cjsYearsTypesDir = join(cjsTypesDir, "years"); -if (!existsSync(cjsYearsDir)) { - mkdirSync(cjsYearsDir, { recursive: true }); -} -if (!existsSync(cjsYearsTypesDir)) { - mkdirSync(cjsYearsTypesDir, { recursive: true }); -} +console.log("\nšŸ“¦ Building CJS wrappers...\n"); -// Get all year .mjs files from dist/years/ -const distYearsDir = join(distDir, "years"); -const yearFiles = readdirSync(distYearsDir) - .filter((f) => f.match(/^\d{4}\.mjs$/)) - .map((f) => parseInt(f.replace(".mjs", ""))); +// Start from a clean dist/cjs so a wrapper removed here (e.g. the old async loader.cjs) +// never lingers from an earlier build. +rmSync(cjsDir, { recursive: true, force: true }); +mkdirSync(cjsYearsDir, { recursive: true }); +mkdirSync(cjsYearsTypesDir, { recursive: true }); -for (const year of yearFiles) { - const yearCjsContent = `/** - * CommonJS wrapper for @cldmv/jsonv/${year} +/** + * Source of a CJS wrapper that synchronously requires an ESM file. + * @param {string} specifier - Package specifier the wrapper stands for (for the comment and error message). + * @param {string} esmPath - Path of the ESM file, relative to the wrapper. + * @returns {string} The wrapper source. */ - -const loader = (async () => { - const esm = await import('../../years/${year}.mjs'); - return esm; -})(); - -module.exports = loader; -`; - - const yearFile = join(cjsYearsDir, `${year}.cjs`); - writeFileSync(yearFile, yearCjsContent, "utf8"); +function wrapper(specifier, esmPath) { + return `/** + * CommonJS entry for ${specifier} + */ +"use strict"; + +// A thin wrapper: it loads the ESM build through Node's synchronous require(esm), so +// require() returns the same module namespace object as import. Node.js versions without +// require(esm) would fail with a bare ERR_REQUIRE_ESM, so fail early with a message that +// says what to do instead. +if (!process.features?.require_module) { + const error = new Error( + \`@cldmv/jsonv: require() needs Node.js ^20.19.0 or >=22.12.0 (this is \${process.version}). On older Node.js, load the package with import() instead.\` + ); + error.code = "ERR_REQUIRE_ESM"; + throw error; } -console.log(`āœ“ Created ${yearFiles.length} CJS year modules`); -// Generate CJS type declarations for all years -for (const year of yearFiles) { - const yearDts = `export * from '../../years/${year}.mjs'; -import jsonv from '../../years/${year}.mjs'; -export default jsonv; +module.exports = require("${esmPath}"); `; - writeFileSync(join(cjsYearsTypesDir, `${year}.d.cts`), yearDts, "utf8"); } -console.log(`āœ“ Created ${yearFiles.length} CJS year types`); -// Create CJS loader utility -const loaderCjsContent = `/** - * CommonJS wrapper for @cldmv/jsonv/loader - */ +// Main CJS entry point +writeFileSync(join(cjsDir, "index.cjs"), wrapper("@cldmv/jsonv", "../index.mjs"), "utf8"); +console.log("āœ“ Created CJS index → dist/cjs/index.cjs"); -const loader = (async () => { - const esm = await import('../../years/loader.mjs'); - return esm; -})(); +// CJS wrappers for every module under dist/years/: the year modules plus the +// loader and year-resolver utilities, all reachable through the "./*" export. +const yearModules = readdirSync(join(distDir, "years")) + .filter((f) => f.endsWith(".mjs")) + .map((f) => f.slice(0, -".mjs".length)); -module.exports = loader; -`; - -const loaderYearFile = join(cjsYearsDir, "loader.cjs"); -writeFileSync(loaderYearFile, loaderCjsContent, "utf8"); -console.log(`āœ“ Created CJS loader → dist/cjs/years/loader.cjs`); +for (const name of yearModules) { + writeFileSync(join(cjsYearsDir, `${name}.cjs`), wrapper(`@cldmv/jsonv/${name}`, `../../years/${name}.mjs`), "utf8"); +} +console.log(`āœ“ Created ${yearModules.length} CJS year/utility wrappers → dist/cjs/years/`); -// Create .d.cts type declarations for CJS modules +// CJS type declarations. require(esm) returns the ESM namespace, so the +// declarations re-export the ESM types (default export included when present). console.log("\nšŸ“ Generating CJS type declarations...\n"); -// Main index.d.cts const indexDts = `export * from '../index.mjs'; import jsonv from '../index.mjs'; export default jsonv; `; writeFileSync(join(cjsTypesDir, "index.d.cts"), indexDts, "utf8"); -console.log(`āœ“ Created CJS types → dist/types/cjs/index.d.cts`); +console.log("āœ“ Created CJS types → dist/types/cjs/index.d.cts"); -// Loader type -const loaderDts = `export * from '../../years/loader.mjs'; +for (const name of yearModules) { + const hasDefault = /^\d{4}$/.test(name); + const dts = hasDefault + ? `export * from '../../years/${name}.mjs'; +import jsonv from '../../years/${name}.mjs'; +export default jsonv; +` + : `export * from '../../years/${name}.mjs'; `; -writeFileSync(join(cjsYearsTypesDir, "loader.d.cts"), loaderDts, "utf8"); -console.log(`āœ“ Created CJS loader types → dist/types/cjs/years/loader.d.cts`); + writeFileSync(join(cjsYearsTypesDir, `${name}.d.cts`), dts, "utf8"); +} +console.log(`āœ“ Created ${yearModules.length} CJS year/utility types → dist/types/cjs/years/`); console.log("\nāœ… CJS wrappers built successfully\n"); diff --git a/tests/cjs/entry.test.cjs b/tests/cjs/entry.test.cjs new file mode 100644 index 0000000..dca6d4b --- /dev/null +++ b/tests/cjs/entry.test.cjs @@ -0,0 +1,80 @@ +/** + * + * @Project: @cldmv/jsonv + * @Filename: /tests/cjs/entry.test.cjs + * @Date: 2026-10-03T10:28:33-07:00 (1791048513) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T10:29:28-07:00 (1791048568) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +/** + * CommonJS entry tests. These run under Node's own test runner (`node --test`), not Vitest: + * Vitest loads files through its own module runner, so it cannot show whether a plain + * `require()` of the built package works the way it does for a CommonJS consumer. + * They test the built dist/ output, so `npm run test:cjs` builds first. + */ +"use strict"; + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +const repoRoot = path.resolve(__dirname, "../.."); + +/** + * Assert that a require() result exposes exactly the ESM module's exports. Node's require(esm) + * returns the namespace itself, or - for a module with a default export - an object carrying + * the same bindings plus `__esModule: true` for bundler interop, so compare member by member. + * @param {object} required - What require() returned. + * @param {object} esm - The namespace import() returned. + */ +function assertSameExports(required, esm) { + assert.ok(Object.keys(esm).length > 0); + for (const key of Object.keys(esm)) { + assert.equal(required[key], esm[key], `export "${key}" differs`); + } + const extra = Object.keys(required).filter((key) => !(key in esm)); + assert.deepEqual( + extra.filter((key) => key !== "__esModule"), + [] + ); +} + +test("require() of the CJS entry returns the same namespace as import", async () => { + const required = require("../../dist/cjs/index.cjs"); + const esm = await import("../../dist/index.mjs"); + + assert.equal(typeof required.then, "undefined", "require() must not return a Promise"); + assertSameExports(required, esm); + assert.deepEqual(required.parse('{"a":1}'), { a: 1 }); +}); + +test("require() of a year wrapper returns the same namespace as import", async () => { + const required = require("../../dist/cjs/years/2021.cjs"); + const esm = await import("../../dist/years/2021.mjs"); + + assert.equal(typeof required.then, "undefined", "require() must not return a Promise"); + assertSameExports(required, esm); + assert.deepEqual(required.parse("{ value: 1_000 }"), { value: 1000 }); +}); + +test("require() fails with a clear message where Node.js has no require(esm)", () => { + // --no-experimental-require-module turns require(esm) off, which is what Node.js + // versions before 20.19 / 22.12 look like to the entry. + const res = spawnSync(process.execPath, ["--no-experimental-require-module", "-e", "require('./dist/cjs/index.cjs')"], { + cwd: repoRoot, + encoding: "utf8" + }); + + assert.notEqual(res.status, 0); + assert.match(res.stderr, /ERR_REQUIRE_ESM/); + assert.match(res.stderr, /require\(\) needs Node\.js \^20\.19\.0 or >=22\.12\.0/); + assert.match(res.stderr, /import\(\)/); +});