@ultimat3/cli 22.12.0 → 22.14.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.
- package/package.json +28 -28
- package/src/app-mcp.ts +129 -69
- package/src/budgets.ts +35 -2
- package/src/cmd-dev.ts +8 -3
- package/src/dev-child-watch.ts +144 -0
- package/src/dev-live-fixture.ts +122 -0
- package/src/dev-route-table.ts +4 -2
- package/src/dev-supervisor.ts +32 -3
- package/src/error-codes.ts +2 -0
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +234 -170
- package/src/island-identity.ts +158 -0
- package/src/island-link.ts +138 -0
- package/src/island-realtime.ts +28 -11
- package/src/island-routes.ts +3 -1
- package/src/island-store.ts +65 -8
- package/src/mcp-errors.ts +1 -0
- package/src/prerender.ts +6 -1
- package/src/sw-precache-plan.ts +6 -0
- package/src/worker-bundle.ts +1 -1
package/src/island-bundle.ts
CHANGED
|
@@ -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 {
|
|
25
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
26
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
20
27
|
import { IslandBuildFailedError } from './errors';
|
|
21
|
-
import {
|
|
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`
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
92
|
-
|
|
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
|
|
144
|
+
entrypoints,
|
|
145
|
+
root,
|
|
98
146
|
target: 'browser',
|
|
99
147
|
format: 'esm',
|
|
100
|
-
splitting
|
|
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
|
-
...(
|
|
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
|
|
137
|
-
//
|
|
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
|
|
193
|
+
throw await blame(root, files, error, splitting);
|
|
143
194
|
}
|
|
144
|
-
|
|
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
|
|
153
|
-
const
|
|
154
|
-
|
|
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
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
emitted.clear();
|
|
252
|
-
}
|
|
240
|
+
const withoutExtension = (file: string): string =>
|
|
241
|
+
file.slice(0, file.length - posix.extname(file).length);
|
|
253
242
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
317
|
+
return chunks;
|
|
295
318
|
}
|
|
296
319
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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(
|
|
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
|
}
|