From 39182182671790428628211912525462bc9268d0 Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Mon, 12 Oct 2026 17:36:53 -0700 Subject: [PATCH 1/2] feat: publish native ESM alongside CommonJS Add conditional exports and format-specific declarations for every public entry point, preserving existing CommonJS paths and the ES2021 target. Include package compatibility tests, documentation, and CI validation. Closes #120. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.js.yml | 1 + typescript/README.md | 28 +++- typescript/package.json | 58 ++++++- typescript/src/index.ts | 6 +- typescript/src/interactive/index.ts | 2 +- typescript/src/model.ts | 2 +- typescript/src/ts/index.ts | 4 +- typescript/src/ts/program.ts | 8 +- typescript/src/ts/validate.ts | 25 +-- typescript/src/tsconfig.esm.json | 8 + typescript/src/tsconfig.json | 2 +- typescript/src/typechat.ts | 4 +- typescript/src/zod/index.ts | 2 +- typescript/src/zod/validate.ts | 4 +- typescript/tests/package.test.mjs | 235 ++++++++++++++++++++++++++++ 15 files changed, 344 insertions(+), 45 deletions(-) create mode 100644 typescript/src/tsconfig.esm.json create mode 100644 typescript/tests/package.test.mjs diff --git a/.github/workflows/ci.js.yml b/.github/workflows/ci.js.yml index 4d10d41a..dedde931 100644 --- a/.github/workflows/ci.js.yml +++ b/.github/workflows/ci.js.yml @@ -39,3 +39,4 @@ jobs: cache-dependency-path: "typescript/package-lock.json" - run: npm ci - run: npm run build-all + - run: npm test diff --git a/typescript/README.md b/typescript/README.md index 4ed66add..748ad82f 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -24,6 +24,25 @@ Install TypeChat: npm install typechat ``` +TypeChat supports both native ECMAScript modules (ESM) and CommonJS. Use `import` +in an ESM application: + +```js +import { createJsonTranslator } from "typechat"; +import { createTypeScriptJsonValidator } from "typechat/ts"; +``` + +Or use `require` in a CommonJS application: + +```js +const { createJsonTranslator } = require("typechat"); +const { createTypeScriptJsonValidator } = require("typechat/ts"); +``` + +The `typechat`, `typechat/ts`, `typechat/zod`, and `typechat/interactive` entry points +select the matching JavaScript build and TypeScript declarations automatically. +Both builds target ES2021 and require Node.js 22.19.0 or newer. + You can also build TypeChat from source: ```sh @@ -33,8 +52,13 @@ npm install npm run build ``` -Run the test suite with `npm test`. The Zod schema tests also compile their generated -TypeScript schema text to check for syntax errors and unresolved type references. +The build emits CommonJS JavaScript and declarations in `dist`, and native ESM +JavaScript and declarations in `dist/esm`. + +Run the test suite with `npm test`. Package tests verify both module formats and +their declaration resolution using only the files included in the npm package. +The Zod schema tests also compile their generated TypeScript schema text to check +for syntax errors and unresolved type references. To see TypeChat in action, we recommend exploring the [TypeChat example projects](https://github.com/microsoft/TypeChat/tree/main/typescript/examples). You can try them on your local machine or in a GitHub Codespace. diff --git a/typescript/package.json b/typescript/package.json index 1654f47e..148850ea 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -20,22 +20,66 @@ "url": "https://github.com/microsoft/TypeChat/issues" }, "scripts": { - "build": "tsc -p src", - "test": "npm run build && tsc -p test && node --test out/validate.test.js out/zod.test.js out/program.test.js tests/model.test.mjs tests/examples.test.mjs", - "build-all": "npm run build --workspaces", + "build": "npm run build:cjs && npm run build:esm", + "build:cjs": "tsc -p src", + "build:esm": "tsc -p src/tsconfig.esm.json && node -e \"require('fs').writeFileSync('dist/esm/package.json', JSON.stringify({ type: 'module' }))\"", + "test": "npm run build && tsc -p test && node --test out/validate.test.js out/zod.test.js out/program.test.js tests/model.test.mjs tests/examples.test.mjs tests/package.test.mjs", + "build-all": "npm run build && npm run build --workspaces", "prepare": "npm run build-all", "prepublishOnly": "node -e \"require('fs').copyFileSync('../SECURITY.md','SECURITY.md')\"", "postpublish": "node -e \"require('fs').unlinkSync('SECURITY.md')\"", "publish-package": "npm publish", "publish-package:dry-run": "npm publish --dry-run" }, + "type": "commonjs", "main": "./dist/index.js", + "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "exports": { - ".": "./dist/index.js", - "./ts": "./dist/ts/index.js", - "./zod": "./dist/zod/index.js", - "./interactive": "./dist/interactive/index.js" + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "default": "./dist/index.js" + }, + "./ts": { + "import": { + "types": "./dist/esm/ts/index.d.ts", + "default": "./dist/esm/ts/index.js" + }, + "require": { + "types": "./dist/ts/index.d.ts", + "default": "./dist/ts/index.js" + }, + "default": "./dist/ts/index.js" + }, + "./zod": { + "import": { + "types": "./dist/esm/zod/index.d.ts", + "default": "./dist/esm/zod/index.js" + }, + "require": { + "types": "./dist/zod/index.d.ts", + "default": "./dist/zod/index.js" + }, + "default": "./dist/zod/index.js" + }, + "./interactive": { + "import": { + "types": "./dist/esm/interactive/index.d.ts", + "default": "./dist/esm/interactive/index.js" + }, + "require": { + "types": "./dist/interactive/index.d.ts", + "default": "./dist/interactive/index.js" + }, + "default": "./dist/interactive/index.js" + } }, "engines": { "node": ">=22.19.0" diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 7029f515..08b807b4 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,3 +1,3 @@ -export * from './result'; -export * from './model'; -export * from './typechat'; +export * from './result.js'; +export * from './model.js'; +export * from './typechat.js'; diff --git a/typescript/src/interactive/index.ts b/typescript/src/interactive/index.ts index aad61099..68a50677 100644 --- a/typescript/src/interactive/index.ts +++ b/typescript/src/interactive/index.ts @@ -1 +1 @@ -export * from './interactive'; +export * from './interactive.js'; diff --git a/typescript/src/model.ts b/typescript/src/model.ts index c4988bc7..6f081257 100644 --- a/typescript/src/model.ts +++ b/typescript/src/model.ts @@ -1,4 +1,4 @@ -import { Result, success, error } from "./result"; +import { Result, success, error } from "./result.js"; /** * Represents a section of an LLM prompt with an associated role. TypeChat uses the "user" role for diff --git a/typescript/src/ts/index.ts b/typescript/src/ts/index.ts index ef6f6876..7117aa35 100644 --- a/typescript/src/ts/index.ts +++ b/typescript/src/ts/index.ts @@ -1,2 +1,2 @@ -export * from './validate'; -export * from './program'; +export * from './validate.js'; +export * from './program.js'; diff --git a/typescript/src/ts/program.ts b/typescript/src/ts/program.ts index 5fc21b5d..2c633ed1 100644 --- a/typescript/src/ts/program.ts +++ b/typescript/src/ts/program.ts @@ -1,7 +1,7 @@ -import { Result, error, success } from "../result"; -import { TypeChatLanguageModel } from "../model"; -import { createTypeScriptJsonValidator } from "./validate"; -import { TypeChatJsonTranslator, createJsonTranslator } from "../typechat"; +import { Result, error, success } from "../result.js"; +import { TypeChatLanguageModel } from "../model.js"; +import { createTypeScriptJsonValidator } from "./validate.js"; +import { TypeChatJsonTranslator, createJsonTranslator } from "../typechat.js"; const programSchemaText = `// A program consists of a sequence of function calls that are evaluated in order. export type Program = { diff --git a/typescript/src/ts/validate.ts b/typescript/src/ts/validate.ts index b2b0b9c5..53c614d9 100644 --- a/typescript/src/ts/validate.ts +++ b/typescript/src/ts/validate.ts @@ -1,23 +1,10 @@ -import { createRequire } from 'node:module'; -import { Result, success, error } from '../result'; -import { TypeChatJsonValidator } from "../typechat"; - -// TypeScript 7 ships as an ES module, while this package is emitted as CommonJS. Node's -// `require(esm)` support (Node >=22.12) lets us load it synchronously, and `resolution-mode` -// makes the TypeScript compiler resolve the ESM-only types from a CommonJS file. // TODO: Revisit these `typescript/unstable/*` imports once a stable API is available. -type SyncModule = typeof import('typescript/unstable/sync', { with: { 'resolution-mode': 'import' } }); -type FileSystemModule = typeof import('typescript/unstable/fs', { with: { 'resolution-mode': 'import' } }); -type AstIsModule = typeof import('typescript/unstable/ast/is', { with: { 'resolution-mode': 'import' } }); - -type Checker = InstanceType; -type Diagnostic = import('typescript/unstable/sync', { with: { 'resolution-mode': 'import' } }).Diagnostic; -type SourceFile = import('typescript/unstable/ast', { with: { 'resolution-mode': 'import' } }).SourceFile; - -const requireEsm = createRequire(__filename); -const { API, NodeBuilderFlags, SymbolFlags }: SyncModule = requireEsm('typescript/unstable/sync'); -const { createVirtualFileSystem }: FileSystemModule = requireEsm('typescript/unstable/fs'); -const { isTypeReferenceNode, isVariableStatement }: AstIsModule = requireEsm('typescript/unstable/ast/is'); +import { API, NodeBuilderFlags, SymbolFlags, type Checker, type Diagnostic } from 'typescript/unstable/sync'; +import { createVirtualFileSystem } from 'typescript/unstable/fs'; +import type { SourceFile } from 'typescript/unstable/ast'; +import { isTypeReferenceNode, isVariableStatement } from 'typescript/unstable/ast/is'; +import { Result, success, error } from '../result.js'; +import { TypeChatJsonValidator } from "../typechat.js"; const libText = `interface Array { length: number, [n: number]: T } interface Object { toString(): string } diff --git a/typescript/src/tsconfig.esm.json b/typescript/src/tsconfig.esm.json new file mode 100644 index 00000000..1cd1ebe2 --- /dev/null +++ b/typescript/src/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "outDir": "../dist/esm" + } +} diff --git a/typescript/src/tsconfig.json b/typescript/src/tsconfig.json index 9e97b242..e350cd12 100644 --- a/typescript/src/tsconfig.json +++ b/typescript/src/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es2021", "lib": ["es2021"], - "module": "node16", + "module": "node20", "types": ["node"], "esModuleInterop": true, "outDir": "../dist", diff --git a/typescript/src/typechat.ts b/typescript/src/typechat.ts index 74c67331..5ffea307 100644 --- a/typescript/src/typechat.ts +++ b/typescript/src/typechat.ts @@ -1,5 +1,5 @@ -import { Result, success, error } from "./result"; -import { TypeChatLanguageModel, PromptSection, PromptContent } from "./model"; +import { Result, success, error } from "./result.js"; +import { TypeChatLanguageModel, PromptSection, PromptContent } from "./model.js"; /** * Represents an object that can translate natural language requests in JSON objects of the given type. diff --git a/typescript/src/zod/index.ts b/typescript/src/zod/index.ts index 63a2929c..d5593bf2 100644 --- a/typescript/src/zod/index.ts +++ b/typescript/src/zod/index.ts @@ -1 +1 @@ -export * from './validate'; +export * from './validate.js'; diff --git a/typescript/src/zod/validate.ts b/typescript/src/zod/validate.ts index 02e5bb98..905e4e2c 100644 --- a/typescript/src/zod/validate.ts +++ b/typescript/src/zod/validate.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { success, error } from '../result'; -import { TypeChatJsonValidator } from '../typechat'; +import { success, error } from '../result.js'; +import { TypeChatJsonValidator } from '../typechat.js'; /** * Returns a JSON validator for a given Zod schema. The schema is supplied as an object where each property provides diff --git a/typescript/tests/package.test.mjs b/typescript/tests/package.test.mjs new file mode 100644 index 00000000..9b5510d9 --- /dev/null +++ b/typescript/tests/package.test.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, before, describe, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const require = createRequire(import.meta.url); +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const directory = mkdtempSync(join(tmpdir(), "typechat-package-")); +const withPeers = join(directory, "with-peers"); +const withoutPeers = join(directory, "without-peers"); +const stagedPackage = join(withPeers, "node_modules", "typechat"); +const entryPoints = [ + ["typechat", "index"], + ["typechat/ts", "ts/index"], + ["typechat/zod", "zod/index"], + ["typechat/interactive", "interactive/index"], +]; +const childEnv = { ...process.env, NODE_OPTIONS: "", VSCODE_INSPECTOR_OPTIONS: "", NODE_PATH: "" }; +let packedFiles; + +before(() => { + const options = { cwd: packageRoot, encoding: "utf8", env: childEnv, timeout: 30000 }; + const packed = process.platform === "win32" + ? spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", "npm pack --ignore-scripts --dry-run --json"], options) + : spawnSync("npm", ["pack", "--ignore-scripts", "--dry-run", "--json"], options); + assert.ifError(packed.error); + assert.equal(packed.status, 0, `${packed.stdout}${packed.stderr}`); + packedFiles = JSON.parse(packed.stdout)[0].files.map(file => file.path); + + for (const consumer of [withPeers, withoutPeers]) { + for (const file of packedFiles) { + const destination = join(consumer, "node_modules", "typechat", file); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(packageRoot, file), destination); + } + } + for (const peer of ["typescript", "zod"]) { + symlinkSync( + dirname(require.resolve(`${peer}/package.json`)), + join(withPeers, "node_modules", peer), + process.platform === "win32" ? "junction" : "dir" + ); + } + writeFileSync(join(withPeers, "requests.txt"), "first\n\nsecond\r\n"); +}); + +after(() => { + rmSync(directory, { recursive: true, force: true }); +}); + +function runConsumer(consumer, fileName, source) { + const file = join(consumer, fileName); + writeFileSync(file, source); + const result = spawnSync(process.execPath, [file], { + cwd: consumer, + encoding: "utf8", + env: childEnv, + timeout: 30000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); +} + +describe("published package", () => { + test("includes both builds and their declarations for every entry point", () => { + for (const [, entry] of entryPoints) { + for (const prefix of ["dist", "dist/esm"]) { + for (const extension of [".js", ".d.ts"]) { + assert.ok(packedFiles.includes(`${prefix}/${entry}${extension}`)); + } + } + } + assert.ok(packedFiles.includes("dist/esm/package.json")); + assert.equal(JSON.parse(readFileSync(join(stagedPackage, "dist", "esm", "package.json"), "utf8")).type, "module"); + assert.ok(!packedFiles.some(file => file.startsWith("src/") || file.startsWith("tests/"))); + }); + + test("selects native ESM and CommonJS builds with identical named exports", () => { + const expected = entryPoints.map(([specifier, entry]) => ({ + specifier, + cjs: join(stagedPackage, "dist", `${entry}.js`), + esm: pathToFileURL(join(stagedPackage, "dist", "esm", `${entry}.js`)).href, + })); + runConsumer(withPeers, "exports.mjs", ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + for (const { specifier, cjs, esm } of ${JSON.stringify(expected)}) { + assert.equal(require.resolve(specifier), cjs); + assert.equal(import.meta.resolve(specifier), esm); + const commonjs = require(specifier); + const native = await import(specifier); + assert.equal("default" in native, false); + assert.deepEqual(Object.keys(native).sort(), Object.keys(commonjs).sort()); + } + `); + }); + + for (const format of ["cjs", "esm"]) { + test(`${format} core and interactive entry points load without optional peers`, () => { + const imports = format === "esm" + ? `import { success, getData } from "typechat"; + import { processRequests } from "typechat/interactive";` + : `const { success, getData } = require("typechat"); + const { processRequests } = require("typechat/interactive");`; + runConsumer(withoutPeers, `core.${format === "esm" ? "mjs" : "cjs"}`, ` + ${imports} + ${format === "esm" ? 'import assert from "node:assert/strict";' : 'const assert = require("node:assert/strict");'} + assert.deepEqual(success("ok"), { success: true, data: "ok" }); + assert.equal(getData(success("ok")), "ok"); + assert.equal(typeof processRequests, "function"); + `); + }); + + test(`${format} supports translation, both validators, programs, and interactive input`, () => { + const imports = format === "esm" + ? `import assert from "node:assert/strict"; + import * as core from "typechat"; + import * as ts from "typechat/ts"; + import * as adapter from "typechat/zod"; + import * as interactive from "typechat/interactive"; + import { z } from "zod";` + : `const assert = require("node:assert/strict"); + const core = require("typechat"); + const ts = require("typechat/ts"); + const adapter = require("typechat/zod"); + const interactive = require("typechat/interactive"); + const { z } = require("zod");`; + runConsumer(withPeers, `behavior.${format === "esm" ? "mjs" : "cjs"}`, ` + ${imports} + (async () => { + const value = { value: "ok" }; + const validator = ts.createTypeScriptJsonValidator( + "export interface Value { value: string; }", "Value" + ); + try { + assert.deepEqual(validator.validate(value), core.success(value)); + assert.equal(validator.validate({ value: 1 }).success, false); + const model = { complete: async () => core.success(JSON.stringify(value)) }; + const translator = core.createJsonTranslator(model, validator); + assert.deepEqual(await translator.translate("test"), core.success(value)); + } finally { + validator.close(); + } + const schema = { Value: z.object({ value: z.string() }) }; + const zodValidator = adapter.createZodJsonValidator(schema, "Value"); + assert.deepEqual(zodValidator.validate(value), core.success(value)); + assert.equal(zodValidator.validate({ value: 1 }).success, false); + assert.match(adapter.getZodSchemaAsTypeScript(schema), /interface Value/); + const calls = []; + await ts.evaluateJsonProgram( + { "@steps": [{ "@func": "echo", "@args": ["ok"] }] }, + async (name, args) => { calls.push({ name, args }); return args[0]; } + ); + assert.deepEqual(calls, [{ name: "echo", args: ["ok"] }]); + const requests = []; + await interactive.processRequests("> ", "requests.txt", async request => { requests.push(request); }); + assert.deepEqual(requests, ["first", "second"]); + })().catch(error => { console.error(error); process.exitCode = 1; }); + `); + }); + } + + for (const module of ["node16", "nodenext"]) { + test(`resolves format-specific declarations using ${module}`, () => { + const source = ` + import { + createJsonTranslator, success, type Result, type Success, + type TypeChatLanguageModel, type TypeChatJsonTranslator + } from "typechat"; + import { + createTypeScriptJsonValidator, evaluateJsonProgram, + type TypeScriptJsonValidator, type Program + } from "typechat/ts"; + import { createZodJsonValidator } from "typechat/zod"; + import { processRequests } from "typechat/interactive"; + import { z } from "zod"; + interface Value { value: string; } + const model: TypeChatLanguageModel = { complete: async () => success('{"value":"ok"}') }; + const validator: TypeScriptJsonValidator = createTypeScriptJsonValidator( + "export interface Value { value: string; }", "Value" + ); + const translator: TypeChatJsonTranslator = createJsonTranslator(model, validator); + const result: Result = createZodJsonValidator( + { Value: z.object({ value: z.string() }) }, "Value" + ).validate({ value: "ok" }); + const program: Program = { "@steps": [] }; + evaluateJsonProgram(program, async () => "ok"); + processRequests("> ", undefined, async request => { const text: string = request; }); + // @ts-expect-error The result must preserve its data type. + const invalid: Success = success("ok"); + // @ts-expect-error Request callbacks must return a promise. + processRequests("> ", undefined, () => 1); + `; + for (const extension of ["cts", "mts"]) { + writeFileSync(join(withPeers, `consumer.${extension}`), source); + } + writeFileSync(join(withPeers, "tsconfig.json"), JSON.stringify({ + compilerOptions: { + target: "es2021", + lib: ["es2021"], + module, + moduleResolution: module, + strict: true, + noEmit: true, + types: [], + }, + files: ["consumer.cts", "consumer.mts"], + })); + const compiler = join(dirname(require.resolve("typescript/package.json")), "bin", "tsc"); + const result = spawnSync(process.execPath, [compiler, "-p", withPeers, "--traceResolution", "--pretty", "false"], { + encoding: "utf8", + env: childEnv, + timeout: 30000, + }); + assert.ifError(result.error); + const diagnostics = result.stdout.split(/\r?\n/).filter(line => line.includes("error TS")).join("\n"); + assert.equal(result.status, 0, `${diagnostics}\n${result.stderr}`); + const trace = result.stdout.replaceAll("\\", "/"); + for (const [specifier, entry] of entryPoints) { + for (const prefix of ["dist", "dist/esm"]) { + const declaration = join(stagedPackage, prefix, `${entry}.d.ts`).replaceAll("\\", "/"); + assert.ok( + trace.includes(`Module name '${specifier}' was successfully resolved to '${declaration}'`), + `${specifier} must resolve ${declaration}` + ); + } + } + }); + } +}); From 00f80b4b8bb0b4ac2120c3395a517a4a7e350385 Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Tue, 13 Oct 2026 15:06:45 -0700 Subject: [PATCH 2/2] fix: preserve ESM default-import compatibility Retain typed default exports for all four ESM entry points without changing the CommonJS export shape. Add runtime and declaration regression coverage and document compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/README.md | 10 +++- typescript/package.json | 18 +++---- typescript/src/index.mts | 2 + typescript/src/interactive/index.mts | 2 + typescript/src/ts/index.mts | 2 + typescript/src/tsconfig.esm.json | 3 +- typescript/src/tsconfig.json | 3 +- typescript/src/zod/index.mts | 2 + typescript/tests/package.test.mjs | 71 ++++++++++++++++++++++------ 9 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 typescript/src/index.mts create mode 100644 typescript/src/interactive/index.mts create mode 100644 typescript/src/ts/index.mts create mode 100644 typescript/src/zod/index.mts diff --git a/typescript/README.md b/typescript/README.md index 748ad82f..78ad8013 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -43,6 +43,11 @@ The `typechat`, `typechat/ts`, `typechat/zod`, and `typechat/interactive` entry select the matching JavaScript build and TypeScript declarations automatically. Both builds target ES2021 and require Node.js 22.19.0 or newer. +For compatibility with earlier CommonJS-only releases, native ESM default imports +(`import typechat from "typechat"`) and dynamic imports' `.default` properties remain +supported for all four entry points. Default imports also preserve the exported +TypeScript types, such as `typechat.Result`. + You can also build TypeChat from source: ```sh @@ -55,8 +60,9 @@ npm run build The build emits CommonJS JavaScript and declarations in `dist`, and native ESM JavaScript and declarations in `dist/esm`. -Run the test suite with `npm test`. Package tests verify both module formats and -their declaration resolution using only the files included in the npm package. +Run the test suite with `npm test`. Package tests verify named and default imports, +both module formats, and their declaration resolution using only the files included +in the npm package. The Zod schema tests also compile their generated TypeScript schema text to check for syntax errors and unresolved type references. diff --git a/typescript/package.json b/typescript/package.json index 148850ea..8aecd439 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -33,13 +33,13 @@ }, "type": "commonjs", "main": "./dist/index.js", - "module": "./dist/esm/index.js", + "module": "./dist/esm/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "import": { - "types": "./dist/esm/index.d.ts", - "default": "./dist/esm/index.js" + "types": "./dist/esm/index.d.mts", + "default": "./dist/esm/index.mjs" }, "require": { "types": "./dist/index.d.ts", @@ -49,8 +49,8 @@ }, "./ts": { "import": { - "types": "./dist/esm/ts/index.d.ts", - "default": "./dist/esm/ts/index.js" + "types": "./dist/esm/ts/index.d.mts", + "default": "./dist/esm/ts/index.mjs" }, "require": { "types": "./dist/ts/index.d.ts", @@ -60,8 +60,8 @@ }, "./zod": { "import": { - "types": "./dist/esm/zod/index.d.ts", - "default": "./dist/esm/zod/index.js" + "types": "./dist/esm/zod/index.d.mts", + "default": "./dist/esm/zod/index.mjs" }, "require": { "types": "./dist/zod/index.d.ts", @@ -71,8 +71,8 @@ }, "./interactive": { "import": { - "types": "./dist/esm/interactive/index.d.ts", - "default": "./dist/esm/interactive/index.js" + "types": "./dist/esm/interactive/index.d.mts", + "default": "./dist/esm/interactive/index.mjs" }, "require": { "types": "./dist/interactive/index.d.ts", diff --git a/typescript/src/index.mts b/typescript/src/index.mts new file mode 100644 index 00000000..97939873 --- /dev/null +++ b/typescript/src/index.mts @@ -0,0 +1,2 @@ +export * from './index.js'; +export * as default from './index.js'; diff --git a/typescript/src/interactive/index.mts b/typescript/src/interactive/index.mts new file mode 100644 index 00000000..97939873 --- /dev/null +++ b/typescript/src/interactive/index.mts @@ -0,0 +1,2 @@ +export * from './index.js'; +export * as default from './index.js'; diff --git a/typescript/src/ts/index.mts b/typescript/src/ts/index.mts new file mode 100644 index 00000000..97939873 --- /dev/null +++ b/typescript/src/ts/index.mts @@ -0,0 +1,2 @@ +export * from './index.js'; +export * as default from './index.js'; diff --git a/typescript/src/tsconfig.esm.json b/typescript/src/tsconfig.esm.json index 1cd1ebe2..0ca2357a 100644 --- a/typescript/src/tsconfig.esm.json +++ b/typescript/src/tsconfig.esm.json @@ -4,5 +4,6 @@ "module": "es2022", "moduleResolution": "bundler", "outDir": "../dist/esm" - } + }, + "include": ["./**/*.ts", "./**/*.mts"] } diff --git a/typescript/src/tsconfig.json b/typescript/src/tsconfig.json index e350cd12..9f5a767b 100644 --- a/typescript/src/tsconfig.json +++ b/typescript/src/tsconfig.json @@ -10,5 +10,6 @@ "strict": true, "exactOptionalPropertyTypes": true, "declaration": true - } + }, + "include": ["./**/*.ts"] } diff --git a/typescript/src/zod/index.mts b/typescript/src/zod/index.mts new file mode 100644 index 00000000..97939873 --- /dev/null +++ b/typescript/src/zod/index.mts @@ -0,0 +1,2 @@ +export * from './index.js'; +export * as default from './index.js'; diff --git a/typescript/tests/package.test.mjs b/typescript/tests/package.test.mjs index 9b5510d9..f24a6301 100644 --- a/typescript/tests/package.test.mjs +++ b/typescript/tests/package.test.mjs @@ -68,8 +68,11 @@ function runConsumer(consumer, fileName, source) { describe("published package", () => { test("includes both builds and their declarations for every entry point", () => { for (const [, entry] of entryPoints) { - for (const prefix of ["dist", "dist/esm"]) { - for (const extension of [".js", ".d.ts"]) { + for (const [prefix, extensions] of [ + ["dist", [".js", ".d.ts"]], + ["dist/esm", [".mjs", ".d.mts"]], + ]) { + for (const extension of extensions) { assert.ok(packedFiles.includes(`${prefix}/${entry}${extension}`)); } } @@ -83,7 +86,7 @@ describe("published package", () => { const expected = entryPoints.map(([specifier, entry]) => ({ specifier, cjs: join(stagedPackage, "dist", `${entry}.js`), - esm: pathToFileURL(join(stagedPackage, "dist", "esm", `${entry}.js`)).href, + esm: pathToFileURL(join(stagedPackage, "dist", "esm", `${entry}.mjs`)).href, })); runConsumer(withPeers, "exports.mjs", ` import assert from "node:assert/strict"; @@ -94,22 +97,46 @@ describe("published package", () => { assert.equal(import.meta.resolve(specifier), esm); const commonjs = require(specifier); const native = await import(specifier); - assert.equal("default" in native, false); - assert.deepEqual(Object.keys(native).sort(), Object.keys(commonjs).sort()); + const { default: defaultExport, ...namedExports } = native; + assert.equal("default" in commonjs, false); + assert.deepEqual(Object.keys(namedExports).sort(), Object.keys(commonjs).sort()); + assert.deepEqual(Object.keys(defaultExport).sort(), Object.keys(commonjs).sort()); + for (const name of Object.keys(commonjs)) { + assert.equal(defaultExport[name], native[name]); + } } `); }); + test("preserves native ESM default imports for every entry point", () => { + runConsumer(withPeers, "default-imports.mjs", ` + import assert from "node:assert/strict"; + import core, { success } from "typechat"; + import ts, { createTypeScriptJsonValidator } from "typechat/ts"; + import adapter, { createZodJsonValidator } from "typechat/zod"; + import interactive, { processRequests } from "typechat/interactive"; + assert.equal(core.success, success); + assert.equal(ts.createTypeScriptJsonValidator, createTypeScriptJsonValidator); + assert.equal(adapter.createZodJsonValidator, createZodJsonValidator); + assert.equal(interactive.processRequests, processRequests); + assert.deepEqual(core.success("ok"), { success: true, data: "ok" }); + `); + }); + for (const format of ["cjs", "esm"]) { test(`${format} core and interactive entry points load without optional peers`, () => { const imports = format === "esm" - ? `import { success, getData } from "typechat"; - import { processRequests } from "typechat/interactive";` - : `const { success, getData } = require("typechat"); - const { processRequests } = require("typechat/interactive");`; + ? `import core, { success, getData } from "typechat"; + import interactive, { processRequests } from "typechat/interactive";` + : `const core = require("typechat"); + const interactive = require("typechat/interactive"); + const { success, getData } = core; + const { processRequests } = interactive;`; runConsumer(withoutPeers, `core.${format === "esm" ? "mjs" : "cjs"}`, ` ${imports} ${format === "esm" ? 'import assert from "node:assert/strict";' : 'const assert = require("node:assert/strict");'} + assert.equal(core.success, success); + assert.equal(interactive.processRequests, processRequests); assert.deepEqual(success("ok"), { success: true, data: "ok" }); assert.equal(getData(success("ok")), "ok"); assert.equal(typeof processRequests, "function"); @@ -168,16 +195,16 @@ describe("published package", () => { for (const module of ["node16", "nodenext"]) { test(`resolves format-specific declarations using ${module}`, () => { const source = ` - import { + import core, { createJsonTranslator, success, type Result, type Success, type TypeChatLanguageModel, type TypeChatJsonTranslator } from "typechat"; - import { + import ts, { createTypeScriptJsonValidator, evaluateJsonProgram, type TypeScriptJsonValidator, type Program } from "typechat/ts"; - import { createZodJsonValidator } from "typechat/zod"; - import { processRequests } from "typechat/interactive"; + import adapter, { createZodJsonValidator } from "typechat/zod"; + import interactive, { processRequests } from "typechat/interactive"; import { z } from "zod"; interface Value { value: string; } const model: TypeChatLanguageModel = { complete: async () => success('{"value":"ok"}') }; @@ -191,10 +218,24 @@ describe("published package", () => { const program: Program = { "@steps": [] }; evaluateJsonProgram(program, async () => "ok"); processRequests("> ", undefined, async request => { const text: string = request; }); + const defaultValidator: ts.TypeScriptJsonValidator = ts.createTypeScriptJsonValidator( + validator.getSchemaText(), validator.getTypeName() + ); + const defaultTranslator: core.TypeChatJsonTranslator = core.createJsonTranslator(model, defaultValidator); + const defaultResult: core.Result = adapter.createZodJsonValidator( + { Value: z.object({ value: z.string() }) }, "Value" + ).validate({ value: "ok" }); + const defaultProgram: ts.Program = program; + ts.evaluateJsonProgram(defaultProgram, async () => "ok"); + interactive.processRequests("> ", undefined, async request => { const text: string = request; }); // @ts-expect-error The result must preserve its data type. const invalid: Success = success("ok"); // @ts-expect-error Request callbacks must return a promise. processRequests("> ", undefined, () => 1); + // @ts-expect-error Default imports must preserve their result types. + const invalidDefault: core.Success = core.success("ok"); + // @ts-expect-error Default imports must preserve callback types. + interactive.processRequests("> ", undefined, () => 1); `; for (const extension of ["cts", "mts"]) { writeFileSync(join(withPeers, `consumer.${extension}`), source); @@ -222,8 +263,8 @@ describe("published package", () => { assert.equal(result.status, 0, `${diagnostics}\n${result.stderr}`); const trace = result.stdout.replaceAll("\\", "/"); for (const [specifier, entry] of entryPoints) { - for (const prefix of ["dist", "dist/esm"]) { - const declaration = join(stagedPackage, prefix, `${entry}.d.ts`).replaceAll("\\", "/"); + for (const [prefix, extension] of [["dist", ".d.ts"], ["dist/esm", ".d.mts"]]) { + const declaration = join(stagedPackage, prefix, `${entry}${extension}`).replaceAll("\\", "/"); assert.ok( trace.includes(`Module name '${specifier}' was successfully resolved to '${declaration}'`), `${specifier} must resolve ${declaration}`