Skip to content

Commit f693373

Browse files
committed
build(spec): one module graph with shared chunks for every JS output
Every JS output of @objectstack/spec is now one esbuild graph with splitting, so a module two entries reach is one module in one shared chunk. The browser-conditioned Node outputs are built from a node-side copy of only the modules that reach the pg-grammar seam, measured per build by a metafile pre-pass. Claude-Session: https://claude.ai/code/session_01Q3XCv7AXcUSSD479Vt1LXy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47af3dd commit f693373

1 file changed

Lines changed: 192 additions & 38 deletions

File tree

‎packages/spec/tsup.config.ts‎

Lines changed: 192 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { readFile } from 'node:fs/promises';
4-
import { dirname, join } from 'node:path';
4+
import { dirname, join, relative, sep } from 'node:path';
55
import ts from 'typescript';
66
import { defineConfig, type Options } from 'tsup';
77

@@ -161,9 +161,9 @@ const entries = [
161161
'src/identity/index.ts',
162162
'src/shared/index.ts',
163163
// [#10096] Schema-free fine-grained entry for the `/meta` URL-spelling
164-
// contract — per-entry self-contained bundling is unchanged (#8133 stays on
165-
// hold); this entry's whole graph is two pure modules, so "self-contained"
166-
// costs a few hundred bytes here by construction.
164+
// contract: its whole graph is two pure modules, so the chunks it links are
165+
// a few hundred bytes by construction (`check:browser-reachable-entries`
166+
// holds it zod-free across every chunk it reaches).
167167
'src/meta-spelling/index.ts',
168168
// The ADR-0087 migration chain + change manifest, off the root entry: its
169169
// registry is mostly `os migrate meta` guidance text, and its import-time work
@@ -208,25 +208,175 @@ const browserConditionedEntries = [
208208
];
209209

210210
/**
211-
* [#11072] Resolve the pg-grammar arm to its browser twin — the whole
212-
* mechanism by which the `browser`-conditioned bundles exclude the
213-
* driver-config validators' Node-only dependency.
211+
* [#11072] The one audited import whose target differs between the Node and
212+
* the browser builds: the pg-grammar arm, resolved to its browser twin
213+
* (`pg-url-grammar.browser.ts`) everywhere except on the Node side of the
214+
* `browser`-conditioned entries.
214215
*
215216
* Keyed to the seam module's specifier, NOT to `pg-connection-string`
216217
* itself, on purpose: a blanket alias of the package would silently degrade
217218
* any FUTURE import site nobody audited, whereas this swap covers exactly
218-
* the one audited seam and a new direct import lands in the browser bundles
219-
* where `check:browser-reachable-entries` refuses it at this producer.
219+
* the one audited seam and a new direct import lands in the browser-reachable
220+
* bundles where `check:browser-reachable-entries` refuses it at this producer.
220221
*/
221-
const swapServerOnlyGrammarArm: Plugin = {
222-
name: 'swap-server-only-grammar-arm',
223-
setup(build) {
224-
build.onResolve({ filter: /[\\/]pg-url-grammar\.server$/ }, (args) => ({
225-
path: join(dirname(args.importer), 'pg-url-grammar.browser.ts'),
226-
}));
222+
const SERVER_ONLY_GRAMMAR_ARM = /[\\/]pg-url-grammar\.server$/;
223+
224+
/** The esbuild namespace of the Node-side copies, and the entry-point spelling that selects it. */
225+
const NODE_SIDE = 'node-side';
226+
const NODE_SIDE_ENTRY = `${NODE_SIDE}:`;
227+
228+
/** Marks a resolution asked on behalf of a Node-side module, so the browser swap stands aside for it. */
229+
const NODE_SIDE_RESOLVE = { nodeSide: true };
230+
231+
/** The output name of an entry: `src/data/index.ts` → `data/index`. */
232+
const outName = (entry: string): string => entry.replace(/^src\//, '').replace(/\.ts$/, '');
233+
234+
/**
235+
* [#8133] Every JS output of this package, as ONE esbuild module graph, so
236+
* that a module two entries reach is ONE module in ONE shared chunk, and the
237+
* symbol it defines is the same object through either entry
238+
* (`@objectstack/spec`'s `defineConnector` IS `@objectstack/spec/integration`'s).
239+
*
240+
* The per-entry layout this replaced inlined each entry's whole graph into its
241+
* own bundle, so a consumer importing several subpaths bundled several copies
242+
* of every schema they share: measured on `@objectstack/spec@18.0.0`, the
243+
* symbols objectui's console imports cost 1,542 KB gzipped from that layout and
244+
* 452 KB from one graph, and `FieldSchema` was defined in 14 published files.
245+
* `sideEffects: false` and `pureSchemaConstruction` (#10031) let a bundler drop
246+
* what one entry never reaches; they cannot merge copies across entries. Only
247+
* shared chunks can.
248+
*
249+
* ## Why one graph needs two copies of a few modules
250+
*
251+
* A browser bundler loads the ordinary bundles of every entry outside
252+
* `browserConditionedEntries`, so those must never link the pg-grammar arm's
253+
* Node-only dependency (`check:browser-reachable-entries`, rule 2). With
254+
* per-entry bundles, tree-shaking kept it out. With shared chunks it cannot:
255+
* esbuild assigns a module to a chunk by FILE-LEVEL reachability, so an entry
256+
* links every chunk its import graph touches, used or not — measured with a
257+
* bare `splitting: true`, `./shared` and `./migrations` linked the chunk that
258+
* imports `pg-connection-string`, the #11072 breakage. So the browser swap
259+
* above applies to the whole graph, and the Node outputs of the
260+
* `browser`-conditioned entries are built from a NODE-SIDE copy of exactly the
261+
* modules that reach the seam (measured: 28 of 459 at the time of writing; the
262+
* entry files themselves, the barrels above the driver configs, the conversion
263+
* and migration registries) — every other module is shared by every output,
264+
* Node and browser alike.
265+
*
266+
* Which modules reach the seam is not a list kept here. It is measured on each
267+
* build, by a metafile pre-pass over the Node graph of those entries, and the
268+
* pre-pass refuses when the seam is no longer in it — in which case the
269+
* `browser` conditions are moot and withdrawing them is a decision, not
270+
* something this plugin should paper over.
271+
*
272+
* Behaviour is unchanged for every output: the Node outputs of the conditioned
273+
* entries still run the pg grammar; every other output still never runs it
274+
* (the per-entry bundles of those entries linked no pg grammar at all, which
275+
* `check:browser-reachable-entries` held), and now carries its browser twin
276+
* where a chunk it shares reaches the driver configs. The same symbol is the
277+
* same object across all outputs except the exports of the node-side modules,
278+
* which keep one object per side.
279+
*/
280+
const oneModuleGraph: Plugin = {
281+
name: 'one-module-graph',
282+
async setup(build) {
283+
const cwd = build.initialOptions.absWorkingDir ?? process.cwd();
284+
// A copy, never an edit in place: tsup hands the SAME entry object to the
285+
// ESM and the CJS run, so an in-place edit would hide these entries from
286+
// whichever run starts second.
287+
const points = { ...(build.initialOptions.entryPoints as Record<string, string>) };
288+
const nodeSideSources: string[] = [];
289+
for (const [out, source] of Object.entries(points)) {
290+
if (out.startsWith('browser/') || !browserConditionedEntries.includes(source)) continue;
291+
points[out] = NODE_SIDE_ENTRY + source;
292+
nodeSideSources.push(source);
293+
}
294+
build.initialOptions.entryPoints = points;
295+
const nodeSide = nodeSideSources.length > 0 ? await modulesReachingTheSeam(build, cwd, nodeSideSources) : new Set();
296+
const toKey = (path: string): string => relative(cwd, path).split(sep).join('/');
297+
298+
build.onResolve({ filter: new RegExp(`^${NODE_SIDE_ENTRY}`) }, (args) =>
299+
args.kind === 'entry-point' ? { path: args.path.slice(NODE_SIDE_ENTRY.length), namespace: NODE_SIDE } : undefined,
300+
);
301+
build.onResolve({ filter: SERVER_ONLY_GRAMMAR_ARM, namespace: 'file' }, (args) =>
302+
args.pluginData?.nodeSide === true ? undefined : { path: join(dirname(args.importer), 'pg-url-grammar.browser.ts') },
303+
);
304+
// An import made by a node-side module: resolved as usual, minus the swap,
305+
// then kept on the node side only when its target reaches the seam.
306+
build.onResolve({ filter: /.*/, namespace: NODE_SIDE }, async (args) => {
307+
const resolved = await build.resolve(args.path, {
308+
kind: args.kind,
309+
importer: args.importer,
310+
resolveDir: args.resolveDir,
311+
namespace: 'file',
312+
pluginData: NODE_SIDE_RESOLVE,
313+
});
314+
if (resolved.errors.length > 0) return { errors: resolved.errors };
315+
if (!resolved.external && resolved.namespace === 'file' && nodeSide.has(toKey(resolved.path))) {
316+
return { path: toKey(resolved.path), namespace: NODE_SIDE };
317+
}
318+
const { path, external, namespace, sideEffects, suffix } = resolved;
319+
return { path, external, namespace, sideEffects, suffix };
320+
});
321+
build.onLoad({ filter: /.*/, namespace: NODE_SIDE }, async (args) => {
322+
const file = join(cwd, args.path);
323+
const source = await readFile(file, 'utf8');
324+
return { contents: annotatePureCalls(source, file) ?? source, loader: 'ts', resolveDir: dirname(file) };
325+
});
227326
},
228327
};
229328

329+
/**
330+
* The source modules, keyed by their path relative to `cwd`, from which the
331+
* seam is reachable in the Node graph of `sources` — the set that needs a
332+
* node-side copy. Refuses when the seam is not in that graph.
333+
*/
334+
async function modulesReachingTheSeam(
335+
build: Parameters<Plugin['setup']>[0],
336+
cwd: string,
337+
sources: readonly string[],
338+
): Promise<ReadonlySet<string>> {
339+
const { metafile } = await build.esbuild.build({
340+
entryPoints: [...sources],
341+
absWorkingDir: cwd,
342+
bundle: true,
343+
write: false,
344+
metafile: true,
345+
format: 'esm',
346+
platform: 'node',
347+
packages: 'external',
348+
outdir: 'node-side-probe',
349+
logLevel: 'silent',
350+
});
351+
const importers = new Map<string, string[]>();
352+
const reached = new Set<string>();
353+
for (const [file, input] of Object.entries(metafile.inputs)) {
354+
for (const imported of input.imports) {
355+
if (imported.external) continue;
356+
if (imported.original !== undefined && SERVER_ONLY_GRAMMAR_ARM.test(imported.original)) reached.add(imported.path);
357+
importers.set(imported.path, [...(importers.get(imported.path) ?? []), file]);
358+
}
359+
}
360+
if (reached.size === 0) {
361+
throw new Error(
362+
`one-module-graph: no module of the Node graph of ${sources.join(', ')} imports the pg-grammar arm ` +
363+
`(${SERVER_ONLY_GRAMMAR_ARM}). Either the seam moved — update SERVER_ONLY_GRAMMAR_ARM — or these ` +
364+
'entries no longer reach the driver-config validators, and their `browser` conditions are moot: ' +
365+
'withdrawing them is a decision, not something this build should paper over.',
366+
);
367+
}
368+
const pending = [...reached];
369+
for (let file = pending.pop(); file !== undefined; file = pending.pop()) {
370+
for (const importer of importers.get(file) ?? []) {
371+
if (!reached.has(importer)) {
372+
reached.add(importer);
373+
pending.push(importer);
374+
}
375+
}
376+
}
377+
return reached;
378+
}
379+
230380
/**
231381
* Generate DTS separately to avoid memory issues — this pass is by far the
232382
* heaviest thing this package's build does, and `package.json` runs it under an
@@ -313,8 +463,10 @@ const swapServerOnlyGrammarArm: Plugin = {
313463
*/
314464
const isDts = process.env.BUILD_DTS === 'true';
315465

316-
// Exported for `scripts/pure-schema-construction.test.ts`, which builds the
317-
// `./migrations` entry with these options; tsup itself reads only the default.
466+
// The per-entry options. The DTS pass reads exactly these (declarations once
467+
// per entry, one program — the docblock above). Exported for
468+
// `scripts/pure-schema-construction.test.ts`, which builds single entries with
469+
// them; the published JS is `jsConfig` below, which derives from them.
318470
export const mainConfig: Options = {
319471
entry: entries,
320472
splitting: false,
@@ -330,27 +482,29 @@ export const mainConfig: Options = {
330482
};
331483

332484
/**
333-
* The `browser`-conditioned outputs (#11072) — same entry shapes, same
334-
* formats, same target as the main pass, differing ONLY in the grammar-arm
335-
* swap and the `dist/browser/` outDir. No DTS pass of its own: the browser
336-
* build's public API is identical by construction (the swapped module keeps
337-
* the contract), so the exports map points its `browser.types` at the main
338-
* build's declarations.
485+
* Every JS output, as one module graph with shared chunks (`oneModuleGraph`,
486+
* #8133): each entry at `dist/<entry>`, and the `browser`-conditioned outputs
487+
* (#11072) at `dist/browser/<entry>` — the same entry shapes, formats and
488+
* target, differing only in the grammar-arm swap. No DTS of their own: the
489+
* browser build's public API is identical by construction (the swapped module
490+
* keeps the contract), so the exports map points `browser.types` at the main
491+
* declarations. Chunks land at the `dist/` root, `chunk-<hash>.mjs` and
492+
* `chunk-<hash>.js`; nothing outside this package may name one — the exports
493+
* map is the only contract, and it is unchanged.
494+
*
495+
* `oneModuleGraph` goes FIRST: its node-side modules must be loaded by its own
496+
* `onLoad`, not by `pureSchemaConstruction`'s file-namespace reading, and it
497+
* annotates them the same way.
339498
*/
340-
const browserConfig: Options = {
341-
entry: browserConditionedEntries,
342-
outDir: 'dist/browser',
343-
splitting: false,
344-
sourcemap: true,
345-
clean: false, // dist was cleaned (or preserved) by the main pass
346-
dts: false,
347-
format: ['esm', 'cjs'],
348-
target: 'es2020',
349-
treeshake: true,
350-
esbuildPlugins: [pureSchemaConstruction, swapServerOnlyGrammarArm],
351-
esbuildOptions: dropSourcesContent,
499+
const jsConfig: Options = {
500+
...mainConfig,
501+
entry: Object.fromEntries([
502+
...entries.map((entry) => [outName(entry), entry]),
503+
...browserConditionedEntries.map((entry) => [`browser/${outName(entry)}`, entry]),
504+
]),
505+
splitting: true,
506+
esbuildPlugins: [oneModuleGraph, pureSchemaConstruction],
352507
};
353508

354-
// The DTS pass re-runs only the main config (declarations once, per entry);
355-
// the JS pass emits the Node outputs and then the browser-conditioned ones.
356-
export default defineConfig(isDts ? mainConfig : [mainConfig, browserConfig]);
509+
// The DTS pass re-runs only the per-entry config; the JS pass is the one graph.
510+
export default defineConfig(isDts ? mainConfig : jsConfig);

0 commit comments

Comments
 (0)