@ultimat3/cli 22.11.0 → 22.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,13 @@
4
4
  // `data-x-entry` carries. One entry point per island is axiom 6 made mechanical — the page's graph
5
5
  // never reaches an island, so a `site/` document stays at 0kb whatever the island imports.
6
6
  //
7
+ // `islands: { sharedChunks: true }` builds every island in ONE split build instead, so a module two
8
+ // islands import is one shared chunk a browser fetches once (`island-link.ts` names and links the
9
+ // outputs). Measured on a fixture of two islands sharing a 20 kB module, As of 2026-09-29: 41,926 B
10
+ // charged to the page with both off, 21,996 B on; the page with one of them 21,837 → 21,894 B. Off
11
+ // by default: tree shaking across one split build keeps what ANY importer uses (`config-islands.ts`
12
+ // in `@ultimat3/core` has the measurement that decided it).
13
+ //
7
14
  // No page-client bootstrap is prepended (plan 101, decided 2026-09-22): the handle is one
8
15
  // `globalThis` object created lazily by the first transport call or realtime hook. Measured before
9
16
  // that decision on `examples/dummy`, a core-importing wrapper cost contact-sales 875 → 8,827 B.
@@ -14,11 +21,19 @@
14
21
  // Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
15
22
  // POSIX by construction), `join`/`basename` the filesystem side.
16
23
  import { basename, join, posix, relative, sep } from 'node:path';
17
- import { frameworkVersion, renderThrowable } from '@ultimat3/core';
18
24
  import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
19
- import { contentHash } from '@ultimat3/render/server';
25
+ import { APP_CONFIG_EXPORT } from './app-auth';
26
+ import { APP_CONFIG_FILE } from './app-root';
20
27
  import { IslandBuildFailedError } from './errors';
21
- import { islandRealtimePlugin, REALTIME_ISLAND_ENTRY, reachesRealtime } from './island-realtime';
28
+ import { describeBuildError, sourcesContentOf, stableChunk, stripDebugId } from './island-identity';
29
+ import type { BuiltOutput, LinkedFile } from './island-link';
30
+ import { linkOutputs, sharedChunkName } from './island-link';
31
+ import {
32
+ islandRealtimePlugin,
33
+ REALTIME_ISLAND_ENTRY,
34
+ reachesRealtime,
35
+ realtimeIslandEntry,
36
+ } from './island-realtime';
22
37
  import { solidDedupePlugin } from './island-solid-dedupe';
23
38
  import { islandStylesPlugin } from './island-styles';
24
39
  import { hasPathSegment } from './path-segments';
@@ -51,10 +66,27 @@ export interface IslandChunk {
51
66
  /** The built JavaScript. Held in memory so `x dev` and the container serve without a disk hop. */
52
67
  readonly code: string;
53
68
  readonly bytes: number;
69
+ /**
70
+ * Every shared chunk URL this island loads, transitively and sorted — what booting it costs
71
+ * beyond `bytes`, what a precached page must carry with it. Empty for an island sharing nothing.
72
+ */
73
+ readonly imports: readonly string[];
74
+ }
75
+
76
+ /** A module two or more islands import, served once beside them. */
77
+ export interface SharedChunk {
78
+ /** `/islands/chunk-<identity>.js` — source-addressed like an entry, so `immutable` holds. */
79
+ readonly url: string;
80
+ readonly code: string;
81
+ readonly bytes: number;
82
+ /** The islands (app-root-relative) that load it — for a finding that names what an author edits. */
83
+ readonly importers: readonly string[];
54
84
  }
55
85
 
56
86
  export interface IslandBundle {
57
87
  readonly chunks: readonly IslandChunk[];
88
+ /** The shared chunks the entries import — never a `data-x-entry`, always beside one. */
89
+ readonly shared: readonly SharedChunk[];
58
90
  /**
59
91
  * The `resolve` a collector is built with, bound to the route file the specifier is relative to.
60
92
  * Every island on that page goes through it, so an unbuildable specifier fails the render rather
@@ -63,6 +95,8 @@ export interface IslandBundle {
63
95
  resolverFor(routeFile: string): (src: string) => string;
64
96
  /** The chunk a URL names — for serving it, and for naming the island a budget finding blames. */
65
97
  chunkAt(url: string): IslandChunk | undefined;
98
+ /** An entry OR a shared chunk at `url` — what the `/islands/*` route serves. */
99
+ assetAt(url: string): IslandChunk | SharedChunk | undefined;
66
100
  }
