@ultimat3/cli 27.5.0 → 27.6.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/CLAUDE.md +1 -1
- package/package.json +28 -28
- package/src/dev-route-table.ts +1 -0
- package/src/document-options.ts +7 -0
- package/src/island-bundle.ts +26 -6
- package/src/island-realtime-state.ts +78 -0
- package/src/island-realtime.ts +51 -24
- package/src/island-store.ts +34 -1
- package/src/prerender.ts +1 -0
- package/src/route-islands.ts +10 -6
- package/src/serve-web.ts +1 -0
package/CLAUDE.md
CHANGED
|
@@ -111,7 +111,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
111
111
|
| `page-navigation.ts` | the client router for `navigation.client` surfaces — built, routed and named ONCE for `x dev`, the container and the static export; none opted in builds nothing |
|
|
112
112
|
| `page-speculation.ts` | Speculation Rules for documents WITHOUT the router (`navigation.speculation`): the allow-list of pure-read pages, the one tag, and its `script-src` hash — composed once for all three writers. An allow-list, never "every link but": a prefetch is a real GET with cookies |
|
|
113
113
|
| `static-document.ts` | a served `static` page: `304` on a matching `If-None-Match`, and (container only, `memoStatic`) the document kept after its first render. Never `x dev` — a save moves the stylesheet URL the kept document names |
|
|
114
|
-
| `island-bundle.ts` / `island-store.ts` / `island-sources.ts` / `island-realtime.ts` / `island-runtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; the image build writes a verified store the container loads — each chunk's recorded source files (`island-sources.ts`) are rehashed at read, so an edit since the build is a rebuild, never the old chunk. `island-runtime.ts` builds realtime's page runtime ONCE per build as `/islands/page-runtime.<id>.js` (resolved through `@ultimat3/realtime/page-runtime`), which a realtime island awaits where no page boot is rendered |
|
|
114
|
+
| `island-bundle.ts` / `island-store.ts` / `island-sources.ts` / `island-realtime.ts` / `island-realtime-state.ts` / `island-runtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; the image build writes a verified store the container loads — each chunk's recorded source files (`island-sources.ts`) are rehashed at read, so an edit since the build is a rebuild, never the old chunk. `island-runtime.ts` builds realtime's page runtime ONCE per build as `/islands/page-runtime.<id>.js` (resolved through `@ultimat3/realtime/page-runtime`), which a realtime island awaits where no page boot is rendered. `island-realtime-state.ts`: which realtime islands only FOLLOW the socket (not held, no first-paint wait); the answer rides `IslandChunk.realtime` via the store to the renderer |
|
|
115
115
|
| `local-cli.ts` / `local-cli-handoff.ts` | a global `x` hands over to the app's own CLI (`local-cli.ts` decides whether); the hand-off forwards SIGTERM/SIGHUP at once and a SIGINT only if the child is still running after a 1 s grace (a terminal Ctrl-C already reached it), and exits with the child's code |
|
|
116
116
|
| `serve-prebuilt.ts` / `serve-prebuilt-paths.ts` | the image's prebuilt store (`node_modules/.cache/ultimate`, never `.x/` — state, tmpfs): every role adopts its compiled stylesheets before importing the app; a boot that built an island or ran Sass logs `X_IMAGE_NOT_PREBUILT` once |
|
|
117
117
|
| `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, the watcher, the reload, the lock, the port |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.6.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "27.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
42
|
+
"@ultimat3/action": "27.6.0",
|
|
43
|
+
"@ultimat3/admin": "27.6.0",
|
|
44
|
+
"@ultimat3/ai": "27.6.0",
|
|
45
|
+
"@ultimat3/auth": "27.6.0",
|
|
46
|
+
"@ultimat3/cache": "27.6.0",
|
|
47
|
+
"@ultimat3/core": "27.6.0",
|
|
48
|
+
"@ultimat3/db": "27.6.0",
|
|
49
|
+
"@ultimat3/entity": "27.6.0",
|
|
50
|
+
"@ultimat3/flags": "27.6.0",
|
|
51
|
+
"@ultimat3/http": "27.6.0",
|
|
52
|
+
"@ultimat3/i18n": "27.6.0",
|
|
53
|
+
"@ultimat3/jobs": "27.6.0",
|
|
54
|
+
"@ultimat3/mail": "27.6.0",
|
|
55
|
+
"@ultimat3/manifest": "27.6.0",
|
|
56
|
+
"@ultimat3/mcp": "27.6.0",
|
|
57
|
+
"@ultimat3/money": "27.6.0",
|
|
58
|
+
"@ultimat3/notify": "27.6.0",
|
|
59
|
+
"@ultimat3/policy": "27.6.0",
|
|
60
|
+
"@ultimat3/pwa": "27.6.0",
|
|
61
|
+
"@ultimat3/query": "27.6.0",
|
|
62
|
+
"@ultimat3/realtime": "27.6.0",
|
|
63
|
+
"@ultimat3/render": "27.6.0",
|
|
64
|
+
"@ultimat3/schema": "27.6.0",
|
|
65
|
+
"@ultimat3/seo": "27.6.0",
|
|
66
|
+
"@ultimat3/storage": "27.6.0",
|
|
67
|
+
"@ultimat3/testing": "27.6.0",
|
|
68
|
+
"@ultimat3/time": "27.6.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/dev-route-table.ts
CHANGED
|
@@ -188,6 +188,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
188
188
|
buildId: input.buildId,
|
|
189
189
|
...(input.isr === undefined ? {} : { isr: input.isr }),
|
|
190
190
|
resolveIsland: (file) => input.islands().resolverFor(file),
|
|
191
|
+
realtimeIslands: () => input.islands().realtime,
|
|
191
192
|
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
192
193
|
persisted: sync.persisted,
|
|
193
194
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
package/src/document-options.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// 500-line ceiling; one declaration for `x dev`, the container and the static export.
|
|
4
4
|
|
|
5
5
|
import type { ClientSyncHead } from '@ultimat3/render';
|
|
6
|
+
import type { IslandRealtime } from './island-realtime-state';
|
|
6
7
|
import type { NavigationDocumentHead } from './page-navigation';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,6 +16,12 @@ export type IslandResolver = (routeFile: string) => (src: string) => string;
|
|
|
15
16
|
|
|
16
17
|
export interface DocumentOptions {
|
|
17
18
|
readonly resolveIsland?: IslandResolver;
|
|
19
|
+
/**
|
|
20
|
+
* The served bundle's realtime islands, by app-root-relative file (`IslandBundle.realtime`):
|
|
21
|
+
* which pages earn the page boot, and which islands are held until they mount. A function, as
|
|
22
|
+
* `resolveIsland` is — `x dev` swaps the bundle on a rebuild. Absent, no island is realtime.
|
|
23
|
+
*/
|
|
24
|
+
readonly realtimeIslands?: () => ReadonlyMap<string, IslandRealtime>;
|
|
18
25
|
/**
|
|
19
26
|
* `<link rel="manifest">`, both `theme-color` metas and the apple-touch links — `PwaArtifacts.head`
|
|
20
27
|
* from `pwa-artifacts.ts`, or absent when the app is not installable.
|
package/src/island-bundle.ts
CHANGED
|
@@ -33,9 +33,10 @@ import { frameworkDedupePlugins } from './island-package-dedupe';
|
|
|
33
33
|
import {
|
|
34
34
|
islandRealtimePlugin,
|
|
35
35
|
REALTIME_ISLAND_ENTRY,
|
|
36
|
-
reachesRealtime,
|
|
37
36
|
realtimeIslandEntry,
|
|
37
|
+
realtimeKind,
|
|
38
38
|
} from './island-realtime';
|
|
39
|
+
import type { IslandRealtime } from './island-realtime-state';
|
|
39
40
|
import type { Runtimes } from './island-runtime';
|
|
40
41
|
import { loadingRuntime, mergeBuilds, runtimeBuilder, runtimeShared } from './island-runtime';
|
|
41
42
|
import { solidDedupePlugin } from './island-solid-dedupe';
|
|
@@ -79,6 +80,12 @@ export interface IslandChunk {
|
|
|
79
80
|
readonly imports: readonly string[];
|
|
80
81
|
/** The files on disk it was built from (`island-sources.ts`). Absent on a chunk not built here. */
|
|
81
82
|
readonly sources?: SourcePaths;
|
|
83
|
+
/**
|
|
84
|
+
* Absent unless the island's graph reaches `@ultimat3/realtime`: then whether it has page state
|
|
85
|
+
* to restore or only follows the socket (`island-realtime-state.ts`). On the chunk, so the
|
|
86
|
+
* answer travels with the app it was asked of — through the store, to the document renderer.
|
|
87
|
+
*/
|
|
88
|
+
readonly realtime?: IslandRealtime;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
/** A module two or more islands import, served once beside them. */
|
|
@@ -102,6 +109,8 @@ export interface IslandBundle {
|
|
|
102
109
|
* than emitting a `data-x-entry` no browser can import.
|
|
103
110
|
*/
|
|
104
111
|
resolverFor(routeFile: string): (src: string) => string;
|
|
112
|
+
/** Every realtime island of THIS app, by file: what a document's page boot and holds are read from. */
|
|
113
|
+
readonly realtime: ReadonlyMap<string, IslandRealtime>;
|
|
105
114
|
/** The chunk a URL names — for serving it, and for naming the island a budget finding blames. */
|
|
106
115
|
chunkAt(url: string): IslandChunk | undefined;
|
|
107
116
|
/** An entry OR a shared chunk at `url` — what the `/islands/*` route serves. */
|
|
@@ -137,17 +146,19 @@ async function buildAll(
|
|
|
137
146
|
// no code, no file and no fix — before `Bun.build` ever ran to raise the one below.
|
|
138
147
|
const realtime = await Promise.all(
|
|
139
148
|
files.map((file) =>
|
|
140
|
-
|
|
149
|
+
realtimeKind(root, file).catch((error: unknown) => {
|
|
141
150
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
142
151
|
}),
|
|
143
152
|
),
|
|
144
153
|
);
|
|
145
154
|
// Only an island whose own graph reaches `@ultimat3/realtime` is wrapped (`island-realtime.ts`);
|
|
146
155
|
// every other one is built from its own file, byte for byte what it was.
|
|
147
|
-
const live = files.filter((_, index) => realtime[index]
|
|
156
|
+
const live = files.filter((_, index) => realtime[index] !== undefined);
|
|
157
|
+
const follows = new Set(files.filter((_, index) => realtime[index] === 'follows'));
|
|
158
|
+
const kinds = new Map(files.map((file, index) => [file, realtime[index]]));
|
|
148
159
|
const runtime = live[0] === undefined ? undefined : await runtimes(live[0]);
|
|
149
160
|
const entrypoints = files.map((file, index) =>
|
|
150
|
-
realtime[index]
|
|
161
|
+
realtime[index] !== undefined ? realtimeIslandEntry(file) : join(root, file),
|
|
151
162
|
);
|
|
152
163
|
let built: Awaited<ReturnType<typeof Bun.build>>;
|
|
153
164
|
try {
|
|
@@ -177,7 +188,7 @@ async function buildAll(
|
|
|
177
188
|
// gets the same rule (`island-package-dedupe.ts`): a nested copy is a second module where no
|
|
178
189
|
// symlink folds it, which is every `file:` install on Windows.
|
|
179
190
|
plugins: [
|
|
180
|
-
...(runtime === undefined ? [] : [islandRealtimePlugin(root, runtime)]),
|
|
191
|
+
...(runtime === undefined ? [] : [islandRealtimePlugin(root, runtime, follows)]),
|
|
181
192
|
solidDedupePlugin(root),
|
|
182
193
|
...frameworkDedupePlugins(root),
|
|
183
194
|
solidJsxPlugin,
|
|
@@ -225,7 +236,12 @@ async function buildAll(
|
|
|
225
236
|
await refuseDuplicatedModules(files.join(', '), built.metafile);
|
|
226
237
|
const sources = new Map<string, SourcePaths>();
|
|
227
238
|
const linked = linkOutputs(await builtOutputs(root, files, built.outputs, sources));
|
|
228
|
-
const chunks = loadingRuntime(entryChunks(files, linked, sources), live, runtime)
|
|
239
|
+
const chunks = loadingRuntime(entryChunks(files, linked, sources), live, runtime).map(
|
|
240
|
+
(chunk): IslandChunk => {
|
|
241
|
+
const kind = kinds.get(chunk.file);
|
|
242
|
+
return kind === undefined ? chunk : { ...chunk, realtime: kind };
|
|
243
|
+
},
|
|
244
|
+
);
|
|
229
245
|
const shared = sharedChunks(chunks, linked, sources);
|
|
230
246
|
return { chunks, shared: [...shared, ...runtimeShared(runtime, live)] };
|
|
231
247
|
}
|
|
@@ -439,9 +455,13 @@ export function islandBundle(
|
|
|
439
455
|
const byFile = new Map(chunks.map((chunk) => [chunk.file, chunk]));
|
|
440
456
|
const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
|
|
441
457
|
const sharedByUrl = new Map(shared.map((chunk) => [chunk.url, chunk]));
|
|
458
|
+
const realtime = new Map<string, IslandRealtime>();
|
|
459
|
+
for (const chunk of chunks)
|
|
460
|
+
if (chunk.realtime !== undefined) realtime.set(chunk.file, chunk.realtime);
|
|
442
461
|
return {
|
|
443
462
|
chunks,
|
|
444
463
|
shared,
|
|
464
|
+
realtime,
|
|
445
465
|
resolverFor(routeFile: string): (src: string) => string {
|
|
446
466
|
const dir = posix.dirname(routeFile);
|
|
447
467
|
return (src: string): string => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Which realtime islands have page state to RESTORE, and which only follow the socket. A held
|
|
2
|
+
// island (#506, `@ultimat3/render`'s `island-hold.ts`) is hidden until it mounts because its server
|
|
3
|
+
// markup can be older than the record store and the outbox the page boot restores. An island whose
|
|
4
|
+
// whole realtime use is a channel's events, a roster or the connection reads neither: its server
|
|
5
|
+
// markup is as current as any other island's, so hiding it only delayed the page's content.
|
|
6
|
+
|
|
7
|
+
import { firstInGraph } from './live-routes';
|
|
8
|
+
|
|
9
|
+
const REALTIME = '@ultimat3/realtime';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The barrel's value exports that read neither the record store nor the outbox: a membership, its
|
|
13
|
+
* roster, the socket's state, and the declarations that spell a topic. An ALLOW list on purpose —
|
|
14
|
+
* a name this list has never heard of (a hook added next year) is state until someone says not.
|
|
15
|
+
*/
|
|
16
|
+
export const FOLLOW_ONLY_EXPORTS: readonly string[] = [
|
|
17
|
+
'useChannel',
|
|
18
|
+
'usePresence',
|
|
19
|
+
'useConnection',
|
|
20
|
+
'hasPageSocket',
|
|
21
|
+
'readPresence',
|
|
22
|
+
'channel',
|
|
23
|
+
'channelRef',
|
|
24
|
+
'topic',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A named import or re-export of the barrel, as `Bun.Transpiler` PRINTS one: a statement to a
|
|
29
|
+
* line, double quotes, type-only names already erased. Read off the transpiler's output, never
|
|
30
|
+
* off the author's source, so a comment or a multi-line clause is not this pattern's problem.
|
|
31
|
+
*/
|
|
32
|
+
const NAMED = /^(?:import|export) \{([^}]*)\} from "@ultimat3\/realtime";?$/gm;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The barrel's names one module takes by value — `[]` when it takes none, `undefined` when it
|
|
36
|
+
* takes the barrel in a form with no list of names (`import *`, `export *`, a bare or default
|
|
37
|
+
* import, `import()`). Counted against the transpiler's own import records: a statement this file
|
|
38
|
+
* cannot read is never silently skipped, it makes the answer unknown.
|
|
39
|
+
*/
|
|
40
|
+
export function realtimeBindings(source: string, path: string): readonly string[] | undefined {
|
|
41
|
+
const transpiler = new Bun.Transpiler({ loader: path.endsWith('x') ? 'tsx' : 'ts' });
|
|
42
|
+
const statements = transpiler.scanImports(source).filter((entry) => entry.path === REALTIME);
|
|
43
|
+
if (statements.length === 0) return [];
|
|
44
|
+
const names: string[] = [];
|
|
45
|
+
let named = 0;
|
|
46
|
+
for (const match of transpiler.transformSync(source).matchAll(NAMED)) {
|
|
47
|
+
named += 1;
|
|
48
|
+
for (const binding of (match[1] ?? '').split(',')) {
|
|
49
|
+
const name = binding.split(/\sas\s/)[0]?.trim() ?? '';
|
|
50
|
+
if (name !== '') names.push(name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return named === statements.length ? names : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a realtime island is to the page boot: `restores` reads the record store or the outbox
|
|
58
|
+
* (held, and its `mount` waits for the restore), `follows` only follows the socket (neither).
|
|
59
|
+
* Carried on the island's own chunk (`IslandChunk.realtime`), never in process state: an answer is
|
|
60
|
+
* about one app's file, and two apps built in one process can spell two islands the same way.
|
|
61
|
+
*/
|
|
62
|
+
export type IslandRealtime = 'restores' | 'follows';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a realtime island restores nothing: every module of its relative graph takes only
|
|
66
|
+
* `FOLLOW_ONLY_EXPORTS` from the barrel. One unknown name, or one statement with no names, and it
|
|
67
|
+
* restores — the answer every realtime island had before. Pure: asked per build, of that build's
|
|
68
|
+
* root. The blind spot is `reachesRealtime`'s own: a PACKAGE reading the store for the island is
|
|
69
|
+
* not seen.
|
|
70
|
+
*/
|
|
71
|
+
export async function followsOnly(root: string, file: string): Promise<boolean> {
|
|
72
|
+
const restores = await firstInGraph(root, file, (source, path) => {
|
|
73
|
+
const names = realtimeBindings(source, path);
|
|
74
|
+
const follows = names?.every((name) => FOLLOW_ONLY_EXPORTS.includes(name)) === true;
|
|
75
|
+
return follows ? undefined : true;
|
|
76
|
+
});
|
|
77
|
+
return restores !== true;
|
|
78
|
+
}
|
package/src/island-realtime.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Realtime installed for the author, on exactly the islands that use it: an island whose own import
|
|
2
2
|
// graph reaches `@ultimat3/realtime` is built from a virtual entry that first awaits the page
|
|
3
3
|
// runtime (`island-runtime.ts`), then calls `installRealtime({ signal: createSignal })` with THIS
|
|
4
|
-
// bundle's solid-js, then re-exports the island whole. Every other island pays nothing.
|
|
4
|
+
// bundle's solid-js, then re-exports the island whole. Every other island pays nothing. An island
|
|
5
|
+
// that reads the record store or the outbox also waits for the page's restore before it mounts
|
|
6
|
+
// (#506); one that only follows the socket does not (`island-realtime-state.ts`).
|
|
5
7
|
|
|
6
8
|
// why: Bun ships no path API; the entry is joined to the root, the runtime named by its basename.
|
|
7
9
|
import { basename, join } from 'node:path';
|
|
8
10
|
import type { BunPlugin } from 'bun';
|
|
11
|
+
import { followsOnly, type IslandRealtime } from './island-realtime-state';
|
|
9
12
|
import { RUNTIME_CHUNK_SPECIFIER, type RuntimeChunk } from './island-runtime';
|
|
10
13
|
import { firstInGraph } from './live-routes';
|
|
11
14
|
import { PAGE_BOOT_BASE_PATH } from './worker-bundle';
|
|
@@ -23,6 +26,7 @@ export const realtimeIslandEntry = (file: string): string => `${REALTIME_ISLAND_
|
|
|
23
26
|
const REALTIME = '@ultimat3/realtime';
|
|
24
27
|
const NAMESPACE = 'ultimate-island';
|
|
25
28
|
const INSTALL = 'ultimate:island-realtime';
|
|
29
|
+
const RESTORE = 'ultimate:island-restore';
|
|
26
30
|
const MODULE = 'ultimate:island-module:';
|
|
27
31
|
const WAIT = 'ultimate:island-page-wait';
|
|
28
32
|
|
|
@@ -39,33 +43,32 @@ export async function reachesRealtime(root: string, file: string): Promise<boole
|
|
|
39
43
|
);
|
|
40
44
|
return scanned.some((entry) => entry.path === REALTIME) ? true : undefined;
|
|
41
45
|
});
|
|
42
|
-
// Recorded with the answer, so the document renderer can ask it of a page's islands without a
|
|
43
|
-
// second graph walk per request. Every build re-asks, so an edit that drops realtime drops it.
|
|
44
|
-
if (found === true) realtimeIslands.add(file);
|
|
45
|
-
else realtimeIslands.delete(file);
|
|
46
46
|
return found === true;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** App-root-relative island files whose graph reaches realtime, as the last build answered. */
|
|
50
|
-
const realtimeIslands = new Set<string>();
|
|
51
|
-
|
|
52
49
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* for an island that never touched a
|
|
50
|
+
* Both of a build's answers about one island, for its chunk to carry (`IslandChunk.realtime`):
|
|
51
|
+
* `undefined` when its graph never reaches realtime. A page needs realtime's page boot only if one
|
|
52
|
+
* of ITS islands does — `settings` paid 34.9 kB of boot script for an island that never touched a
|
|
53
|
+
* record — and holds only the ones that restore.
|
|
56
54
|
*/
|
|
57
|
-
export function
|
|
58
|
-
|
|
55
|
+
export async function realtimeKind(
|
|
56
|
+
root: string,
|
|
57
|
+
file: string,
|
|
58
|
+
): Promise<IslandRealtime | undefined> {
|
|
59
|
+
if (!(await reachesRealtime(root, file))) return undefined;
|
|
60
|
+
return (await followsOnly(root, file)) ? 'follows' : 'restores';
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
64
|
* `export *` and never a named list: the hydration runtime reads `mount` off the module, and the
|
|
63
65
|
* wrapper must not decide which of an island's names survive. The install is imported FIRST, so it
|
|
64
66
|
* has run before the island's module body — and every hook the island calls — does. One install
|
|
65
|
-
* module for every realtime island of the build, so a page with two of them loads it once
|
|
67
|
+
* module for every realtime island of the build, so a page with two of them loads it once; an
|
|
68
|
+
* island with page state to restore imports the restore, which imports that same install.
|
|
66
69
|
*/
|
|
67
|
-
const entrySource = (file: string): string =>
|
|
68
|
-
`import '${INSTALL}';\
|
|
70
|
+
const entrySource = (file: string, follows: boolean): string =>
|
|
71
|
+
`import '${follows ? INSTALL : RESTORE}';\n` + `export * from '${MODULE}${file}';\n`;
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* The install names `@ultimat3/realtime` BARE, and `islandRealtimePlugin` resolves it from the
|
|
@@ -80,25 +83,40 @@ const entrySource = (file: string): string =>
|
|
|
80
83
|
* on a document rendered for a principal; anywhere else the island loads the runtime chunk, by a
|
|
81
84
|
* path relative to its own URL, so it resolves under `/islands/` and beside it in `mountIsland`.
|
|
82
85
|
* The chunk's URL is in this source, so a new runtime is a new URL for every island loading it.
|
|
83
|
-
* Then the first-paint hold (#506): `mount` waits for the restored records and the open outbox
|
|
84
|
-
* (capped), so a reload's first render already carries a queued write's overlay.
|
|
85
86
|
*/
|
|
86
87
|
const installSource = (runtimeUrl: string): string =>
|
|
87
|
-
`import {
|
|
88
|
+
`import { installRealtime } from '${REALTIME}';\n` +
|
|
88
89
|
`import { awaitPageRuntime } from '${WAIT}';\n` +
|
|
89
90
|
`import { createSignal } from 'solid-js';\n` +
|
|
90
91
|
`await awaitPageRuntime({ boot: '${PAGE_BOOT_BASE_PATH}/', load: () => import('./${basename(runtimeUrl)}') });\n` +
|
|
91
|
-
`await holdFirstPaint();\n` +
|
|
92
92
|
`installRealtime({ signal: createSignal });\n`;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The first-paint hold (#506), for an island that reads the record store or the outbox: `mount`
|
|
96
|
+
* waits for the restored records and the open outbox (capped), so a reload's first render already
|
|
97
|
+
* carries a queued write's overlay. After the install, which it imports: the wait needs the page
|
|
98
|
+
* runtime, and no hook runs before the island's module graph — this module included — is done.
|
|
99
|
+
* An island that only follows the socket never imports it, so it mounts as soon as the runtime is
|
|
100
|
+
* there and its chunk carries no `first-paint-hold.ts`.
|
|
101
|
+
*/
|
|
102
|
+
const restoreSource = (): string =>
|
|
103
|
+
`import '${INSTALL}';\n` +
|
|
104
|
+
`import { holdFirstPaint } from '${REALTIME}';\n` +
|
|
105
|
+
`await holdFirstPaint();\n`;
|
|
106
|
+
|
|
94
107
|
/**
|
|
95
108
|
* `runtime` names the realtime every bare `@ultimat3/realtime` of the build resolves to — the copy
|
|
96
109
|
* the runtime chunk was built against, resolved from the first realtime island's directory. One
|
|
97
110
|
* copy for all of them, because one copy in the bundle is the requirement: the signal the install
|
|
98
111
|
* sets is only read by hooks from the same module instance, and the page object it reads is the
|
|
99
|
-
* shape that copy's runtime installs.
|
|
112
|
+
* shape that copy's runtime installs. `follows` is THIS build's islands that restore nothing: an
|
|
113
|
+
* island absent from it waits for the restore, which is the answer that can never paint stale.
|
|
100
114
|
*/
|
|
101
|
-
export function islandRealtimePlugin(
|
|
115
|
+
export function islandRealtimePlugin(
|
|
116
|
+
root: string,
|
|
117
|
+
runtime: RuntimeChunk,
|
|
118
|
+
follows: ReadonlySet<string> = new Set(),
|
|
119
|
+
): BunPlugin {
|
|
102
120
|
return {
|
|
103
121
|
name: 'ultimate-island-realtime',
|
|
104
122
|
setup(build) {
|
|
@@ -110,6 +128,10 @@ export function islandRealtimePlugin(root: string, runtime: RuntimeChunk): BunPl
|
|
|
110
128
|
path: 'install',
|
|
111
129
|
namespace: NAMESPACE,
|
|
112
130
|
}));
|
|
131
|
+
build.onResolve({ filter: /^ultimate:island-restore$/ }, () => ({
|
|
132
|
+
path: 'restore',
|
|
133
|
+
namespace: NAMESPACE,
|
|
134
|
+
}));
|
|
113
135
|
build.onResolve({ filter: /^ultimate:island-module:/ }, (args) => ({
|
|
114
136
|
path: join(root, args.path.slice(MODULE.length)),
|
|
115
137
|
}));
|
|
@@ -126,8 +148,13 @@ export function islandRealtimePlugin(root: string, runtime: RuntimeChunk): BunPl
|
|
|
126
148
|
build.onResolve({ filter: /^@ultimat3\/realtime$/ }, () => ({ path: runtime.realtime }));
|
|
127
149
|
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => ({
|
|
128
150
|
contents: args.path.startsWith('entry:')
|
|
129
|
-
? entrySource(
|
|
130
|
-
|
|
151
|
+
? entrySource(
|
|
152
|
+
args.path.slice('entry:'.length),
|
|
153
|
+
follows.has(args.path.slice('entry:'.length)),
|
|
154
|
+
)
|
|
155
|
+
: args.path === 'restore'
|
|
156
|
+
? restoreSource()
|
|
157
|
+
: installSource(runtime.url),
|
|
131
158
|
loader: 'js',
|
|
132
159
|
}));
|
|
133
160
|
},
|
package/src/island-store.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { contentHash, loadStylesheet } from '@ultimat3/render/server';
|
|
|
13
13
|
import { IslandBuildFailedError } from './errors';
|
|
14
14
|
import type { IslandBundle, IslandChunk, SharedChunk } from './island-bundle';
|
|
15
15
|
import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
|
|
16
|
+
import type { IslandRealtime } from './island-realtime-state';
|
|
16
17
|
import type { SourceDigester, SourceStamp } from './island-sources';
|
|
17
18
|
import { changedSource, isRecordableSource, sourceDigester, stampSources } from './island-sources';
|
|
18
19
|
import { islandStylesheets } from './island-styles';
|
|
@@ -54,6 +55,13 @@ interface StoreIndex {
|
|
|
54
55
|
* read the store, and present on any that rebuilt.
|
|
55
56
|
*/
|
|
56
57
|
readonly stylesheets: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Each realtime island's kind, by file — `IslandChunk.realtime` as the build answered it, which
|
|
60
|
+
* the document renderer reads per request and a pod that builds nothing never computed. One
|
|
61
|
+
* object for the index rather than a field per chunk, so its ABSENCE is readable: `undefined`
|
|
62
|
+
* only for an index that did not record it, which is stale.
|
|
63
|
+
*/
|
|
64
|
+
readonly realtime: ReadonlyMap<string, IslandRealtime> | undefined;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
const chunkFile = (url: string): string => url.slice(url.lastIndexOf('/') + 1);
|
|
@@ -119,8 +127,11 @@ export async function writeIslandStore(
|
|
|
119
127
|
chunks,
|
|
120
128
|
shared,
|
|
121
129
|
stylesheets,
|
|
130
|
+
realtime: bundle.realtime,
|
|
122
131
|
};
|
|
123
|
-
|
|
132
|
+
// The bundle's own answer as a JSON object, sorted so the index is reproducible.
|
|
133
|
+
const realtime = Object.fromEntries([...bundle.realtime].sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
134
|
+
await Bun.write(join(dir, INDEX), `${JSON.stringify({ ...index, realtime }, null, 2)}\n`);
|
|
124
135
|
return [...written, `${ISLAND_STORE_DIR}/${INDEX}`];
|
|
125
136
|
}
|
|
126
137
|
|
|
@@ -129,6 +140,19 @@ const isString = (value: unknown): value is string => typeof value === 'string';
|
|
|
129
140
|
const stringsOf = (value: unknown): readonly string[] =>
|
|
130
141
|
Array.isArray(value) ? value.filter(isString) : [];
|
|
131
142
|
|
|
143
|
+
/** `undefined` unless every value is a kind this build knows: anything else is not an answer. */
|
|
144
|
+
function kindsOf(value: unknown): ReadonlyMap<string, IslandRealtime> | undefined {
|
|
145
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
146
|
+
// A Map, never the parsed object indexed by file: the file names are the index's own DATA, and
|
|
147
|
+
// an object answers `constructor` or `__proto__` with something that is not a kind.
|
|
148
|
+
const kinds = new Map<string, IslandRealtime>();
|
|
149
|
+
for (const [file, kind] of Object.entries(value)) {
|
|
150
|
+
if (kind !== 'restores' && kind !== 'follows') return undefined;
|
|
151
|
+
kinds.set(file, kind);
|
|
152
|
+
}
|
|
153
|
+
return kinds;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
/**
|
|
133
157
|
* `undefined` when the field is absent or any entry is malformed or names a path outside the app:
|
|
134
158
|
* a store that cannot say what it was built from cannot be checked, which is stale — never
|
|
@@ -187,6 +211,7 @@ function parseIndex(value: unknown): StoreIndex | { readonly unchecked: string }
|
|
|
187
211
|
chunks: parsed,
|
|
188
212
|
shared,
|
|
189
213
|
stylesheets,
|
|
214
|
+
realtime: kindsOf(record['realtime']),
|
|
190
215
|
};
|
|
191
216
|
}
|
|
192
217
|
|
|
@@ -223,6 +248,11 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
223
248
|
stale: `built by framework ${index.framework} on Bun ${index.bun}, serving ${frameworkVersion()} on Bun ${Bun.version}`,
|
|
224
249
|
};
|
|
225
250
|
}
|
|
251
|
+
if (index.realtime === undefined) {
|
|
252
|
+
return {
|
|
253
|
+
stale: `${ISLAND_STORE_DIR}/${INDEX} does not say which islands are realtime, so no page would carry the boot they need`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
226
256
|
const stored = index.chunks.map((chunk) => chunk.file).sort();
|
|
227
257
|
const present = [...(await discoverIslands(root))];
|
|
228
258
|
if (stored.join('\n') !== present.join('\n')) {
|
|
@@ -243,12 +273,14 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
243
273
|
const code = (await bytes.exists()) ? await bytes.text() : undefined;
|
|
244
274
|
return code === undefined || contentHash(code) !== entry.identity ? undefined : code;
|
|
245
275
|
};
|
|
276
|
+
const kinds = index.realtime;
|
|
246
277
|
const chunks: IslandChunk[] = [];
|
|
247
278
|
for (const entry of index.chunks) {
|
|
248
279
|
const code = await verified(entry);
|
|
249
280
|
if (code === undefined) {
|
|
250
281
|
return { stale: `${entry.url} is missing or does not match its recorded hash` };
|
|
251
282
|
}
|
|
283
|
+
const kind = kinds.get(entry.file);
|
|
252
284
|
chunks.push({
|
|
253
285
|
file: entry.file,
|
|
254
286
|
moduleId: entry.moduleId,
|
|
@@ -256,6 +288,7 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
256
288
|
code,
|
|
257
289
|
bytes: new TextEncoder().encode(code).byteLength,
|
|
258
290
|
imports: entry.imports,
|
|
291
|
+
...(kind === undefined ? {} : { realtime: kind }),
|
|
259
292
|
});
|
|
260
293
|
}
|
|
261
294
|
const shared: SharedChunk[] = [];
|
package/src/prerender.ts
CHANGED
|
@@ -267,6 +267,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
267
267
|
const database = measureDatabase(options.root);
|
|
268
268
|
const documentOptions = {
|
|
269
269
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
270
|
+
realtimeIslands: () => islands.realtime,
|
|
270
271
|
themeHead: theme.head,
|
|
271
272
|
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
272
273
|
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
package/src/route-islands.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
renderHead,
|
|
14
14
|
} from '@ultimat3/render';
|
|
15
15
|
import type { DocumentOptions } from './document-options';
|
|
16
|
-
import { realtimeIslandFiles } from './island-realtime';
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* One collector per RENDER, never module-global: two requests render different params, and a
|
|
@@ -34,12 +33,17 @@ export const collectorFor = (
|
|
|
34
33
|
// realtime island's server markup is held off screen until it mounts over the restored store.
|
|
35
34
|
...(scope === undefined || options.sync === undefined
|
|
36
35
|
? {}
|
|
37
|
-
: { hold: (src: string) =>
|
|
36
|
+
: { hold: (src: string) => restoresState(entry, options, src) }),
|
|
38
37
|
});
|
|
39
38
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Whether `src`, as the page at `entry` wrote it, is an island the served bundle built as realtime
|
|
41
|
+
* AND reading the record store or the outbox. An island that only follows the socket — a channel's
|
|
42
|
+
* events, a roster, the connection — has no markup the restore could contradict, so hiding it
|
|
43
|
+
* only delayed the page's content (`island-realtime-state.ts`).
|
|
44
|
+
*/
|
|
45
|
+
const restoresState = (entry: RouteEntry, options: DocumentOptions, src: string): boolean =>
|
|
46
|
+
options.realtimeIslands?.().get(posix.join(posix.dirname(entry.file), src)) === 'restores';
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
@@ -61,7 +65,7 @@ export function bootScript(
|
|
|
61
65
|
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
62
66
|
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
63
67
|
const pageDir = posix.dirname(entry.file);
|
|
64
|
-
const reaches = [...
|
|
68
|
+
const reaches = [...(options.realtimeIslands?.().keys() ?? [])].some((file) => {
|
|
65
69
|
const src = posix.relative(pageDir, file);
|
|
66
70
|
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
67
71
|
});
|
package/src/serve-web.ts
CHANGED
|
@@ -151,6 +151,7 @@ export async function webSurface(
|
|
|
151
151
|
...appRoutes({
|
|
152
152
|
buildId,
|
|
153
153
|
resolveIsland: (file) => islands.resolverFor(file),
|
|
154
|
+
realtimeIslands: () => islands.realtime,
|
|
154
155
|
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
155
156
|
persisted: sync.persisted,
|
|
156
157
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|