@ultimat3/cli 27.4.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 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.4.0",
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.4.0",
43
- "@ultimat3/admin": "27.4.0",
44
- "@ultimat3/ai": "27.4.0",
45
- "@ultimat3/auth": "27.4.0",
46
- "@ultimat3/cache": "27.4.0",
47
- "@ultimat3/core": "27.4.0",
48
- "@ultimat3/db": "27.4.0",
49
- "@ultimat3/entity": "27.4.0",
50
- "@ultimat3/flags": "27.4.0",
51
- "@ultimat3/http": "27.4.0",
52
- "@ultimat3/i18n": "27.4.0",
53
- "@ultimat3/jobs": "27.4.0",
54
- "@ultimat3/mail": "27.4.0",
55
- "@ultimat3/manifest": "27.4.0",
56
- "@ultimat3/mcp": "27.4.0",
57
- "@ultimat3/money": "27.4.0",
58
- "@ultimat3/notify": "27.4.0",
59
- "@ultimat3/policy": "27.4.0",
60
- "@ultimat3/pwa": "27.4.0",
61
- "@ultimat3/query": "27.4.0",
62
- "@ultimat3/realtime": "27.4.0",
63
- "@ultimat3/render": "27.4.0",
64
- "@ultimat3/schema": "27.4.0",
65
- "@ultimat3/seo": "27.4.0",
66
- "@ultimat3/storage": "27.4.0",
67
- "@ultimat3/testing": "27.4.0",
68
- "@ultimat3/time": "27.4.0",
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
  }
