diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json index 9ece5e7ab..2a156a64b 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json +++ b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json @@ -334,6 +334,7 @@ "McpStatelessClient": "class", "McpStatelessClientAdapter": "class", "McpStatelessError": "class", + "MemoryEventStore": "class", "MemorySkillProvider": "class", "MethodNotImplementedError": "class", "MissingClientCapabilityError": "class", @@ -407,6 +408,9 @@ "RUN_TASK_ENV_VAR": "string", "RUN_TASK_SUBCOMMAND": "string", "RateLimitError": "class", + "RecreateableSSEServerTransport": "class", + "RecreateableStreamableHTTPServerTransport": "class", + "RedisEventStore": "class", "RegisterJobTool": "class", "RegisterWorkflowTool": "class", "RegistryDefinitionNotFoundError": "class", @@ -450,6 +454,7 @@ "SCHEMA_DESCRIPTION_FORMATS": "array", "SERVER_MANIFEST_FILE": "string", "SERVER_MANIFEST_VERSION": "number", + "SSEServerTransport": "class", "STATELESS_SESSION_ID": "string", "SURFACE_META_KEY": "string", "SamplingNotAvailableError": "class", @@ -639,6 +644,7 @@ "buildRemoteResourceRecord": "function", "buildRemoteResourceTemplateRecord": "function", "buildRemoteToolRecord": "function", + "buildScopedServerOptions": "function", "buildSkillsCatalogSummary": "function", "buildStorageKey": "function", "canDeliverNotifications": "function", @@ -680,6 +686,7 @@ "createEsmResourceInstance": "function", "createEsmToolContextClass": "function", "createEsmToolInstance": "function", + "createEventStore": "function", "createExtAppsMessageHandler": "function", "createGuardManager": "function", "createInMemoryServer": "function", diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs new file mode 100644 index 000000000..1fbe0f14c --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs @@ -0,0 +1,12 @@ +// Loads @frontmcp/sdk and one of its subpaths from the project this file is copied into, with require() or import(), and +// prints, as JSON, the subpath's export names and those not the root's: node subpath-exports.mjs +import { createRequire } from 'node:module'; + +const [subpath, loader] = process.argv.slice(2); +const load = loader === 'require' ? createRequire(import.meta.url) : (specifier) => import(specifier); +const root = await load('@frontmcp/sdk'); +const subpathExports = await load(`@frontmcp/sdk/${subpath}`); + +const names = Object.keys(subpathExports).sort(); +const notFromRoot = names.filter((name) => subpathExports[name] === undefined || subpathExports[name] !== root[name]); +process.stdout.write(JSON.stringify({ names, notFromRoot })); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts index 0d9a2e377..cf55fb21d 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts @@ -11,7 +11,8 @@ import { workspaceRoot } from './helpers/browser-bundle'; type Step = { step: string; initialized: string[] }; -const SUBSYSTEM_MODULE = /^libs\/sdk\/src\/(job|workflow|channel|remote-mcp|esm-loader)\//; +const SUBSYSTEM_MODULE = + /^libs\/sdk\/src\/((job|workflow|channel|remote-mcp|esm-loader)\/|transport\/(event-stores\/|adapters\/(base-sse|sse|streamable-http)-transport\.ts))/; // Helpers the startup path itself uses: the decorators parse `Tool.esm()` specifiers, the scope normalizes app entries const STARTUP_HELPERS = [ 'libs/sdk/src/esm-loader/package-specifier.ts', @@ -48,6 +49,9 @@ describe('the subsystems of the SDK under require()', () => { ['ChannelRegistry', 'libs/sdk/src/channel/channel.registry.ts'], ['McpClientService', 'libs/sdk/src/remote-mcp/mcp-client.service.ts'], ['EsmModuleLoader', 'libs/sdk/src/esm-loader/esm-module-loader.ts'], + ['RecreateableStreamableHTTPServerTransport', 'libs/sdk/src/transport/adapters/streamable-http-transport.ts'], + ['SSEServerTransport', 'libs/sdk/src/transport/adapters/base-sse-transport.ts'], + ['createEventStore', 'libs/sdk/src/transport/event-stores/event-store.factory.ts'], ])('are set up when %s is read', (exportName, source) => { const [, read] = initializedModules(`read:${exportName}`); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts new file mode 100644 index 000000000..3d1da0b4b --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts @@ -0,0 +1,89 @@ +/** + * The published exports map of @frontmcp/sdk names only files its build emits, and each subpath hands back the root + * entry's own classes and functions: under require() and import in Node, and in a browser bundle. + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { copyFile, fileExists, mkdir, mkdtemp, readFileSync, rm, symlink } from '@frontmcp/utils'; + +import { bundleForBrowser, fixtureDir, inputSizes, SERVER_MODULES, workspaceRoot } from './helpers/browser-bundle'; + +type Loader = 'require' | 'import'; + +const sdkDist = resolve(workspaceRoot, 'libs/sdk/dist'); +const exportsMap: Record = JSON.parse(readFileSync(join(sdkDist, 'package.json'))).exports; +const SUBPATHS = Object.keys(exportsMap) + .filter((key) => key !== '.' && key !== './package.json' && exportsMap[key] !== null) + .map((key) => key.slice('./'.length)); + +function exportTargets(entry: unknown): string[] { + if (typeof entry === 'string') return [entry]; + if (entry === null || typeof entry !== 'object') return []; + return Object.values(entry).flatMap(exportTargets); +} + +describe('the exports map of the published @frontmcp/sdk', () => { + it('names only files its build emits', async () => { + const targets = exportTargets(exportsMap); + const missing: string[] = []; + for (const target of targets) { + if (!(await fileExists(join(sdkDist, target)))) missing.push(target); + } + + expect(targets.length).toBeGreaterThan(0); + expect(missing).toEqual([]); + }); +}); + +describe('a subpath of @frontmcp/sdk installed in a project', () => { + let project: string; + + beforeAll(async () => { + project = await mkdtemp(join(tmpdir(), 'frontmcp-sdk-subpaths-')); + await mkdir(join(project, 'node_modules', '@frontmcp'), { recursive: true }); + await symlink(sdkDist, join(project, 'node_modules', '@frontmcp', 'sdk'), 'dir'); + await copyFile(resolve(__dirname, 'helpers', 'subpath-exports.mjs'), join(project, 'subpath-exports.mjs')); + }); + + afterAll(async () => { + if (project) await rm(project, { recursive: true, force: true }); + }); + + function subpathExports(subpath: string, loader: Loader): { names: string[]; notFromRoot: string[] } { + return JSON.parse( + execFileSync(process.execPath, [join(project, 'subpath-exports.mjs'), subpath, loader], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + ); + } + + describe.each(SUBPATHS)('@frontmcp/sdk/%s', (subpath) => { + it.each(['require', 'import'])("hands back the root entry's own values under %s", (loader) => { + const { names, notFromRoot } = subpathExports(subpath, loader); + + expect(names.length).toBeGreaterThan(0); + expect(notFromRoot).toEqual([]); + }); + + it('exports the same names under require and import', () => { + expect(subpathExports(subpath, 'import').names).toEqual(subpathExports(subpath, 'require').names); + }); + }); +}); + +describe('a browser bundle of the subpaths of @frontmcp/sdk', () => { + it('takes their values from the browser build the root entry comes from', async () => { + const bundle = await bundleForBrowser(resolve(fixtureDir, 'subpath-imports.mjs'), SERVER_MODULES); + const sdkInputs = inputSizes(bundle.metafile) + .map(([input]) => input) + .filter((input) => input.startsWith('libs/sdk/dist/')); + + expect(sdkInputs).toContain('libs/sdk/dist/browser/job/tools/execute-job.tool.mjs'); + expect(sdkInputs.filter((input) => !input.startsWith('libs/sdk/dist/browser/'))).toEqual([]); + }); +}); diff --git a/apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs b/apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs new file mode 100644 index 000000000..ded892825 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs @@ -0,0 +1,4 @@ +export { ExecuteJobTool } from '@frontmcp/sdk/job/tools'; +export { ExecuteWorkflowTool } from '@frontmcp/sdk/workflow/tools'; +export { createEventStore } from '@frontmcp/sdk/transport'; +export { ToolContext } from '@frontmcp/sdk'; diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index 6dba7614c..661ba88c0 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -53,6 +53,7 @@ const fixtureFiles = [ 'build-manifest-must-compile.ts', 'persistent-questions-must-compile.ts', 'lazy-apps-must-compile.ts', + 'subpath-exports-must-compile.ts', ].map((fileName) => path.join(fixtureDir, fileName)); function compileFixture(withoutExpectErrors?: string): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } { @@ -255,6 +256,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('lazy-apps-must-compile.ts'))).toEqual([]); }); + it('types the transport, job/tools and workflow/tools subpaths as the root exports they re-export', () => { + expect(describeEach(inFixtureFile('subpath-exports-must-compile.ts'))).toEqual([]); + }); + it('ships declaration files that type-check with skipLibCheck disabled', () => { const sdkDeclarationDiagnostics = diagnostics.filter((diagnostic) => diagnostic.fileName.startsWith(sdkDeclarationsDir + path.sep), diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts new file mode 100644 index 000000000..4c3ab5921 --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts @@ -0,0 +1,33 @@ +import { + createEventStore, + type buildScopedServerOptions, + type ExecuteJobTool, + type ExecuteWorkflowTool, + type MemoryEventStore, + type RecreateableSSEServerTransport, + type RecreateableStreamableHTTPServerTransport, + type RedisEventStore, + type SSEServerTransport, +} from '@frontmcp/sdk'; +import { ExecuteJobTool as SubpathExecuteJobTool } from '@frontmcp/sdk/job/tools'; +import * as transport from '@frontmcp/sdk/transport'; +import { ExecuteWorkflowTool as SubpathExecuteWorkflowTool } from '@frontmcp/sdk/workflow/tools'; + +export const jobTool: typeof ExecuteJobTool = SubpathExecuteJobTool; +export const workflowTool: typeof ExecuteWorkflowTool = SubpathExecuteWorkflowTool; +export const streamableTransport: typeof RecreateableStreamableHTTPServerTransport = + transport.RecreateableStreamableHTTPServerTransport; +export const sseTransport: typeof RecreateableSSEServerTransport = transport.RecreateableSSEServerTransport; +export const baseSseTransport: typeof SSEServerTransport = transport.SSEServerTransport; +export const memoryStore: typeof MemoryEventStore = transport.MemoryEventStore; +export const redisStore: typeof RedisEventStore = transport.RedisEventStore; +export const serverOptions: typeof buildScopedServerOptions = transport.buildScopedServerOptions; + +export function eventStoreOf(config: transport.EventStoreConfig): transport.EventStoreResult['eventStore'] { + return createEventStore(config).eventStore; +} + +export function notAConfig(): void { + // @ts-expect-error createEventStore takes an EventStoreConfig + transport.createEventStore('memory'); +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 07a4ff299..a562898e5 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -41,6 +41,7 @@ "http-routes-must-compile.ts", "persistent-questions-must-compile.ts", "lazy-apps-must-compile.ts", + "subpath-exports-must-compile.ts", "http-routes-must-compile.ts" ] } diff --git a/libs/sdk/project.json b/libs/sdk/project.json index 9ef6533b9..037fe3737 100644 --- a/libs/sdk/project.json +++ b/libs/sdk/project.json @@ -137,10 +137,12 @@ "build": { "executor": "nx:run-commands", "dependsOn": ["build-cjs", "build-esm", "build-browser", "build-browser-esm"], + "inputs": ["production", "^production", "{workspaceRoot}/scripts/build-subpath-reexports.mjs"], "options": { "commands": [ "npx esbuild libs/sdk/src/esm-loader/import-computed.ts --format=cjs --platform=node --outfile=libs/sdk/dist/esm-loader/import-computed.js", "npx esbuild libs/sdk/src/esm-loader/import-computed.ts --format=esm --platform=node --outfile=libs/sdk/dist/esm/esm-loader/import-computed.mjs", + "node scripts/build-subpath-reexports.mjs libs/sdk", "node scripts/strip-dist-from-pkg.js libs/sdk/dist/package.json", "node scripts/inject-esm-require-banner.js libs/sdk/dist/esm libs/sdk/dist/browser", "node scripts/escape-non-ascii.mjs libs/sdk/dist" diff --git a/libs/sdk/src/__tests__/subpath-exports.spec.ts b/libs/sdk/src/__tests__/subpath-exports.spec.ts new file mode 100644 index 000000000..61a1fc92f --- /dev/null +++ b/libs/sdk/src/__tests__/subpath-exports.spec.ts @@ -0,0 +1,15 @@ +// The build writes each subpath export of the package as a re-export of the root entry, so the root exports all its values +describe.each([ + ['./transport', () => import('../transport')], + ['./job/tools', () => import('../job/tools')], + ['./workflow/tools', () => import('../workflow/tools')], +])('the %s subpath', (_subpath, loadSubpath) => { + it('exports only values the root entry exports', async () => { + const root: Record = await import('../index'); + const subpathExports: Record = await loadSubpath(); + + const names = Object.keys(subpathExports); + expect(names.length).toBeGreaterThan(0); + expect(names.filter((name) => root[name] !== subpathExports[name])).toEqual([]); + }); +}); diff --git a/libs/sdk/src/index.ts b/libs/sdk/src/index.ts index d394692e6..4b969a098 100644 --- a/libs/sdk/src/index.ts +++ b/libs/sdk/src/index.ts @@ -766,6 +766,17 @@ export type { PersistentSessionOwnerStore } from './transport/persistent-session // Transport types export type { TransportType, TransportKey } from './transport/transport.types'; export { STATELESS_SESSION_ID } from './transport/transport.types'; +export { buildScopedServerOptions } from './transport/build-scoped-server-options'; + +// The transports and SSE event stores `@frontmcp/sdk/transport` also exports, which the CommonJS build loads on first use +export { + RecreateableStreamableHTTPServerTransport, + RecreateableSSEServerTransport, + SSEServerTransport, + createEventStore, + MemoryEventStore, + RedisEventStore, +} from './subsystems'; // ───────────────────────────────────────────────────────────────────────────── // MCP Apps (ext-apps) Support diff --git a/libs/sdk/src/lazy-subsystems.ts b/libs/sdk/src/lazy-subsystems.ts index 659c9d1e0..f0aa7c6cb 100644 --- a/libs/sdk/src/lazy-subsystems.ts +++ b/libs/sdk/src/lazy-subsystems.ts @@ -89,6 +89,16 @@ () => require('./workflow/tools'), ['ExecuteWorkflowTool', 'GetWorkflowStatusTool', 'ListWorkflowsTool', 'RegisterWorkflowTool', 'RemoveWorkflowTool'], ); + defineLazyExports( + () => require('./transport/adapters/streamable-http-transport'), + ['RecreateableStreamableHTTPServerTransport'], + ); + defineLazyExports(() => require('./transport/adapters/sse-transport'), ['RecreateableSSEServerTransport']); + defineLazyExports(() => require('#sse-transport'), ['SSEServerTransport']); + defineLazyExports( + () => require('./transport/event-stores'), + ['createEventStore', 'MemoryEventStore', 'RedisEventStore'], + ); defineLazyExports( () => require('./channel'), [ diff --git a/libs/sdk/src/subsystems.ts b/libs/sdk/src/subsystems.ts index f8eedce9b..b6ab25a87 100644 --- a/libs/sdk/src/subsystems.ts +++ b/libs/sdk/src/subsystems.ts @@ -19,6 +19,10 @@ export { RegisterWorkflowTool, RemoveWorkflowTool, } from './workflow/tools'; +export { RecreateableStreamableHTTPServerTransport } from './transport/adapters/streamable-http-transport'; +export { RecreateableSSEServerTransport } from './transport/adapters/sse-transport'; +export { SSEServerTransport } from '#sse-transport'; +export { createEventStore, MemoryEventStore, RedisEventStore } from './transport/event-stores'; export { ChannelInstance, ChannelRegistry, diff --git a/libs/sdk/src/transport/event-stores/index.ts b/libs/sdk/src/transport/event-stores/index.ts index 6c4fd1c6b..da2459579 100644 --- a/libs/sdk/src/transport/event-stores/index.ts +++ b/libs/sdk/src/transport/event-stores/index.ts @@ -15,7 +15,7 @@ * * @example Memory EventStore (single-node) * ```typescript - * import { createEventStore } from '@frontmcp/sdk/transport/event-stores'; + * import { createEventStore } from '@frontmcp/sdk'; * * const { eventStore, type } = createEventStore({ * enabled: true, @@ -27,7 +27,7 @@ * * @example Redis EventStore (distributed) * ```typescript - * import { createEventStore } from '@frontmcp/sdk/transport/event-stores'; + * import { createEventStore } from '@frontmcp/sdk'; * * const { eventStore, type } = createEventStore({ * enabled: true, diff --git a/libs/skills/catalog/frontmcp-config/references/configure-transport.md b/libs/skills/catalog/frontmcp-config/references/configure-transport.md index af106a409..6e32882a7 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-transport.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-transport.md @@ -149,6 +149,8 @@ transport: { From 1.7.2 each session gets a view over the shared store scoped to its own session id: an event id belonging to another session replays nothing. +Code that builds its own transport can import the stores and transports: `createEventStore`, `MemoryEventStore`, `RedisEventStore`, `RecreateableStreamableHTTPServerTransport`, `RecreateableSSEServerTransport` and `SSEServerTransport` come from `@frontmcp/sdk`, and from `@frontmcp/sdk/transport`, which re-exports them from the root entry. Up to 1.10 neither loaded them at runtime: the root did not export them, and the subpath shipped type declarations only (`MODULE_NOT_FOUND` under `require`, `ERR_MODULE_NOT_FOUND` under `import`). + ## Target-Specific Recommendations | Target | Recommended Preset | Persistence | Event Store | diff --git a/libs/skills/catalog/frontmcp-development/references/create-job.md b/libs/skills/catalog/frontmcp-development/references/create-job.md index b2f3000ec..54402c790 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-job.md +++ b/libs/skills/catalog/frontmcp-development/references/create-job.md @@ -644,7 +644,7 @@ import { App, ExecuteJobTool, GetJobStatusTool, ListJobsTool } from '@frontmcp/s class DataApp {} ``` -The same classes are also reachable via the subpath `@frontmcp/sdk/job/tools` and `@frontmcp/sdk/workflow/tools` for bundlers that prefer narrower imports. +The subpaths `@frontmcp/sdk/job/tools` and `@frontmcp/sdk/workflow/tools` export the same classes: they re-export them from the root entry, so `ExecuteJobTool` from either is the class `@frontmcp/sdk` exports, and a subpath import does not make a bundle smaller. Up to 1.10 the subpaths shipped type declarations only, so loading one failed with `MODULE_NOT_FOUND` under `require` and `ERR_MODULE_NOT_FOUND` under `import`; on those versions import the classes from `@frontmcp/sdk`. ## Nx Generator diff --git a/scripts/build-subpath-reexports.mjs b/scripts/build-subpath-reexports.mjs new file mode 100644 index 000000000..ca43e3ce8 --- /dev/null +++ b/scripts/build-subpath-reexports.mjs @@ -0,0 +1,61 @@ +// Writes the require and import files of each subpath export as a re-export of the package root, so a subpath hands back the +// root's own classes instead of a second copy of them: node scripts/build-subpath-reexports.mjs +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { build } from 'esbuild'; + +const [packageDirArg] = process.argv.slice(2); +if (!packageDirArg) { + console.error('Usage: node scripts/build-subpath-reexports.mjs '); + process.exit(1); +} + +const packageDir = resolve(packageDirArg); +const manifest = JSON.parse(readFileSync(resolve(packageDir, 'package.json'), 'utf8')); +const FORMAT_BY_CONDITION = { require: 'cjs', import: 'esm' }; + +async function exportedNames(sourcePath) { + const { outputFiles } = await build({ + entryPoints: [sourcePath], + bundle: true, + packages: 'external', + write: false, + format: 'esm', + platform: 'node', + logLevel: 'warning', + }); + const code = outputFiles[0].text; + if (/^export \* from /m.test(code)) { + throw new Error( + `${sourcePath} re-exports another package with export *, so the names its subpath re-exports are unknown`, + ); + } + // The metafile of the bundle also lists a type re-exported without `export type`; the bundled code leaves it out + const { metafile } = await build({ + stdin: { contents: code, loader: 'js' }, + outfile: 'names.js', + write: false, + metafile: true, + format: 'esm', + logLevel: 'warning', + }); + return Object.values(metafile.outputs)[0].exports; +} + +for (const [subpath, conditions] of Object.entries(manifest.exports)) { + if (subpath === '.' || !conditions?.development) continue; + const names = await exportedNames(resolve(packageDir, conditions.development)); + const contents = `export { ${names.join(', ')} } from '${manifest.name}';`; + for (const [condition, format] of Object.entries(FORMAT_BY_CONDITION)) { + await build({ + stdin: { contents, resolveDir: packageDir, loader: 'js' }, + outfile: resolve(packageDir, conditions[condition].default), + bundle: true, + external: [manifest.name], + format, + platform: 'node', + logLevel: 'warning', + }); + } +} diff --git a/scripts/build-subpath-reexports.test.mjs b/scripts/build-subpath-reexports.test.mjs new file mode 100644 index 000000000..6ea074284 --- /dev/null +++ b/scripts/build-subpath-reexports.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const scriptPath = path.join(__dirname, 'build-subpath-reexports.mjs'); +const require = createRequire(import.meta.url); +const tempDirs = []; + +after(async () => { + for (const dir of tempDirs) await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); +}); + +const MANIFEST = { + name: '@acme/tools', + exports: { + '.': { require: { default: './dist/index.js' }, import: { default: './dist/esm/index.mjs' } }, + './hand': { + development: './src/hand/index.ts', + require: { default: './dist/hand/index.js' }, + import: { default: './dist/esm/hand/index.mjs' }, + }, + }, +}; + +async function packageWithBarrel(barrel) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'build-subpath-reexports-')); + tempDirs.push(dir); + const packageDir = path.join(dir, 'node_modules', '@acme', 'tools'); + const files = { + 'package.json': JSON.stringify(MANIFEST), + 'src/hand/index.ts': barrel, + 'src/hand/hammer.ts': 'export class Hammer {}\nexport interface HammerOptions { weight: number }\n', + 'dist/index.js': 'class Hammer {}\nmodule.exports = { Hammer };\n', + 'dist/esm/index.mjs': 'export class Hammer {}\n', + }; + for (const [name, body] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(packageDir, name)), { recursive: true }); + await fs.writeFile(path.join(packageDir, name), body); + } + return packageDir; +} + +const run = (packageDir) => spawnSync(process.execPath, [scriptPath, packageDir], { encoding: 'utf8' }); + +test("writes a subpath's require and import files as re-exports of the package root", async () => { + const packageDir = await packageWithBarrel( + "export { Hammer } from './hammer';\nexport type { HammerOptions } from './hammer';\n", + ); + + const res = run(packageDir); + + assert.equal(res.status, 0, res.stderr); + const root = require(path.join(packageDir, 'dist/index.js')); + const required = require(path.join(packageDir, 'dist/hand/index.js')); + assert.deepEqual(Object.keys(required), ['Hammer']); + assert.equal(required.Hammer, root.Hammer); + const rootModule = await import(pathToFileURL(path.join(packageDir, 'dist/esm/index.mjs')).href); + const imported = await import(pathToFileURL(path.join(packageDir, 'dist/esm/hand/index.mjs')).href); + assert.deepEqual(Object.keys(imported), ['Hammer']); + assert.equal(imported.Hammer, rootModule.Hammer); +}); + +test('leaves out a type the barrel re-exports without export type', async () => { + const packageDir = await packageWithBarrel("export { Hammer, HammerOptions } from './hammer';\n"); + + const res = run(packageDir); + + assert.equal(res.status, 0, res.stderr); + const imported = await import(pathToFileURL(path.join(packageDir, 'dist/esm/hand/index.mjs')).href); + assert.deepEqual(Object.keys(imported), ['Hammer']); +}); + +test("reads the names of a barrel's local export *", async () => { + const packageDir = await packageWithBarrel("export * from './hammer';\n"); + + const res = run(packageDir); + + assert.equal(res.status, 0, res.stderr); + assert.deepEqual(Object.keys(require(path.join(packageDir, 'dist/hand/index.js'))), ['Hammer']); +}); + +test('refuses a barrel that re-exports another package with export *', async () => { + const res = run(await packageWithBarrel("export * from 'saws';\n")); + + assert.notEqual(res.status, 0); + assert.match(res.stderr, /export \*/); +});