@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.
Files changed (49) hide show
  1. package/CLAUDE.md +69 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/dev-live-feed.ts +2 -0
  16. package/src/dev-render.ts +119 -20
  17. package/src/dev-route-table.ts +119 -0
  18. package/src/dev-services.ts +4 -1
  19. package/src/dev-sync.ts +5 -3
  20. package/src/e2e-app.ts +103 -0
  21. package/src/e2e-browser-handle.ts +55 -0
  22. package/src/e2e-driver.ts +32 -12
  23. package/src/e2e-errors.ts +14 -0
  24. package/src/e2e-page.ts +5 -2
  25. package/src/e2e-preload.ts +64 -0
  26. package/src/e2e-probe.ts +23 -0
  27. package/src/e2e-spawn.ts +169 -0
  28. package/src/error-codes.ts +7 -0
  29. package/src/error-unthrown.ts +130 -0
  30. package/src/errors.ts +8 -29
  31. package/src/index.ts +18 -4
  32. package/src/island-bundle.ts +33 -11
  33. package/src/island-realtime.ts +91 -0
  34. package/src/island-verdict.ts +1 -1
  35. package/src/live-routes.ts +82 -42
  36. package/src/mcp-errors.ts +3 -0
  37. package/src/page-sync.ts +54 -0
  38. package/src/realtime-browser-probe-fixture.ts +2 -2
  39. package/src/serve.ts +9 -0
  40. package/src/sw-artifacts.ts +13 -3
  41. package/src/sync-url.ts +31 -0
  42. package/src/templates/resource-form-island.ts +30 -21
  43. package/src/templates/route.ts +3 -0
  44. package/src/templates/scaffold-container.ts +18 -3
  45. package/src/templates/scaffold-env.ts +6 -0
  46. package/src/verify-e2e.ts +38 -0
  47. package/src/verify-run.ts +105 -50
  48. package/src/verify-tests.ts +21 -4
  49. package/src/worker-bundle.ts +192 -0
@@ -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 with a registered `LiveClient`. Each one either subscribes, mutates
19
- * or reads the connection, so a module naming one is a module that needs a browser to have booted
20
- * it — `hasLiveClient` and `LiveClient` itself are deliberately absent: the first IS the guard, and
21
- * the second is what an island's `mount()` constructs.
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
- 'useLive',
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
- * The one escape hatch, and it is a call an author writes on purpose: a module that ASKS whether
33
- * there is a client has already written what happens when there is none. `app/update-banner.tsx`
34
- * in the reference app is the shape — imported by the layout, so by every page, and correct on all
35
- * of them.
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 = 'hasLiveClient';
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(GUARD)) return [];
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 the route module's own import graph and answer the first live hook in it.
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 liveReachOf(root: string, file: string): Promise<LiveReach | undefined> {
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 hook = liveHooksIn(module.source)[0];
108
- if (hook !== undefined) return { at: module.path, hook };
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 route that reads live rows with nothing to receive them. Two shapes, one condition — no
136
- * island at all, and an island the route declares `hydrate: 'never'` for. `X_ISLAND_NOT_HYDRATED`
137
- * covers the second only at render time, and only for a render that reaches the island, so a route
138
- * can hold the contradiction and never be asked.
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
- if (entry.islands.length > 0 && entry.config.hydrate !== 'never') continue;
148
- const reach = await liveReachOf(root, entry.file);
149
- if (reach === undefined) continue;
150
- gaps.push({
151
- ...reach,
152
- route: entry.path,
153
- file: entry.file,
154
- hydrate: entry.config.hydrate,
155
- islands: entry.islands,
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}() (${gap.at}) and ` +
165
- (gap.islands.length === 0
166
- ? 'declares no island'
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
- `${generatorFor(gap.file)}, declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) above defineRoute in ${gap.file}, ` +
171
- `and move the ${gap.hook}() read into its mount() — which is where setLiveClient() can be called`,
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 .',
@@ -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 { useLive } from '@ultimat3/realtime';
7
+ import { useQuery } from '@ultimat3/realtime';
8
8
 
9
- export const probeUseLive = useLive;
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
@@ -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 = (islands: IslandBundle, styles: StyleBundle): readonly PrecacheAsset[] =>
121
- [...islands.chunks, ...styles.chunks]
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
  );
@@ -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
- * A plain \`fetch\` to the path the server minted, not the typed client: \`rpc()\` pulls
75
- * \`@ultimat3/action\` into the chunk, which is larger than everything else here put together. The
76
- * naming rule is still the framework's; only the transport is second.
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 rejected \`fetch\` — offline, DNS, an aborted request — is the same OUTCOME as a refused one,
83
- // and it is the one \`retry\` exists for. Without the catch, \`setState\` is never reached: the
84
- // status line stays empty, and the rejection escapes the \`void send()\` below as an unhandled one.
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
- const response = await fetch(props.endpoint, {
88
- method: 'POST',
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
- fetch: (url: string, init: { body: string }): Promise<{ ok: boolean }> => {
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: \`response.ok\`
204
- // is never read on this path, which is exactly why the form has to catch it.
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({ ok: true });
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 Promise.resolve();
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 reaches no \`response.ok\`, so without
257
- // the catch in \`send\` the status line stays on its last value and the rejection escapes.
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 Promise.resolve();
261
- await Promise.resolve();
270
+ await statusSettled();
262
271
 
263
272
  expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
264
273
  });
@@ -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: [../.env.production]
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
- environment: [ROLE=web]
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 # db → migrate → the rest
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
+ }