@ultimat3/cli 17.0.0 → 19.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 (45) hide show
  1. package/CLAUDE.md +194 -17
  2. package/package.json +30 -30
  3. package/src/app-root.ts +22 -1
  4. package/src/cdp-browser.ts +100 -0
  5. package/src/cdp-connection.ts +211 -0
  6. package/src/cdp-e2e-page.ts +209 -0
  7. package/src/cdp-errors.ts +56 -0
  8. package/src/cdp-launch.ts +130 -0
  9. package/src/cmd-dev.ts +28 -2
  10. package/src/cmd-doctor.ts +1 -1
  11. package/src/cmd-test.ts +20 -9
  12. package/src/compile-externals.ts +11 -4
  13. package/src/db-accept-created.ts +207 -0
  14. package/src/db-generate.ts +18 -1
  15. package/src/db-subscribes.ts +81 -0
  16. package/src/db-ungeneratable.ts +14 -2
  17. package/src/dev-assets.ts +19 -54
  18. package/src/dev-notify-retention.ts +69 -0
  19. package/src/dev-purge.ts +47 -2
  20. package/src/dev-render.ts +20 -4
  21. package/src/dev-replicator.ts +19 -1
  22. package/src/dev-runtime.ts +12 -3
  23. package/src/dev-services.ts +8 -0
  24. package/src/e2e-driver.ts +35 -17
  25. package/src/e2e-page.ts +15 -3
  26. package/src/error-codes.ts +20 -0
  27. package/src/icon-assets.ts +74 -0
  28. package/src/index.ts +46 -4
  29. package/src/island-harness-script.ts +8 -1
  30. package/src/island-shot.ts +37 -4
  31. package/src/island-verdict.ts +16 -4
  32. package/src/mcp-errors.ts +15 -0
  33. package/src/messages.ts +5 -1
  34. package/src/prerender.ts +41 -1
  35. package/src/pwa-artifacts.ts +230 -0
  36. package/src/serve.ts +24 -1
  37. package/src/static-report.ts +46 -3
  38. package/src/sw-artifacts.ts +162 -0
  39. package/src/sw-routes.ts +53 -0
  40. package/src/templates/naming.ts +11 -0
  41. package/src/templates/scaffold-app.ts +58 -7
  42. package/src/templates/scaffold-repo.ts +17 -1
  43. package/src/test-shards.ts +150 -116
  44. package/src/ts-scan.ts +6 -1
  45. package/src/verify-test-run.ts +31 -46
@@ -27,10 +27,13 @@ export const ISLAND_SHOT_MESSAGE_KEYS = [
27
27
  * this tool cannot support — and both are properties of the port rather than of a run, which is
28
28
  * why they are a constant and not a per-run list.
29
29
  *
30
- * The crop one is the honest limit of the shipped browser port: `CaptureRequest` is `fullPage`
31
- * alone (`packages/scraping/src/page.ts`), so a picture is the VIEWPORT and the framing knob is the
32
- * state's own `viewport`, not a clip rectangle. The locale one is the reach of a page-side clock
33
- * patch: `date.toLocaleString()` resolves the zone inside the engine and never through the patched
30
+ * The crop one is what a rectangle still cannot see: the picture IS the component's own box now
31
+ * (`clipFor`, `island-shot.ts`), so what a reader loses is the surroundings — a component that
32
+ * overflows its box, or one whose fault is the space around it, is outside the frame. It said the
33
+ * opposite until 2026-08-26 — "the browser port takes no clip rectangle" — which stopped being
34
+ * true when the port gained `CaptureClip`, and a blind spot that names a capability the tool has
35
+ * is the same lie as one that hides a gap. The locale one is the reach of a page-side clock patch:
36
+ * `date.toLocaleString()` resolves the zone inside the engine and never through the patched
34
37
  * `Intl.DateTimeFormat`.
35
38
  */
36
39
  export const ISLAND_BLIND_SPOTS = [
@@ -54,7 +57,15 @@ export interface IslandReadiness {
54
57
  readonly mounted: boolean;
55
58
  readonly failed: string | null;
56
59
  readonly filled: boolean;
60
+ /** The crop target's rectangle in VIEWPORT coordinates, which is what the DOM answers. */
57
61
  readonly box: IslandBox;
62
+ /**
63
+ * The page's scroll offset at the moment the box was measured. A capture clip is in PAGE
64
+ * coordinates, so this is what turns one into the other — and it is a separate field rather than
65
+ * an addition inside the probe because `box` is published in the verdict and means the DOM's own
66
+ * answer there.
67
+ */
68
+ readonly scroll: { readonly x: number; readonly y: number };
58
69
  }
59
70
 
60
71
  const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
@@ -66,6 +77,7 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
66
77
  failed: t.nullable(t.string),
67
78
  filled: t.boolean,
68
79
  box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
80
+ scroll: t.object({ x: t.number, y: t.number }),
69
81
  }) as unknown as StandardSchemaV1<unknown, IslandReadiness>;
