@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.
- package/CLAUDE.md +194 -17
- package/package.json +30 -30
- package/src/app-root.ts +22 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev.ts +28 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/compile-externals.ts +11 -4
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +20 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +46 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +37 -4
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +5 -1
- package/src/prerender.ts +41 -1
- package/src/pwa-artifacts.ts +230 -0
- package/src/serve.ts +24 -1
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +17 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
package/src/island-verdict.ts
CHANGED
|
@@ -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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/static-report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
: {
|
|
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),
|