diff --git a/bindings/typescript/__tests__/cjs-consumer.cts b/bindings/typescript/__tests__/cjs-consumer.cts new file mode 100644 index 0000000..c84e532 --- /dev/null +++ b/bindings/typescript/__tests__/cjs-consumer.cts @@ -0,0 +1,35 @@ +// CJS package-specifier consumer fixture. Verifies that TypeScript Node16 +// resolution against the package `exports` map selects the CommonJS declaration +// (index.d.cts) for require/import-from-CJS consumers. +import { + Peer, + Listener, + SchemaRegistry, + control, + CONTROL, + COMMAND, + DATA, + TELEMETRY, + ERROR, + type JsFrame, + type AuthTokenResult, +} from "@3leaps/ipcprims"; + +export function surface(): number { + const peer: Peer = Peer.connect("/tmp/ipcprims-cjs.sock", [ + CONTROL, + COMMAND, + DATA, + TELEMETRY, + ERROR, + ]); + const listener: Listener = Listener.bind("/tmp/ipcprims-cjs.sock"); + const reg: SchemaRegistry = SchemaRegistry.fromDirectory("/tmp"); + const token: AuthTokenResult = peer.takeClientAuthToken(); + const frame: JsFrame = { channel: control(), payload: Buffer.from("x") }; + void frame; + void listener; + void reg; + void token; + return CONTROL + COMMAND + DATA + TELEMETRY + ERROR; +} diff --git a/bindings/typescript/__tests__/esm-consumer.mts b/bindings/typescript/__tests__/esm-consumer.mts new file mode 100644 index 0000000..969fe78 --- /dev/null +++ b/bindings/typescript/__tests__/esm-consumer.mts @@ -0,0 +1,63 @@ +// ESM package-specifier consumer fixture. Verifies that TypeScript Node16 +// resolution against the package `exports` map selects the ESM declaration +// (index.d.mts) and that the full named-export surface type-checks, including +// the four type-only interfaces. +import { + AsyncChannelReceiver, + AsyncListener, + AsyncPeer, + Listener, + Peer, + SchemaRegistry, + control, + command, + data, + telemetry, + error, + CONTROL, + COMMAND, + DATA, + TELEMETRY, + ERROR, + type JsFrame, + type RecvAsyncOptions, + type ListenerOptions, + type AuthTokenResult, +} from "@3leaps/ipcprims"; + +const controller = new AbortController(); +const signal: RecvAsyncOptions = { signal: controller.signal }; + +export async function surface(): Promise { + const listener: AsyncListener = AsyncListener.bind("/tmp/ipcprims-esm.sock", { + channels: [COMMAND], + } satisfies ListenerOptions); + const accepted: Promise = listener.accept(); + const client: AsyncPeer = await AsyncPeer.connect("/tmp/ipcprims-esm.sock", [ + CONTROL, + COMMAND, + DATA, + TELEMETRY, + ERROR, + ]); + const receiver: AsyncChannelReceiver = await client.openChannel(COMMAND); + const frame: JsFrame = { channel: control(), payload: Buffer.from("x") }; + await client.send(frame.channel, frame.payload); + const up: number = control() + command() + data() + telemetry() + error(); + const syncPeer: Peer = Peer.connect("/tmp/ipcprims-esm.sock", [CONTROL]); + const syncListener: Listener = Listener.bind("/tmp/ipcprims-esm.sock"); + const reg: SchemaRegistry = SchemaRegistry.fromDirectory("/tmp"); + const token: AuthTokenResult = syncPeer.takeClientAuthToken(); + for await (const received of receiver) { + void received; + } + void frame; + void up; + void syncListener; + void receiver; + void signal; + void reg; + void token; + await client.shutdown(); + return 0; +} diff --git a/bindings/typescript/index.d.cts b/bindings/typescript/index.d.cts new file mode 100644 index 0000000..9ea1b63 --- /dev/null +++ b/bindings/typescript/index.d.cts @@ -0,0 +1,89 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export interface RecvAsyncOptions { + signal?: AbortSignal; +} +export interface JsFrame { + channel: number; + payload: Buffer; +} +export interface ListenerOptions { + channels?: Array; + schemaDir?: string; +} +export interface AuthTokenResult { + present: boolean; + token?: Buffer; +} +export declare function control(): number; +export declare function command(): number; +export declare function data(): number; +export declare function telemetry(): number; +export declare function error(): number; +export declare const CONTROL: number; +export declare const COMMAND: number; +export declare const DATA: number; +export declare const TELEMETRY: number; +export declare const ERROR: number; +export declare class AsyncPeer { + static connect(path: string, channels: Array): Promise; + static connectWithAuth( + path: string, + channels: Array, + authToken: Buffer, + ): Promise; + send(channel: number, data: Buffer): Promise; + recvAsync(options?: RecvAsyncOptions | undefined | null): Promise; + recvOnAsync( + channel: number, + options?: RecvAsyncOptions | undefined | null, + ): Promise; + openChannel(channel: number): Promise; + ping(): Promise; + takeClientAuthToken(): AuthTokenResult; + shutdown(): Promise; + close(): void; +} +export declare class AsyncChannelReceiver implements AsyncIterable { + recvAsync(options?: RecvAsyncOptions | undefined | null): Promise; + [Symbol.asyncIterator](): AsyncIterator; +} +export declare class AsyncListener { + static bind( + path: string, + options?: ListenerOptions | undefined | null, + ): AsyncListener; + accept(): Promise; + close(): Promise; +} +export declare class Listener { + static bind( + path: string, + options?: ListenerOptions | undefined | null, + ): Listener; + accept(): Peer; + close(): void; +} +export declare class Peer { + static connect(path: string, channels: Array): Peer; + static connectWithAuth( + path: string, + channels: Array, + authToken: Buffer, + ): Peer; + send(channel: number, data: Buffer): void; + recv(): JsFrame; + recvOn(channel: number): JsFrame; + ping(): number; + takeClientAuthToken(): AuthTokenResult; + shutdown(): void; + close(): void; +} +export declare class SchemaRegistry { + static fromDirectory(path: string): SchemaRegistry; + validate(channel: number, data: Buffer): void; + close(): void; +} diff --git a/bindings/typescript/index.d.mts b/bindings/typescript/index.d.mts new file mode 100644 index 0000000..9ea1b63 --- /dev/null +++ b/bindings/typescript/index.d.mts @@ -0,0 +1,89 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export interface RecvAsyncOptions { + signal?: AbortSignal; +} +export interface JsFrame { + channel: number; + payload: Buffer; +} +export interface ListenerOptions { + channels?: Array; + schemaDir?: string; +} +export interface AuthTokenResult { + present: boolean; + token?: Buffer; +} +export declare function control(): number; +export declare function command(): number; +export declare function data(): number; +export declare function telemetry(): number; +export declare function error(): number; +export declare const CONTROL: number; +export declare const COMMAND: number; +export declare const DATA: number; +export declare const TELEMETRY: number; +export declare const ERROR: number; +export declare class AsyncPeer { + static connect(path: string, channels: Array): Promise; + static connectWithAuth( + path: string, + channels: Array, + authToken: Buffer, + ): Promise; + send(channel: number, data: Buffer): Promise; + recvAsync(options?: RecvAsyncOptions | undefined | null): Promise; + recvOnAsync( + channel: number, + options?: RecvAsyncOptions | undefined | null, + ): Promise; + openChannel(channel: number): Promise; + ping(): Promise; + takeClientAuthToken(): AuthTokenResult; + shutdown(): Promise; + close(): void; +} +export declare class AsyncChannelReceiver implements AsyncIterable { + recvAsync(options?: RecvAsyncOptions | undefined | null): Promise; + [Symbol.asyncIterator](): AsyncIterator; +} +export declare class AsyncListener { + static bind( + path: string, + options?: ListenerOptions | undefined | null, + ): AsyncListener; + accept(): Promise; + close(): Promise; +} +export declare class Listener { + static bind( + path: string, + options?: ListenerOptions | undefined | null, + ): Listener; + accept(): Peer; + close(): void; +} +export declare class Peer { + static connect(path: string, channels: Array): Peer; + static connectWithAuth( + path: string, + channels: Array, + authToken: Buffer, + ): Peer; + send(channel: number, data: Buffer): void; + recv(): JsFrame; + recvOn(channel: number): JsFrame; + ping(): number; + takeClientAuthToken(): AuthTokenResult; + shutdown(): void; + close(): void; +} +export declare class SchemaRegistry { + static fromDirectory(path: string): SchemaRegistry; + validate(channel: number, data: Buffer): void; + close(): void; +} diff --git a/bindings/typescript/index.js b/bindings/typescript/index.js index fe1e31e..b85ba6b 100644 --- a/bindings/typescript/index.js +++ b/bindings/typescript/index.js @@ -71,6 +71,11 @@ module.exports = { Listener: native.Listener, Peer: native.Peer, SchemaRegistry: native.SchemaRegistry, + control: native.control, + command: native.command, + data: native.data, + telemetry: native.telemetry, + error: native.error, CONTROL: native.control(), COMMAND: native.command(), DATA: native.data(), diff --git a/bindings/typescript/index.mjs b/bindings/typescript/index.mjs new file mode 100644 index 0000000..f9abf45 --- /dev/null +++ b/bindings/typescript/index.mjs @@ -0,0 +1,27 @@ +// ESM façade over the CommonJS binding wrapper. +// +// The wrapper (index.js) owns the single native-addon instance and +// loadBinding()/platform-selection logic. This file imports it exactly once +// and re-exports its values as explicit bindings, so CJS and ESM consumers in +// one process share one native instance and identical class/prototype +// identities. It must not call loadBinding() or load a native addon itself. +import cjs from "./index.js"; + +export const { + AsyncChannelReceiver, + AsyncListener, + AsyncPeer, + Listener, + Peer, + SchemaRegistry, + control, + command, + data, + telemetry, + error, + CONTROL, + COMMAND, + DATA, + TELEMETRY, + ERROR, +} = cjs; diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json index 8dbc47e..a13c988 100644 --- a/bindings/typescript/package.json +++ b/bindings/typescript/package.json @@ -10,9 +10,45 @@ }, "main": "index.js", "types": "index.d.ts", + "exports": { + ".": { + "import": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./index.d.cts", + "default": "./index.js" + } + }, + "./index": { + "import": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./index.d.cts", + "default": "./index.js" + } + }, + "./index.js": { + "import": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./index.d.cts", + "default": "./index.js" + } + }, + "./package.json": "./package.json" + }, "files": [ "index.js", + "index.mjs", "index.d.ts", + "index.d.cts", + "index.d.mts", "npm/", "README.md", "LICENSE-MIT", @@ -37,8 +73,8 @@ "scripts": { "build:native": "napi build --cargo-cwd . --release --platform --js false --strip", "postbuild:native": "node scripts/augment-types.mjs", - "test": "npm run build:native && tsc -p tsconfig.test.json && node --test .tmp-test/ipcprims.test.js", - "typecheck": "tsc --noEmit" + "test": "npm run build:native && tsc -p tsconfig.test.json && node --test .tmp-test/ipcprims.test.js && node scripts/check-export-drift.mjs && node scripts/check-pack.mjs", + "typecheck": "tsc --noEmit && tsc -p tsconfig.consumer.json" }, "optionalDependencies": { "@3leaps/ipcprims-linux-x64-gnu": "0.2.4", diff --git a/bindings/typescript/scripts/augment-types.mjs b/bindings/typescript/scripts/augment-types.mjs index a87173d..c1ebdaf 100644 --- a/bindings/typescript/scripts/augment-types.mjs +++ b/bindings/typescript/scripts/augment-types.mjs @@ -43,3 +43,10 @@ if (format.error && format.error.code !== "ENOENT") { if (!format.error && format.status !== 0) { process.exit(format.status ?? 1); } + +// Synchronize the loader-specific declaration variants (.cts for require, .mts +// for import) from the finalized index.d.ts, so the three declaration files +// never drift independently after a NAPI-RS regeneration + augmentation. +const synced = readFileSync(dtsPath, "utf8"); +writeFileSync(path.join(dir, "..", "index.d.cts"), synced); +writeFileSync(path.join(dir, "..", "index.d.mts"), synced); diff --git a/bindings/typescript/scripts/check-export-drift.mjs b/bindings/typescript/scripts/check-export-drift.mjs new file mode 100644 index 0000000..5beb4af --- /dev/null +++ b/bindings/typescript/scripts/check-export-drift.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +// Export-drift guard for the @3leaps/ipcprims dual CJS/ESM package surface. +// +// Asserts, in one pass, that the declared value space matches the runtime and +// that the CJS and ESM entry points expose the same named exports with shared +// class/prototype identity and a single native binding instance. Kept +// value-space only: type-only declarations (interfaces) must compile but are +// not runtime-parity candidates. +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(dir, ".."); + +const values = [ + "AsyncChannelReceiver", + "AsyncListener", + "AsyncPeer", + "Listener", + "Peer", + "SchemaRegistry", + "control", + "command", + "data", + "telemetry", + "error", + "CONTROL", + "COMMAND", + "DATA", + "TELEMETRY", + "ERROR", +].sort(); +const types = [ + "AuthTokenResult", + "JsFrame", + "ListenerOptions", + "RecvAsyncOptions", +].sort(); +const classNames = [ + "AsyncChannelReceiver", + "AsyncListener", + "AsyncPeer", + "Listener", + "Peer", + "SchemaRegistry", +]; +const functions = ["control", "command", "data", "telemetry", "error"]; +const constants = ["CONTROL", "COMMAND", "DATA", "TELEMETRY", "ERROR"]; + +function fail(msg) { + console.error(`::error::${msg}`); + process.exit(1); +} + +function parseDeclaration(file) { + const text = readFileSync(file, "utf8"); + const source = ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const value = new Map(); + const type = new Set(); + for (const stmt of source.statements) { + if (ts.isClassDeclaration(stmt) && stmt.name) { + value.set(stmt.name.text, "class"); + } else if (ts.isFunctionDeclaration(stmt) && stmt.name) { + value.set(stmt.name.text, "function"); + } else if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) value.set(decl.name.text, "const"); + } + } else if (ts.isInterfaceDeclaration(stmt)) { + type.add(stmt.name.text); + } else if (ts.isTypeAliasDeclaration(stmt)) { + type.add(stmt.name.text); + } + } + return { value, type }; +} + +function sorted(map) { + return [...map.keys()].sort(); +} + +const declFiles = ["index.d.ts", "index.d.cts", "index.d.mts"].map((f) => + path.join(root, f), +); +const parsed = declFiles.map(parseDeclaration); +const valueSpaces = parsed.map((p) => sorted(p.value)); +const typeSpaces = parsed.map((p) => [...p.type].sort()); + +for (let i = 1; i < parsed.length; i++) { + if (JSON.stringify(valueSpaces[i]) !== JSON.stringify(valueSpaces[0])) { + fail( + `declaration value-space drift (${path.basename(declFiles[i])}): ${JSON.stringify( + valueSpaces[i], + )}`, + ); + } + if (JSON.stringify(typeSpaces[i]) !== JSON.stringify(typeSpaces[0])) { + fail( + `declaration type-space drift (${path.basename(declFiles[i])}): ${JSON.stringify( + typeSpaces[i], + )}`, + ); + } +} + +if (JSON.stringify(valueSpaces[0]) !== JSON.stringify(values)) { + fail( + `declared value space diverged: expected ${JSON.stringify(values)}, got ${JSON.stringify( + valueSpaces[0], + )}`, + ); +} +if (JSON.stringify(typeSpaces[0]) !== JSON.stringify(types)) { + fail( + `declared type space diverged: expected ${JSON.stringify(types)}, got ${JSON.stringify( + typeSpaces[0], + )}`, + ); +} + +const cjs = require(path.join(root, "index.js")); +const cjsKeys = Object.keys(cjs).sort(); +if (JSON.stringify(cjsKeys) !== JSON.stringify(values)) { + fail( + `CJS runtime keys diverged: expected ${JSON.stringify(values)}, got ${JSON.stringify( + cjsKeys, + )}`, + ); +} + +const esm = await import(pathToFileURL(path.join(root, "index.mjs")).href); +const esmKeys = Object.keys(esm).sort(); +if (JSON.stringify(esmKeys) !== JSON.stringify(values)) { + fail( + `ESM runtime keys diverged: expected ${JSON.stringify(values)}, got ${JSON.stringify( + esmKeys, + )}`, + ); +} + +for (const name of classNames) { + if (cjs[name] !== esm[name]) { + fail(`class identity mismatch for ${name} (dual-package hazard)`); + } +} + +for (let i = 0; i < functions.length; i++) { + const fn = functions[i]; + const cnst = constants[i]; + for (const [label, ns] of [ + ["CJS", cjs], + ["ESM", esm], + ]) { + if (typeof ns[fn] !== "function") { + fail(`${label} ${fn} is not a function`); + } + if (typeof ns[cnst] !== "number") { + fail(`${label} ${cnst} is not a number`); + } + if (ns[fn]() !== ns[cnst]) { + fail(`${label} ${fn}() !== ${cnst}`); + } + } +} + +console.log( + "OK: 16 value + 4 type-only exports consistent across .d.ts/.d.cts/.d.mts, CJS, and ESM; class identity and constant equivalence hold.", +); diff --git a/bindings/typescript/scripts/check-pack.mjs b/bindings/typescript/scripts/check-pack.mjs new file mode 100644 index 0000000..475cd59 --- /dev/null +++ b/bindings/typescript/scripts/check-pack.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Tarball proof for the @3leaps/ipcprims dual CJS/ESM package surface. +// +// Packs the root package and asserts the ESM facade plus all three declaration +// variants ship, no native-addon/platform-selection internals leak into the +// root tarball, and that Node ESM + CJS resolution against the packed surface +// succeeds (with the locally built addon staged beside it, since the real +// binary is supplied by the platform optional packages). +import { execFileSync, spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { fileURLToPath } from "node:url"; +import os from "node:os"; +import path from "node:path"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(dir, ".."); + +const required = [ + "index.js", + "index.mjs", + "index.d.ts", + "index.d.cts", + "index.d.mts", + "package.json", + "README.md", + "LICENSE-MIT", + "LICENSE-APACHE", +]; + +function fail(msg) { + console.error(`::error::${msg}`); + process.exit(1); +} + +const tmp = mkdtempSync(path.join(os.tmpdir(), "ipcprims-pack-")); +try { + const packJson = execFileSync( + "npm", + ["pack", "--json", "--ignore-scripts", "--pack-destination", tmp], + { cwd: root, encoding: "utf8" }, + ); + const pack = JSON.parse(packJson)[0]; + const tgz = path.join(tmp, pack.filename); + const packedFiles = new Set(pack.files.map((f) => f.path)); + + for (const f of required) { + if (!packedFiles.has(f)) fail(`tarball missing ${f}`); + } + if (![...packedFiles].some((f) => f.startsWith("npm/"))) { + fail("tarball missing npm/ platform manifests"); + } + for (const f of packedFiles) { + if ( + f.endsWith(".node") || + f.startsWith("src/") || + f.startsWith("scripts/") + ) { + fail(`tarball unexpectedly ships ${f}`); + } + } + + const extractDir = path.join(tmp, "extract"); + mkdirSync(extractDir, { recursive: true }); + const tar = spawnSync("tar", ["-xzf", tgz, "-C", extractDir], { + stdio: "inherit", + }); + if (tar.status !== 0) fail("tar extraction failed"); + + // Install the packed package under an external consumer's node_modules so + // the smoke exercises the `exports` map and its import/require conditions + // via real package specifiers, not relative paths that bypass it. + const consumerDir = path.join(tmp, "consumer"); + const pkgDest = path.join(consumerDir, "node_modules", "@3leaps", "ipcprims"); + mkdirSync(path.dirname(pkgDest), { recursive: true }); + renameSync(path.join(extractDir, "package"), pkgDest); + + const localNode = readdirSync(root).find( + (n) => n.startsWith("ipcprims.") && n.endsWith(".node"), + ); + if (!localNode) fail("no locally built .node to stage for the tarball smoke"); + copyFileSync(path.join(root, localNode), path.join(pkgDest, localNode)); + + const smoke = ` +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const names = ["AsyncChannelReceiver","AsyncListener","AsyncPeer","Listener","Peer","SchemaRegistry","control","command","data","telemetry","error","CONTROL","COMMAND","DATA","TELEMETRY","ERROR"]; +const expected = names.slice().sort().join(","); +const classes = ["AsyncChannelReceiver","AsyncListener","AsyncPeer","Listener","Peer","SchemaRegistry"]; +const pairs = [["control","CONTROL"],["command","COMMAND"],["data","DATA"],["telemetry","TELEMETRY"],["error","ERROR"]]; +const esmRoot = await import("@3leaps/ipcprims"); +const esmIndex = await import("@3leaps/ipcprims/index"); +const esmJs = await import("@3leaps/ipcprims/index.js"); +const cjsRoot = require("@3leaps/ipcprims"); +const cjsIndex = require("@3leaps/ipcprims/index"); +const cjsJs = require("@3leaps/ipcprims/index.js"); +const namespaces = { "esm-root": esmRoot, "esm-index": esmIndex, "esm-js": esmJs, "cjs-root": cjsRoot, "cjs-index": cjsIndex, "cjs-js": cjsJs }; +for (const [label, ns] of Object.entries(namespaces)) { + if (Object.keys(ns).sort().join(",") !== expected || "default" in ns) { + console.error(label + " export mismatch: " + Object.keys(ns).sort().join(",")); + process.exit(1); + } + for (const c of classes) if (ns[c] !== esmRoot[c]) { console.error(label + " class identity mismatch: " + c); process.exit(1); } + for (const [fn, cn] of pairs) if (ns[fn]() !== ns[cn]) { console.error(label + " constant equivalence mismatch: " + fn); process.exit(1); } +} +console.log("TARBALL-SMOKE-OK: 6 specifier/alias namespaces expose 16 names; 6 class identities + 5 constant equivalences hold across all"); +`; + writeFileSync(path.join(consumerDir, "smoke.mjs"), smoke); + const run = spawnSync("node", [path.join(consumerDir, "smoke.mjs")], { + encoding: "utf8", + }); + if (run.status !== 0) + fail(`tarball smoke failed: ${run.stderr || run.stdout}`); + process.stdout.write(run.stdout); + + console.log( + "OK: tarball ships the ESM facade + three declaration variants and no native internals; packed ESM/CJS resolution verified.", + ); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/bindings/typescript/tsconfig.consumer.json b/bindings/typescript/tsconfig.consumer.json new file mode 100644 index 0000000..afdefeb --- /dev/null +++ b/bindings/typescript/tsconfig.consumer.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": [ + "node" + ], + "noEmit": true + }, + "include": [ + "__tests__/esm-consumer.mts", + "__tests__/cjs-consumer.cts" + ] +}