70
82
 
71
83
  /**
package/src/mcp-errors.ts CHANGED
@@ -78,6 +78,15 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
78
78
  X_E2E_LOCATOR_AMBIGUOUS:
79
79
  'x test e2e --json # the fix line carries the same call with .first() on it',
80
80
  X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
81
+ // The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
82
+ // whole job is reporting what this machine does not have; the other three are raised inside a
83
+ // running suite, so the runnable half is the command that re-runs it.
84
+ X_CDP_BROWSER_MISSING:
85
+ 'x doctor --json # or set CHROME_PATH to a Chrome binary; unset, the browser-backed suite skips',
86
+ X_CDP_LAUNCH_FAILED:
87
+ 'x test e2e --json # the cause carries the last lines of the browser\u2019s own stderr',
88
+ X_CDP_CALL_FAILED: 'x test e2e --json # the cause names the DevTools call the browser refused',
89
+ X_CDP_TIMEOUT: 'x test e2e --json # the cause names the call that never answered',
81
90
  X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
82
91
  X_GH_NOT_AUTHENTICATED: 'gh auth login',
83
92
  X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
@@ -168,6 +177,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
168
177
  // reproduces the finding, and the finding names the file and the header line to add.
169
178
  X_MIGRATION_UNGENERATABLE:
170
179
  'x verify --only drift --json # then add the `-- ungeneratable: <n>` header line the finding names',
180
+ // The edit comes first and the command re-runs it, exactly as the two schema codes above do:
181
+ // the cause names the query and the name that matched nothing, and `subscribes:` is a field in
182
+ // that query's own file. Regenerating is safe here — this refusal happens BEFORE anything is
183
+ // written, so there is no half-generated migration to undo.
184
+ X_QUERY_SUBSCRIBES_UNKNOWN:
185
+ 'x db gen "retry after fixing subscribes" --json # first edit subscribes: on the query the cause names',
171
186
  X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
172
187
  X_DB_BRANCH_FAILED: 'x db branch ls --json',
173
188
  X_DB_STUDIO_FAILED: 'x doctor --json',
package/src/messages.ts CHANGED
@@ -11,6 +11,10 @@ const CATALOG = {
11
11
  'cli.commands.heading': 'commands',
12
12
  'cli.build.done': 'built {target}',
13
13
  'cli.build.failed': '{target} build failed',
14
+ // A build-output line, so it belongs here rather than inline beside the emitter: `x build` is a
15
+ // human surface and every other word it prints comes from this catalog.
16
+ 'cli.build.pushUnwired':
17
+ 'pwa.push is true and no VAPID key is configured, so the emitted sw.js carries no push handler',
14
18
  // `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
15
19
  // caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
16
20
  // exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
@@ -212,7 +216,7 @@ const CATALOG = {
212
216
  'cli.shot.island.picture': ' pictures {path}',
213
217
  'cli.shot.island.verdict': ' verdict {path}',
214
218
  'cli.shot.island.blind.crop':
215
- 'the picture is the viewport, not a crop — the browser port takes no clip rectangle, so a state sizes its own frame with viewport',
219
+ 'the picture is the crop target and nothing around it — a component that overflows its own box, or whose fault is the space beside it, is outside the frame',
216
220
  'cli.shot.island.blind.locale':
217
221
  'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
218
222
  'cli.ci.failed':
package/src/prerender.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { join } from 'node:path';
7
7
  import { createContext, renderThrowable, runWithContext } from '@ultimat3/core';
8
8
  import type { RouteEntry } from '@ultimat3/render';
9
- import { routeEntries } from '@ultimat3/render';
9
+ import { describeRoutes, routeEntries } from '@ultimat3/render';
10
10
  import { renderStatic } from '@ultimat3/render/server';
11
11
  import { loadApp } from './app-load';
12
12
  import { appManifest } from './app-manifest';
@@ -17,8 +17,10 @@ import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
17
17
  import { FAVICON_PATH, faviconBytes } from './favicon';
18
18
  import type { IslandBundle } from './island-bundle';
19
19
  import { buildIslands, writeIslands } from './island-bundle';
20
+ import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
20
21
  import type { SkippedRoute, UnmeasuredRoute } from './static-report';
21
22
  import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
23
+ import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, serviceWorkerArtifacts } from './sw-artifacts';
22
24
 
23
25
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
24
26
  // carries it, and this file already imports that module.
@@ -75,6 +77,15 @@ export interface PrerenderReport {
75
77
  readonly report: string;
76
78
  /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
77
79
  readonly islands: readonly string[];
80
+ /**
81
+ * What the service worker could not express, and what its precache manifest weighs too much of.
82
+ *
83
+ * `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
84
+ * `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
85
+ * that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
86
+ * has to reach the build's own report. Empty for an app with no service worker.
87
+ */
88
+ readonly serviceWorkerWarnings: readonly string[];
78
89
  }
