@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
package/src/live-routes.ts
CHANGED
|
@@ -12,29 +12,35 @@ import { join, posix } from 'node:path';
|
|
|
12
12
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
13
13
|
import type { RouteEntry } from '@ultimat3/render';
|
|
14
14
|
import { ISLAND_EXTENSION, routeEntries } from '@ultimat3/render';
|
|
15
|
+
import { discoverIslands } from './island-bundle';
|
|
15
16
|
import type { Finding } from './output';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* The exports that only work
|
|
19
|
-
* or
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
19
|
+
* The exports that only work in a booted browser page: each one reads the page's store, its socket
|
|
20
|
+
* or its connection, so a module naming one needs an island to have run it. `hasPageSocket` is
|
|
21
|
+
* deliberately absent — it IS the guard — and so is `installRealtime`, which the island bundle
|
|
22
|
+
* writes for the author (plan 101, slice 14).
|
|
22
23
|
*/
|
|
23
24
|
export const LIVE_HOOKS = [
|
|
24
|
-
'
|
|
25
|
-
'liveHookFor',
|
|
25
|
+
'useQuery',
|
|
26
26
|
'useConnection',
|
|
27
27
|
'useMutation',
|
|
28
28
|
'useMutationQueue',
|
|
29
|
+
'useRecord',
|
|
30
|
+
'useChannel',
|
|
29
31
|
] as const;
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* NOT an escape hatch, `As of 2026-09-22`: `hasPageSocket()` answers false on the server, every
|
|
35
|
+
* time, so a module that guards on it and never runs in a browser renders nothing forever. It was
|
|
36
|
+
* exempted here — "a module that asks has handled the absence" — and that is exactly how
|
|
37
|
+
* `examples/dummy`'s update banner, in a layout no island imports, never showed "A new version is
|
|
38
|
+
* ready.". It is a browser-only read like any hook, so it is reported like one.
|
|
36
39
|
*/
|
|
37
|
-
const GUARD = '
|
|
40
|
+
const GUARD = 'hasPageSocket';
|
|
41
|
+
|
|
42
|
+
/** Everything that only means something in a booted browser page: the hooks, and the guard. */
|
|
43
|
+
const BROWSER_ONLY: readonly string[] = [GUARD, ...LIVE_HOOKS];
|
|
38
44
|
|
|
39
45
|
/** Value imports only: `import type` is erased, so it boots nothing and needs nothing. */
|
|
40
46
|
const REALTIME_IMPORT = /import\s+([^;]*?)from\s*['"]@ultimat3\/realtime(?:\/[\w-]+)?['"]/g;
|
|
@@ -45,18 +51,14 @@ const bindingsOf = (clause: string): readonly string[] =>
|
|
|
45
51
|
.map((entry) => entry.split(/\bas\b/)[0]?.trim() ?? '')
|
|
46
52
|
.filter((name) => name.length > 0 && !name.startsWith('type '));
|
|
47
53
|
|
|
48
|
-
/**
|
|
49
|
-
* Which live hooks one module imports, or `[]` — including for a module that guards, which is a
|
|
50
|
-
* per-FILE verdict on purpose: the guard is written next to the read it protects.
|
|
51
|
-
*/
|
|
54
|
+
/** Which browser-only reads one module imports — the hooks and `hasPageSocket` — or `[]`. */
|
|
52
55
|
export function liveHooksIn(source: string): readonly string[] {
|
|
53
56
|
const hooks: string[] = [];
|
|
54
57
|
for (const match of source.matchAll(REALTIME_IMPORT)) {
|
|
55
58
|
const clause = match[1] ?? '';
|
|
56
59
|
if (clause.trimStart().startsWith('type ')) continue;
|
|
57
60
|
const names = bindingsOf(clause);
|
|
58
|
-
if (names.includes(
|
|
59
|
-
for (const hook of LIVE_HOOKS) if (names.includes(hook)) hooks.push(hook);
|
|
61
|
+
for (const hook of BROWSER_ONLY) if (names.includes(hook)) hooks.push(hook);
|
|
60
62
|
}
|
|
61
63
|
return hooks;
|
|
62
64
|
}
|
|
@@ -88,14 +90,18 @@ export interface LiveReach {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
/**
|
|
91
|
-
* Walk
|
|
93
|
+
* Walk a module's own import graph and answer the first thing `probe` finds in it.
|
|
92
94
|
*
|
|
93
95
|
* Relative specifiers only. A bare one resolves through `node_modules` or a workspace name, and
|
|
94
96
|
* following either would mean guessing which package a name came from — the limit `fix-imports.ts`
|
|
95
97
|
* records for the same walk. So this UNDER-reports rather than over-reports: a finding here is
|
|
96
98
|
* always a real one, which is what lets the rule ship with no pin table.
|
|
97
99
|
*/
|
|
98
|
-
export async function
|
|
100
|
+
export async function firstInGraph<T>(
|
|
101
|
+
root: string,
|
|
102
|
+
file: string,
|
|
103
|
+
probe: (source: string, path: string) => T | undefined,
|
|
104
|
+
): Promise<T | undefined> {
|
|
99
105
|
const seen = new Set<string>();
|
|
100
106
|
const queue = [file];
|
|
101
107
|
while (queue.length > 0) {
|
|
@@ -104,8 +110,8 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
|
|
|
104
110
|
seen.add(next);
|
|
105
111
|
const module = await readModule(root, next);
|
|
106
112
|
if (module === undefined) continue;
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
113
|
+
const found = probe(module.source, module.path);
|
|
114
|
+
if (found !== undefined) return found;
|
|
109
115
|
const loader = module.path.endsWith('x') ? 'tsx' : 'ts';
|
|
110
116
|
// Bun's transpiler is the parser, exactly as in `scripts/boundaries.ts`: it erases type-only
|
|
111
117
|
// imports and finds the dynamic ones, which no regex over this source could do.
|
|
@@ -117,6 +123,36 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
|
|
|
117
123
|
return undefined;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/** Every module in a file's relative import graph, app-root-relative — one walk, no probe. */
|
|
127
|
+
export async function graphModules(root: string, file: string): Promise<ReadonlySet<string>> {
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
await firstInGraph(root, file, (_source, path) => {
|
|
130
|
+
seen.add(path);
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
return seen;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every module a browser can run: the union of every island's graph in the app. */
|
|
137
|
+
async function islandModules(root: string): Promise<ReadonlySet<string>> {
|
|
138
|
+
const modules = new Set<string>();
|
|
139
|
+
for (const island of await discoverIslands(root)) {
|
|
140
|
+
for (const path of await graphModules(root, island)) modules.add(path);
|
|
141
|
+
}
|
|
142
|
+
return modules;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Each module in the route's graph that reads a browser-only name, with the first name it reads. */
|
|
146
|
+
async function browserOnlyReads(root: string, file: string): Promise<readonly LiveReach[]> {
|
|
147
|
+
const reads: LiveReach[] = [];
|
|
148
|
+
await firstInGraph(root, file, (source, path) => {
|
|
149
|
+
const hook = liveHooksIn(source)[0];
|
|
150
|
+
if (hook !== undefined) reads.push({ at: path, hook });
|
|
151
|
+
return undefined;
|
|
152
|
+
});
|
|
153
|
+
return reads;
|
|
154
|
+
}
|
|
155
|
+
|
|
120
156
|
export interface LiveRouteGap extends LiveReach {
|
|
121
157
|
readonly route: string;
|
|
122
158
|
readonly file: string;
|
|
@@ -132,28 +168,34 @@ const generatorFor = (file: string): string => {
|
|
|
132
168
|
};
|
|
133
169
|
|
|
134
170
|
/**
|
|
135
|
-
* Every
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
171
|
+
* Every browser-only read a route's SERVER graph holds that no browser will ever run. A page's
|
|
172
|
+
* imports are server-rendered — an island is reached by a `src` string, never an import — so a
|
|
173
|
+
* module there runs in a browser only if some island's own graph imports it too. Anything else
|
|
174
|
+
* renders its server state (nothing, or `loading`) forever, at 200. `hydrate: 'never'` boots no
|
|
175
|
+
* island at all, so every read on such a route is reported. One finding per MODULE: a layout every
|
|
176
|
+
* page imports is one mistake, not one per route.
|
|
139
177
|
*/
|
|
140
178
|
export async function liveRouteGaps(
|
|
141
179
|
root: string,
|
|
142
180
|
entries: readonly RouteEntry[],
|
|
143
181
|
): Promise<readonly LiveRouteGap[]> {
|
|
182
|
+
const inBrowser = await islandModules(root);
|
|
183
|
+
const reported = new Set<string>();
|
|
144
184
|
const gaps: LiveRouteGap[] = [];
|
|
145
185
|
for (const entry of entries) {
|
|
146
186
|
if (entry.surface === 'api') continue;
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
187
|
+
const never = entry.config.hydrate === 'never';
|
|
188
|
+
for (const read of await browserOnlyReads(root, entry.file)) {
|
|
189
|
+
if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
|
|
190
|
+
reported.add(read.at);
|
|
191
|
+
gaps.push({
|
|
192
|
+
...read,
|
|
193
|
+
route: entry.path,
|
|
194
|
+
file: entry.file,
|
|
195
|
+
hydrate: entry.config.hydrate,
|
|
196
|
+
islands: entry.islands,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
157
199
|
}
|
|
158
200
|
return gaps;
|
|
159
201
|
}
|
|
@@ -161,14 +203,12 @@ export async function liveRouteGaps(
|
|
|
161
203
|
export const liveRouteFindingFor = (gap: LiveRouteGap): Finding => ({
|
|
162
204
|
code: 'X_LIVE_ROUTE_NO_ISLAND',
|
|
163
205
|
cause:
|
|
164
|
-
`${gap.route} reads ${gap.hook}()
|
|
165
|
-
(gap.
|
|
166
|
-
|
|
167
|
-
: `declares hydrate: 'never' beside ${gap.islands.join(', ')}`) +
|
|
168
|
-
', so no module of this route ever runs in a browser: its rows have nowhere to arrive and the page renders its loading branch forever, at 200',
|
|
206
|
+
`${gap.route} reads ${gap.hook}() in ${gap.at}, which ` +
|
|
207
|
+
(gap.hydrate === 'never' ? `sits on a route declaring hydrate: 'never'` : 'no island imports') +
|
|
208
|
+
' — so it only ever runs on the server, where it answers its server state (nothing, or loading) forever, at 200',
|
|
169
209
|
fix:
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
`move it into an island: ${generatorFor(gap.file)}, import ${gap.at} from that island's mount(), and declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) in ${gap.file}` +
|
|
211
|
+
(gap.hydrate === 'never' ? `, with a hydrate other than 'never'` : ''),
|
|
172
212
|
docs: ERROR_DOCS_URL,
|
|
173
213
|
at: gap.at,
|
|
174
214
|
});
|
package/src/mcp-errors.ts
CHANGED
|
@@ -102,6 +102,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
102
102
|
X_E2E_LOCATOR_AMBIGUOUS:
|
|
103
103
|
'x test e2e --json # the fix line carries the same call with .first() on it',
|
|
104
104
|
X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
|
|
105
|
+
X_E2E_APP_FAILED: 'x dev --json',
|
|
105
106
|
// The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
|
|
106
107
|
// whole job is reporting what this machine does not have; the other three are raised inside a
|
|
107
108
|
// running suite, so the runnable half is the command that re-runs it.
|
|
@@ -122,6 +123,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
122
123
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
123
124
|
X_ERROR_CODE_UNRESOLVED:
|
|
124
125
|
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
126
|
+
X_ERROR_CODE_UNTHROWN:
|
|
127
|
+
'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
|
|
125
128
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
126
129
|
X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
|
|
127
130
|
X_LINT_FAILED: 'bunx biome check --write .',
|
package/src/page-sync.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What a page needs to reach the sync node, composed ONCE for `x dev` and the container alike: the
|
|
2
|
+
// worker bundle and the page boot with their routes, the head a document carries (`ultimate-sync`,
|
|
3
|
+
// `ultimate-build`, `ultimate-sync-worker`, the boot script), and the persisted record types a
|
|
4
|
+
// private document names. One call from both boots, so the two cannot serve different targets.
|
|
5
|
+
|
|
6
|
+
import { persistedRecordTypes } from '@ultimat3/entity';
|
|
7
|
+
import type { Route } from '@ultimat3/http';
|
|
8
|
+
import type { ClientSyncHead } from '@ultimat3/render';
|
|
9
|
+
import { syncUrlFrom } from './sync-url';
|
|
10
|
+
import {
|
|
11
|
+
buildPageBoot,
|
|
12
|
+
buildSyncWorker,
|
|
13
|
+
type FrameworkScript,
|
|
14
|
+
pageBootRoutes,
|
|
15
|
+
syncWorkerRoutes,
|
|
16
|
+
} from './worker-bundle';
|
|
17
|
+
|
|
18
|
+
export interface PageSync {
|
|
19
|
+
readonly routes: readonly Route[];
|
|
20
|
+
/** The scripts those routes serve, for the service worker to precache beside the islands. */
|
|
21
|
+
readonly scripts: readonly FrameworkScript[];
|
|
22
|
+
readonly head: ClientSyncHead;
|
|
23
|
+
/**
|
|
24
|
+
* The record types the app persists, read per render off the entity registry — the app's modules
|
|
25
|
+
* register their entities during boot, so a value captured here could predate them.
|
|
26
|
+
*/
|
|
27
|
+
readonly persisted: () => readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Built at boot and never on the watcher tick: the worker is framework code, not the app's, and a
|
|
32
|
+
* worker that changes under tabs already running it is exactly what a source-addressed URL exists
|
|
33
|
+
* to keep from happening mid-session.
|
|
34
|
+
*/
|
|
35
|
+
export async function pageSync(
|
|
36
|
+
root: string,
|
|
37
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
38
|
+
buildId: string,
|
|
39
|
+
): Promise<PageSync> {
|
|
40
|
+
const syncUrl = syncUrlFrom(env);
|
|
41
|
+
const worker = await buildSyncWorker(root);
|
|
42
|
+
const boot = await buildPageBoot(root);
|
|
43
|
+
return {
|
|
44
|
+
routes: [...syncWorkerRoutes(() => worker), ...pageBootRoutes(() => boot)],
|
|
45
|
+
scripts: [worker, boot].filter((script): script is FrameworkScript => script !== undefined),
|
|
46
|
+
head: {
|
|
47
|
+
syncUrl,
|
|
48
|
+
buildId,
|
|
49
|
+
...(worker === undefined ? {} : { workerUrl: worker.url }),
|
|
50
|
+
...(boot === undefined ? {} : { bootUrl: boot.url }),
|
|
51
|
+
},
|
|
52
|
+
persisted: persistedRecordTypes,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
// Bundled AND imported by `realtime-browser-barrel.test.ts` — the import is what gives it an lcov
|
|
5
5
|
// record, since `Bun.build()` reads this file without evaluating it.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { useQuery } from '@ultimat3/realtime';
|
|
8
8
|
|
|
9
|
-
export const
|
|
9
|
+
export const probeUseQuery = useQuery;
|
package/src/serve.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { islandRoutes } from './island-routes';
|
|
|
48
48
|
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
49
49
|
import { readMigrations } from './migrations';
|
|
50
50
|
import { startOtlpExport } from './otlp-export';
|
|
51
|
+
import { pageSync } from './page-sync';
|
|
51
52
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
52
53
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
53
54
|
import { styleBundle } from './style-bundle';
|
|
@@ -363,6 +364,9 @@ async function bootRoles(boot: {
|
|
|
363
364
|
// prevent, and it is the one an operator cannot see without installing the app.
|
|
364
365
|
const pwa = await loadPwaArtifacts(options.root);
|
|
365
366
|
const theme = themeBoot(await loadThemeMode(options.root));
|
|
367
|
+
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
368
|
+
// Before the service worker, which precaches those scripts.
|
|
369
|
+
const sync = await pageSync(options.root, options.env, buildId);
|
|
366
370
|
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
367
371
|
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
368
372
|
// route added here cannot be missing from the precache manifest.
|
|
@@ -375,6 +379,7 @@ async function bootRoles(boot: {
|
|
|
375
379
|
routes: describeRoutes(),
|
|
376
380
|
islands,
|
|
377
381
|
styles: styleBundle(),
|
|
382
|
+
scripts: sync.scripts,
|
|
378
383
|
});
|
|
379
384
|
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
380
385
|
const mcpMount = await mountAppMcp(options.root);
|
|
@@ -393,9 +398,13 @@ async function bootRoles(boot: {
|
|
|
393
398
|
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
394
399
|
// filled, so this process serves exactly the CSS it renders against.
|
|
395
400
|
...styleRoutes(() => styleBundle()),
|
|
401
|
+
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
402
|
+
...sync.routes,
|
|
396
403
|
...appRoutes({
|
|
397
404
|
buildId,
|
|
398
405
|
resolveIsland: (file) => islands.resolverFor(file),
|
|
406
|
+
sync: sync.head,
|
|
407
|
+
persisted: sync.persisted,
|
|
399
408
|
themeHead: theme.head,
|
|
400
409
|
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
401
410
|
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface ServiceWorkerInput {
|
|
|
57
57
|
* this map does not name.
|
|
58
58
|
*/
|
|
59
59
|
readonly documents?: ReadonlyMap<string, RenderedDocument>;
|
|
60
|
+
/**
|
|
61
|
+
* The page's framework scripts — realtime's page boot and sync worker (`pageSync(…).scripts`).
|
|
62
|
+
* Precached beside the island chunks for their reason: source-addressed and `immutable`, and an
|
|
63
|
+
* offline reload that cannot load the boot restores no record and shows the old count.
|
|
64
|
+
*/
|
|
65
|
+
readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -117,8 +123,12 @@ const pwaRoutes = (
|
|
|
117
123
|
* Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
|
|
118
124
|
* decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
|
|
119
125
|
*/
|
|
120
|
-
const staticAssets = (
|
|
121
|
-
|
|
126
|
+
const staticAssets = (
|
|
127
|
+
islands: IslandBundle,
|
|
128
|
+
styles: StyleBundle,
|
|
129
|
+
scripts: readonly { readonly url: string; readonly bytes: number }[],
|
|
130
|
+
): readonly PrecacheAsset[] =>
|
|
131
|
+
[...islands.chunks, ...styles.chunks, ...scripts]
|
|
122
132
|
.map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
|
|
123
133
|
.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
124
134
|
|
|
@@ -200,7 +210,7 @@ export function serviceWorkerArtifacts(
|
|
|
200
210
|
neverCache: pwa.offline.neverCache,
|
|
201
211
|
},
|
|
202
212
|
capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
|
|
203
|
-
assets: staticAssets(input.islands, input.styles),
|
|
213
|
+
assets: staticAssets(input.islands, input.styles, input.scripts ?? []),
|
|
204
214
|
},
|
|
205
215
|
input.buildId,
|
|
206
216
|
);
|
package/src/sync-url.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Where a page's one socket dials — the framework's answer, so no app owns a `sync-url.ts`. Read
|
|
2
|
+
// once at boot from the deployment's env and handed to every document as `ultimate-sync`.
|
|
3
|
+
|
|
4
|
+
import { ConfigInvalidError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The sync node's own path (`createSyncNode`'s default in `@ultimat3/realtime`). Same origin by
|
|
8
|
+
* default because every rung already serves it there: `x dev` and a combined-role container mount
|
|
9
|
+
* the node on the web port, and `docker/helm`'s ingress routes `/_x/sync` to the `sync` service.
|
|
10
|
+
*/
|
|
11
|
+
export const SYNC_PATH = '/_x/sync';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `SYNC_URL` verbatim when the deployment states one — the Compose rung publishes `sync` on its
|
|
15
|
+
* own port with no proxy in front, so only the deployment knows that URL — else `SYNC_PATH`,
|
|
16
|
+
* resolved by the browser against its own origin. Never derived from a port: behind any ingress a
|
|
17
|
+
* neighbouring port is a URL nothing publishes.
|
|
18
|
+
*/
|
|
19
|
+
export function syncUrlFrom(env: Readonly<Record<string, string | undefined>>): string {
|
|
20
|
+
const declared = env['SYNC_URL']?.trim() ?? '';
|
|
21
|
+
if (declared === '') return SYNC_PATH;
|
|
22
|
+
const parsed = URL.parse(declared);
|
|
23
|
+
if (parsed === null || (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) {
|
|
24
|
+
throw new ConfigInvalidError({
|
|
25
|
+
cause: 'SYNC_URL is set but is not a ws:// or wss:// URL, so no browser could dial it',
|
|
26
|
+
fix: 'export SYNC_URL="wss://sync.example.com/_x/sync" # or unset it to dial /_x/sync on the page origin',
|
|
27
|
+
meta: { key: 'SYNC_URL' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return declared;
|
|
31
|
+
}
|
|
@@ -37,6 +37,7 @@ const formIslandSource = (
|
|
|
37
37
|
// <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
|
|
38
38
|
// A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
|
|
39
39
|
|
|
40
|
+
import { clientTransport } from '@ultimat3/core';
|
|
40
41
|
import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
|
|
41
42
|
import type { JSX } from 'solid-js';
|
|
42
43
|
import {
|
|
@@ -71,25 +72,21 @@ type SaveState = 'idle' | 'saved' | 'failed';
|
|
|
71
72
|
* Presentation only: the action this submits to owns validation server-side, so the form never
|
|
72
73
|
* re-implements the invariant — a blank title fails at the boundary, not in the DOM.
|
|
73
74
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
75
|
+
* \`clientTransport\` — the framework's one browser HTTP function — to the path the server minted.
|
|
76
|
+
* Never a raw \`fetch\`: the transport is what decodes a refusal into its code, fences a sign-out,
|
|
77
|
+
* and hands any entity rows the answer carries to the page's store.
|
|
77
78
|
*/
|
|
78
79
|
function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
|
|
79
80
|
const [title, setTitle] = createSignal('');
|
|
80
81
|
const [state, setState] = createSignal<SaveState>('idle');
|
|
81
82
|
|
|
82
|
-
// A
|
|
83
|
-
//
|
|
84
|
-
//
|
|
83
|
+
// A refusal and a request that never got a response both REJECT here — the transport turns a
|
|
84
|
+
// non-2xx into its code and an offline \`fetch\` into X_CLIENT_TRANSPORT_FAILED — and both are the
|
|
85
|
+
// outcome \`retry\` exists for. Without the catch the rejection escapes \`void send()\` unhandled.
|
|
85
86
|
const send = async (): Promise<void> => {
|
|
86
87
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
headers: { 'content-type': 'application/json' },
|
|
90
|
-
body: JSON.stringify({ title: title() }),
|
|
91
|
-
});
|
|
92
|
-
setState(response.ok ? 'saved' : 'failed');
|
|
88
|
+
await clientTransport({ method: 'POST', url: props.endpoint, body: { title: title() } });
|
|
89
|
+
setState('saved');
|
|
93
90
|
} catch {
|
|
94
91
|
setState('failed');
|
|
95
92
|
}
|
|
@@ -198,13 +195,14 @@ beforeAll(async () => {
|
|
|
198
195
|
// What the server rendered inside the island's wrapper. \`mount\` replaces it.
|
|
199
196
|
shell: '<p>Loading</p>',
|
|
200
197
|
globals: {
|
|
201
|
-
|
|
198
|
+
// The form sends through \`clientTransport\`, which calls \`globalThis.fetch\` — this stub.
|
|
199
|
+
fetch: (url: string, init: { body: string }): Promise<Response> => {
|
|
202
200
|
calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
|
|
203
|
-
// What a browser rejects with when there is no network. Not a response
|
|
204
|
-
//
|
|
201
|
+
// What a browser rejects with when there is no network. Not a response, which is exactly
|
|
202
|
+
// why the form has to catch it.
|
|
205
203
|
return networkFails
|
|
206
204
|
? Promise.reject(new TypeError('Failed to fetch'))
|
|
207
|
-
: Promise.resolve({
|
|
205
|
+
: Promise.resolve(Response.json({ id: 'created' }));
|
|
208
206
|
},
|
|
209
207
|
},
|
|
210
208
|
});
|
|
@@ -221,6 +219,18 @@ afterAll(() => {
|
|
|
221
219
|
mounted?.[Symbol.dispose]();
|
|
222
220
|
});
|
|
223
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Until the status line changes, a bounded number of macrotasks: the send is several awaits deep
|
|
224
|
+
* inside \`clientTransport\`, so counting microtasks would pin the transport, not the form.
|
|
225
|
+
*/
|
|
226
|
+
async function statusSettled(): Promise<void> {
|
|
227
|
+
const before = mounted.text('[data-role="status"]');
|
|
228
|
+
for (let tick = 0; tick < 50; tick += 1) {
|
|
229
|
+
if (mounted.text('[data-role="status"]') !== before) return;
|
|
230
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
224
234
|
/**
|
|
225
235
|
* One mount, driven as a session: the cases below run in order against the same island, because
|
|
226
236
|
* building the real chunk costs seconds and repeating it per case would pay them for state each
|
|
@@ -242,7 +252,7 @@ describe('the ${feature.kebab} form island', () => {
|
|
|
242
252
|
// identical to a selector typo otherwise.
|
|
243
253
|
expect(mounted.fire(field, 'input')).toBe(true);
|
|
244
254
|
expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
245
|
-
await
|
|
255
|
+
await statusSettled();
|
|
246
256
|
|
|
247
257
|
expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
|
|
248
258
|
});
|
|
@@ -253,12 +263,11 @@ describe('the ${feature.kebab} form island', () => {
|
|
|
253
263
|
});
|
|
254
264
|
|
|
255
265
|
test('a request that never got a response still reaches retry', async () => {
|
|
256
|
-
// The outcome \`retry\` is FOR. A \`fetch\` that rejects
|
|
257
|
-
//
|
|
266
|
+
// The outcome \`retry\` is FOR. A \`fetch\` that rejects produces no answer, so without the
|
|
267
|
+
// catch in \`send\` the status line stays on its last value and the rejection escapes.
|
|
258
268
|
networkFails = true;
|
|
259
269
|
expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
|
|
260
|
-
await
|
|
261
|
-
await Promise.resolve();
|
|
270
|
+
await statusSettled();
|
|
262
271
|
|
|
263
272
|
expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
|
|
264
273
|
});
|
package/src/templates/route.ts
CHANGED
|
@@ -219,6 +219,9 @@ import { e2eTest, expect } from '@ultimat3/testing';
|
|
|
219
219
|
// \`e2eTest\` reports itself skipped, naming the command that builds what it would drive.
|
|
220
220
|
e2eTest('/${path} renders offline', async ({ page, offline }) => {
|
|
221
221
|
await page.goto('/${sampleUrl(path)}');
|
|
222
|
+
// \`offline()\` cuts the service worker's network too, so the reload is answered from its cache
|
|
223
|
+
// or not at all — which needs the worker in control of this page before the cut.
|
|
224
|
+
await page.waitForServiceWorker();
|
|
222
225
|
await offline();
|
|
223
226
|
await page.reload();
|
|
224
227
|
expect(await page.title()).not.toBe('');
|
|
@@ -112,6 +112,13 @@ coverage
|
|
|
112
112
|
**/playwright-report
|
|
113
113
|
`;
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The production env file, relative to the app root. Named once because two readers must agree:
|
|
117
|
+
* the compose file's \`env_file:\` (what the containers see) and \`x deploy\`'s \`--env-file\` (what
|
|
118
|
+
* compose interpolates \`\${VAR:?…}\` from). Two spellings would let them drift apart silently.
|
|
119
|
+
*/
|
|
120
|
+
export const PROD_ENV_FILE = '.env.production';
|
|
121
|
+
|
|
115
122
|
const composeProd = (
|
|
116
123
|
app: NameSet,
|
|
117
124
|
): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
|
|
@@ -119,6 +126,12 @@ const composeProd = (
|
|
|
119
126
|
#
|
|
120
127
|
# IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
|
|
121
128
|
#
|
|
129
|
+
# By hand, always with \`--env-file ${PROD_ENV_FILE}\`: Compose fills \`\${VAR:?…}\` below from the shell
|
|
130
|
+
# and \`--env-file\` only, NEVER from \`env_file:\`, so without it a value set only in that file is
|
|
131
|
+
# "missing" and the parse fails. \`x deploy\` passes it on every step.
|
|
132
|
+
#
|
|
133
|
+
# docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
|
|
134
|
+
#
|
|
122
135
|
# A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
|
|
123
136
|
# box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
|
|
124
137
|
# file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
|
|
@@ -128,7 +141,7 @@ name: ${app.kebab}
|
|
|
128
141
|
|
|
129
142
|
x-image: &image
|
|
130
143
|
image: \${IMAGE:-${app.kebab}:dev}
|
|
131
|
-
env_file: [
|
|
144
|
+
env_file: [../${PROD_ENV_FILE}]
|
|
132
145
|
restart: unless-stopped
|
|
133
146
|
stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
|
|
134
147
|
depends_on:
|
|
@@ -193,7 +206,9 @@ services:
|
|
|
193
206
|
|
|
194
207
|
web:
|
|
195
208
|
<<: *image
|
|
196
|
-
|
|
209
|
+
# The page dials SYNC_URL; unset, it dials /_x/sync on :3000, which web does not serve here.
|
|
210
|
+
# Set it in ${PROD_ENV_FILE}: \`--env-file\` (header) is what lets this line read it there.
|
|
211
|
+
environment: [ROLE=web, 'SYNC_URL=\${SYNC_URL:?set SYNC_URL=ws://<host>:3001/_x/sync, see wiki/Deployment.md}']
|
|
197
212
|
depends_on:
|
|
198
213
|
db: { condition: service_healthy }
|
|
199
214
|
migrate: { condition: service_completed_successfully }
|
|
@@ -276,7 +291,7 @@ docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
|
|
|
276
291
|
## One box, every role
|
|
277
292
|
|
|
278
293
|
\`\`\`sh
|
|
279
|
-
docker compose -f docker/docker-compose.prod.yml up -d
|
|
294
|
+
docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
|
|
280
295
|
x deploy --image ${app.kebab}:dev --dry-run --json # the same plan, printed
|
|
281
296
|
\`\`\`
|
|
282
297
|
|
|
@@ -29,6 +29,12 @@ export const SCAFFOLD_ENV_SCHEMA = {
|
|
|
29
29
|
role: 'sync',
|
|
30
30
|
description: 'Realtime fan-out cluster. Only the sync role is asked for it.',
|
|
31
31
|
},
|
|
32
|
+
SYNC_URL: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
required: false,
|
|
35
|
+
role: 'web',
|
|
36
|
+
description: 'Where a page dials the sync socket. Compose: ws://<host>:3001/_x/sync',
|
|
37
|
+
},
|
|
32
38
|
SESSION_SECRET: {
|
|
33
39
|
type: 'string',
|
|
34
40
|
required: false,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// What the gate's `e2e` step does around the suite in an APP: when this machine has a browser, it
|
|
2
|
+
// runs the suite with the e2e preload and names the app root, and the PRELOAD spawns the app — so
|
|
3
|
+
// the app lives in the test process, where `deploy.newBuild()` can restart it. No browser, or not an
|
|
4
|
+
// app: the suite runs as before and its browser-backed cases skip — or refuse under
|
|
5
|
+
// `E2E_BROWSER_REQUIRED=1`.
|
|
6
|
+
|
|
7
|
+
// why: Bun exposes no path API — the preload is addressed by an absolute path the child resolves.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { findChrome } from './cdp-launch';
|
|
10
|
+
import { E2E_ROOT_ENV } from './e2e-browser-handle';
|
|
11
|
+
import type { ExecResult } from './exec';
|
|
12
|
+
|
|
13
|
+
/** The preload `bun test` is handed, beside the app's own from `bunfig.toml`. */
|
|
14
|
+
export const E2E_PRELOAD = join(import.meta.dir, 'e2e-preload.ts');
|
|
15
|
+
|
|
16
|
+
export interface E2eRun {
|
|
17
|
+
readonly command: readonly string[];
|
|
18
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Wrap one e2e `bun test` invocation: `run` receives the command and extra environment to use. */
|
|
22
|
+
export async function withE2eApp(
|
|
23
|
+
input: {
|
|
24
|
+
readonly root: string;
|
|
25
|
+
readonly isApp: boolean;
|
|
26
|
+
readonly command: readonly string[];
|
|
27
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
28
|
+
},
|
|
29
|
+
run: (e2e: E2eRun) => Promise<ExecResult>,
|
|
30
|
+
): Promise<ExecResult> {
|
|
31
|
+
const chrome = input.isApp ? await findChrome(Bun.env) : undefined;
|
|
32
|
+
if (chrome === undefined) return run({ command: input.command, env: input.env });
|
|
33
|
+
const [bun = 'bun', test = 'test', ...rest] = input.command;
|
|
34
|
+
return run({
|
|
35
|
+
command: [bun, test, '--preload', E2E_PRELOAD, ...rest],
|
|
36
|
+
env: { ...input.env, [E2E_ROOT_ENV]: input.root },
|
|
37
|
+
});
|
|
38
|
+
}
|