@ultimat3/cli 16.0.0 → 18.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 +90 -7
- package/package.json +30 -30
- package/src/app-root.ts +13 -1
- package/src/cmd-dev.ts +16 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- 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-roles.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/dev-traces.ts +5 -1
- package/src/e2e-page.ts +16 -2
- package/src/error-codes.ts +9 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +19 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +45 -6
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +1 -1
- package/src/metrics-endpoint.ts +6 -1
- package/src/prerender.ts +16 -0
- package/src/pwa-artifacts.ts +188 -0
- package/src/serve.ts +13 -1
- package/src/sync-authenticator.ts +8 -2
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-repo.ts +14 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Single responsibility: the one source image every generated icon derives from, where the matrix
|
|
2
|
+
// is served, and the renderer that turns one into the other. Its own module so the two things that
|
|
3
|
+
// need it — `dev-assets.ts`, which SERVES the matrix, and `pwa-artifacts.ts`, which NAMES it in the
|
|
4
|
+
// web manifest — can share it without importing each other.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and `ICON_SOURCE` is app-root-relative, so resolving it
|
|
7
|
+
// against the root is string work no `Bun.file` overload does.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import type { IconPlan } from '@ultimat3/pwa';
|
|
10
|
+
import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
|
|
14
|
+
* it and this file reads it — one constant, because a second spelling is an app that passes the
|
|
15
|
+
* diagnostic and still serves no icons. PNG, not SVG: core's pipeline decodes PNG and JPEG only.
|
|
16
|
+
*/
|
|
17
|
+
export const ICON_SOURCE = 'apps/web/site/icon.png';
|
|
18
|
+
|
|
19
|
+
/** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
|
|
20
|
+
export const ICON_BASE_PATH = '/icons';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The matrix's whole plan, off one constant pair. One call, so the icons `/icons/*` serves and the
|
|
24
|
+
* icons `manifest.webmanifest` names can never be two different lists.
|
|
25
|
+
*/
|
|
26
|
+
export const iconPlan = (): IconPlan =>
|
|
27
|
+
planIcons({ sourceIcon: ICON_SOURCE, outDir: ICON_BASE_PATH });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the app committed the one file the whole matrix derives from. Read where the answer
|
|
31
|
+
* changes what is EMITTED — a manifest naming twelve icons an app has no source for is twelve 404s
|
|
32
|
+
* in an install prompt, which is the promise-nothing-keeps shape this module's callers exist to
|
|
33
|
+
* close. `x doctor` owns the diagnostic and reports the same condition with `X_PWA_ICON_MISSING`.
|
|
34
|
+
*/
|
|
35
|
+
export const hasSourceIcon = (root: string): Promise<boolean> =>
|
|
36
|
+
Bun.file(join(root, ICON_SOURCE)).exists();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rendered once per process, not per request: the fourteen matrix entries are pure functions of
|
|
40
|
+
* one source file, and re-encoding a 512px PNG on every hit would be work no caller can observe.
|
|
41
|
+
*/
|
|
42
|
+
export function iconRenderer(root: string): (plan: IconPlan, path: string) => Promise<Uint8Array> {
|
|
43
|
+
const pipeline = new BuiltinImagePipeline();
|
|
44
|
+
const rendered = new Map<string, Promise<Uint8Array>>();
|
|
45
|
+
const sourceBytes = async (): Promise<Uint8Array> => {
|
|
46
|
+
const file = Bun.file(join(root, ICON_SOURCE));
|
|
47
|
+
if (!(await file.exists())) {
|
|
48
|
+
throw new PwaIconMissingError(
|
|
49
|
+
`${ICON_SOURCE} does not exist, so every icon the web manifest declares is unbacked and ` +
|
|
50
|
+
'the app is not installable',
|
|
51
|
+
// The same edit `x doctor` reports for the same condition, in `@ultimat3/pwa`'s own words.
|
|
52
|
+
// `x new` was here and takes an app name, so it could never run inside the broken app.
|
|
53
|
+
`add a 1024x1024 square PNG at ${ICON_SOURCE}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return file.bytes();
|
|
57
|
+
};
|
|
58
|
+
return async (plan, path) => {
|
|
59
|
+
const entry = plan.entries.find((candidate) => candidate.outputPath === path);
|
|
60
|
+
if (entry === undefined) {
|
|
61
|
+
throw new PwaIconMissingError(
|
|
62
|
+
`${path} is not in the icon matrix, so no transform describes it`,
|
|
63
|
+
`request one of ${plan.entries.map((one) => one.outputPath).join(', ')}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const existing = rendered.get(path);
|
|
67
|
+
if (existing !== undefined) return existing;
|
|
68
|
+
const bytes = sourceBytes().then((source) => pipeline.resize(source, entry.transform));
|
|
69
|
+
rendered.set(path, bytes);
|
|
70
|
+
// A failed render must not be remembered — the next request comes after the source was added.
|
|
71
|
+
bytes.catch(() => rendered.delete(path));
|
|
72
|
+
return bytes;
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export { testCommand } from './cmd-test';
|
|
|
72
72
|
export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
|
|
73
73
|
export type { CliCommand, CommandContext } from './command';
|
|
74
74
|
export { failed, ok } from './command';
|
|
75
|
+
export { acceptCreatedTables, createdTables } from './db-accept-created';
|
|
75
76
|
export type { BranchRow, BranchSubcommand } from './db-branch';
|
|
76
77
|
export {
|
|
77
78
|
BRANCH_SUBCOMMANDS,
|
|
@@ -83,11 +84,11 @@ export {
|
|
|
83
84
|
} from './db-branch';
|
|
84
85
|
export type { GeneratedFiles, GenerateMigrationOptions, GenerateOutcome } from './db-generate';
|
|
85
86
|
export { generateAppMigration, migrationSql } from './db-generate';
|
|
87
|
+
export type { SubscribingQuery } from './db-subscribes';
|
|
88
|
+
export { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
|
|
86
89
|
export type { AssetRoutesOptions } from './dev-assets';
|
|
87
90
|
export {
|
|
88
91
|
assetRoutes,
|
|
89
|
-
ICON_BASE_PATH,
|
|
90
|
-
ICON_SOURCE,
|
|
91
92
|
MEDIA_BASE_PATH,
|
|
92
93
|
} from './dev-assets';
|
|
93
94
|
export type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
@@ -234,6 +235,13 @@ export {
|
|
|
234
235
|
} from './framework-schema';
|
|
235
236
|
export type { Guard } from './guards';
|
|
236
237
|
export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
|
|
238
|
+
export {
|
|
239
|
+
hasSourceIcon,
|
|
240
|
+
ICON_BASE_PATH,
|
|
241
|
+
ICON_SOURCE,
|
|
242
|
+
iconPlan,
|
|
243
|
+
iconRenderer,
|
|
244
|
+
} from './icon-assets';
|
|
237
245
|
// The island bundler, and only its entry point. An island is the one module Ultimate ships to a
|
|
238
246
|
// browser, so an app has to be able to build one to TEST one — `mountIsland` from
|
|
239
247
|
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
@@ -274,6 +282,13 @@ export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
|
|
|
274
282
|
export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
|
|
275
283
|
export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
|
|
276
284
|
export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
|
|
285
|
+
export type { PwaArtifacts } from './pwa-artifacts';
|
|
286
|
+
export {
|
|
287
|
+
loadPwaArtifacts,
|
|
288
|
+
pwaManifestRoute,
|
|
289
|
+
WEB_MANIFEST_PATH,
|
|
290
|
+
writePwaIcons,
|
|
291
|
+
} from './pwa-artifacts';
|
|
277
292
|
export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
|
|
278
293
|
export type { SchemaDifference, SchemaDirection, SchemaPart } from './schema-diff';
|
|
279
294
|
export { diffDeclaredSchema } from './schema-diff';
|
|
@@ -323,8 +338,8 @@ export type { TestCounts } from './test-counts';
|
|
|
323
338
|
export { countsOf } from './test-counts';
|
|
324
339
|
export type { TestFile } from './test-select';
|
|
325
340
|
export { belongsToType, discoverTests, sampleFiles } from './test-select';
|
|
326
|
-
export type { ReproduceOptions, RunShardsOptions
|
|
327
|
-
export {
|
|
341
|
+
export type { ReproduceOptions, RunShardsOptions } from './test-shards';
|
|
342
|
+
export { filesIn, reproduceFor, runShards, testArgs } from './test-shards';
|
|
328
343
|
export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
|
|
329
344
|
export type {
|
|
330
345
|
CodeFixSite,
|
|
@@ -144,4 +144,11 @@ export const readinessProbe = (selector: string): string =>
|
|
|
144
144
|
// Children OR text: a component that renders one text node has painted, and one that mounted
|
|
145
145
|
// and rendered nothing is the silence a non-zero box would otherwise read as success.
|
|
146
146
|
'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
|
|
147
|
-
'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)}
|
|
147
|
+
'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)},' +
|
|
148
|
+
// The box is VIEWPORT coordinates — what `getBoundingClientRect()` answers — and a capture clip
|
|
149
|
+
// is PAGE coordinates. They agree only while the page is at the origin, which is the one case a
|
|
150
|
+
// harness happens to be in and is not a rule anything enforces: a state whose component sits
|
|
151
|
+
// below the fold scrolls, and a clip taken from the raw rect then crops the wrong band with
|
|
152
|
+
// nothing to report it. The offset is returned rather than added here so `box` keeps meaning
|
|
153
|
+
// exactly what the verdict already publishes.
|
|
154
|
+
'scroll:{x:Math.round(window.scrollX||0),y:Math.round(window.scrollY||0)}};})()';
|
package/src/island-shot.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
// why: no Bun native joins a path; `Bun.write` and `Bun.file` both take one already joined.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import
|
|
8
|
+
import { finiteCount } from '@ultimat3/core';
|
|
9
|
+
import type { CaptureClip, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
9
10
|
import { systemScrapeClock } from '@ultimat3/scraping';
|
|
10
11
|
import type { IslandShotTarget, IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
|
|
11
12
|
import { islandShotTargets, islandStatesFile } from '@ultimat3/testing';
|
|
@@ -138,6 +139,21 @@ export function photographFault(
|
|
|
138
139
|
return undefined;
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The capture rectangle for a readiness answer, in PAGE coordinates.
|
|
144
|
+
*
|
|
145
|
+
* `seen` is non-null and its box has area by the time this is reached — `photographFault` refuses
|
|
146
|
+
* both above, and it refuses them BEFORE the shutter for exactly this reason: a zero-area clip is
|
|
147
|
+
* `X_SCRAPE_CAPTURE_CLIP_EMPTY` from the port, which is a worse report of the same fault than
|
|
148
|
+
* "rendered nothing". The `?? 0` pair is the parser's floor and not a second opinion.
|
|
149
|
+
*/
|
|
150
|
+
const clipFor = (seen: IslandReadiness | null): CaptureClip => ({
|
|
151
|
+
x: (seen?.box.x ?? 0) + (seen?.scroll.x ?? 0),
|
|
152
|
+
y: (seen?.box.y ?? 0) + (seen?.scroll.y ?? 0),
|
|
153
|
+
width: seen?.box.width ?? 0,
|
|
154
|
+
height: seen?.box.height ?? 0,
|
|
155
|
+
});
|
|
156
|
+
|
|
141
157
|
const hostFix = (target: IslandShotTarget): string =>
|
|
142
158
|
`in ${islandStatesFile(target.island)} set island to a path that exports mount(el, props)`;
|
|
143
159
|
|
|
@@ -157,6 +173,7 @@ async function captureOne(
|
|
|
157
173
|
options: IslandShotRun,
|
|
158
174
|
server: ShotServer,
|
|
159
175
|
target: IslandShotTarget,
|
|
176
|
+
floor: number,
|
|
160
177
|
): Promise<IslandStateShot> {
|
|
161
178
|
const url = new URL(`${ISLAND_HARNESS_PATH}${target.query}`, server.url).toString();
|
|
162
179
|
let session: ScrapeSession | undefined;
|
|
@@ -169,6 +186,17 @@ async function captureOne(
|
|
|
169
186
|
timeoutMs: options.timeoutMs,
|
|
170
187
|
});
|
|
171
188
|
const page = session.page;
|
|
189
|
+
// BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
|
|
190
|
+
// media query, and the theme a component resolves on mount is the one it will keep.
|
|
191
|
+
//
|
|
192
|
+
// This is the INPUT and the harness's `data-theme` attribute is the OUTCOME, and both are set
|
|
193
|
+
// deliberately. The attribute is right for a component that READS a theme it does not own; the
|
|
194
|
+
// preference is the only thing that reaches one that RESOLVES its own. `examples/dummy`'s
|
|
195
|
+
// settings island is the second kind — its state's `theme` prop is `'system'`, so on mount it
|
|
196
|
+
// DELETES the attribute the harness set, both pictures fall through to `:root`, and the two
|
|
197
|
+
// came back byte-identical with the same md5 (issue #338). Re-setting the attribute after
|
|
198
|
+
// readiness is not the repair: it photographs a state the component would never reach.
|
|
199
|
+
await page.colorScheme(target.theme);
|
|
172
200
|
await page.goto(url, { timeout: options.timeoutMs });
|
|
173
201
|
const expression = readinessProbe(target.target ?? '[data-x-island]');
|
|
174
202
|
const probe = (): Promise<IslandReadiness | null> =>
|
|
@@ -200,10 +228,16 @@ async function captureOne(
|
|
|
200
228
|
...fault,
|
|
201
229
|
});
|
|
202
230
|
}
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
231
|
+
// The COMPONENT, not the viewport it happens to sit in — the crop this feature was designed
|
|
232
|
+
// around, and which nothing passed until 2026-08-26 (issue #338). The rectangle is the
|
|
233
|
+
// readiness probe's own box, which is the crop target the manifest declared, translated from
|
|
234
|
+
// the DOM's viewport coordinates into the page coordinates a capture clip is in.
|
|
235
|
+
//
|
|
236
|
+
// The clip ALONE. `fullPage: false` beside it is accepted — `assertCaptureFraming` refuses only
|
|
237
|
+
// `=== true`, and `cdp-target.ts` sends `{ clip }` and nothing else either way, so all four
|
|
238
|
+
// pictures really were written with the pair — but it is a field that says nothing: the two
|
|
239
|
+
// are exclusive, and spelling out the default of the one you did not ask for reads as a choice.
|
|
240
|
+
const bytes = await page.screenshot({ clip: clipFor(seen) });
|
|
207
241
|
if (bytes.byteLength < floor) {
|
|
208
242
|
throw new IslandUnphotographableError({
|
|
209
243
|
island: target.island,
|
|
@@ -257,6 +291,11 @@ export async function runIslandShot(options: IslandShotRun): Promise<IslandArtif
|
|
|
257
291
|
const targets = islandShotTargets(options.manifest).filter(
|
|
258
292
|
(target) => chosen === null || chosen.has(target.state),
|
|
259
293
|
);
|
|
294
|
+
// Before the boot, and before a browser: `bytes.byteLength < NaN` is false for every picture, so
|
|
295
|
+
// an unchecked floor does not lower the backstop — it removes it, and "produced nothing and
|
|
296
|
+
// exited 0" is the one outcome a reader cannot tell from success. 0 stays legal and is what
|
|
297
|
+
// `island-shot.test.ts` passes: the fake driver answers an 8-byte PNG signature.
|
|
298
|
+
const floor = finiteCount('runIslandShot', 'minBytes', options.minBytes ?? MIN_SHOT_BYTES);
|
|
260
299
|
const server = await options.boot();
|
|
261
300
|
const shots: IslandStateShot[] = [];
|
|
262
301
|
const failures: unknown[] = [];
|
|
@@ -266,7 +305,7 @@ export async function runIslandShot(options: IslandShotRun): Promise<IslandArtif
|
|
|
266
305
|
// the app CAN produce plus a named reason for each one it cannot, and the missing-shot gate
|
|
267
306
|
// below is what turns those reasons into a non-zero exit.
|
|
268
307
|
try {
|
|
269
|
-
shots.push(await captureOne(options, server, target));
|
|
308
|
+
shots.push(await captureOne(options, server, target, floor));
|
|
270
309
|
} catch (error) {
|
|
271
310
|
failures.push(error);
|
|
272
311
|
}
|
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
|
@@ -168,6 +168,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
168
168
|
// reproduces the finding, and the finding names the file and the header line to add.
|
|
169
169
|
X_MIGRATION_UNGENERATABLE:
|
|
170
170
|
'x verify --only drift --json # then add the `-- ungeneratable: <n>` header line the finding names',
|
|
171
|
+
// The edit comes first and the command re-runs it, exactly as the two schema codes above do:
|
|
172
|
+
// the cause names the query and the name that matched nothing, and `subscribes:` is a field in
|
|
173
|
+
// that query's own file. Regenerating is safe here — this refusal happens BEFORE anything is
|
|
174
|
+
// written, so there is no half-generated migration to undo.
|
|
175
|
+
X_QUERY_SUBSCRIBES_UNKNOWN:
|
|
176
|
+
'x db gen "retry after fixing subscribes" --json # first edit subscribes: on the query the cause names',
|
|
171
177
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
172
178
|
X_DB_BRANCH_FAILED: 'x db branch ls --json',
|
|
173
179
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
package/src/messages.ts
CHANGED
|
@@ -212,7 +212,7 @@ const CATALOG = {
|
|
|
212
212
|
'cli.shot.island.picture': ' pictures {path}',
|
|
213
213
|
'cli.shot.island.verdict': ' verdict {path}',
|
|
214
214
|
'cli.shot.island.blind.crop':
|
|
215
|
-
'the picture is the
|
|
215
|
+
'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
216
|
'cli.shot.island.blind.locale':
|
|
217
217
|
'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
|
|
218
218
|
'cli.ci.failed':
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `docker/helm`'s HPAs read a number instead of `<unknown>`.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
finiteCount,
|
|
6
7
|
logger,
|
|
7
8
|
METRICS_CONTENT_TYPE,
|
|
8
9
|
METRICS_PATH,
|
|
@@ -78,7 +79,11 @@ export interface MetricsEndpoint {
|
|
|
78
79
|
* signal at the moment of load is worse than no autoscaler.
|
|
79
80
|
*/
|
|
80
81
|
export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
|
|
81
|
-
|
|
82
|
+
// Screened here rather than left to `Bun.serve`, which refuses a `NaN` with a bare `RangeError`
|
|
83
|
+
// — no code, no `fix:` — at exactly the boot path the refusal below exists to stop reporting
|
|
84
|
+
// that way. Floor 0, because 0 asks the kernel for a free port and `dev-roles.ts` passes it for
|
|
85
|
+
// an ephemeral boot; the ceiling stays Bun's, which names the range it refuses.
|
|
86
|
+
const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
|
|
82
87
|
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
|
83
88
|
// second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
|
|
84
89
|
// The return type is inferred, keeping `Bun.serve`'s own shape stated once.
|
package/src/prerender.ts
CHANGED
|
@@ -17,6 +17,7 @@ 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';
|
|
22
23
|
|
|
@@ -147,6 +148,20 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
147
148
|
join(options.out, STATIC_ERROR_PAGE),
|
|
148
149
|
await errorPageDocument(options.root, NOT_FOUND_STATUS),
|
|
149
150
|
);
|
|
151
|
+
// And the file every document above is about to name. A static export is served with no process
|
|
152
|
+
// behind it, so `<link rel="manifest">` resolves to a 404 unless the bytes are in the artifact —
|
|
153
|
+
// an installable app that is installable only under `x dev` is the dev/prod split this whole
|
|
154
|
+
// wiring exists to close. `undefined` when the app is not installable, and then no document
|
|
155
|
+
// names it either.
|
|
156
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
157
|
+
if (pwa !== undefined) {
|
|
158
|
+
await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
|
|
159
|
+
// And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
|
|
160
|
+
// `/icons/*` entry would be a 404 in the install prompt — the manifest half of the same
|
|
161
|
+
// promise `favicon.ico` above keeps. Nothing when the app committed no source icon, which is
|
|
162
|
+
// also when the manifest names no icon.
|
|
163
|
+
await writePwaIcons(options.root, options.out);
|
|
164
|
+
}
|
|
150
165
|
|
|
151
166
|
// Every render below goes through `routeDocument`, which is the function a REQUEST reaches — and
|
|
152
167
|
// a request arrives inside `runWithContext`, installed by the HTTP pipeline (`dev-render.ts`).
|
|
@@ -161,6 +176,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
161
176
|
runWithContext(ctx, () =>
|
|
162
177
|
routeDocument(entry, data, {
|
|
163
178
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
179
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head }),
|
|
164
180
|
}),
|
|
165
181
|
);
|
|
166
182
|
|
|
@@ -0,0 +1,188 @@
|
|
|
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 } 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
|
+
|
|
61
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
62
|
+
typeof value === 'object' && value !== null;
|
|
63
|
+
|
|
64
|
+
const text = (value: unknown): string | undefined =>
|
|
65
|
+
typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `colors` read structurally, for `loadSignInPath`'s reason: `defineConfig` returns a plain object
|
|
69
|
+
* and a config resolved through an older core simply has no such key. `validate()` already refused
|
|
70
|
+
* a blank one an `await import` above this line, so anything this rejects is a hand-written config
|
|
71
|
+
* object — and the honest answer for one is no manifest at all, never a colour we invented.
|
|
72
|
+
*/
|
|
73
|
+
function colorsOf(value: unknown): PwaColors | undefined {
|
|
74
|
+
if (!isRecord(value)) return undefined;
|
|
75
|
+
const light = isRecord(value['light']) ? value['light'] : undefined;
|
|
76
|
+
const dark = isRecord(value['dark']) ? value['dark'] : undefined;
|
|
77
|
+
if (light === undefined || dark === undefined) return undefined;
|
|
78
|
+
const [lt, lb, dt, db] = [
|
|
79
|
+
text(light['themeColor']),
|
|
80
|
+
text(light['backgroundColor']),
|
|
81
|
+
text(dark['themeColor']),
|
|
82
|
+
text(dark['backgroundColor']),
|
|
83
|
+
];
|
|
84
|
+
if (lt === undefined || lb === undefined || dt === undefined || db === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
return {
|
|
87
|
+
light: { themeColor: lt, backgroundColor: lb },
|
|
88
|
+
dark: { themeColor: dt, backgroundColor: db },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
|
|
93
|
+
interface InstallableApp {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly colors: PwaColors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
|
|
99
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
100
|
+
if (!existsSync(configPath)) return undefined;
|
|
101
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
102
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
103
|
+
if (!isRecord(config)) return undefined;
|
|
104
|
+
const pwa = config['pwa'];
|
|
105
|
+
// `pwa.enabled`, read. Not a truthiness test: `enabled` is the key this whole file exists to
|
|
106
|
+
// give a reader, and `=== true` is what makes a hand-written `enabled: 'yes'` produce no
|
|
107
|
+
// manifest rather than one nobody asked for.
|
|
108
|
+
if (!isRecord(pwa) || pwa['enabled'] !== true) return undefined;
|
|
109
|
+
const name = text(pwa['name']);
|
|
110
|
+
const colors = colorsOf(pwa['colors']);
|
|
111
|
+
if (name === undefined || colors === undefined) return undefined;
|
|
112
|
+
return { name, colors };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolved ONCE at boot, like `loadSignInPath` and `loadCacheTiers` and unlike `faviconBytes`:
|
|
117
|
+
* `await import` caches the module, so re-reading per request would answer the same object at a
|
|
118
|
+
* per-request cost, and the head string has to be available synchronously while a document renders.
|
|
119
|
+
*/
|
|
120
|
+
export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | undefined> {
|
|
121
|
+
const app = await loadInstallable(root);
|
|
122
|
+
if (app === undefined) return undefined;
|
|
123
|
+
// The icons the manifest promises are exactly the ones `/icons/*` serves and `x build` writes —
|
|
124
|
+
// ONE plan, so no surface can name a size another will not produce.
|
|
125
|
+
//
|
|
126
|
+
// AND ONLY WHEN THE APP HAS A SOURCE FOR THEM. `planIcons` answers the same fourteen entries
|
|
127
|
+
// whether `apps/web/site/icon.png` exists or not, so a manifest built off it unconditionally
|
|
128
|
+
// promises twelve icons and three apple-touch links that are twelve 404s in an install prompt —
|
|
129
|
+
// the promise-nothing-keeps shape this whole module exists to close, one level down.
|
|
130
|
+
// `examples/dummy` is exactly that app: it declares `pwa.enabled: true` and commits no icon.
|
|
131
|
+
// A missing source is NOT reported here — `x doctor` already refuses it by name with
|
|
132
|
+
// `X_PWA_ICON_MISSING`, and a second reporter of one condition is the duplication this package's
|
|
133
|
+
// own rule forbids. Read at boot, like the rest of this function: adding the file takes effect
|
|
134
|
+
// on the next start, because the manifest is generated once and served as bytes.
|
|
135
|
+
const icons = (await hasSourceIcon(root)) ? iconPlan() : undefined;
|
|
136
|
+
const result = generateWebManifest({
|
|
137
|
+
name: app.name,
|
|
138
|
+
tokens: app.colors,
|
|
139
|
+
icons: icons?.manifestIcons ?? [],
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
body: serializeWebManifest(result.manifest),
|
|
143
|
+
head:
|
|
144
|
+
`<link rel="manifest" href="${escapeAttribute(WEB_MANIFEST_PATH)}">` +
|
|
145
|
+
renderThemeColorMeta(result.themeColorMeta) +
|
|
146
|
+
(icons === undefined ? '' : appleTouchLinks(icons)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The icon bytes a STATIC export has to carry, written under `out`. Answers the paths it wrote.
|
|
152
|
+
*
|
|
153
|
+
* A static host runs no `assetRoutes()`, so every `/icons/*` entry the manifest names is a 404
|
|
154
|
+
* unless the bytes are in the artifact — the same rule `prerenderSite` already applies to
|
|
155
|
+
* `favicon.ico` and `404.html`, one asset class further along. Nothing when the app has no source
|
|
156
|
+
* icon, which is also when the manifest names none.
|
|
157
|
+
*/
|
|
158
|
+
export async function writePwaIcons(root: string, out: string): Promise<readonly string[]> {
|
|
159
|
+
if (!(await hasSourceIcon(root))) return [];
|
|
160
|
+
const plan = iconPlan();
|
|
161
|
+
const render = iconRenderer(root);
|
|
162
|
+
const written: string[] = [];
|
|
163
|
+
for (const entry of plan.entries) {
|
|
164
|
+
// `outputPath` is `/icons/<file>`; `out` is the export root, so the leading slash goes.
|
|
165
|
+
await Bun.write(join(out, entry.outputPath.slice(1)), await render(plan, entry.outputPath));
|
|
166
|
+
written.push(entry.outputPath);
|
|
167
|
+
}
|
|
168
|
+
return written;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mounted through `assetRoutes`, so `x dev` and the container serve it from one place — a surface
|
|
173
|
+
* that answers in dev and not in the image is the failure that file's own header names.
|
|
174
|
+
* Public: a browser fetches a manifest before anyone has signed in, and an installable app that
|
|
175
|
+
* needs a session to describe itself is not installable.
|
|
176
|
+
*/
|
|
177
|
+
export const pwaManifestRoute = (artifacts: PwaArtifacts): Route => ({
|
|
178
|
+
method: 'GET',
|
|
179
|
+
path: WEB_MANIFEST_PATH,
|
|
180
|
+
meta: { name: 'assets.manifest', auth: 'public', cache: MANIFEST_CACHE, tags: ['assets'] },
|
|
181
|
+
handler: async (_request: UltimateRequest): Promise<Response> =>
|
|
182
|
+
applyCacheHeaders(
|
|
183
|
+
new Response(artifacts.body, {
|
|
184
|
+
headers: { 'content-type': 'application/manifest+json; charset=utf-8' },
|
|
185
|
+
}),
|
|
186
|
+
MANIFEST_CACHE,
|
|
187
|
+
),
|
|
188
|
+
});
|
package/src/serve.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { apiRoutes } from './api-routes';
|
|
|
25
25
|
import { loadSignInPath } from './app-auth';
|
|
26
26
|
import { loadApp } from './app-load';
|
|
27
27
|
import { appManifest } from './app-manifest';
|
|
28
|
+
import { acceptCreatedTables } from './db-accept-created';
|
|
28
29
|
import { assetRoutes } from './dev-assets';
|
|
29
30
|
import { startQueue } from './dev-queue';
|
|
30
31
|
import { appRoutes } from './dev-render';
|
|
@@ -43,6 +44,7 @@ import { islandRoutes } from './island-routes';
|
|
|
43
44
|
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
44
45
|
import { readMigrations } from './migrations';
|
|
45
46
|
import { startOtlpExport } from './otlp-export';
|
|
47
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
46
48
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
47
49
|
|
|
48
50
|
export const DEFAULT_PORT = 3000;
|
|
@@ -200,7 +202,11 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
|
|
|
200
202
|
available: migrations.length,
|
|
201
203
|
appVersion: report.appVersion,
|
|
202
204
|
});
|
|
203
|
-
|
|
205
|
+
// Every migration above has just been applied, so a `create table` in one of them is proof
|
|
206
|
+
// the app owns that relation — and a snapshot records only what ENTITIES declare, so without
|
|
207
|
+
// this a hand-written table is `unexpected-table` on this deploy and on every deploy after it
|
|
208
|
+
// (issue #345). Only that one difference, only for a name a migration's SQL creates.
|
|
209
|
+
const drift = acceptCreatedTables(await checkDrift({ migrations }), migrations);
|
|
204
210
|
// Logged with the first difference, not just a count: a release phase's log is the only place
|
|
205
211
|
// an operator sees this, and "3 differences" names nothing to act on.
|
|
206
212
|
if (!drift.ok) {
|
|
@@ -293,18 +299,24 @@ async function bootRoles(boot: {
|
|
|
293
299
|
// does from the same source — the alternative is a second bundler invocation in the image build
|
|
294
300
|
// whose output nothing compares against the one the dev loop proved.
|
|
295
301
|
const islands = await buildIslands(options.root);
|
|
302
|
+
// The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
|
|
303
|
+
// on a laptop and absent in the image is exactly the dev/prod difference this file exists to
|
|
304
|
+
// prevent, and it is the one an operator cannot see without installing the app.
|
|
305
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
296
306
|
const routes: readonly Route[] = [
|
|
297
307
|
...apiRoutes(),
|
|
298
308
|
...assetRoutes({
|
|
299
309
|
root: options.root,
|
|
300
310
|
storage: runtime.storage,
|
|
301
311
|
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
312
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
302
313
|
}),
|
|
303
314
|
...storageRoutes({ storage: runtime.storage }),
|
|
304
315
|
...islandRoutes(() => islands),
|
|
305
316
|
...appRoutes({
|
|
306
317
|
buildId,
|
|
307
318
|
resolveIsland: (file) => islands.resolverFor(file),
|
|
319
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head }),
|
|
308
320
|
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
309
321
|
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
310
322
|
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// by wiring, not by design.
|
|
6
6
|
|
|
7
7
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
8
|
-
import { systemClock } from '@ultimat3/core';
|
|
8
|
+
import { finiteCount, systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { HttpConfig } from '@ultimat3/http';
|
|
10
10
|
import {
|
|
11
11
|
configuredAuthenticator,
|
|
@@ -102,7 +102,13 @@ export function syncAuthenticator(
|
|
|
102
102
|
// this per connection otherwise.
|
|
103
103
|
const config = upgradeConfig(buildId);
|
|
104
104
|
const clock = options.clock ?? systemClock;
|
|
105
|
-
|
|
105
|
+
// A credential lifetime, screened where it is declared. `expiresAt` is `now + ttlMs`, and
|
|
106
|
+
// `GrantBook.expired()` asks `expiresAt <= now` — false for every `now` when the sum is `NaN`,
|
|
107
|
+
// so the grant never reaches a sweep and the socket keeps a revoked actor's authority for as
|
|
108
|
+
// long as the tab is open. That is the hole this whole file was written to close, and a `??`
|
|
109
|
+
// does not close it: `NaN` is not nullish. At least 1ms — a window that has already shut when
|
|
110
|
+
// the grant is minted is not a window.
|
|
111
|
+
const ttlMs = finiteCount('syncAuthenticator', 'ttlMs', options.ttlMs ?? SYNC_GRANT_TTL_MS, 1);
|
|
106
112
|
|
|
107
113
|
const resolve = async (credential: Credential): Promise<SyncGrant | null> => {
|
|
108
114
|
const request = new Request(credential.url, {
|