79
90
 
80
91
  /**
@@ -147,6 +158,32 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
147
158
  join(options.out, STATIC_ERROR_PAGE),
148
159
  await errorPageDocument(options.root, NOT_FOUND_STATUS),
149
160
  );
161
+ // And the file every document above is about to name. A static export is served with no process
162
+ // behind it, so `<link rel="manifest">` resolves to a 404 unless the bytes are in the artifact —
163
+ // an installable app that is installable only under `x dev` is the dev/prod split this whole
164
+ // wiring exists to close. `undefined` when the app is not installable, and then no document
165
+ // names it either.
166
+ const pwa = await loadPwaArtifacts(options.root);
167
+ // The worker and its registration script, written as FILES. A static host runs no route table,
168
+ // so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the
169
+ // artifact — the same promise `favicon.ico` and the icons above keep, for the asset that decides
170
+ // whether the export works offline at all.
171
+ const serviceWorker =
172
+ pwa === undefined
173
+ ? undefined
174
+ : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
175
+ if (serviceWorker !== undefined) {
176
+ await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
177
+ await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
178
+ }
179
+ if (pwa !== undefined) {
180
+ await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
181
+ // And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
182
+ // `/icons/*` entry would be a 404 in the install prompt — the manifest half of the same
183
+ // promise `favicon.ico` above keeps. Nothing when the app committed no source icon, which is
184
+ // also when the manifest names no icon.
185
+ await writePwaIcons(options.root, options.out);
186
+ }
150
187
 
151
188
  // Every render below goes through `routeDocument`, which is the function a REQUEST reaches — and
152
189
  // a request arrives inside `runWithContext`, installed by the HTTP pipeline (`dev-render.ts`).
@@ -161,6 +198,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
161
198
  runWithContext(ctx, () =>
162
199
  routeDocument(entry, data, {
163
200
  resolveIsland: (file: string) => islands.resolverFor(file),
201
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
164
202
  }),
165
203
  );
166
204
 
@@ -251,6 +289,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
251
289
  // stdout — so the one command the finding tells an author to run printed no `unmeasured` key
252
290
  // and no reason. Written into the report is what makes the instruction true.
253
291
  unmeasured,
292
+ serviceWorkerWarnings: serviceWorker?.warnings ?? [],
254
293
  });
255
294
  return {
256
295
  out: options.out,
@@ -261,5 +300,6 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
261
300
  stats,
262
301
  report,
263
302
  islands: islands.chunks.map((chunk) => chunk.file),
303
+ serviceWorkerWarnings: serviceWorker?.warnings ?? [],
264
304
  };
265
305
  }
@@ -0,0 +1,230 @@
1
+ // The web manifest an installable app promises, and that no build had ever produced.
2
+ //
3
+ // `pwa.enabled` was a switch with no reader anywhere in the tree (issue #362, and
4
+ // `scripts/lib/config-reader-pins.ts` pinned it as a `jobs.driver` candidate). `@ultimat3/pwa` has
5
+ // shipped `generateWebManifest`, `renderThemeColorMeta`, `planIcons` and `appleTouchLinks` since it
6
+ // existed and NOTHING called them, so every Ultimate app served a `<head>` with no
7
+ // `<link rel="manifest">`, no `theme-color` and no apple-touch icon — and no browser has ever
8
+ // offered to install one, however the config was written.
9
+ //
10
+ // WHY HERE. `dev-assets.ts`'s reason exactly: three packages declare what an installable app is and
11
+ // none of them can read a config file off disk. This one composes tier 0's `pwa` block with tier
12
+ // 4's generator and hands both served surfaces and the static export the same two strings.
13
+ //
14
+ // WHAT IT DOES NOT DO: emit a service worker. `offline`, `backgroundSync` and `push` still have no
15
+ // build behind them — `wiki/PWA-And-Offline.md` says so — and a bad `sw.js` is sticky in a way a
16
+ // manifest is not, so the worker lands behind a real browser check rather than beside this.
17
+
18
+ // why: Bun exposes no synchronous file-existence primitive, and this read is the same one
19
+ // `app-auth.ts` and `dev-cache.ts` each make before importing an app's config — a root with no
20
+ // `app.config.ts` is an ordinary answer here (a scratch root, `x build` outside an app).
21
+ import { existsSync } from 'node:fs';
22
+ // why: Bun exposes no path-join primitive, and `APP_CONFIG_FILE` is app-root-relative — the same
23
+ // necessity `favicon.ts` and `dev-assets.ts` each record for their own root-relative constant.
24
+ import { join } from 'node:path';
25
+ import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
26
+ import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
27
+ import { applyCacheHeaders } from '@ultimat3/http';
28
+ import {
29
+ appleTouchLinks,
30
+ generateWebManifest,
31
+ renderThemeColorMeta,
32
+ serializeWebManifest,
33
+ } from '@ultimat3/pwa';
34
+ import { escapeAttribute } from '@ultimat3/seo';
35
+ import { APP_CONFIG_EXPORT } from './app-auth';
36
+ import { APP_CONFIG_FILE } from './app-root';
37
+ import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
38
+
39
+ /** What a browser fetches from `<link rel="manifest">`. The spec's own extension, not `.json`. */
40
+ export const WEB_MANIFEST_PATH = '/manifest.webmanifest';
41
+
42
+ /**
43
+ * Short, never immutable. The path carries no content hash, so an app that changed its install
44
+ * title must be able to publish it — an hour is `favicon.ts`'s number, for the same asset class.
45
+ */
46
+ const MANIFEST_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
47
+
48
+ /** The two strings every surface needs: the file's bytes, and what `<head>` must carry to name it. */
49
+ export interface PwaArtifacts {
50
+ /** `manifest.webmanifest`, serialized. */
51
+ readonly body: string;
52
+ /**
53
+ * `<link rel="manifest">`, both `theme-color` metas, and every apple-touch icon link. One string
54
+ * because a document either carries all of it or none: a manifest link with no theme colour
55
+ * installs an app whose status bar flashes white on every launch, and an apple-touch link with
56
+ * no manifest is an iOS icon for an app iOS will not add.
57
+ */
58
+ readonly head: string;
59
+ /**
60
+ * The three `pwa` keys the SERVICE WORKER needs, carried here because this is the one module
61
+ * that reads an app's config file — `sw-artifacts.ts` needs the route table and the island
62
+ * bundle as well, and a second `await import` of `app.config.ts` would be a second answer to
63
+ * "what did this app declare".
64
+ */
65
+ readonly offline: PwaOfflineConfig;
66
+ readonly backgroundSync: boolean;
67
+ readonly push: boolean;
68
+ }
69
+
70
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
71
+ typeof value === 'object' && value !== null;
72
+
73
+ const text = (value: unknown): string | undefined =>
74
+ typeof value === 'string' && value.trim() !== '' ? value : undefined;
75
+
76
+ /**
77
+ * `colors` read structurally, for `loadSignInPath`'s reason: `defineConfig` returns a plain object
78
+ * and a config resolved through an older core simply has no such key. `validate()` already refused
79
+ * a blank one an `await import` above this line, so anything this rejects is a hand-written config
80
+ * object — and the honest answer for one is no manifest at all, never a colour we invented.
81
+ */
82
+ function colorsOf(value: unknown): PwaColors | undefined {
83
+ if (!isRecord(value)) return undefined;
84
+ const light = isRecord(value['light']) ? value['light'] : undefined;
85
+ const dark = isRecord(value['dark']) ? value['dark'] : undefined;
86
+ if (light === undefined || dark === undefined) return undefined;
87
+ const [lt, lb, dt, db] = [
88
+ text(light['themeColor']),
89
+ text(light['backgroundColor']),
90
+ text(dark['themeColor']),
91
+ text(dark['backgroundColor']),
92
+ ];
93
+ if (lt === undefined || lb === undefined || dt === undefined || db === undefined)
94
+ return undefined;
95
+ return {
96
+ light: { themeColor: lt, backgroundColor: lb },
97
+ dark: { themeColor: dt, backgroundColor: db },
98
+ };
99
+ }
100
+
101
+ /** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
102
+ interface InstallableApp {
103
+ readonly name: string;
104
+ readonly colors: PwaColors;
105
+ readonly offline: PwaOfflineConfig;
106
+ readonly backgroundSync: boolean;
107
+ readonly push: boolean;
108
+ }
109
+
110
+ async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
111
+ const configPath = join(root, APP_CONFIG_FILE);
112
+ if (!existsSync(configPath)) return undefined;
113
+ const module = (await import(configPath)) as Record<string, unknown>;
114
+ const config = module[APP_CONFIG_EXPORT];
115
+ if (!isRecord(config)) return undefined;
116
+ const pwa = config['pwa'];
117
+ // `pwa.enabled`, read. Not a truthiness test: `enabled` is the key this whole file exists to
118
+ // give a reader, and `=== true` is what makes a hand-written `enabled: 'yes'` produce no
119
+ // manifest rather than one nobody asked for.
120
+ if (!isRecord(pwa) || pwa['enabled'] !== true) return undefined;
121
+ const name = text(pwa['name']);
122
+ const colors = colorsOf(pwa['colors']);
123
+ if (name === undefined || colors === undefined) return undefined;
124
+ return { name, colors, offline: offlineOf(pwa['offline']), ...flags(pwa) };
125
+ }
126
+
127
+ /**
128
+ * The offline block, read structurally for `colorsOf`'s reason: `defineConfig` refuses
129
+ * `enabled: true` without an absolute `offline.fallback`, but a HAND-WRITTEN config object never
130
+ * passed through it. A missing or relative fallback answers `null`, and `serviceWorkerArtifacts`
131
+ * then emits no worker at all — never a path the framework invented, which offline would be a
132
+ * cached 404 answering every navigation.
133
+ */
134
+ function offlineOf(value: unknown): PwaOfflineConfig {
135
+ const block = isRecord(value) ? value : {};
136
+ const fallback = text(block['fallback']);
137
+ const patterns = block['neverCache'];
138
+ return {
139
+ fallback: fallback?.startsWith('/') === true ? fallback : null,
140
+ image: text(block['image']) ?? null,
141
+ font: text(block['font']) ?? null,
142
+ neverCache: Array.isArray(patterns)
143
+ ? patterns.filter((entry): entry is string => typeof entry === 'string')
144
+ : [],
145
+ };
146
+ }
147
+
148
+ /** `=== true` for `enabled`'s reason: a hand-written `backgroundSync: 'yes'` wires nothing. */
149
+ const flags = (pwa: Record<string, unknown>): { backgroundSync: boolean; push: boolean } => ({
150
+ backgroundSync: pwa['backgroundSync'] === true,
151
+ push: pwa['push'] === true,
152
+ });
153
+
154
+ /**
155
+ * Resolved ONCE at boot, like `loadSignInPath` and `loadCacheTiers` and unlike `faviconBytes`:
156
+ * `await import` caches the module, so re-reading per request would answer the same object at a
157
+ * per-request cost, and the head string has to be available synchronously while a document renders.
158
+ */
159
+ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | undefined> {
160
+ const app = await loadInstallable(root);
161
+ if (app === undefined) return undefined;
162
+ // The icons the manifest promises are exactly the ones `/icons/*` serves and `x build` writes —
163
+ // ONE plan, so no surface can name a size another will not produce.
164
+ //
165
+ // AND ONLY WHEN THE APP HAS A SOURCE FOR THEM. `planIcons` answers the same fourteen entries
166
+ // whether `apps/web/site/icon.png` exists or not, so a manifest built off it unconditionally
167
+ // promises twelve icons and three apple-touch links that are twelve 404s in an install prompt —
168
+ // the promise-nothing-keeps shape this whole module exists to close, one level down.
169
+ // `examples/dummy` is exactly that app: it declares `pwa.enabled: true` and commits no icon.
170
+ // A missing source is NOT reported here — `x doctor` already refuses it by name with
171
+ // `X_PWA_ICON_MISSING`, and a second reporter of one condition is the duplication this package's
172
+ // own rule forbids. Read at boot, like the rest of this function: adding the file takes effect
173
+ // on the next start, because the manifest is generated once and served as bytes.
174
+ const icons = (await hasSourceIcon(root)) ? iconPlan() : undefined;
175
+ const result = generateWebManifest({
176
+ name: app.name,
177
+ tokens: app.colors,
178
+ icons: icons?.manifestIcons ?? [],
179
+ });
180
+ return {
181
+ offline: app.offline,
182
+ backgroundSync: app.backgroundSync,
183
+ push: app.push,
184
+ body: serializeWebManifest(result.manifest),
185
+ head:
186
+ `<link rel="manifest" href="${escapeAttribute(WEB_MANIFEST_PATH)}">` +
187
+ renderThemeColorMeta(result.themeColorMeta) +
188
+ (icons === undefined ? '' : appleTouchLinks(icons)),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * The icon bytes a STATIC export has to carry, written under `out`. Answers the paths it wrote.
194
+ *
195
+ * A static host runs no `assetRoutes()`, so every `/icons/*` entry the manifest names is a 404
196
+ * unless the bytes are in the artifact — the same rule `prerenderSite` already applies to
197
+ * `favicon.ico` and `404.html`, one asset class further along. Nothing when the app has no source
198
+ * icon, which is also when the manifest names none.
199
+ */
200
+ export async function writePwaIcons(root: string, out: string): Promise<readonly string[]> {
201
+ if (!(await hasSourceIcon(root))) return [];
202
+ const plan = iconPlan();
203
+ const render = iconRenderer(root);
204
+ const written: string[] = [];
205
+ for (const entry of plan.entries) {
206
+ // `outputPath` is `/icons/<file>`; `out` is the export root, so the leading slash goes.
207
+ await Bun.write(join(out, entry.outputPath.slice(1)), await render(plan, entry.outputPath));
208
+ written.push(entry.outputPath);
209
+ }
210
+ return written;
211
+ }
212
+
213
+ /**
214
+ * Mounted through `assetRoutes`, so `x dev` and the container serve it from one place — a surface
215
+ * that answers in dev and not in the image is the failure that file's own header names.
216
+ * Public: a browser fetches a manifest before anyone has signed in, and an installable app that
217
+ * needs a session to describe itself is not installable.
218
+ */
219
+ export const pwaManifestRoute = (artifacts: PwaArtifacts): Route => ({
220
+ method: 'GET',
221
+ path: WEB_MANIFEST_PATH,
222
+ meta: { name: 'assets.manifest', auth: 'public', cache: MANIFEST_CACHE, tags: ['assets'] },
223
+ handler: async (_request: UltimateRequest): Promise<Response> =>
224
+ applyCacheHeaders(
225
+ new Response(artifacts.body, {
226
+ headers: { 'content-type': 'application/manifest+json; charset=utf-8' },
227
+ }),
228
+ MANIFEST_CACHE,
229
+ ),
230
+ });
package/src/serve.ts CHANGED
@@ -20,11 +20,13 @@ import {
20
20
  migrate,
21
21
  } from '@ultimat3/db';
22
22
  import type { Route } from '@ultimat3/http';
23
+ import { describeRoutes } from '@ultimat3/render';
23
24
  import { createIsrController } from '@ultimat3/render/server';
24
25
  import { apiRoutes } from './api-routes';
25
26
  import { loadSignInPath } from './app-auth';
26
27
  import { loadApp } from './app-load';
27
28
  import { appManifest } from './app-manifest';
29
+ import { acceptCreatedTables } from './db-accept-created';
28
30
  import { assetRoutes } from './dev-assets';
29
31
  import { startQueue } from './dev-queue';
30
32
  import { appRoutes } from './dev-render';
@@ -43,7 +45,10 @@ import { islandRoutes } from './island-routes';
43
45
  import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
44
46
  import { readMigrations } from './migrations';
45
47
  import { startOtlpExport } from './otlp-export';
48
+ import { loadPwaArtifacts } from './pwa-artifacts';
46
49
  import type { RuntimeOverrides } from './runtime-overrides';
50
+ import { serviceWorkerArtifacts } from './sw-artifacts';
51
+ import { serviceWorkerRoutes } from './sw-routes';
47
52
 
48
53
  export const DEFAULT_PORT = 3000;
49
54
 
@@ -200,7 +205,11 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
200
205
  available: migrations.length,
201
206
  appVersion: report.appVersion,
202
207
  });
