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 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(); 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" }, 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/src/NtpTimeSync.ts b/src/NtpTimeSync.ts index 4c075e0..159693c 100644 --- a/src/NtpTimeSync.ts +++ b/src/NtpTimeSync.ts @@ -1,9 +1,9 @@ "use strict"; -import * as dgram from "dgram"; +import * as dgram from "node: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"; 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/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"); 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"); 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" + } }