@ultimat3/cli 20.2.1 → 21.0.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 +69 -1
- package/package.json +30 -30
- package/src/app-env.ts +2 -2
- package/src/budgets.ts +45 -12
- package/src/build-errors.ts +54 -0
- package/src/cdp-browser.ts +21 -27
- package/src/cdp-connection.ts +66 -30
- package/src/cdp-e2e-page.ts +84 -113
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-launch.ts +95 -41
- package/src/cdp-offline-script.ts +73 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cmd-deploy.ts +7 -0
- package/src/cmd-dev.ts +23 -86
- package/src/dev-live-feed.ts +2 -0
- package/src/dev-render.ts +119 -20
- package/src/dev-route-table.ts +119 -0
- package/src/dev-services.ts +4 -1
- package/src/dev-sync.ts +5 -3
- package/src/e2e-app.ts +103 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-driver.ts +32 -12
- package/src/e2e-errors.ts +14 -0
- package/src/e2e-page.ts +5 -2
- package/src/e2e-preload.ts +64 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-spawn.ts +169 -0
- package/src/error-codes.ts +7 -0
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +8 -29
- package/src/index.ts +18 -4
- package/src/island-bundle.ts +33 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-verdict.ts +1 -1
- package/src/live-routes.ts +82 -42
- package/src/mcp-errors.ts +3 -0
- package/src/page-sync.ts +54 -0
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/serve.ts +9 -0
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/resource-form-island.ts +30 -21
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-container.ts +18 -3
- package/src/templates/scaffold-env.ts +6 -0
- package/src/verify-e2e.ts +38 -0
- package/src/verify-run.ts +105 -50
- package/src/verify-tests.ts +21 -4
- package/src/worker-bundle.ts +192 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One responsibility: `navigator.onLine` reads `false` from a document's FIRST script while the
|
|
2
|
+
// session's offline switch is thrown, and reads `true` again — with the `online` event a page
|
|
3
|
+
// reconnects on — when it goes back. `Network.emulateNetworkConditions` cuts the NETWORK for a
|
|
4
|
+
// document created under the switch but, measured on Chrome 150, never tells it so: a reload under
|
|
5
|
+
// the switch read `navigator.onLine === true` at its first script every time, COOP or not, still
|
|
6
|
+
// read `true` a second later, and got no `online` event when the switch went back.
|
|
7
|
+
// `Network.overrideNetworkState` changed nothing. The dummy's page boot asks at its first script
|
|
8
|
+
// and replayed its outbox with a real POST under the cut (`offline-like.e2e.test.ts`, two attempts).
|
|
9
|
+
//
|
|
10
|
+
// The override is an OWN property on `navigator`, so the real getter on `Navigator.prototype` is one
|
|
11
|
+
// `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
|
|
12
|
+
// restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
|
|
13
|
+
|
|
14
|
+
import type { CdpResult } from './cdp-connection';
|
|
15
|
+
|
|
16
|
+
type Send = (
|
|
17
|
+
method: string,
|
|
18
|
+
params: Record<string, unknown>,
|
|
19
|
+
session: string,
|
|
20
|
+
) => Promise<CdpResult>;
|
|
21
|
+
|
|
22
|
+
/** Runs before the page's own scripts, in every document a page session creates while cut. */
|
|
23
|
+
export const OFFLINE_FIRST_SCRIPT = `(() => {
|
|
24
|
+
let told = false;
|
|
25
|
+
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
|
|
26
|
+
addEventListener('offline', () => { told = true; });
|
|
27
|
+
addEventListener('online', () => { delete navigator.onLine; }, { once: true });
|
|
28
|
+
Object.defineProperty(window, '__xRestoreOnLine', { configurable: true, value: () => {
|
|
29
|
+
if (!Object.getOwnPropertyDescriptor(navigator, 'onLine')) return;
|
|
30
|
+
delete navigator.onLine;
|
|
31
|
+
if (!told) dispatchEvent(new Event('online'));
|
|
32
|
+
} });
|
|
33
|
+
})();`;
|
|
34
|
+
|
|
35
|
+
/** Evaluated in every open page when the switch goes back: a no-op in a document never cut. */
|
|
36
|
+
export const RESTORE_ONLINE = `window.__xRestoreOnLine?.()`;
|
|
37
|
+
|
|
38
|
+
export interface OfflineScripts {
|
|
39
|
+
/** Register the script on one page session; answers once the browser has it. */
|
|
40
|
+
add(session: string): Promise<unknown>;
|
|
41
|
+
/** Unregister it from one page session, if it holds it, and restore the open document. */
|
|
42
|
+
remove(session: string): Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const field = (from: unknown, key: string): string | undefined => {
|
|
46
|
+
const value =
|
|
47
|
+
typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
|
|
48
|
+
return typeof value === 'string' ? value : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Per-session registration, keyed by the identifier the browser hands back. */
|
|
52
|
+
export function offlineScripts(send: Send): OfflineScripts {
|
|
53
|
+
const held = new Map<string, string>();
|
|
54
|
+
return {
|
|
55
|
+
async add(session) {
|
|
56
|
+
const answer = await send(
|
|
57
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
58
|
+
{ source: OFFLINE_FIRST_SCRIPT },
|
|
59
|
+
session,
|
|
60
|
+
);
|
|
61
|
+
const identifier = field(answer.result, 'identifier');
|
|
62
|
+
if (identifier !== undefined) held.set(session, identifier);
|
|
63
|
+
return answer;
|
|
64
|
+
},
|
|
65
|
+
async remove(session) {
|
|
66
|
+
const identifier = held.get(session);
|
|
67
|
+
if (identifier === undefined) return undefined;
|
|
68
|
+
held.delete(session);
|
|
69
|
+
await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
|
|
70
|
+
return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/cdp-pipe.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// One responsibility: CDP over the pipe `--remote-debugging-pipe` opens — Chrome reads commands on
|
|
2
|
+
// its fd 3 and writes replies and events on its fd 4, each message one JSON text ended by a NUL
|
|
3
|
+
// byte. This is the e2e driver's wire; the WebSocket in `cdp-connection.ts` is for a remote browser.
|
|
4
|
+
//
|
|
5
|
+
// Why a pipe rather than the WebSocket Chrome also offers: Bun 1.4.0's WebSocket client handed
|
|
6
|
+
// `onmessage` text spliced from several frames under the dummy's `offline-feed` load — 64
|
|
7
|
+
// unparseable frames in one run, one of them a `Runtime.evaluate` reply that then waited out its
|
|
8
|
+
// 30 s deadline. A frame nobody can parse has no `id`, so no layer above can even tell which call
|
|
9
|
+
// it lost. A pipe is bytes and a delimiter, read here, and nothing in between.
|
|
10
|
+
|
|
11
|
+
import type { CdpTransport } from './cdp-connection';
|
|
12
|
+
|
|
13
|
+
/** The two ends a transport needs: a sink for whole messages, and the byte stream Chrome writes. */
|
|
14
|
+
export interface PipeEnds {
|
|
15
|
+
/** Write these bytes to Chrome's fd 3, in order. */
|
|
16
|
+
readonly write: (bytes: Uint8Array) => void;
|
|
17
|
+
/** Chrome's fd 4. */
|
|
18
|
+
readonly read: ReadableStream<Uint8Array>;
|
|
19
|
+
/** Release the write end — Chrome treats its fd 3 closing as the client going away. */
|
|
20
|
+
readonly end: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NUL = 0;
|
|
24
|
+
|
|
25
|
+
/** Concatenate two byte arrays; the reader only ever holds the unterminated tail. */
|
|
26
|
+
const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
|
|
27
|
+
if (a.length === 0) return b;
|
|
28
|
+
const out = new Uint8Array(a.length + b.length);
|
|
29
|
+
out.set(a);
|
|
30
|
+
out.set(b, a.length);
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function pipeTransport(ends: PipeEnds): CdpTransport {
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
let closed = false;
|
|
37
|
+
const reader = ends.read.getReader();
|
|
38
|
+
return {
|
|
39
|
+
send(text: string): void {
|
|
40
|
+
if (closed) return;
|
|
41
|
+
const body = encoder.encode(text);
|
|
42
|
+
const framed = new Uint8Array(body.length + 1);
|
|
43
|
+
framed.set(body);
|
|
44
|
+
framed[body.length] = NUL;
|
|
45
|
+
ends.write(framed);
|
|
46
|
+
},
|
|
47
|
+
close(): void {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
closed = true;
|
|
50
|
+
ends.end();
|
|
51
|
+
void reader.cancel().catch(() => undefined);
|
|
52
|
+
},
|
|
53
|
+
listen(handlers): void {
|
|
54
|
+
void (async () => {
|
|
55
|
+
// Split on BYTES, decoded per message: a multi-byte character may straddle two reads, and
|
|
56
|
+
// decoding each read on its own would corrupt it — the failure this file exists to end.
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let tail: Uint8Array = new Uint8Array(0);
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { value, done } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
tail = join(tail, value);
|
|
64
|
+
for (let at = tail.indexOf(NUL); at !== -1; at = tail.indexOf(NUL)) {
|
|
65
|
+
handlers.message(decoder.decode(tail.subarray(0, at)));
|
|
66
|
+
tail = tail.subarray(at + 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// A read that fails is a pipe that is gone — reported as the close it is, below.
|
|
71
|
+
}
|
|
72
|
+
closed = true;
|
|
73
|
+
handlers.closed('the browser closed the CDP pipe');
|
|
74
|
+
})();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { msg } from './messages';
|
|
|
11
11
|
import type { CommandResult, JsonValue } from './output';
|
|
12
12
|
import { flagBool, flagString } from './parse';
|
|
13
13
|
import { quoteArg } from './shell-quote';
|
|
14
|
+
import { PROD_ENV_FILE } from './templates/scaffold-container';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
|
|
@@ -146,6 +147,12 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
|
|
|
146
147
|
command: [
|
|
147
148
|
'docker',
|
|
148
149
|
'compose',
|
|
150
|
+
// Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
|
|
151
|
+
// `--env-file` only — never from a service's `env_file:`. Without this an operator who put
|
|
152
|
+
// them in `.env.production`, the one file the compose file tells them to fill, had every
|
|
153
|
+
// step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
|
|
154
|
+
'--env-file',
|
|
155
|
+
join(root, PROD_ENV_FILE),
|
|
149
156
|
'-f',
|
|
150
157
|
join(root, 'docker', 'docker-compose.prod.yml'),
|
|
151
158
|
ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
|
package/src/cmd-dev.ts
CHANGED
|
@@ -9,58 +9,43 @@ import { devShellStyle } from '@ultimat3/admin/dev';
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
|
|
11
11
|
import { setStatementObserver } from '@ultimat3/db';
|
|
12
|
-
import type { OverlayNotice, RequestContext
|
|
12
|
+
import type { OverlayNotice, RequestContext } from '@ultimat3/http';
|
|
13
13
|
import { asCtx } from '@ultimat3/http';
|
|
14
14
|
import type { Manifest } from '@ultimat3/manifest';
|
|
15
15
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
16
|
-
import { describeRoutes } from '@ultimat3/render';
|
|
17
|
-
import { apiRoutes } from './api-routes';
|
|
18
16
|
import { loadSignInPath } from './app-auth';
|
|
19
17
|
import { appManifest } from './app-manifest';
|
|
20
|
-
import { mountAppMcp } from './app-mcp';
|
|
21
18
|
import { requireAppRoot } from './app-root';
|
|
22
19
|
import { loadAppRuntime } from './app-runtime';
|
|
23
20
|
import type { CliCommand, CommandContext } from './command';
|
|
24
|
-
import { assetRoutes } from './dev-assets';
|
|
25
21
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
26
|
-
import {
|
|
22
|
+
import { devPanels } from './dev-dashboard';
|
|
27
23
|
import { declareDevEnvironment } from './dev-environment';
|
|
28
24
|
import { liveFeedLabel } from './dev-live-feed';
|
|
29
25
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
30
26
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
31
27
|
import { coalesceReloads } from './dev-reload';
|
|
32
|
-
import { appRoutes } from './dev-render';
|
|
33
28
|
import { replicaOverrides } from './dev-replica';
|
|
34
29
|
import type { RunningRoles } from './dev-roles';
|
|
35
30
|
import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
|
|
31
|
+
import { devRouteTable } from './dev-route-table';
|
|
36
32
|
import type { RunningServices } from './dev-runtime';
|
|
37
33
|
import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
|
|
38
34
|
import type { DevServices } from './dev-services';
|
|
39
35
|
import { describeServices, reportedUrls, resolveServices } from './dev-services';
|
|
40
|
-
import { storageRoutes } from './dev-storage';
|
|
41
36
|
import { createTraceRecorder } from './dev-traces';
|
|
42
37
|
import { watchTree } from './dev-watch-tree';
|
|
43
|
-
import { errorPageStyleSources } from './error-page-csp';
|
|
44
38
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
45
39
|
import { holdUntilShutdown } from './hold';
|
|
46
40
|
import type { IslandBundle } from './island-bundle';
|
|
47
41
|
import { buildIslands } from './island-bundle';
|
|
48
42
|
import { FRAME_STYLE } from './island-harness';
|
|
49
|
-
import { islandHarnessRoutes } from './island-harness-route';
|
|
50
|
-
import { islandRoutes } from './island-routes';
|
|
51
|
-
import { loadIslandStates } from './island-states-load';
|
|
52
43
|
import { msg } from './messages';
|
|
53
44
|
import type { CommandResult, Finding } from './output';
|
|
54
45
|
import { findingFrom } from './output';
|
|
55
46
|
import { flagString } from './parse';
|
|
56
|
-
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
57
47
|
import { metricsPortFor } from './serve';
|
|
58
48
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
59
|
-
import { styleBundle } from './style-bundle';
|
|
60
|
-
import { styleRoutes } from './style-routes';
|
|
61
|
-
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
62
|
-
import { serviceWorkerRoutes } from './sw-routes';
|
|
63
|
-
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
64
49
|
|
|
65
50
|
const DEFAULT_PORT = 3000;
|
|
66
51
|
|
|
@@ -68,7 +53,10 @@ export interface DevServer {
|
|
|
68
53
|
readonly url: string;
|
|
69
54
|
readonly services: DevServices;
|
|
70
55
|
readonly roles: readonly Role[];
|
|
71
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
|
|
58
|
+
* now, so a reload that registers a new route moves it.
|
|
59
|
+
*/
|
|
72
60
|
readonly buildId: string;
|
|
73
61
|
/**
|
|
74
62
|
* Modules that would not import, primitives that would not register, reloads that would not
|
|
@@ -164,7 +152,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
164
152
|
// boot on purpose: the header is handed to the HTTP config and the render modes once, and a
|
|
165
153
|
// reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
|
|
166
154
|
// divergence between the two is visible rather than silent, and a restart closes it.
|
|
167
|
-
|
|
155
|
+
// `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
|
|
156
|
+
// as a new build with the same sources.
|
|
157
|
+
// Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
|
|
158
|
+
const rawStamp = options.env['BUILD_ID'];
|
|
159
|
+
const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
|
|
160
|
+
const buildId = stamped ?? state.manifest.buildId;
|
|
168
161
|
|
|
169
162
|
let server: DevServer;
|
|
170
163
|
// Read at request time, never captured at boot: `/_x/services` must report the reload counter
|
|
@@ -185,70 +178,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
185
178
|
};
|
|
186
179
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
187
180
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// worker that changes under a page it already controls is the update path, and re-emitting one
|
|
197
|
-
// per keystroke would exercise it on every save.
|
|
198
|
-
const serviceWorker =
|
|
199
|
-
pwa === undefined
|
|
200
|
-
? undefined
|
|
201
|
-
: serviceWorkerArtifacts({
|
|
202
|
-
pwa,
|
|
203
|
-
buildId,
|
|
204
|
-
routes: describeRoutes(),
|
|
205
|
-
islands: state.islands,
|
|
206
|
-
styles: styleBundle(),
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
210
|
-
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
211
|
-
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
212
|
-
const mcpMount = await mountAppMcp(options.root);
|
|
213
|
-
const routes: readonly Route[] = [
|
|
214
|
-
...devDashboardRoutes(dashboard),
|
|
215
|
-
// The same API table the container serves: a read that answers here and 404s in production
|
|
216
|
-
// is exactly the drift one composition exists to prevent.
|
|
217
|
-
...apiRoutes(),
|
|
218
|
-
...mcpMount.routes,
|
|
219
|
-
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
220
|
-
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
221
|
-
// never shadow `/icons` or `/media`.
|
|
222
|
-
...assetRoutes({
|
|
223
|
-
root: options.root,
|
|
224
|
-
storage: runtime.storage,
|
|
225
|
-
...(pwa === undefined ? {} : { pwa }),
|
|
226
|
-
}),
|
|
227
|
-
...storageRoutes({ storage: runtime.storage }),
|
|
228
|
-
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
229
|
-
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
230
|
-
...islandRoutes(() => state.islands),
|
|
231
|
-
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
232
|
-
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
233
|
-
// captured at boot would answer 404 for the href the document now carries.
|
|
234
|
-
...styleRoutes(() => styleBundle()),
|
|
235
|
-
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
236
|
-
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
237
|
-
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
238
|
-
// may have. The states are read per REQUEST — an author editing a state and re-running the
|
|
239
|
-
// command must not need a restart to see it.
|
|
240
|
-
...islandHarnessRoutes({
|
|
241
|
-
islands: () => state.islands,
|
|
242
|
-
states: () => loadIslandStates(options.root),
|
|
243
|
-
}),
|
|
244
|
-
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
245
|
-
...appRoutes({
|
|
246
|
-
buildId,
|
|
247
|
-
resolveIsland: (file) => state.islands.resolverFor(file),
|
|
248
|
-
themeHead: theme.head,
|
|
249
|
-
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
250
|
-
}),
|
|
251
|
-
];
|
|
181
|
+
const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
|
|
182
|
+
root: options.root,
|
|
183
|
+
env: options.env,
|
|
184
|
+
buildId,
|
|
185
|
+
storage: runtime.storage,
|
|
186
|
+
dashboard,
|
|
187
|
+
islands: () => state.islands,
|
|
188
|
+
});
|
|
252
189
|
|
|
253
190
|
// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
|
|
254
191
|
// `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
|
|
@@ -320,9 +257,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
320
257
|
url: running.url ?? `http://localhost:${options.port}`,
|
|
321
258
|
services,
|
|
322
259
|
roles: running.roles,
|
|
323
|
-
mcp:
|
|
260
|
+
mcp: mcpPath,
|
|
324
261
|
get buildId(): string {
|
|
325
|
-
return state.manifest.buildId;
|
|
262
|
+
return stamped ?? state.manifest.buildId;
|
|
326
263
|
},
|
|
327
264
|
// A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
|
|
328
265
|
// loop the last request tripped, not the findings as they were when the route table was built.
|
package/src/dev-live-feed.ts
CHANGED
|
@@ -49,6 +49,8 @@ export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFe
|
|
|
49
49
|
}
|
|
50
50
|
const bridge = await startLiveReplicator({
|
|
51
51
|
registry: input.sync.registry,
|
|
52
|
+
// The same changes the channels read — a real node's change subscription feeds both.
|
|
53
|
+
channels: input.sync.hub,
|
|
52
54
|
// Logged, never thrown: one change nobody could fan out must not take the dev server down.
|
|
53
55
|
onError: (error) =>
|
|
54
56
|
logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
|
package/src/dev-render.ts
CHANGED
|
@@ -14,15 +14,32 @@
|
|
|
14
14
|
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
15
|
// "second file" cost is one `Bun.write`.
|
|
16
16
|
|
|
17
|
+
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
18
|
+
import { posix } from 'node:path';
|
|
19
|
+
import { clientScopeOf } from '@ultimat3/auth';
|
|
17
20
|
import type { Ctx } from '@ultimat3/core';
|
|
21
|
+
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
18
22
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
19
23
|
import { asCtx, html, stream } from '@ultimat3/http';
|
|
20
24
|
import { currentLocale } from '@ultimat3/i18n';
|
|
21
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
ClientSyncHead,
|
|
27
|
+
IslandCollector,
|
|
28
|
+
RenderResult,
|
|
29
|
+
RouteData,
|
|
30
|
+
RouteEntry,
|
|
31
|
+
} from '@ultimat3/render';
|
|
22
32
|
import {
|
|
33
|
+
clientBootTags,
|
|
34
|
+
clientPersistTags,
|
|
35
|
+
clientScopeTag,
|
|
36
|
+
clientSyncTags,
|
|
23
37
|
createIslandCollector,
|
|
38
|
+
documentCarriesScope,
|
|
24
39
|
headFromMeta,
|
|
25
40
|
hydrateRuntime,
|
|
41
|
+
islandModuleId,
|
|
42
|
+
islandModuleIds,
|
|
26
43
|
metaContextFor,
|
|
27
44
|
renderHead,
|
|
28
45
|
routeDataFor,
|
|
@@ -39,9 +56,11 @@ import {
|
|
|
39
56
|
ROOT_ELEMENT_ID,
|
|
40
57
|
renderComponent,
|
|
41
58
|
renderSsr,
|
|
59
|
+
ssrHeaders,
|
|
42
60
|
staticHeaders,
|
|
43
61
|
streamResult,
|
|
44
62
|
} from '@ultimat3/render/server';
|
|
63
|
+
import { realtimeIslandFiles } from './island-realtime';
|
|
45
64
|
import { styleBundle } from './style-bundle';
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -71,6 +90,18 @@ export interface DocumentOptions {
|
|
|
71
90
|
* decided by `app.config.ts`, which the boot read and the renderer cannot.
|
|
72
91
|
*/
|
|
73
92
|
readonly themeHead?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
|
|
95
|
+
* document this process serves. Principal-free, so a shareable document carries it too; absent
|
|
96
|
+
* for a caller that serves no socket at all (the static export).
|
|
97
|
+
*/
|
|
98
|
+
readonly sync?: ClientSyncHead;
|
|
99
|
+
/**
|
|
100
|
+
* The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
|
|
101
|
+
* as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
|
|
102
|
+
* with no scope carries none.
|
|
103
|
+
*/
|
|
104
|
+
readonly persisted?: () => readonly string[];
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
export interface DevRenderOptions extends DocumentOptions {
|
|
@@ -94,16 +125,32 @@ export interface DevRouteData extends Record<string, unknown> {
|
|
|
94
125
|
*/
|
|
95
126
|
const lang = (): string => currentLocale();
|
|
96
127
|
|
|
128
|
+
/**
|
|
129
|
+
* `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
|
|
130
|
+
* client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
|
|
131
|
+
* one store per principal. A shareable document (`static`, `isr`, ungated `ssr`) carries NO scope
|
|
132
|
+
* tag — absent means "not rendered for anyone", a different answer from `''`, the anonymous page.
|
|
133
|
+
*/
|
|
97
134
|
const headFor = async (
|
|
98
135
|
entry: RouteEntry,
|
|
99
136
|
ctx: DevRouteData,
|
|
100
137
|
data: RouteData,
|
|
101
138
|
options: DocumentOptions,
|
|
139
|
+
scope?: string,
|
|
102
140
|
): Promise<string> =>
|
|
103
141
|
renderHead(
|
|
104
142
|
headFromMeta(
|
|
105
143
|
await entry.config.meta(metaContextFor(ctx, data)),
|
|
106
144
|
seoRenderers({ path: new URL(ctx.url).pathname }),
|
|
145
|
+
[
|
|
146
|
+
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
147
|
+
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
148
|
+
// records and replaying its queued writes — is per principal, and a shareable document
|
|
149
|
+
// (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
|
|
150
|
+
...(scope === undefined
|
|
151
|
+
? []
|
|
152
|
+
: [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
|
|
153
|
+
],
|
|
107
154
|
),
|
|
108
155
|
) +
|
|
109
156
|
(options.themeHead ?? '') +
|
|
@@ -166,6 +213,33 @@ export async function routeBody(
|
|
|
166
213
|
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
167
214
|
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
168
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
218
|
+
* document carries a principal scope (restoring persisted records and replaying queued writes are
|
|
219
|
+
* per principal; a shareable document has neither), AND one of the islands this render emitted
|
|
220
|
+
* reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
|
|
221
|
+
* into and no write to replay). After the body, because which islands rendered is a fact the walk
|
|
222
|
+
* just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
|
|
223
|
+
*/
|
|
224
|
+
function bootScript(
|
|
225
|
+
entry: RouteEntry,
|
|
226
|
+
islands: IslandCollector,
|
|
227
|
+
options: DocumentOptions,
|
|
228
|
+
scope: string | undefined,
|
|
229
|
+
): string {
|
|
230
|
+
if (scope === undefined || options.sync === undefined) return '';
|
|
231
|
+
const rendered = new Set(islandModuleIds(islands.directives));
|
|
232
|
+
if (rendered.size === 0) return '';
|
|
233
|
+
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
234
|
+
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
235
|
+
const pageDir = posix.dirname(entry.file);
|
|
236
|
+
const reaches = [...realtimeIslandFiles()].some((file) => {
|
|
237
|
+
const src = posix.relative(pageDir, file);
|
|
238
|
+
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
239
|
+
});
|
|
240
|
+
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
241
|
+
}
|
|
242
|
+
|
|
169
243
|
const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
|
|
170
244
|
createIslandCollector({
|
|
171
245
|
file: entry.file,
|
|
@@ -199,15 +273,16 @@ async function documentFrom(
|
|
|
199
273
|
ctx: DevRouteData,
|
|
200
274
|
data: RouteData,
|
|
201
275
|
options: DocumentOptions,
|
|
276
|
+
scope?: string,
|
|
202
277
|
): Promise<string> {
|
|
203
278
|
const islands = collectorFor(entry, options);
|
|
204
279
|
const [head, body] = await Promise.all([
|
|
205
|
-
headFor(entry, ctx, data, options),
|
|
280
|
+
headFor(entry, ctx, data, options, scope),
|
|
206
281
|
routeBody(entry, ctx, data, islands),
|
|
207
282
|
]);
|
|
208
283
|
return (
|
|
209
284
|
`<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
|
|
210
|
-
`<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
|
|
285
|
+
`<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
|
|
211
286
|
);
|
|
212
287
|
}
|
|
213
288
|
|
|
@@ -256,31 +331,55 @@ async function resultFor(
|
|
|
256
331
|
// correct output, no streaming benefit.
|
|
257
332
|
const islands = collectorFor(entry, options);
|
|
258
333
|
const [head, shell] = await Promise.all([
|
|
259
|
-
|
|
334
|
+
// A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
|
|
335
|
+
headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
|
|
260
336
|
routeBody(entry, request, data, islands),
|
|
261
337
|
]);
|
|
262
|
-
return
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
338
|
+
return withScope(
|
|
339
|
+
streamResult(
|
|
340
|
+
{
|
|
341
|
+
head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
|
|
342
|
+
// The runtime rides the first flush, with the shell it boots. A later chunk would leave
|
|
343
|
+
// the window between flush one and the close with inert islands and no listeners on
|
|
344
|
+
// them — which is exactly the first-click-lost failure `interaction` replay exists for.
|
|
345
|
+
shell: `${shell}${bootScript(entry, islands, options, clientScopeOf(ctx.actor))}${hydrateRuntime(islands.directives)}`,
|
|
346
|
+
holes: [],
|
|
347
|
+
},
|
|
348
|
+
{ buildId: options.buildId },
|
|
349
|
+
status,
|
|
350
|
+
),
|
|
351
|
+
clientScopeOf(ctx.actor),
|
|
273
352
|
);
|
|
274
353
|
}
|
|
275
|
-
default:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
354
|
+
default: {
|
|
355
|
+
// Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
|
|
356
|
+
// scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
|
|
357
|
+
// none — absent, which core reads as "not rendered for anyone", never as anonymous.
|
|
358
|
+
const scope = documentCarriesScope(ssrHeaders(entry, { buildId: options.buildId }))
|
|
359
|
+
? clientScopeOf(ctx.actor)
|
|
360
|
+
: undefined;
|
|
361
|
+
return withScope(
|
|
362
|
+
await renderSsr(
|
|
363
|
+
{ entry, params: request.params, url, ctx },
|
|
364
|
+
() => documentFrom(entry, request, data, options, scope),
|
|
365
|
+
{ buildId: options.buildId, status },
|
|
366
|
+
),
|
|
367
|
+
scope,
|
|
280
368
|
);
|
|
369
|
+
}
|
|
281
370
|
}
|
|
282
371
|
}
|
|
283
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A private document's scope, as a RESPONSE header too: the service worker partitions its offline
|
|
375
|
+
* pages by principal and never parses HTML, so the meta alone cannot reach it. Exactly the
|
|
376
|
+
* documents that carry the scope tag carry this — a shareable one carries neither.
|
|
377
|
+
*/
|
|
378
|
+
const withScope = (result: RenderResult, scope: string | undefined): RenderResult =>
|
|
379
|
+
scope === undefined
|
|
380
|
+
? result
|
|
381
|
+
: { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
|
|
382
|
+
|
|
284
383
|
const responseOf = (result: RenderResult): Response =>
|
|
285
384
|
typeof result.body === 'string'
|
|
286
385
|
? html(result.body, { status: result.status, headers: result.headers })
|