203
- const drift = await checkDrift({ migrations });
208
+ // Every migration above has just been applied, so a `create table` in one of them is proof
209
+ // the app owns that relation — and a snapshot records only what ENTITIES declare, so without
210
+ // this a hand-written table is `unexpected-table` on this deploy and on every deploy after it
211
+ // (issue #345). Only that one difference, only for a name a migration's SQL creates.
212
+ const drift = acceptCreatedTables(await checkDrift({ migrations }), migrations);
204
213
  // Logged with the first difference, not just a count: a release phase's log is the only place
205
214
  // an operator sees this, and "3 differences" names nothing to act on.
206
215
  if (!drift.ok) {
@@ -293,18 +302,32 @@ async function bootRoles(boot: {
293
302
  // does from the same source — the alternative is a second bundler invocation in the image build
294
303
  // whose output nothing compares against the one the dev loop proved.
295
304
  const islands = await buildIslands(options.root);
305
+ // The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
306
+ // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
307
+ // prevent, and it is the one an operator cannot see without installing the app.
308
+ const pwa = await loadPwaArtifacts(options.root);
309
+ // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
310
+ // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
311
+ // route added here cannot be missing from the precache manifest.
312
+ const serviceWorker =
313
+ pwa === undefined
314
+ ? undefined
315
+ : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
296
316
  const routes: readonly Route[] = [
297
317
  ...apiRoutes(),
318
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
298
319
  ...assetRoutes({
299
320
  root: options.root,
300
321
  storage: runtime.storage,
301
322
  ...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
323
+ ...(pwa === undefined ? {} : { pwa }),
302
324
  }),
303
325
  ...storageRoutes({ storage: runtime.storage }),
304
326
  ...islandRoutes(() => islands),
305
327
  ...appRoutes({
306
328
  buildId,
307
329
  resolveIsland: (file) => islands.resolverFor(file),
330
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
308
331
  // Only when a store was supplied. `createIsrController` defaults to a per-process memory
309
332
  // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
310
333
  // the fleet while the other eleven keep serving the page it just invalidated.
@@ -13,6 +13,7 @@ import { RENDER_MODES } from '@ultimat3/core';
13
13
  import type { Surface } from '@ultimat3/render';
14
14
  import { SURFACE_SPECS, SURFACES, surfaceAllows } from '@ultimat3/render';
15
15
  import type { JsonValue } from './output';
16
+ import { SERVICE_WORKER_PATH } from './sw-artifacts';
16
17
 
17
18
  /** Beside `.x/build-stats.json`, and written by the same call — see `readStaticReport` below. */
18
19
  export const STATIC_REPORT_FILE = join('.x', 'static-report.json');
@@ -84,6 +85,15 @@ export type StaticReport = {
84
85
  * the reader.
85
86
  */
86
87
  readonly unmeasured: readonly UnmeasuredRoute[];
88
+ /**
89
+ * The service worker's own findings: a capability declared with nothing to wire it to, and a
90
+ * precache manifest over its byte ceiling. `PrecacheManifest.warnings` had no reader anywhere in
91
+ * the tree (#390), so the ceiling was — in `wiki/Troubleshooting.md`'s own words — "a designed
92
+ * thing that is not one". Written here for `unmeasured`'s reason: `cmd-build.ts` discards a
93
+ * successful subprocess's stdout, so a warning that lives only on the in-process report reaches
94
+ * nobody. Empty for an app with no service worker.
95
+ */
96
+ readonly serviceWorkerWarnings: readonly string[];
87
97
  };
88
98
 
89
99
  /**
@@ -177,7 +187,7 @@ const isEmitted = (value: unknown): value is EmittedPage =>
177
187
  */
178
188
  export function parseStaticReport(value: unknown): StaticReport | undefined {
179
189
  if (!isRecord(value)) return undefined;
180
- const { target, out, buildId, emitted, skipped, unmeasured } = value;
190
+ const { target, out, buildId, emitted, skipped, unmeasured, serviceWorkerWarnings } = value;
181
191
  if (target !== 'static' || typeof out !== 'string' || typeof buildId !== 'string') {
182
192
  return undefined;
183
193
  }
@@ -190,7 +200,25 @@ export function parseStaticReport(value: unknown): StaticReport | undefined {
190
200
  if (unmeasured !== undefined && (!Array.isArray(unmeasured) || !unmeasured.every(isUnmeasured))) {
191
201
  return undefined;
192
202
  }
193
- return { target, out, buildId, emitted, skipped, unmeasured: unmeasured ?? [] };
203
+ // Optional on the way in for `unmeasured`'s reason, and a non-string entry drops the whole
204
+ // report for a malformed skip row's reason: a warning list with a hole in it is a build that
205
+ // says less than it measured, which is how the worker's findings went unread in the first place.
206
+ if (
207
+ serviceWorkerWarnings !== undefined &&
208
+ (!Array.isArray(serviceWorkerWarnings) ||
209
+ !serviceWorkerWarnings.every((entry) => typeof entry === 'string'))
210
+ ) {
211
+ return undefined;
212
+ }
213
+ return {
214
+ target,
215
+ out,
216
+ buildId,
217
+ emitted,
218
+ skipped,
219
+ unmeasured: unmeasured ?? [],
220
+ serviceWorkerWarnings: (serviceWorkerWarnings as readonly string[] | undefined) ?? [],
221
+ };
194
222
  }
195
223
 
196
224
  export async function writeStaticReport(root: string, report: StaticReport): Promise<string> {
@@ -232,7 +260,12 @@ export function staticReportData(report: StaticReport | undefined): Record<strin
232
260
  // which the inventory is about — `data` already carries `artifact` and the build's own id.
233
261
  return report === undefined
234
262
  ? {}
235
- : { emitted: report.emitted, skipped: report.skipped, unmeasured: report.unmeasured };
263
+ : {
264
+ emitted: report.emitted,
265
+ skipped: report.skipped,
266
+ unmeasured: report.unmeasured,
267
+ serviceWorkerWarnings: report.serviceWorkerWarnings,
268
+ };
236
269
  }
237
270
 
238
271
  /**
@@ -247,6 +280,16 @@ export function renderStaticReport(report: StaticReport): readonly string[] {
247
280
  // whose budget could not be weighed is invisible in `emitted` and, when it also rendered, in
248
281
  // `skipped` too — and it is the row `X_BUDGET_UNMEASURED` sends its reader here to read.
249
282
  ...report.unmeasured.map((route) => ['unmeasured', route.path, route.reason]),
283
+ // The service worker's own findings, in the same three columns — a precache manifest over its
284
+ // byte ceiling AND a capability declared with nothing to wire it to, which is why neither the
285
+ // field nor this label says `precache`. `sw.js` is the one artifact that keeps serving after a
286
+ // deploy is over, so both are build-time facts that have to be visible in the build's own
287
+ // output — `PrecacheManifest` computed the first and nothing read it (#390).
288
+ ...report.serviceWorkerWarnings.map((warning) => [
289
+ 'service-worker',
290
+ SERVICE_WORKER_PATH,
291
+ warning,
292
+ ]),
250
293
  ];
251
294
  const widths = [0, 1].map((index) =>
252
295
  Math.max(...rows.map((row) => (row[index] ?? '').length), 0),