From 80158c574f1733fd3c35369fa43f27777d4e0677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:39:55 +0200 Subject: [PATCH 1/8] build: split tsconfig into cjs and esm targets Introduces tsconfig.base.json holding the shared compiler options, plus tsconfig.cjs.json and tsconfig.esm.json for the respective dual-build outputs (./dist/cjs and ./dist/esm). Root tsconfig.json now extends the base and targets the ESM layout so editors/IDEs default to the modern configuration while the explicit build variants drive the publishable artifacts. Uses moduleResolution: bundler in both variants because TypeScript 6 deprecated plain "node" and nodenext tripped on an upstream package shipping .d.ts files without .js-extension imports. Adds scripts/write-package-type.mjs to write the correct subfolder package.json type marker after each tsc run. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/write-package-type.mjs | 25 +++++++++++++++++++++++++ tsconfig.base.json | 20 ++++++++++++++++++++ tsconfig.cjs.json | 8 ++++++++ tsconfig.esm.json | 8 ++++++++ tsconfig.json | 24 +++++------------------- 5 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 scripts/write-package-type.mjs create mode 100644 tsconfig.base.json create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.esm.json diff --git a/scripts/write-package-type.mjs b/scripts/write-package-type.mjs new file mode 100644 index 0000000..d24b210 --- /dev/null +++ b/scripts/write-package-type.mjs @@ -0,0 +1,25 @@ +// Writes the appropriate `package.json` marker into dist/{cjs,esm} so Node's +// module resolver treats the emitted .js files with the correct format. +// +// Usage: node scripts/write-package-type.mjs + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const target = process.argv[2]; +if (target !== "cjs" && target !== "esm") { + console.error(`Usage: node scripts/write-package-type.mjs `); + process.exit(1); +} + +const type = target === "cjs" ? "commonjs" : "module"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const outPath = resolve(__dirname, "..", "dist", target, "package.json"); + +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, JSON.stringify({ type }) + "\n"); + +console.log(`wrote ${outPath} → {"type":"${type}"}`); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..606c89b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "allowJs": true, + "lib": [ + "es2019" + ], + "target": "es2019", + "types": [ + "node" + ], + "noEmitOnError": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "declaration": true + }, + "include": [ + "./src/**/*" + ] +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..52d7cdf --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "bundler", + "outDir": "./dist/cjs" + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..17d5a98 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "outDir": "./dist/esm" + } +} diff --git a/tsconfig.json b/tsconfig.json index 0a168d3..17d5a98 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,8 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "allowJs": true, - "lib": [ - "es2019" - ], - "module": "commonjs", - "target": "es2019", - "types": [ - "node" - ], - "noEmitOnError": true, - "noImplicitAny": true, - "noImplicitReturns": true, - "declaration": true - }, - "include": [ - "./src/**/*" - ] + "module": "es2022", + "moduleResolution": "bundler", + "outDir": "./dist/esm" + } } From 6a395c9a354816925e528a97035abc168043a3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:40:03 +0200 Subject: [PATCH 2/8] build: use .js extensions in src relative imports for dual build ECMAScript module resolution in Node requires explicit file extensions on relative imports. Appending .js keeps the TypeScript source working under both the CJS and ESM outputs and aligns with the standard modern Node/TS pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/NtpTimeSync.ts | 4 ++-- src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/NtpTimeSync.ts b/src/NtpTimeSync.ts index 4c075e0..d838517 100644 --- a/src/NtpTimeSync.ts +++ b/src/NtpTimeSync.ts @@ -2,8 +2,8 @@ import * as dgram from "dgram"; import { NtpPacket, NtpPacketParser } from "ntp-packet-parser"; -import { NtpTimeResult } from "./NtpTimeResult"; -import { RecursivePartial } from "./RecursivePartial"; +import { NtpTimeResult } from "./NtpTimeResult.js"; +import { RecursivePartial } from "./RecursivePartial.js"; let singleton: NtpTimeSync | undefined; let lastPoll: number | undefined; diff --git a/src/index.ts b/src/index.ts index 48af6c3..3fe158d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export * from "./NtpTimeResult"; -export * from "./NtpTimeSync"; +export * from "./NtpTimeResult.js"; +export * from "./NtpTimeSync.js"; From 24e63f9ea1a1647118f888899df8e7aa6335bb77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:40:08 +0200 Subject: [PATCH 3/8] build: add exports field and dual build scripts Publishes both CJS and ESM entries via the conditional exports map so consumers get the correct format automatically while keeping type: commonjs at the root. Adds build:cjs / build:esm / build / clean scripts plus test:integration variants that drive the new runtime consumer tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 098b5bb..944de65 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,24 @@ "name": "ntp-time-sync", "version": "0.5.0", "description": "Fetches the current time from NTP servers and returns offset information", - "main": "dist/index.js", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", "type": "commonjs", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "dependencies": { "ntp-packet-parser": "^0.6.0" }, @@ -15,7 +31,14 @@ }, "scripts": { "prepublishOnly": "yarn prettier:lint && yarn build", - "build": "tsc", + "clean": "rm -rf dist", + "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/write-package-type.mjs cjs", + "build:esm": "tsc -p tsconfig.esm.json && node scripts/write-package-type.mjs esm", + "build": "yarn build:cjs && yarn build:esm", + "test:integration:cjs": "node test/integration/cjs.cjs", + "test:integration:esm": "node test/integration/esm.mjs", + "test:integration:deno": "deno run --allow-read test/integration/deno.ts && deno run --allow-read test/integration/deno-esm.ts", + "test:integration": "yarn test:integration:cjs && yarn test:integration:esm", "prettier": "prettier --write src/**/*.ts", "prettier:lint": "prettier --list-different src/**/*.ts" }, From e3f9b559ec7bd531fdae984f7d0608593e9ced60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:40:13 +0200 Subject: [PATCH 4/8] test: add CJS and ESM integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the dual build outputs through native Node runtimes using node:assert/strict. Each test imports the respective dist entry and verifies the exported surface, singleton behavior, constructor option merging, and that resolved options carry no __proto__ own properties — mirroring the existing Deno scenario so regressions in either output format are caught. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/integration/cjs.cjs | 45 ++++++++++++++++++++++++++++++++++++++++ test/integration/esm.mjs | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 test/integration/cjs.cjs create mode 100644 test/integration/esm.mjs diff --git a/test/integration/cjs.cjs b/test/integration/cjs.cjs new file mode 100644 index 0000000..74cdc7b --- /dev/null +++ b/test/integration/cjs.cjs @@ -0,0 +1,45 @@ +// CJS integration test — verifies the dual build's CommonJS output works for plain `require()` consumers. +// Run: node test/integration/cjs.cjs + +"use strict"; + +const assert = require("node:assert/strict"); + +const mod = require("../../dist/cjs/index.js"); +const { NtpTimeSync, NtpTimeSyncDefaultOptions } = mod; + +assert.equal(typeof NtpTimeSync, "function", "NtpTimeSync should be exported as a class"); +assert.ok(NtpTimeSyncDefaultOptions && typeof NtpTimeSyncDefaultOptions === "object", "defaults should be exported"); + +// Instantiate with nested options to exercise recursiveResolveOptions → isPlainObject +const instance = new NtpTimeSync({ + sampleCount: 4, + ntpDefaults: { + minPoll: 4, + maxPoll: 10, + }, +}); +assert.ok(instance instanceof NtpTimeSync, "constructor should return an NtpTimeSync instance"); + +// Verify singleton +const a = NtpTimeSync.getInstance(); +const b = NtpTimeSync.getInstance(); +assert.equal(a, b, "getInstance must return the same singleton"); +assert.ok(a instanceof NtpTimeSync, "singleton must be an NtpTimeSync instance"); + +// Verify API surface (shape only — no network calls) +assert.equal(typeof a.getTime, "function", "getTime must be a method"); +assert.equal(typeof a.now, "function", "now must be a method"); +assert.equal(typeof a.getNetworkTime, "function", "getNetworkTime must be a method"); + +// Verify no __proto__ pollution in resolved options +const opts = /** @type {any} */ (a).options; +assert.ok(opts, "instance.options must be defined"); +assert.equal(Object.getPrototypeOf(opts.ntpDefaults), Object.prototype, "ntpDefaults must have Object.prototype"); +assert.ok(!Object.prototype.hasOwnProperty.call(opts, "__proto__"), "no __proto__ own property on options"); +assert.ok( + !Object.prototype.hasOwnProperty.call(opts.ntpDefaults, "__proto__"), + "no __proto__ own property on ntpDefaults" +); + +console.log("ok - cjs integration test passed"); diff --git a/test/integration/esm.mjs b/test/integration/esm.mjs new file mode 100644 index 0000000..9a990d5 --- /dev/null +++ b/test/integration/esm.mjs @@ -0,0 +1,42 @@ +// ESM integration test — verifies the dual build's ESM output works for `import` consumers. +// Run: node test/integration/esm.mjs + +import assert from "node:assert/strict"; + +import { NtpTimeSync, NtpTimeSyncDefaultOptions } from "../../dist/esm/index.js"; + +assert.equal(typeof NtpTimeSync, "function", "NtpTimeSync should be exported as a class"); +assert.ok(NtpTimeSyncDefaultOptions && typeof NtpTimeSyncDefaultOptions === "object", "defaults should be exported"); + +// Instantiate with nested options to exercise recursiveResolveOptions → isPlainObject +const instance = new NtpTimeSync({ + sampleCount: 4, + ntpDefaults: { + minPoll: 4, + maxPoll: 10, + }, +}); +assert.ok(instance instanceof NtpTimeSync, "constructor should return an NtpTimeSync instance"); + +// Verify singleton +const a = NtpTimeSync.getInstance(); +const b = NtpTimeSync.getInstance(); +assert.equal(a, b, "getInstance must return the same singleton"); +assert.ok(a instanceof NtpTimeSync, "singleton must be an NtpTimeSync instance"); + +// Verify API surface (shape only — no network calls) +assert.equal(typeof a.getTime, "function", "getTime must be a method"); +assert.equal(typeof a.now, "function", "now must be a method"); +assert.equal(typeof a.getNetworkTime, "function", "getNetworkTime must be a method"); + +// Verify no __proto__ pollution in resolved options +const opts = /** @type {any} */ (a).options; +assert.ok(opts, "instance.options must be defined"); +assert.equal(Object.getPrototypeOf(opts.ntpDefaults), Object.prototype, "ntpDefaults must have Object.prototype"); +assert.ok(!Object.prototype.hasOwnProperty.call(opts, "__proto__"), "no __proto__ own property on options"); +assert.ok( + !Object.prototype.hasOwnProperty.call(opts.ntpDefaults, "__proto__"), + "no __proto__ own property on ntpDefaults" +); + +console.log("ok - esm integration test passed"); From 78dcd8c9751eb7b0304d8de60540a7f1b3e9071d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:40:20 +0200 Subject: [PATCH 5/8] test: update Deno integration test for new dist layout Points the createRequire-based scenario at ./dist/cjs/index.js and extends the assertions with the __proto__ pollution checks that the new Node integration tests share. Adds deno-esm.ts so Deno also exercises the ESM dist natively, giving coverage of both output formats across the Deno matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/integration/deno-esm.ts | 36 ++++++++++++++++++++++++++++++++++++ test/integration/deno.ts | 18 +++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 test/integration/deno-esm.ts diff --git a/test/integration/deno-esm.ts b/test/integration/deno-esm.ts new file mode 100644 index 0000000..32eef37 --- /dev/null +++ b/test/integration/deno-esm.ts @@ -0,0 +1,36 @@ +// Deno ESM integration test — verifies the ESM dist loads natively under Deno. +// Run: deno run --allow-read test/integration/deno-esm.ts + +import { NtpTimeSync } from "../../dist/esm/index.js"; + +// Instantiate with nested options to exercise recursiveResolveOptions → isPlainObject +new NtpTimeSync({ + sampleCount: 4, + ntpDefaults: { + minPoll: 4, + maxPoll: 10, + }, +}); + +// Verify singleton +const a = NtpTimeSync.getInstance(); +const b = NtpTimeSync.getInstance(); +if (a !== b) { + throw new Error("Singleton check failed: expected same instance"); +} + +// Verify no __proto__ pollution in resolved options +const opts = (a as unknown as { options: { ntpDefaults: Record } }).options; +if (Object.getPrototypeOf(opts.ntpDefaults) !== Object.prototype) { + throw new Error("ntpDefaults prototype is not Object.prototype"); +} +if (Object.prototype.hasOwnProperty.call(opts, "__proto__")) { + throw new Error("options has unexpected __proto__ own property"); +} +if (Object.prototype.hasOwnProperty.call(opts.ntpDefaults, "__proto__")) { + throw new Error("ntpDefaults has unexpected __proto__ own property"); +} + +console.log("✓ NtpTimeSync imported (ESM) and instantiated"); +console.log("✓ Nested options merged without __proto__ issues"); +console.log("✓ Singleton pattern works"); diff --git a/test/integration/deno.ts b/test/integration/deno.ts index f4b077f..ec54524 100644 --- a/test/integration/deno.ts +++ b/test/integration/deno.ts @@ -1,11 +1,11 @@ -// Deno integration test — verifies ntp-time-sync works without --unstable-unsafe-proto -// Run: deno run test/integration/deno.ts +// Deno integration test — verifies the CJS dist loads under Deno via createRequire. +// Run: deno run --allow-read test/integration/deno.ts import { createRequire } from "node:module"; const require = createRequire(import.meta.url); // eslint-disable-next-line @typescript-eslint/no-var-requires -const { NtpTimeSync } = require("../../dist/index.js"); +const { NtpTimeSync } = require("../../dist/cjs/index.js"); // Instantiate with nested options to exercise recursiveResolveOptions → isPlainObject new NtpTimeSync({ @@ -23,6 +23,18 @@ if (a !== b) { throw new Error("Singleton check failed: expected same instance"); } +// Verify no __proto__ pollution in resolved options +const opts = a.options; +if (Object.getPrototypeOf(opts.ntpDefaults) !== Object.prototype) { + throw new Error("ntpDefaults prototype is not Object.prototype"); +} +if (Object.prototype.hasOwnProperty.call(opts, "__proto__")) { + throw new Error("options has unexpected __proto__ own property"); +} +if (Object.prototype.hasOwnProperty.call(opts.ntpDefaults, "__proto__")) { + throw new Error("ntpDefaults has unexpected __proto__ own property"); +} + console.log("✓ NtpTimeSync imported and instantiated"); console.log("✓ Nested options merged without __proto__ issues"); console.log("✓ Singleton pattern works"); From a920f55d270c85dadea08b2fbd35bb0c1c5aa7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:40:24 +0200 Subject: [PATCH 6/8] ci: run dual build and integration tests across node matrix The build job now runs the dual CJS+ESM build and invokes the CJS and ESM integration tests on every Node version ['20','22','24','25']. The Deno job also runs the new CJS and ESM Deno scenarios after building. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/nodejs.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 53e4e28..2aa3b8f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -28,8 +28,12 @@ jobs: - name: Install dependencies if: steps.yarn-cache.outputs.cache-hit != 'true' run: yarn install - - name: Build + - name: Build (dual CJS + ESM) run: yarn build + - name: Run CJS integration test + run: yarn test:integration:cjs + - name: Run ESM integration test + run: yarn test:integration:esm - name: Run examples run: node examples/example.js @@ -54,11 +58,13 @@ jobs: - name: Install dependencies if: steps.yarn-cache.outputs.cache-hit != 'true' run: yarn install - - name: Build + - name: Build (dual CJS + ESM) run: yarn build - name: Setup Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - - name: Run Deno integration test + - name: Run Deno CJS integration test run: deno run --allow-read test/integration/deno.ts + - name: Run Deno ESM integration test + run: deno run --allow-read test/integration/deno-esm.ts From d43a7702a7b268aea8c633605b96483204dd57cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 02:40:28 +0200 Subject: [PATCH 7/8] chore: point example at new CJS dist path With the dual build output, the CJS entry lives at dist/cjs/index. Updating the require path keeps the example runnable from CI and local development against the published API surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/example.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example.js b/examples/example.js index 4651f7e..0a3c174 100644 --- a/examples/example.js +++ b/examples/example.js @@ -1,4 +1,4 @@ -const NtpTimeSync = require("../dist/index").NtpTimeSync; +const NtpTimeSync = require("../dist/cjs/index").NtpTimeSync; (async function() { const ntpInstance1 = NtpTimeSync.getInstance(); From ddbb3d4cb61c6c50336f887b924c90504b9723f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Wed, 22 Apr 2026 07:27:05 +0200 Subject: [PATCH 8/8] fix(esm): use node: prefix for built-in module imports Deno's strict ESM loader rejects bare specifiers for Node built-ins. TypeScript preserves specifiers verbatim, so the source must use the node: prefix for both CJS and ESM output to work across Node, Deno, and other strict ESM runtimes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/NtpTimeSync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NtpTimeSync.ts b/src/NtpTimeSync.ts index d838517..159693c 100644 --- a/src/NtpTimeSync.ts +++ b/src/NtpTimeSync.ts @@ -1,6 +1,6 @@ "use strict"; -import * as dgram from "dgram"; +import * as dgram from "node:dgram"; import { NtpPacket, NtpPacketParser } from "ntp-packet-parser"; import { NtpTimeResult } from "./NtpTimeResult.js"; import { RecursivePartial } from "./RecursivePartial.js";