@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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// An island output's identity and the one byte string served under it: the source-graph hash
|
|
2
|
+
// that names a URL (`graphHash`), the per-process pin that keeps one URL one byte string
|
|
3
|
+
// (`stableChunk`), and the bundler-diagnostic flattening every island refusal carries. Split from
|
|
4
|
+
// `island-bundle.ts`, which builds; `worker-bundle.ts` names its chunks by the same rules.
|
|
5
|
+
|
|
6
|
+
import { frameworkVersion, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
8
|
+
import { IslandBuildFailedError } from './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `sourcemap: 'external'` appends `//# debugId=<hex>` to the chunk. It is a pointer to a map this
|
|
12
|
+
* framework does not serve, so it is removed rather than shipped — and removing it makes the
|
|
13
|
+
* emitted bytes identical to what the same build produced before the map was asked for, which is
|
|
14
|
+
* what keeps `bytes` a budget number and not a build-flag artefact. `slice`, never a `replace` with
|
|
15
|
+
* an empty replacement — `bun run sql-literal-copies` refuses that shape anywhere but `db/sql.ts`.
|
|
16
|
+
*/
|
|
17
|
+
const DEBUG_ID_COMMENT = '\n//# debugId=';
|
|
18
|
+
|
|
19
|
+
export function stripDebugId(code: string): string {
|
|
20
|
+
const at = code.lastIndexOf(DEBUG_ID_COMMENT);
|
|
21
|
+
return at === -1 ? code : code.slice(0, at);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The chunk's identity, computed from what went IN rather than from what came out.
|
|
26
|
+
*
|
|
27
|
+
* `Bun.build` is not byte-deterministic under `minify`. Measured on 1.4.0, one entry point, no
|
|
28
|
+
* source file touched: a 131,589-byte island alternated between two outputs of IDENTICAL length
|
|
29
|
+
* differing only in minified identifier names (`var ca=Object.defineProperty` against
|
|
30
|
+
* `var la=…`) — roughly one build in ten, which is a race in the renamer and not anything a caller
|
|
31
|
+
* can order. Hashing that output made the URL flap: ten distinct `session-console-*.js` names in
|
|
32
|
+
* ten minutes, so a service worker's precache manifest named a chunk that already 404ed and a
|
|
33
|
+
* browser's `immutable` cache never hit on a 131 kB download. Twelve consecutive builds hash
|
|
34
|
+
* identically here.
|
|
35
|
+
*
|
|
36
|
+
* `sourcesContent`, hashed per file and SORTED, so the identity is independent of the order the
|
|
37
|
+
* bundler happened to visit the graph in. The PATHS are deliberately not in it: they are absolute
|
|
38
|
+
* on the build machine and would make a chunk built in a container disagree with the same chunk
|
|
39
|
+
* built on a laptop for no difference a browser could observe. `file` is, so two islands with
|
|
40
|
+
* byte-identical sources under different names stay two chunks; the framework version and the Bun
|
|
41
|
+
* version are, because both decide the emitted bytes while no source file moves — an upgrade must
|
|
42
|
+
* mint a new URL rather than leave a stale chunk pinned in a browser for a year.
|
|
43
|
+
*
|
|
44
|
+
* What this gives up, stated plainly: the URL is source-addressed, not byte-addressed, so two
|
|
45
|
+
* processes building the same sources can serve two byte-strings at one URL. They are the same
|
|
46
|
+
* program under different local identifier names. That is the trade a nondeterministic bundler
|
|
47
|
+
* forces, and the alternative — `minify: { identifiers: false }`, which IS deterministic — was
|
|
48
|
+
* measured at 193,590 bytes against 131,649, +47% raw and +20% gzipped, on every island of every
|
|
49
|
+
* app. Delete this the day `Bun.build` is deterministic.
|
|
50
|
+
*/
|
|
51
|
+
export function graphHash(file: string, map: string): string {
|
|
52
|
+
const parsed: unknown = JSON.parse(map);
|
|
53
|
+
const contents = sourcesContentOf(parsed);
|
|
54
|
+
if (contents === undefined) {
|
|
55
|
+
throw new IslandBuildFailedError({
|
|
56
|
+
file,
|
|
57
|
+
logs: 'the bundler emitted a source map with no sourcesContent, so the chunk has no stable identity',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const graph = contents.map((source) => contentHash(source)).sort();
|
|
61
|
+
return contentHash([file, frameworkVersion(), Bun.version, ...graph].join('\u0000'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `sourcesContent`, read the way `aggregatedErrors` below reads `errors`: narrowed first,
|
|
66
|
+
* dereferenced inside a `try`, `undefined` for anything that is not a full list of strings. A
|
|
67
|
+
* partial list is refused rather than padded — a graph with holes in it hashes two different
|
|
68
|
+
* islands the same.
|
|
69
|
+
*/
|
|
70
|
+
export function sourcesContentOf(
|
|
71
|
+
value: unknown,
|
|
72
|
+
allowEmpty = false,
|
|
73
|
+
): readonly string[] | undefined {
|
|
74
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
75
|
+
try {
|
|
76
|
+
const held: unknown = (value as Record<string, unknown>)['sourcesContent'];
|
|
77
|
+
// Empty is admitted only for a split build's output, where a chunk of bundler runtime helpers
|
|
78
|
+
// has no source file behind it and `island-link.ts` names it by its code instead.
|
|
79
|
+
if (!Array.isArray(held) || (held.length === 0 && !allowEmpty)) return undefined;
|
|
80
|
+
return held.every((one: unknown) => typeof one === 'string')
|
|
81
|
+
? (held as readonly string[])
|
|
82
|
+
: undefined;
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The code this process already emitted for these inputs, or the code it just built.
|
|
90
|
+
*
|
|
91
|
+
* Keyed by PATH and validated by the input hash, `transformIslandTsx`'s cache's shape and for its
|
|
92
|
+
* reason: one entry per island bounds the map by the island count, which is the only quantity that
|
|
93
|
+
* should bound it, and an entry whose hash no longer matches is replaced rather than served.
|
|
94
|
+
*/
|
|
95
|
+
const emitted = new Map<string, { readonly graph: string; readonly code: string }>();
|
|
96
|
+
|
|
97
|
+
/** Test seam: the table is process-global because the dev server it serves is too. */
|
|
98
|
+
export function clearIslandChunkCache(): void {
|
|
99
|
+
emitted.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `graph`, never `hash`: `bun run secret-compare` reads the NAME of a comparison's operands, and a
|
|
104
|
+
* value called `hash` is a digest an attacker may be probing. This one is a build input's
|
|
105
|
+
* identity — the same reason `pr-threads.ts` calls a review state `wanted`.
|
|
106
|
+
*/
|
|
107
|
+
export function stableChunk(
|
|
108
|
+
file: string,
|
|
109
|
+
graph: string,
|
|
110
|
+
code: string,
|
|
111
|
+
): { readonly code: string; readonly bytes: number } {
|
|
112
|
+
const hit = emitted.get(file);
|
|
113
|
+
const served = hit !== undefined && hit.graph === graph ? hit.code : code;
|
|
114
|
+
if (served === code) emitted.set(file, { graph, code });
|
|
115
|
+
// Measured on the code that is SERVED. It was measured on this build's output, which under a
|
|
116
|
+
// minifier that renames differently between builds is a second size for one URL in one process
|
|
117
|
+
// — and a budget weighed on bytes no browser receives.
|
|
118
|
+
return { code: served, bytes: new TextEncoder().encode(served).byteLength };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The bundler's own diagnostics, kept verbatim. An `AggregateError` holds one entry per unresolved
|
|
123
|
+
* import or syntax error, and flattening them is what puts the line number in the cause instead of
|
|
124
|
+
* the word "Bundle failed".
|
|
125
|
+
*/
|
|
126
|
+
export function describeBuildError(error: unknown): string {
|
|
127
|
+
// `renderThrowable`, never `instanceof` + `.message` + `String()`. All three run on a value this
|
|
128
|
+
// process did not build — a `Proxy` traps `getPrototypeOf`, a `message` getter can raise, and
|
|
129
|
+
// `String()` throws outright on a Symbol — and what comes back is carried in
|
|
130
|
+
// `IslandBuildFailedError.logs`, which `errors.ts` interpolates straight into a `cause:`. That
|
|
131
|
+
// is a cross-file hop neither `scripts/catch-render.ts` nor `scripts/error-render.ts` can
|
|
132
|
+
// follow: a throw here loses the whole refusal and replaces it with a TypeError about reporting.
|
|
133
|
+
//
|
|
134
|
+
// The AggregateError branch stays, and it is the reason this function exists: `Bun.build` packs
|
|
135
|
+
// one entry per unresolved import or syntax error into `errors`, and flattening them is what
|
|
136
|
+
// puts a line number in the cause instead of the words "Bundle failed". `stringField` decides
|
|
137
|
+
// whether the value really is that shape, because `instanceof` is a question a Proxy answers.
|
|
138
|
+
const aggregate = aggregatedErrors(error);
|
|
139
|
+
if (aggregate !== undefined && aggregate.length > 0) {
|
|
140
|
+
return aggregate.map((one: unknown) => renderThrowable(one)).join('; ');
|
|
141
|
+
}
|
|
142
|
+
return renderThrowable(error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `value.errors`, read the way `@ultimat3/core`'s `stringField` reads a string field: narrowed
|
|
147
|
+
* first, dereferenced inside a `try`, `undefined` for anything else. `instanceof AggregateError`
|
|
148
|
+
* is a question a `Proxy` answers with its own `getPrototypeOf` trap, so it is not a check.
|
|
149
|
+
*/
|
|
150
|
+
function aggregatedErrors(value: unknown): readonly unknown[] | undefined {
|
|
151
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
152
|
+
try {
|
|
153
|
+
const held: unknown = (value as Record<string, unknown>)['errors'];
|
|
154
|
+
return Array.isArray(held) ? held : undefined;
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// One split `Bun.build` of every island, turned into the files a browser fetches: an entry per
|
|
2
|
+
// island and a chunk per module two or more islands share, each named by its SOURCE identity and
|
|
3
|
+
// importing the others by that name. `island-bundle.ts` runs the build; this file only links.
|
|
4
|
+
//
|
|
5
|
+
// Why the names are rewritten rather than taken from Bun's `chunk-[hash]`: that hash is of the
|
|
6
|
+
// OUTPUT, and `Bun.build` under `minify` is not byte-deterministic (`graphHash` in
|
|
7
|
+
// `island-bundle.ts` has the measurement). An output-addressed shared chunk flaps its URL, and
|
|
8
|
+
// every entry importing it flaps with it — the precache-404 and cold-cache defect, back.
|
|
9
|
+
|
|
10
|
+
import { posix } from 'node:path'; // why: Bun ships no path API; `basename` of a specifier.
|
|
11
|
+
import { frameworkVersion } from '@ultimat3/core';
|
|
12
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
13
|
+
import { IslandBuildFailedError } from './errors';
|
|
14
|
+
|
|
15
|
+
/** One code output of the build, as `island-bundle.ts` hands it over. */
|
|
16
|
+
export interface BuiltOutput {
|
|
17
|
+
/** Bun's own output path, `./` stripped: `apps/web/app/x.island.js`, `chunk-ab12cd34.js`. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
/** The island file it is the entry for; `undefined` for a shared chunk. */
|
|
20
|
+
readonly file?: string;
|
|
21
|
+
/** Emitted code, the debug-id comment already removed. */
|
|
22
|
+
readonly code: string;
|
|
23
|
+
/** Its source map's `sourcesContent` — empty for a chunk of bundler runtime helpers only. */
|
|
24
|
+
readonly sources: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LinkedFile {
|
|
28
|
+
/** The output this came from, so the caller can pair it with its island. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly file?: string;
|
|
31
|
+
/** `chunk-<identity>` for a shared chunk; the caller prefixes an entry with its module id. */
|
|
32
|
+
readonly identity: string;
|
|
33
|
+
/** The code with every import of another output rewritten to that output's final name. */
|
|
34
|
+
readonly code: string;
|
|
35
|
+
/** Final basenames of the outputs this one imports, statically or by `import()`, direct only. */
|
|
36
|
+
readonly imports: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The final basename a shared chunk is served under — beside every entry, under one base path. */
|
|
40
|
+
export const sharedChunkName = (identity: string): string => `chunk-${identity}.js`;
|
|
41
|
+
|
|
42
|
+
/** Static imports and `import()` both: a browser fetches either before the island is whole. */
|
|
43
|
+
const LOADS = new Set(['import-statement', 'dynamic-import']);
|
|
44
|
+
|
|
45
|
+
interface Scanned {
|
|
46
|
+
readonly exports: readonly string[];
|
|
47
|
+
/** Specifiers exactly as written, each naming another output of this build. */
|
|
48
|
+
readonly specifiers: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scan(code: string, byName: ReadonlyMap<string, BuiltOutput>): Scanned {
|
|
52
|
+
const scanned = new Bun.Transpiler({ loader: 'js' }).scan(code);
|
|
53
|
+
const specifiers = scanned.imports
|
|
54
|
+
.filter((one) => LOADS.has(one.kind) && byName.has(posix.basename(one.path)))
|
|
55
|
+
.map((one) => one.path);
|
|
56
|
+
return { exports: [...scanned.exports].sort(), specifiers: [...new Set(specifiers)] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every output's identity, from what went in: its own sources (hashed and sorted, as `graphHash`
|
|
61
|
+
* does), its EXPORT names, and the identities of every output it imports. The exports are in it
|
|
62
|
+
* because they are the link between two files built together — an importer's `import{a as o}`
|
|
63
|
+
* is only correct against a chunk exporting `a`, so a chunk whose interface moved must not keep a
|
|
64
|
+
* URL an older importer is holding. The imports are in it because an entry's bytes NAME its
|
|
65
|
+
* chunks: a moved chunk moves every entry that loads it.
|
|
66
|
+
*/
|
|
67
|
+
export function linkOutputs(outputs: readonly BuiltOutput[]): readonly LinkedFile[] {
|
|
68
|
+
const byName = new Map(outputs.map((one) => [posix.basename(one.path), one]));
|
|
69
|
+
const scans = new Map(outputs.map((one) => [one.path, scan(one.code, byName)]));
|
|
70
|
+
const identities = new Map<string, string>();
|
|
71
|
+
const visiting = new Set<string>();
|
|
72
|
+
|
|
73
|
+
const identityOf = (output: BuiltOutput): string => {
|
|
74
|
+
const known = identities.get(output.path);
|
|
75
|
+
if (known !== undefined) return known;
|
|
76
|
+
if (visiting.has(output.path)) throw cycle(output);
|
|
77
|
+
visiting.add(output.path);
|
|
78
|
+
const scanned = scans.get(output.path) ?? { exports: [], specifiers: [] };
|
|
79
|
+
const deps = scanned.specifiers
|
|
80
|
+
.map((spec) => byName.get(posix.basename(spec)))
|
|
81
|
+
.filter((dep): dep is BuiltOutput => dep !== undefined)
|
|
82
|
+
.map(identityOf)
|
|
83
|
+
.sort();
|
|
84
|
+
// A chunk of bundler runtime helpers has no source file behind it — its code IS its source.
|
|
85
|
+
const own = output.sources.length === 0 ? [contentHash(output.code)] : output.sources;
|
|
86
|
+
const identity = contentHash(
|
|
87
|
+
[
|
|
88
|
+
output.file ?? 'shared',
|
|
89
|
+
frameworkVersion(),
|
|
90
|
+
Bun.version,
|
|
91
|
+
...own.map((source) => contentHash(source)).sort(),
|
|
92
|
+
'exports',
|
|
93
|
+
...scanned.exports,
|
|
94
|
+
'imports',
|
|
95
|
+
...deps,
|
|
96
|
+
].join('\u0000'),
|
|
97
|
+
);
|
|
98
|
+
visiting.delete(output.path);
|
|
99
|
+
identities.set(output.path, identity);
|
|
100
|
+
return identity;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return outputs.map((output) => {
|
|
104
|
+
const identity = identityOf(output);
|
|
105
|
+
const scanned = scans.get(output.path) ?? { exports: [], specifiers: [] };
|
|
106
|
+
let code = output.code;
|
|
107
|
+
const imports: string[] = [];
|
|
108
|
+
for (const spec of scanned.specifiers) {
|
|
109
|
+
const dep = byName.get(posix.basename(spec));
|
|
110
|
+
if (dep === undefined) continue;
|
|
111
|
+
const name = sharedChunkName(identityOf(dep));
|
|
112
|
+
imports.push(name);
|
|
113
|
+
// The quoted literal, whole: Bun writes every specifier double-quoted, and a chunk name is a
|
|
114
|
+
// hash no program text contains by accident. Relative, so it resolves beside the importer
|
|
115
|
+
// under any base path — `/islands/` served, a temp directory in `mountIsland`.
|
|
116
|
+
code = code.split(JSON.stringify(spec)).join(JSON.stringify(`./${name}`));
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
path: output.path,
|
|
120
|
+
...(output.file === undefined ? {} : { file: output.file }),
|
|
121
|
+
identity,
|
|
122
|
+
code,
|
|
123
|
+
imports: [...new Set(imports)].sort(),
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Two outputs importing each other: esbuild-family splitting does not emit one, and an identity
|
|
130
|
+
* built from its imports' identities has no answer for it. Refused by name rather than answered
|
|
131
|
+
* with a hash that silently ignores an edge.
|
|
132
|
+
*/
|
|
133
|
+
function cycle(output: BuiltOutput): IslandBuildFailedError {
|
|
134
|
+
return new IslandBuildFailedError({
|
|
135
|
+
file: output.file ?? output.path,
|
|
136
|
+
logs: `the island build emitted a chunk import cycle through ${output.path}, so its outputs have no source identity to be named by`,
|
|
137
|
+
});
|
|
138
|
+
}
|
package/src/island-realtime.ts
CHANGED
|
@@ -8,13 +8,20 @@ import { dirname, join } from 'node:path';
|
|
|
8
8
|
import type { BunPlugin } from 'bun';
|
|
9
9
|
import { firstInGraph } from './live-routes';
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* What `Bun.build` is handed for a realtime island: this prefix plus the island's app-root-relative
|
|
13
|
+
* path, so ONE build can carry every realtime island's wrapper as its own entry point. Resolved by
|
|
14
|
+
* `islandRealtimePlugin`, and the prefix is what `island-bundle.ts` strips back off Bun's output path.
|
|
15
|
+
*/
|
|
16
|
+
export const REALTIME_ISLAND_ENTRY = 'ultimate:island-entry:';
|
|
17
|
+
|
|
18
|
+
/** The entry point `Bun.build` is handed for a realtime island. */
|
|
19
|
+
export const realtimeIslandEntry = (file: string): string => `${REALTIME_ISLAND_ENTRY}${file}`;
|
|
13
20
|
|
|
14
21
|
const REALTIME = '@ultimat3/realtime';
|
|
15
22
|
const NAMESPACE = 'ultimate-island';
|
|
16
23
|
const INSTALL = 'ultimate:island-realtime';
|
|
17
|
-
const MODULE = 'ultimate:island-module';
|
|
24
|
+
const MODULE = 'ultimate:island-module:';
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Whether the island's own graph value-imports realtime. Relative specifiers only, which is the
|
|
@@ -51,9 +58,11 @@ export function realtimeIslandFiles(): ReadonlySet<string> {
|
|
|
51
58
|
/**
|
|
52
59
|
* `export *` and never a named list: the hydration runtime reads `mount` off the module, and the
|
|
53
60
|
* wrapper must not decide which of an island's names survive. The install is imported FIRST, so it
|
|
54
|
-
* has run before the island's module body — and every hook the island calls — does.
|
|
61
|
+
* has run before the island's module body — and every hook the island calls — does. One install
|
|
62
|
+
* module for every realtime island of the build, so a page with two of them loads it once.
|
|
55
63
|
*/
|
|
56
|
-
const
|
|
64
|
+
const entrySource = (file: string): string =>
|
|
65
|
+
`import '${INSTALL}';\nexport * from '${MODULE}${file}';\n`;
|
|
57
66
|
|
|
58
67
|
/**
|
|
59
68
|
* The install names `@ultimat3/realtime` BARE, and `islandRealtimePlugin` resolves it from the
|
|
@@ -68,29 +77,37 @@ const INSTALL_SOURCE =
|
|
|
68
77
|
`import { createSignal } from 'solid-js';\n` +
|
|
69
78
|
`installRealtime({ signal: createSignal });\n`;
|
|
70
79
|
|
|
80
|
+
/**
|
|
81
|
+
* `file` is the island whose directory every bare `@ultimat3/realtime` resolves from — the first
|
|
82
|
+
* realtime island of the build. One directory for all of them, because one copy in the bundle is
|
|
83
|
+
* the requirement: the signal the install sets is only read by hooks from the same module instance.
|
|
84
|
+
*/
|
|
71
85
|
export function islandRealtimePlugin(root: string, file: string): BunPlugin {
|
|
72
86
|
const island = join(root, file);
|
|
73
87
|
return {
|
|
74
88
|
name: 'ultimate-island-realtime',
|
|
75
89
|
setup(build) {
|
|
76
|
-
build.onResolve({ filter: /^ultimate:island-entry
|
|
77
|
-
path:
|
|
90
|
+
build.onResolve({ filter: /^ultimate:island-entry:/ }, (args) => ({
|
|
91
|
+
path: `entry:${args.path.slice(REALTIME_ISLAND_ENTRY.length)}`,
|
|
78
92
|
namespace: NAMESPACE,
|
|
79
93
|
}));
|
|
80
94
|
build.onResolve({ filter: /^ultimate:island-realtime$/ }, () => ({
|
|
81
95
|
path: 'install',
|
|
82
96
|
namespace: NAMESPACE,
|
|
83
97
|
}));
|
|
84
|
-
build.onResolve({ filter: /^ultimate:island-module
|
|
98
|
+
build.onResolve({ filter: /^ultimate:island-module:/ }, (args) => ({
|
|
99
|
+
path: join(root, args.path.slice(MODULE.length)),
|
|
100
|
+
}));
|
|
85
101
|
// Every bare `@ultimat3/realtime` in this build, the virtual install's included — a virtual
|
|
86
102
|
// module has no directory to resolve from. The island's directory for all of them, which is
|
|
87
|
-
// what the island itself would get
|
|
88
|
-
// the signal the install sets is only read by hooks from the same module instance.
|
|
103
|
+
// what the island itself would get.
|
|
89
104
|
build.onResolve({ filter: /^@ultimat3\/realtime$/ }, () => ({
|
|
90
105
|
path: Bun.resolveSync(REALTIME, dirname(island)),
|
|
91
106
|
}));
|
|
92
107
|
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => ({
|
|
93
|
-
contents: args.path
|
|
108
|
+
contents: args.path.startsWith('entry:')
|
|
109
|
+
? entrySource(args.path.slice('entry:'.length))
|
|
110
|
+
: INSTALL_SOURCE,
|
|
94
111
|
loader: 'js',
|
|
95
112
|
}));
|
|
96
113
|
},
|
package/src/island-routes.ts
CHANGED
|
@@ -26,7 +26,9 @@ export function islandRoutes(source: IslandSource): readonly Route[] {
|
|
|
26
26
|
path: `${ISLAND_BASE_PATH}/*file`,
|
|
27
27
|
meta: { name: 'assets.island', auth: 'public', tags: ['assets'] },
|
|
28
28
|
handler: (request: UltimateRequest): Response => {
|
|
29
|
-
|
|
29
|
+
// An entry or a shared chunk: an entry's bytes import its chunks by a relative name, so both
|
|
30
|
+
// have to answer at the one base path, under the one cache rule.
|
|
31
|
+
const chunk = source().assetAt(request.pathname);
|
|
30
32
|
if (chunk === undefined) {
|
|
31
33
|
const missing = json(
|
|
32
34
|
{
|
package/src/island-store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { join, relative, sep } from 'node:path'; // why: Bun ships no path-join primitive.
|
|
11
11
|
import { frameworkVersion, logger } from '@ultimat3/core';
|
|
12
12
|
import { contentHash, loadStylesheet } from '@ultimat3/render/server';
|
|
13
|
-
import type { IslandBundle, IslandChunk } from './island-bundle';
|
|
13
|
+
import type { IslandBundle, IslandChunk, SharedChunk } from './island-bundle';
|
|
14
14
|
import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
|
|
15
15
|
import { islandStylesheets } from './island-styles';
|
|
16
16
|
|
|
@@ -23,12 +23,23 @@ interface StoredChunk {
|
|
|
23
23
|
readonly moduleId: string;
|
|
24
24
|
readonly url: string;
|
|
25
25
|
readonly identity: string;
|
|
26
|
+
/** The shared chunk URLs the entry loads, transitively (`IslandChunk.imports`). */
|
|
27
|
+
readonly imports: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A shared chunk: no island of its own, verified by the same hash. */
|
|
31
|
+
interface StoredShared {
|
|
32
|
+
readonly url: string;
|
|
33
|
+
readonly identity: string;
|
|
34
|
+
readonly importers: readonly string[];
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
interface StoreIndex {
|
|
29
38
|
readonly framework: string;
|
|
30
39
|
readonly bun: string;
|
|
31
40
|
readonly chunks: readonly StoredChunk[];
|
|
41
|
+
/** Absent in a store written before islands were split: read as none. */
|
|
42
|
+
readonly shared: readonly StoredShared[];
|
|
32
43
|
/**
|
|
33
44
|
* The stylesheets the island build registered, app-relative and sorted. Registered again at boot
|
|
34
45
|
* from the app's own sources, so a container serving these chunks joins the SAME surface
|
|
@@ -56,8 +67,15 @@ export async function writeIslandStore(
|
|
|
56
67
|
moduleId: chunk.moduleId,
|
|
57
68
|
url: chunk.url,
|
|
58
69
|
identity: contentHash(chunk.code),
|
|
70
|
+
imports: chunk.imports,
|
|
59
71
|
});
|
|
60
72
|
}
|
|
73
|
+
const shared: StoredShared[] = [];
|
|
74
|
+
for (const chunk of bundle.shared) {
|
|
75
|
+
await Bun.write(join(dir, chunkFile(chunk.url)), chunk.code);
|
|
76
|
+
written.push(`${ISLAND_STORE_DIR}/${chunkFile(chunk.url)}`);
|
|
77
|
+
shared.push({ url: chunk.url, identity: contentHash(chunk.code), importers: chunk.importers });
|
|
78
|
+
}
|
|
61
79
|
const stylesheets = islandStylesheets()
|
|
62
80
|
.map((path) => relative(root, path).split(sep).join('/'))
|
|
63
81
|
.filter((path) => !path.startsWith('..'))
|
|
@@ -66,6 +84,7 @@ export async function writeIslandStore(
|
|
|
66
84
|
framework: frameworkVersion(),
|
|
67
85
|
bun: Bun.version,
|
|
68
86
|
chunks,
|
|
87
|
+
shared,
|
|
69
88
|
stylesheets,
|
|
70
89
|
};
|
|
71
90
|
await Bun.write(join(dir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
|
|
@@ -74,6 +93,9 @@ export async function writeIslandStore(
|
|
|
74
93
|
|
|
75
94
|
const isString = (value: unknown): value is string => typeof value === 'string';
|
|
76
95
|
|
|
96
|
+
const stringsOf = (value: unknown): readonly string[] =>
|
|
97
|
+
Array.isArray(value) ? value.filter(isString) : [];
|
|
98
|
+
|
|
77
99
|
/** The index, narrowed field by field — a file on disk is input, never a trusted shape. */
|
|
78
100
|
function parseIndex(value: unknown): StoreIndex | undefined {
|
|
79
101
|
if (typeof value !== 'object' || value === null) return undefined;
|
|
@@ -86,15 +108,28 @@ function parseIndex(value: unknown): StoreIndex | undefined {
|
|
|
86
108
|
for (const entry of chunks) {
|
|
87
109
|
if (typeof entry !== 'object' || entry === null) return undefined;
|
|
88
110
|
const chunk = entry as Record<string, unknown>;
|
|
89
|
-
const { file, moduleId, url, identity } = chunk;
|
|
111
|
+
const { file, moduleId, url, identity, imports } = chunk;
|
|
90
112
|
if (!isString(file) || !isString(moduleId) || !isString(url) || !isString(identity))
|
|
91
113
|
return undefined;
|
|
92
|
-
parsed.push({ file, moduleId, url, identity });
|
|
114
|
+
parsed.push({ file, moduleId, url, identity, imports: stringsOf(imports) });
|
|
115
|
+
}
|
|
116
|
+
const shared: StoredShared[] = [];
|
|
117
|
+
for (const entry of Array.isArray(record['shared']) ? record['shared'] : []) {
|
|
118
|
+
if (typeof entry !== 'object' || entry === null) return undefined;
|
|
119
|
+
const { url, identity, importers } = entry as Record<string, unknown>;
|
|
120
|
+
if (!isString(url) || !isString(identity)) return undefined;
|
|
121
|
+
shared.push({ url, identity, importers: stringsOf(importers) });
|
|
93
122
|
}
|
|
94
123
|
const sheets = record['stylesheets'];
|
|
95
124
|
// Absent in a 22.3.2 store: read as none, and the boot builds when it matters (see below).
|
|
96
125
|
const stylesheets = Array.isArray(sheets) ? sheets.filter(isString) : [];
|
|
97
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
framework: record['framework'],
|
|
128
|
+
bun: record['bun'],
|
|
129
|
+
chunks: parsed,
|
|
130
|
+
shared,
|
|
131
|
+
stylesheets,
|
|
132
|
+
};
|
|
98
133
|
}
|
|
99
134
|
|
|
100
135
|
/** A verified store, or the one sentence saying why it cannot be served. */
|
|
@@ -118,11 +153,15 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
118
153
|
if (stored.join('\n') !== present.join('\n')) {
|
|
119
154
|
return { stale: 'the stored islands are not the islands this app has' };
|
|
120
155
|
}
|
|
121
|
-
const
|
|
122
|
-
for (const entry of index.chunks) {
|
|
156
|
+
const verified = async (entry: { readonly url: string; readonly identity: string }) => {
|
|
123
157
|
const bytes = Bun.file(join(dir, chunkFile(entry.url)));
|
|
124
158
|
const code = (await bytes.exists()) ? await bytes.text() : undefined;
|
|
125
|
-
|
|
159
|
+
return code === undefined || contentHash(code) !== entry.identity ? undefined : code;
|
|
160
|
+
};
|
|
161
|
+
const chunks: IslandChunk[] = [];
|
|
162
|
+
for (const entry of index.chunks) {
|
|
163
|
+
const code = await verified(entry);
|
|
164
|
+
if (code === undefined) {
|
|
126
165
|
return { stale: `${entry.url} is missing or does not match its recorded hash` };
|
|
127
166
|
}
|
|
128
167
|
chunks.push({
|
|
@@ -131,8 +170,26 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
131
170
|
url: entry.url,
|
|
132
171
|
code,
|
|
133
172
|
bytes: new TextEncoder().encode(code).byteLength,
|
|
173
|
+
imports: entry.imports,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const shared: SharedChunk[] = [];
|
|
177
|
+
for (const entry of index.shared) {
|
|
178
|
+
const code = await verified(entry);
|
|
179
|
+
if (code === undefined) {
|
|
180
|
+
return { stale: `${entry.url} is missing or does not match its recorded hash` };
|
|
181
|
+
}
|
|
182
|
+
shared.push({
|
|
183
|
+
url: entry.url,
|
|
184
|
+
code,
|
|
185
|
+
bytes: new TextEncoder().encode(code).byteLength,
|
|
186
|
+
importers: entry.importers,
|
|
134
187
|
});
|
|
135
188
|
}
|
|
189
|
+
// An entry importing a chunk the store does not hold would boot to a 404 in every pod.
|
|
190
|
+
const held = new Set(shared.map((chunk) => chunk.url));
|
|
191
|
+
const orphan = chunks.flatMap((chunk) => chunk.imports).find((url) => !held.has(url));
|
|
192
|
+
if (orphan !== undefined) return { stale: `${orphan}, a shared chunk, is not in the store` };
|
|
136
193
|
// In sorted order, as `island` sheets: `stylesFor` orders those by path, so this is the same
|
|
137
194
|
// surface stylesheet the build that wrote the store joined.
|
|
138
195
|
for (const sheet of index.stylesheets) {
|
|
@@ -140,7 +197,7 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
140
197
|
if (!(await source.exists())) return { stale: `${sheet}, an island stylesheet, is missing` };
|
|
141
198
|
loadStylesheet(join(root, sheet), await source.text(), 'island');
|
|
142
199
|
}
|
|
143
|
-
return { bundle: islandBundle(chunks) };
|
|
200
|
+
return { bundle: islandBundle(chunks, shared) };
|
|
144
201
|
}
|
|
145
202
|
|
|
146
203
|
/**
|
package/src/mcp-errors.ts
CHANGED
|
@@ -255,6 +255,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
255
255
|
'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
|
|
256
256
|
X_DEV_RESTART_REQUIRED:
|
|
257
257
|
'x dev --json # a fresh process; the supervised x dev restarts itself on such a save',
|
|
258
|
+
X_DEV_ROOT_GONE: 'x dev --json # from the app root, once it exists again',
|
|
258
259
|
};
|
|
259
260
|
|
|
260
261
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/prerender.ts
CHANGED
|
@@ -141,7 +141,12 @@ function heaviestSource(
|
|
|
141
141
|
);
|
|
142
142
|
if (heaviest === undefined) return undefined;
|
|
143
143
|
const chunk = bundle.chunkAt(heaviest.url);
|
|
144
|
-
|
|
144
|
+
if (chunk !== undefined) return [chunk.file];
|
|
145
|
+
// A shared chunk has no file of its own: the chain is an island on THIS page that imports it.
|
|
146
|
+
const importer = bundle.chunks.find(
|
|
147
|
+
(one) => one.imports.includes(heaviest.url) && entries.some((entry) => entry.url === one.url),
|
|
148
|
+
);
|
|
149
|
+
return importer === undefined ? [heaviest.url] : [importer.file, heaviest.url];
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
export const DEFAULT_ORIGIN = 'https://localhost';
|
package/src/sw-precache-plan.ts
CHANGED
|
@@ -135,8 +135,14 @@ export function precacheAssets(input: PrecacheAssetInput): readonly PrecacheAsse
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
// A wanted entry brings the shared chunks it imports: offline, an entry whose chunk was never
|
|
139
|
+
// cached is an island that fails to boot on the one page that promised to work without a network.
|
|
140
|
+
for (const chunk of input.islands.chunks) {
|
|
141
|
+
if (wanted.has(chunk.url)) for (const url of chunk.imports) wanted.add(url);
|
|
142
|
+
}
|
|
138
143
|
return [
|
|
139
144
|
...input.islands.chunks.filter((chunk) => wanted.has(chunk.url)),
|
|
145
|
+
...input.islands.shared.filter((chunk) => wanted.has(chunk.url)),
|
|
140
146
|
...input.styles.chunks,
|
|
141
147
|
...input.scripts,
|
|
142
148
|
]
|
package/src/worker-bundle.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { dirname, join, relative } from 'node:path';
|
|
|
9
9
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
10
10
|
import { applyCacheHeaders, json } from '@ultimat3/http';
|
|
11
11
|
import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
|
|
12
|
-
import { describeBuildError, graphHash, stripDebugId } from './island-
|
|
12
|
+
import { describeBuildError, graphHash, stripDebugId } from './island-identity';
|
|
13
13
|
|
|
14
14
|
/** Under the dev namespace `/_x` — where the socket it opens (`/_x/sync`) already lives. */
|
|
15
15
|
export const SYNC_WORKER_BASE_PATH = '/_x/sync-worker';
|