@@ -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 }),
@@ -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.
@@ -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
- reachesRealtime(root, file).catch((error: unknown) => {
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] === true);
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] === true ? realtimeIslandEntry(file) : join(root, file),
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
+ }
@@ -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
- * The islands (app-root-relative POSIX paths) the last build found reaching realtime. A page needs
54
- * realtime's page boot only if one of ITS islands does — `settings` paid 34.9 kB of boot script
55
- * for an island that never touched a record.
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 realtimeIslandFiles(): ReadonlySet<string> {
58
- return realtimeIslands;
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}';\nexport * from '${MODULE}${file}';\n`;
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 { holdFirstPaint, installRealtime } from '${REALTIME}';\n` +
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(root: string, runtime: RuntimeChunk): BunPlugin {
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(args.path.slice('entry:'.length))
130
- : installSource(runtime.url),
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
  },
@@ -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
- await Bun.write(join(dir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
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/role-start.ts CHANGED
@@ -39,7 +39,7 @@ import { rolesUnderRealtime } from './role-realtime';
39
39
  import { startReplicator } from './role-replicator';
40
40
  import type { RunningRoles, StartRolesOptions } from './role-start-types';
41
41
  import { DEV_ROLES } from './role-start-types';
42
- import { prepareSync, type RunningSync } from './role-sync';
42
+ import { prepareSync, type RunningSync, syncReadsChanges } from './role-sync';
43
43
  import { startWorkerWake } from './role-wake';
44
44
  import { devHooks } from './runtime-hooks';
45
45
  import { workerOptionsFor } from './runtime-jobs';
@@ -393,6 +393,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
393
393
  dbMode: options.runtime.services.db.mode,
394
394
  transport: options.runtime.transport.name,
395
395
  replicatorHere: replicator !== null,
396
+ readsChanges: syncReadsChanges(),
396
397
  });
397
398
  started.push(async () => live.stop());
398
399
  // The web server and the sync node mark ready when they bind; a process with neither — a
package/src/role-sync.ts CHANGED
@@ -14,6 +14,7 @@ import { listQueries } from '@ultimat3/query';
14
14
  import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
15
15
  import {
16
16
  ChannelHub,
17
+ describeChannels,
17
18
  LiveQueryRegistry,
18
19
  listenSyncNode,
19
20
  liveQueryDefinition,
@@ -195,6 +196,19 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
195
196
  return registry;
196
197
  }
197
198
 
199
+ /**
200
+ * Whether a node in this process would read a committed change at all: a `live: true` query, or a
201
+ * declared channel that lists `records`. What is left when neither exists is events channels, and
202
+ * those ride the transport from whoever publishes them (`publishChannelEvent`) — so the boot asks
203
+ * this before it holds a real database to having a replicator (`runtime-live-feed.ts`).
204
+ */
205
+ export function syncReadsChanges(): boolean {
206
+ return (
207
+ listQueries().some((target) => target.isLive) ||
208
+ describeChannels().some((declared) => declared.records.length > 0)
209
+ );
210
+ }
211
+
198
212
  /**
199
213
  * Who dialled the socket, resolved by `@ultimat3/http`'s `clientAddress()` — the rule the web role's
200
214
  * `ctx.ip` follows, at the same `TRUSTED_PROXY_HOPS`. It keys an anonymous live reader's rate
@@ -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) => reachesRealtime(entry, src) }),
36
+ : { hold: (src: string) => restoresState(entry, options, src) }),
38
37
  });
39
38
 
40
- /** Whether `src`, as the page at `entry` wrote it, is an island the last build found realtime. */
41
- const reachesRealtime = (entry: RouteEntry, src: string): boolean =>
42
- realtimeIslandFiles().has(posix.join(posix.dirname(entry.file), src));
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 = [...realtimeIslandFiles()].some((file) => {
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
  });
@@ -40,6 +40,12 @@ export interface LiveFeedInput {
40
40
  readonly transport: string;
41
41
  /** Whether the `replicator` role runs in THIS process. */
42
42
  readonly replicatorHere: boolean;
43
+ /**
44
+ * Whether anything this node serves reads a committed change: a `live: true` query, or a channel
45
+ * that lists `records` (`role-sync.ts`'s `syncReadsChanges`). An events-only channel does not —
46
+ * the app publishes its frames through the transport — so such a node needs no replicator.
47
+ */
48
+ readonly readsChanges: boolean;
43
49
  }
44
50
 
45
51
  /** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
@@ -49,11 +55,16 @@ export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
49
55
  * `embedded` → the in-process bridge; anything else → replication, which is the WAL decoder's
50
56
  * job whether the replicator role runs in this process or another. Never both: with a real
51
57
  * database the decoder already delivers this process's own writes, and a bridge beside it would
52
- * deliver every one of them twice. No sync node, no feed to speak of.
58
+ * deliver every one of them twice. No sync node, no feed to speak of — and none for a node on a
59
+ * real database that serves only events channels, which the transport alone carries.
53
60
  */
54
61
  export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
55
62
  if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
56
63
  if (input.dbMode !== 'embedded') {
64
+ // Nothing here reads the log, so nothing is owed one: refusing this node demanded a
65
+ // replication slot (and the cluster-wide REPLICATION grant) to carry channel events the
66
+ // write-ahead log never held. `none`, said on the boot line, never a `replication` nobody runs.
67
+ if (!input.readsChanges) return { feed: 'none', bridge: null, stop: () => undefined };
57
68
  // A decoder this node can hear: in this process, or on a bus other processes share. Neither
58
69
  // is a sync node that reported `replication` while nothing could ever reach it.
59
70
  if (input.transport === 'in-process' && !input.replicatorHere)
@@ -27,7 +27,7 @@ import {
27
27
  setMailDriver,
28
28
  } from '@ultimat3/mail';
29
29
  import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
30
- import { selectTransport } from '@ultimat3/realtime/server';
30
+ import { selectTransport, setChannelTransport } from '@ultimat3/realtime/server';
31
31
  import type { Storage } from '@ultimat3/storage';
32
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
33
33
  import { loadAppConfig } from './app-config-load';
@@ -415,6 +415,10 @@ export async function startServices(
415
415
  started.push(() => bus.transport.close());
416
416
  transport = bus.transport;
417
417
  }
418
+ // The bus an app's own code publishes a channel event on (`publishChannelEvent`), in EVERY
419
+ // role: the hub is the `sync` role's, so a job or an action reached none, and an events-only
420
+ // channel — the one kind that needs no replicator — could be fed by nothing but a socket.
421
+ started.push(setChannelTransport(transport));
418
422
  // Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
419
423
  // synchronous getter over the client's own state, so no probe is needed; the in-process bus
420
424
  // has nothing to lose a connection to, and a check that can only answer `true` is a number in
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 }),