67
101
 
68
102
  /** App-root-relative POSIX paths of every client entry, sorted, so a build is reproducible. */
@@ -76,28 +110,45 @@ export async function discoverIslands(root: string): Promise<readonly string[]>
76
110
  }
77
111
 
78
112
  /**
79
- * One `Bun.build` per island, never one call with N entry points: splitting is off, so each chunk
80
- * is self-contained and its size is the whole answer to "what does booting this island cost?" —
81
- * a shared chunk would make the honest number a graph walk, and the budget compares against bytes.
113
+ * One `Bun.build` over `files`. With `splitting`, a module two of them import becomes one shared
114
+ * chunk, fetched once by a page that renders both — a page rendering two islands that shared an
115
+ * upload helper downloaded it twice without (notificado.co, 2026-09-29: 55.5 kB for ~34 kB of
116
+ * code). Without, `files` is one island and its chunk is self-contained. Either way a route's cost
117
+ * is the graph walk `measureDocumentJs` does off the emitted files.
82
118
  */
83
- async function buildOne(root: string, file: string): Promise<IslandChunk> {
84
- // `Bun.build` REJECTS on a failed bundle, it does not answer `success: false` — so the catch is
85
- // the real path here and the `success` test below is the belt for a future default.
86
- // Only an island whose own graph reaches `@ultimat3/realtime` is wrapped (`island-realtime.ts`);
87
- // every other one is built from its own file, byte for byte what it was.
88
- // Inside the refusal too: the realtime probe PARSES the island's graph, and a file that will not
119
+ async function buildAll(
120
+ root: string,
121
+ files: readonly string[],
122
+ splitting: boolean,
123
+ ): Promise<{ readonly chunks: readonly IslandChunk[]; readonly shared: readonly SharedChunk[] }> {
124
+ if (files.length === 0) return { chunks: [], shared: [] };
125
+ // Inside the refusal: the realtime probe PARSES the island's graph, and a file that will not
89
126
  // parse rejected with a raw `AggregateError: Failed to scan imports` — out of the web boot, with
90
127
  // no code, no file and no fix — before `Bun.build` ever ran to raise the one below.
91
- const realtime = await reachesRealtime(root, file).catch((error: unknown) => {
92
- throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
93
- });
128
+ const realtime = await Promise.all(
129
+ files.map((file) =>
130
+ reachesRealtime(root, file).catch((error: unknown) => {
131
+ throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
132
+ }),
133
+ ),
134
+ );
135
+ // Only an island whose own graph reaches `@ultimat3/realtime` is wrapped (`island-realtime.ts`);
136
+ // every other one is built from its own file, byte for byte what it was.
137
+ const live = files.filter((_, index) => realtime[index] === true);
138
+ const entrypoints = files.map((file, index) =>
139
+ realtime[index] === true ? realtimeIslandEntry(file) : join(root, file),
140
+ );
94
141
  let built: Awaited<ReturnType<typeof Bun.build>>;
95
142
  try {
96
143
  built = await Bun.build({
97
- entrypoints: [realtime ? REALTIME_ISLAND_ENTRY : join(root, file)],
144
+ entrypoints,
145
+ root,
98
146
  target: 'browser',
99
147
  format: 'esm',
100
- splitting: false,
148
+ splitting,
149
+ // `[dir]` keeps two islands sharing a filename apart and maps an output back to its island;
150
+ // a chunk's Bun hash is only a placeholder — `island-link.ts` renames every one.
151
+ naming: { entry: '[dir]/[name].[ext]', chunk: 'chunk-[hash].[ext]' },
101
152
  minify: true,
102
153
  // A build with no `plugins` is a build with no JSX transform: `Bun.plugin` installs into the
103
154
  // RUNTIME's loader and `Bun.build` walks its own graph, so render's `.tsx` loader never sees
@@ -113,7 +164,7 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
113
164
  // so the `solid-js/web` helpers the JSX transform writes into a symlinked package resolve
114
165
  // to the app's one copy. See `island-solid-dedupe.ts` for the measurement.
115
166
  plugins: [
116
- ...(realtime ? [islandRealtimePlugin(root, file)] : []),
167
+ ...(live[0] === undefined ? [] : [islandRealtimePlugin(root, live[0])]),
117
168
  solidDedupePlugin(root),
118
169
  solidJsxPlugin,
119
170
  islandStylesPlugin,
@@ -133,180 +184,158 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
133
184
  // NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
134
185
  define: { 'process.env.NODE_ENV': '"production"' },
135
186
  // The fourth, and it is asked for its INPUT list rather than its output: `sourcesContent` is
136
- // the whole module graph this chunk was built from, which is the only stable identity a
137
- // chunk has. See `graphHash`. Measured on 1.4.0 against a 131 kB island: 277ms with it and
187
+ // the whole module graph each output was built from, which is the only stable identity an
188
+ // output has. See `graphHash`. Measured on 1.4.0 against a 131 kB island: 277ms with it and
138
189
  // 276ms without, so the map costs nothing worth naming.
139
190
  sourcemap: 'external',
140
191
  });
141
192
  } catch (error) {
142
- throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
193
+ throw await blame(root, files, error, splitting);
143
194
  }
144
- const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
145
- const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
146
- if (!built.success || output === undefined || map === undefined) {
195
+ if (!built.success) {
147
196
  throw new IslandBuildFailedError({
148
- file,
197
+ file: files.join(', '),
149
198
  logs: built.logs.map((log) => String(log)).join('; '),
150
199
  });
151
200
  }
152
- const code = stripDebugId(await output.text());
153
- const hash = graphHash(file, await map.text());
154
- const moduleId = islandModuleId(basename(file));
155
- return {
156
- file,
157
- moduleId,
158
- url: `${ISLAND_BASE_PATH}/${moduleId}-${hash}.js`,
159
- // The FIRST bytes this process emitted for these inputs, so a URL served `immutable` answers
160
- // one byte string for as long as the process lives. Without it `x dev` re-mints the chunk on
161
- // every watcher tick and a browser holding the previous one under `max-age=31536000` has two
162
- // different files at one address. `bytes` is measured on THAT code, never on this build's.
163
- ...stableChunk(file, hash, code),
164
- };
165
- }
166
-
167
- /**
168
- * `sourcemap: 'external'` appends `//# debugId=<hex>` to the chunk. It is a pointer to a map this
169
- * framework does not serve, so it is removed rather than shipped — and removing it makes the
170
- * emitted bytes identical to what the same build produced before the map was asked for, which is
171
- * what keeps `bytes` a budget number and not a build-flag artefact. `slice`, never a `replace` with
172
- * an empty replacement — `bun run sql-literal-copies` refuses that shape anywhere but `db/sql.ts`.
173
- */
174
- const DEBUG_ID_COMMENT = '\n//# debugId=';
175
-
176
- export function stripDebugId(code: string): string {
177
- const at = code.lastIndexOf(DEBUG_ID_COMMENT);
178
- return at === -1 ? code : code.slice(0, at);
179
- }
180
-
181
- /**
182
- * The chunk's identity, computed from what went IN rather than from what came out.
183
- *
184
- * `Bun.build` is not byte-deterministic under `minify`. Measured on 1.4.0, one entry point, no
185
- * source file touched: a 131,589-byte island alternated between two outputs of IDENTICAL length
186
- * differing only in minified identifier names (`var ca=Object.defineProperty` against
187
- * `var la=…`) — roughly one build in ten, which is a race in the renamer and not anything a caller
188
- * can order. Hashing that output made the URL flap: ten distinct `session-console-*.js` names in
189
- * ten minutes, so a service worker's precache manifest named a chunk that already 404ed and a
190
- * browser's `immutable` cache never hit on a 131 kB download. Twelve consecutive builds hash
191
- * identically here.
192
- *
193
- * `sourcesContent`, hashed per file and SORTED, so the identity is independent of the order the
194
- * bundler happened to visit the graph in. The PATHS are deliberately not in it: they are absolute
195
- * on the build machine and would make a chunk built in a container disagree with the same chunk
196
- * built on a laptop for no difference a browser could observe. `file` is, so two islands with
197
- * byte-identical sources under different names stay two chunks; the framework version and the Bun
198
- * version are, because both decide the emitted bytes while no source file moves — an upgrade must
199
- * mint a new URL rather than leave a stale chunk pinned in a browser for a year.
200
- *
201
- * What this gives up, stated plainly: the URL is source-addressed, not byte-addressed, so two
202
- * processes building the same sources can serve two byte-strings at one URL. They are the same
203
- * program under different local identifier names. That is the trade a nondeterministic bundler
204
- * forces, and the alternative — `minify: { identifiers: false }`, which IS deterministic — was
205
- * measured at 193,590 bytes against 131,649, +47% raw and +20% gzipped, on every island of every
206
- * app. Delete this the day `Bun.build` is deterministic.
207
- */
208
- export function graphHash(file: string, map: string): string {
209
- const parsed: unknown = JSON.parse(map);
210
- const contents = sourcesContentOf(parsed);
211
- if (contents === undefined) {
212
- throw new IslandBuildFailedError({
213
- file,
214
- logs: 'the bundler emitted a source map with no sourcesContent, so the chunk has no stable identity',
215
- });
216
- }
217
- const graph = contents.map((source) => contentHash(source)).sort();
218
- return contentHash([file, frameworkVersion(), Bun.version, ...graph].join('\u0000'));
201
+ const linked = linkOutputs(await builtOutputs(files, built.outputs));
202
+ const chunks = entryChunks(files, linked);
203
+ return { chunks, shared: sharedChunks(chunks, linked) };
219
204
  }
220
205
 
221
206
  /**
222
- * `sourcesContent`, read the way `aggregatedErrors` below reads `errors`: narrowed first,
223
- * dereferenced inside a `try`, `undefined` for anything that is not a full list of strings. A
224
- * partial list is refused rather than padded — a graph with holes in it hashes two different
225
- * islands the same.
207
+ * Which island a failed build is about. One build over N entries answers one `AggregateError`,
208
+ * and an author owed the file to open would get the whole list. Every island is rebuilt ALONE —
209
+ * only on this path, which is already a refusal — and the first that fails alone is the one named.
226
210
  */
227
- function sourcesContentOf(value: unknown): readonly string[] | undefined {
228
- if (typeof value !== 'object' || value === null) return undefined;
229
- try {
230
- const held: unknown = (value as Record<string, unknown>)['sourcesContent'];
231
- if (!Array.isArray(held) || held.length === 0) return undefined;
232
- return held.every((one: unknown) => typeof one === 'string')
233
- ? (held as readonly string[])
234
- : undefined;
235
- } catch {
236
- return undefined;
211
+ async function blame(
212
+ root: string,
213
+ files: readonly string[],
214
+ error: unknown,
215
+ splitting: boolean,
216
+ ): Promise<IslandBuildFailedError> {
217
+ if (files.length > 1) {
218
+ for (const file of files) {
219
+ try {
220
+ await buildAll(root, [file], splitting);
221
+ } catch (alone) {
222
+ if (alone instanceof IslandBuildFailedError) return alone;
223
+ }
224
+ }
237
225
  }
226
+ return new IslandBuildFailedError({ file: files.join(', '), logs: describeBuildError(error) });
238
227
  }
239
228
 
240
229
  /**
241
- * The code this process already emitted for these inputs, or the code it just built.
242
- *
243
- * Keyed by PATH and validated by the input hash, `transformIslandTsx`'s cache's shape and for its
244
- * reason: one entry per island bounds the map by the island count, which is the only quantity that
245
- * should bound it, and an entry whose hash no longer matches is replaced rather than served.
230
+ * Bun's `./a/b.island.js` → `a/b.island.js`. A realtime wrapper's output is named after its
231
+ * VIRTUAL entry, which Bun places relative to the cwd behind `_.._/` segments — so everything up to
232
+ * and including the prefix goes, leaving the island's own spelling.
246
233
  */
247
- const emitted = new Map<string, { readonly graph: string; readonly code: string }>();
234
+ const outputKey = (path: string): string => {
235
+ const bare = path.startsWith('./') ? path.slice(2) : path;
236
+ const at = bare.indexOf(REALTIME_ISLAND_ENTRY);
237
+ return at === -1 ? bare : bare.slice(at + REALTIME_ISLAND_ENTRY.length);
238
+ };
248
239
 
249
- /** Test seam: the table is process-global because the dev server it serves is too. */
250
- export function clearIslandChunkCache(): void {
251
- emitted.clear();
252
- }
240
+ const withoutExtension = (file: string): string =>
241
+ file.slice(0, file.length - posix.extname(file).length);
253
242
 
254
- /**
255
- * `graph`, never `hash`: `bun run secret-compare` reads the NAME of a comparison's operands, and a
256
- * value called `hash` is a digest an attacker may be probing. This one is a build input's
257
- * identity — the same reason `pr-threads.ts` calls a review state `wanted`.
258
- */
259
- export function stableChunk(
260
- file: string,
261
- graph: string,
262
- code: string,
263
- ): { readonly code: string; readonly bytes: number } {
264
- const hit = emitted.get(file);
265
- const served = hit !== undefined && hit.graph === graph ? hit.code : code;
266
- if (served === code) emitted.set(file, { graph, code });
267
- // Measured on the code that is SERVED. It was measured on this build's output, which under a
268
- // minifier that renames differently between builds is a second size for one URL in one process
269
- // — and a budget weighed on bytes no browser receives.
270
- return { code: served, bytes: new TextEncoder().encode(served).byteLength };
243
+ /** Every code output paired with its source map's inputs and, for an entry, its island. */
244
+ async function builtOutputs(
245
+ files: readonly string[],
246
+ artifacts: readonly Bun.BuildArtifact[],
247
+ ): Promise<readonly BuiltOutput[]> {
248
+ const byStem = new Map(files.map((file) => [withoutExtension(file), file]));
249
+ const maps = new Map<string, string>();
250
+ for (const artifact of artifacts) {
251
+ if (artifact.kind === 'sourcemap') maps.set(artifact.path, await artifact.text());
252
+ }
253
+ const outputs: BuiltOutput[] = [];
254
+ for (const artifact of artifacts) {
255
+ if (artifact.kind !== 'entry-point' && artifact.kind !== 'chunk') continue;
256
+ const key = outputKey(artifact.path);
257
+ const file = artifact.kind === 'entry-point' ? byStem.get(withoutExtension(key)) : undefined;
258
+ const map = maps.get(`${artifact.path}.map`);
259
+ const sources = map === undefined ? undefined : sourcesContentOf(JSON.parse(map), true);
260
+ if (sources === undefined || (artifact.kind === 'entry-point' && file === undefined)) {
261
+ throw new IslandBuildFailedError({
262
+ file: file ?? key,
263
+ logs: `the bundler emitted ${key} with no source map or no island behind it, so the output has no stable identity`,
264
+ });
265
+ }
266
+ outputs.push({
267
+ path: key,
268
+ ...(file === undefined ? {} : { file }),
269
+ code: stripDebugId(await artifact.text()),
270
+ sources,
271
+ });
272
+ }
273
+ return outputs;
271
274
  }
272
275
 
273
- /**
274
- * The bundler's own diagnostics, kept verbatim. An `AggregateError` holds one entry per unresolved
275
- * import or syntax error, and flattening them is what puts the line number in the cause instead of
276
- * the word "Bundle failed".
277
- */
278
- export function describeBuildError(error: unknown): string {
279
- // `renderThrowable`, never `instanceof` + `.message` + `String()`. All three run on a value this
280
- // process did not build — a `Proxy` traps `getPrototypeOf`, a `message` getter can raise, and
281
- // `String()` throws outright on a Symbol — and what comes back is carried in
282
- // `IslandBuildFailedError.logs`, which `errors.ts` interpolates straight into a `cause:`. That
283
- // is a cross-file hop neither `scripts/catch-render.ts` nor `scripts/error-render.ts` can
284
- // follow: a throw here loses the whole refusal and replaces it with a TypeError about reporting.
285
- //
286
- // The AggregateError branch stays, and it is the reason this function exists: `Bun.build` packs
287
- // one entry per unresolved import or syntax error into `errors`, and flattening them is what
288
- // puts a line number in the cause instead of the words "Bundle failed". `stringField` decides
289
- // whether the value really is that shape, because `instanceof` is a question a Proxy answers.
290
- const aggregate = aggregatedErrors(error);
291
- if (aggregate !== undefined && aggregate.length > 0) {
292
- return aggregate.map((one: unknown) => renderThrowable(one)).join('; ');
276
+ /** The linked outputs, as the chunk table: the entries in `files` order, their chunks attached. */
277
+ function entryChunks(
278
+ files: readonly string[],
279
+ linked: readonly LinkedFile[],
280
+ ): readonly IslandChunk[] {
281
+ const shared = new Map(
282
+ linked
283
+ .filter((one) => one.file === undefined)
284
+ .map((one) => [sharedChunkName(one.identity), one]),
285
+ );
286
+ const closure = (start: readonly string[]): readonly string[] => {
287
+ const seen = new Set<string>();
288
+ const walk = (names: readonly string[]): void => {
289
+ for (const name of names) {
290
+ if (seen.has(name)) continue;
291
+ seen.add(name);
292
+ walk(shared.get(name)?.imports ?? []);
293
+ }
294
+ };
295
+ walk(start);
296
+ return [...seen].sort().map((name) => `${ISLAND_BASE_PATH}/${name}`);
297
+ };
298
+ const chunks: IslandChunk[] = [];
299
+ for (const file of files) {
300
+ const entry = linked.find((one) => one.file === file);
301
+ if (entry === undefined) {
302
+ throw new IslandBuildFailedError({ file, logs: 'the bundler emitted no entry point for it' });
303
+ }
304
+ const moduleId = islandModuleId(basename(file));
305
+ chunks.push({
306
+ file,
307
+ moduleId,
308
+ url: `${ISLAND_BASE_PATH}/${moduleId}-${entry.identity}.js`,
309
+ // The FIRST bytes this process emitted for these inputs, so a URL served `immutable` answers
310
+ // one byte string for as long as the process lives. Without it `x dev` re-mints the chunk on
311
+ // every watcher tick and a browser holding the previous one under `max-age=31536000` has two
312
+ // different files at one address. `bytes` is measured on THAT code, never on this build's.
313
+ ...stableChunk(file, entry.identity, entry.code),
314
+ imports: closure(entry.imports),
315
+ });
293
316
  }
294
- return renderThrowable(error);
317
+ return chunks;
295
318
  }
296
319
 
297
- /**
298
- * `value.errors`, read the way `@ultimat3/core`'s `stringField` reads a string field: narrowed
299
- * first, dereferenced inside a `try`, `undefined` for anything else. `instanceof AggregateError`
300
- * is a question a `Proxy` answers with its own `getPrototypeOf` trap, so it is not a check.
301
- */
302
- function aggregatedErrors(value: unknown): readonly unknown[] | undefined {
303
- if (typeof value !== 'object' || value === null) return undefined;
304
- try {
305
- const held: unknown = (value as Record<string, unknown>)['errors'];
306
- return Array.isArray(held) ? held : undefined;
307
- } catch {
308
- return undefined;
320
+ /** Every shared chunk the entries load, pinned the same way, with the islands that load it. */
321
+ function sharedChunks(
322
+ chunks: readonly IslandChunk[],
323
+ linked: readonly LinkedFile[],
324
+ ): readonly SharedChunk[] {
325
+ const out: SharedChunk[] = [];
326
+ for (const one of linked) {
327
+ if (one.file !== undefined) continue;
328
+ const url = `${ISLAND_BASE_PATH}/${sharedChunkName(one.identity)}`;
329
+ out.push({
330
+ url,
331
+ ...stableChunk(url, one.identity, one.code),
332
+ importers: chunks
333
+ .filter((chunk) => chunk.imports.includes(url))
334
+ .map((chunk) => chunk.file)
335
+ .sort(),
336
+ });
309
337
  }
338
+ return out.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
310
339
  }
311
340
 
312
341
  export interface BuildIslandsOptions {
@@ -320,6 +349,28 @@ export interface BuildIslandsOptions {
320
349
  * is what keeps the `cli -> testing` edge pointing the one legal way.
321
350
  */
322
351
  readonly only?: string;
352
+ /**
353
+ * One split build over every island (`app.config.ts`'s `islands.sharedChunks`, off by default),
354
+ * or one self-contained build per island. Passed, it overrides the app's config — the seam a test
355
+ * asks both questions of one fixture through.
356
+ */
357
+ readonly sharedChunks?: boolean;
358
+ }
359
+
360
+ /**
361
+ * `islands.sharedChunks` as `defineConfig` validated it — `false` for an app with no config file or
362
+ * none of it. Structural, never `instanceof`, for `loadNavigation`'s reason.
363
+ */
364
+ export async function loadSharedChunks(root: string): Promise<boolean> {
365
+ const configPath = join(root, APP_CONFIG_FILE);
366
+ if (!(await Bun.file(configPath).exists())) return false;
367
+ const config: unknown = ((await import(configPath)) as Record<string, unknown>)[
368
+ APP_CONFIG_EXPORT
369
+ ];
370
+ if (typeof config !== 'object' || config === null) return false;
371
+ const islands: unknown = (config as Record<string, unknown>)['islands'];
372
+ if (typeof islands !== 'object' || islands === null) return false;
373
+ return (islands as Record<string, unknown>)['sharedChunks'] === true;
323
374
  }
324
375
 
325
376
  /** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
@@ -334,8 +385,14 @@ export async function buildIslands(
334
385
  // an empty bundle here would surface two steps later, as a chunk table with no entry for a file
335
386
  // the caller can see on disk.
336
387
  if (only !== undefined && files.length === 0) throw onlyMissing(only, discovered);
337
- const chunks = await Promise.all(files.map((file) => buildOne(root, file)));
338
- return islandBundle(chunks);
388
+ if (options.sharedChunks ?? (await loadSharedChunks(root))) {
389
+ const built = await buildAll(root, files, true);
390
+ return islandBundle(built.chunks, built.shared);
391
+ }
392
+ // One build per island, splitting off: each chunk is self-contained, so its size is the whole
393
+ // answer to "what does booting this island cost?" and nothing is shaken for another island.
394
+ const solo = await Promise.all(files.map((file) => buildAll(root, [file], false)));
395
+ return islandBundle(solo.flatMap((one) => one.chunks));
339
396
  }
340
397
 
341
398
  /**
@@ -367,11 +424,16 @@ function onlyMissing(only: string, discovered: readonly string[]): IslandInvalid
367
424
  return new IslandInvalidError(cause, `buildIslands(root, { only: '${nearest}' })`);
368
425
  }
369
426
 
370
- export function islandBundle(chunks: readonly IslandChunk[]): IslandBundle {
427
+ export function islandBundle(
428
+ chunks: readonly IslandChunk[],
429
+ shared: readonly SharedChunk[] = [],
430
+ ): IslandBundle {
371
431
  const byFile = new Map(chunks.map((chunk) => [chunk.file, chunk]));
372
432
  const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
433
+ const sharedByUrl = new Map(shared.map((chunk) => [chunk.url, chunk]));
373
434
  return {
374
435
  chunks,
436
+ shared,
375
437
  resolverFor(routeFile: string): (src: string) => string {
376
438
  const dir = posix.dirname(routeFile);
377
439
  return (src: string): string => {
@@ -382,6 +444,8 @@ export function islandBundle(chunks: readonly IslandChunk[]): IslandBundle {
382
444
  };
383
445
  },
384
446
  chunkAt: (url: string): IslandChunk | undefined => byUrl.get(url),
447
+ assetAt: (url: string): IslandChunk | SharedChunk | undefined =>
448
+ byUrl.get(url) ?? sharedByUrl.get(url),
385
449
  };
386
450
  }
387
451
 
@@ -406,7 +470,7 @@ function entryMissing(
406
470
 
407
471
  /** Write every chunk under the static export, at the same URL the documents already carry. */
408
472
  export async function writeIslands(bundle: IslandBundle, out: string): Promise<void> {
409
- for (const chunk of bundle.chunks) {
473
+ for (const chunk of [...bundle.chunks, ...bundle.shared]) {
410
474
  await Bun.write(join(out, chunk.url.slice(1)), chunk.code);
411
475
  }
412
476
  }