11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { readFile } from 'node:fs/promises' ;
4- import { dirname , join } from 'node:path' ;
4+ import { dirname , join , relative , sep } from 'node:path' ;
55import ts from 'typescript' ;
66import { 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 : / [ \\ / ] p g - u r l - g r a m m a r \. s e r v e r $ / } , ( args ) => ( {
225- path : join ( dirname ( args . importer ) , 'pg-url-grammar.browser.ts' ) ,
226- } ) ) ;
222+ const SERVER_ONLY_GRAMMAR_ARM = / [ \\ / ] p g - u r l - g r a m m a r \. s e r v e r $ / ;
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 ( / ^ s r c \/ / , '' ) . replace ( / \. t s $ / , '' ) ;
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 */
314464const 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.
318470export 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