@ultimat3/cli 22.3.0 → 22.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.3.0",
3
+ "version": "22.3.2",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.3.0",
42
- "@ultimat3/admin": "22.3.0",
43
- "@ultimat3/ai": "22.3.0",
44
- "@ultimat3/auth": "22.3.0",
45
- "@ultimat3/cache": "22.3.0",
46
- "@ultimat3/core": "22.3.0",
47
- "@ultimat3/db": "22.3.0",
48
- "@ultimat3/entity": "22.3.0",
49
- "@ultimat3/flags": "22.3.0",
50
- "@ultimat3/http": "22.3.0",
51
- "@ultimat3/i18n": "22.3.0",
52
- "@ultimat3/jobs": "22.3.0",
53
- "@ultimat3/mail": "22.3.0",
54
- "@ultimat3/manifest": "22.3.0",
55
- "@ultimat3/mcp": "22.3.0",
56
- "@ultimat3/money": "22.3.0",
57
- "@ultimat3/notify": "22.3.0",
58
- "@ultimat3/policy": "22.3.0",
59
- "@ultimat3/pwa": "22.3.0",
60
- "@ultimat3/query": "22.3.0",
61
- "@ultimat3/realtime": "22.3.0",
62
- "@ultimat3/render": "22.3.0",
63
- "@ultimat3/schema": "22.3.0",
64
- "@ultimat3/seo": "22.3.0",
65
- "@ultimat3/storage": "22.3.0",
66
- "@ultimat3/testing": "22.3.0",
67
- "@ultimat3/time": "22.3.0",
41
+ "@ultimat3/action": "22.3.2",
42
+ "@ultimat3/admin": "22.3.2",
43
+ "@ultimat3/ai": "22.3.2",
44
+ "@ultimat3/auth": "22.3.2",
45
+ "@ultimat3/cache": "22.3.2",
46
+ "@ultimat3/core": "22.3.2",
47
+ "@ultimat3/db": "22.3.2",
48
+ "@ultimat3/entity": "22.3.2",
49
+ "@ultimat3/flags": "22.3.2",
50
+ "@ultimat3/http": "22.3.2",
51
+ "@ultimat3/i18n": "22.3.2",
52
+ "@ultimat3/jobs": "22.3.2",
53
+ "@ultimat3/mail": "22.3.2",
54
+ "@ultimat3/manifest": "22.3.2",
55
+ "@ultimat3/mcp": "22.3.2",
56
+ "@ultimat3/money": "22.3.2",
57
+ "@ultimat3/notify": "22.3.2",
58
+ "@ultimat3/policy": "22.3.2",
59
+ "@ultimat3/pwa": "22.3.2",
60
+ "@ultimat3/query": "22.3.2",
61
+ "@ultimat3/realtime": "22.3.2",
62
+ "@ultimat3/render": "22.3.2",
63
+ "@ultimat3/schema": "22.3.2",
64
+ "@ultimat3/seo": "22.3.2",
65
+ "@ultimat3/storage": "22.3.2",
66
+ "@ultimat3/testing": "22.3.2",
67
+ "@ultimat3/time": "22.3.2",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -26,6 +26,8 @@ export interface FakeShotPage {
26
26
  readonly url: string;
27
27
  readonly html: string;
28
28
  readonly evaluate?: Readonly<Record<string, string>> | undefined;
29
+ /** The document's HTTP status. Absent is 200 — the page was recorded because it exists. */
30
+ readonly status?: number | undefined;
29
31
  }
30
32
 
31
33
  /** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
@@ -76,7 +78,8 @@ function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionIni
76
78
  network.push({
77
79
  method: 'GET',
78
80
  url: page.url,
79
- status: 200,
81
+ // Data the page answers with, never a bound — a recorded page exists, so absent is a 200.
82
+ status: page.status === undefined ? 200 : page.status,
80
83
  resourceType: 'document',
81
84
  at: init.clock.now().getTime(),
82
85
  });
@@ -7,7 +7,7 @@ export const shotSpec: CommandSpec = {
7
7
  name: 'shot',
8
8
  summary: 'photograph one route, one island in a state it declares, or every island in the app',
9
9
  usage:
10
- 'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--port 0] [--out <dir>] [--settle 2000] [--json]',
10
+ 'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
11
11
  requiresApp: true,
12
12
  flags: [
13
13
  { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
@@ -15,6 +15,12 @@ export const shotSpec: CommandSpec = {
15
15
  { name: 'full', type: 'boolean', summary: 'whole page, not the fold', default: true },
16
16
  { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
17
17
  { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
18
+ {
19
+ name: 'expect-status',
20
+ type: 'string',
21
+ summary:
22
+ 'the document status this shot is ok with (e.g. 404); absent: any 2xx, else it fails',
23
+ },
18
24
  { name: 'browser', type: 'string', summary: 'Chrome or Chromium binary to launch' },
19
25
  {
20
26
  name: 'cdp-url',
package/src/cmd-shot.ts CHANGED
@@ -45,6 +45,7 @@ import { readThemeFlag, themeChoiceExpression } from './shot-theme';
45
45
  import type { IslandCount, ShotArtifacts } from './shot-verdict';
46
46
  import {
47
47
  buildVerdict,
48
+ documentStatus,
48
49
  ISLAND_PROBE,
49
50
  parseIslandProbe,
50
51
  shotLines,
@@ -218,6 +219,11 @@ export interface ShotRun {
218
219
  */
219
220
  readonly acceptLanguage?: string | undefined;
220
221
  readonly now?: (() => Date) | undefined;
222
+ /**
223
+ * `--expect-status`: the document status this shot is ok with — `404` to photograph the not-found
224
+ * page on purpose. Absent means any 2xx, and anything else fails the verdict with the status.
225
+ */
226
+ readonly expectStatus?: number | undefined;
221
227
  /**
222
228
  * Something to do with the page AFTER the islands settled and BEFORE the picture — `ui.inspect`
223
229
  * reads the DOM here, on the one navigation the picture already paid for. `settle` re-runs the
@@ -265,6 +271,9 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
265
271
  if (choice !== undefined) await page.prepare(choice);
266
272
  }
267
273
  await page.goto(requestedUrl, { timeout: options.timeoutMs });
274
+ // Read NOW as well as at capture: the network ring is bounded, and a page that fires a few
275
+ // hundred requests while it settles evicts the document entry the verdict needs.
276
+ const landedStatus = documentStatus(page.network(), page.url());
268
277
  if (options.settleMs > 0) await Bun.sleep(options.settleMs);
269
278
  // The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
270
279
  // JS engine. `null` says so; a `0` would read as "the route renders no islands", which is a
@@ -303,6 +312,8 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
303
312
  network: page.network(),
304
313
  networkDropped: page.networkDropped(),
305
314
  islands,
315
+ landedStatus,
316
+ ...(options.expectStatus === undefined ? {} : { expectStatus: options.expectStatus }),
306
317
  });
307
318
  mkdirSync(options.outDir, { recursive: true });
308
319
  const image = join(options.outDir, SHOT_IMAGE);
@@ -387,6 +398,11 @@ export const shotCommand: CliCommand = {
387
398
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
388
399
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
389
400
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
401
+ // Read only when given: its absence means "any 2xx", which no single default number can say.
402
+ const expectStatus =
403
+ flagString(ctx.args, 'expect-status') === undefined
404
+ ? undefined
405
+ : intFlag(ctx.args, 'expect-status', 100, 200, 599);
390
406
  // Which browser this run gets — start one here, or attach to one somebody else is running.
391
407
  // Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
392
408
  // provider session exists to pay for a typo. It also PROBES for an installed Chrome and refuses
@@ -427,6 +443,7 @@ export const shotCommand: CliCommand = {
427
443
  timeoutMs,
428
444
  fullPage: flagBool(ctx.args, 'full'),
429
445
  extraHosts: flagString(ctx.args, 'allow-hosts'),
446
+ ...(expectStatus === undefined ? {} : { expectStatus }),
430
447
  };
431
448
  if (matrix) {
432
449
  // `--locale` and `--theme` narrow the matrix to one value of their axis.
package/src/messages.ts CHANGED
@@ -189,6 +189,12 @@ const CATALOG = {
189
189
  'cli.shot.ok': '{route} clean — {islands} island(s) mounted, nothing logged and nothing threw',
190
190
  'cli.shot.errors': '{route}: {errors} console error(s) — verdict.json names each one',
191
191
  'cli.shot.redirected': '{route} redirected to {url} — the picture is not the route asked for',
192
+ // After the redirect and before everything else: a 404 page can be spotless, and a picture of
193
+ // "Not found" read as the route is the capture lying about its subject.
194
+ 'cli.shot.status':
195
+ '{route} answered HTTP {status}, expected {expected} — the picture is that response, not the page',
196
+ 'cli.shot.document': ' document HTTP {status}',
197
+ 'cli.shot.documentUnknown': ' document status not seen — the driver recorded no response for it',
192
198
  'cli.shot.server.booted': ' server booted for this shot on {url}',
193
199
  'cli.shot.server.reused': ' server the x dev already running on {url}',
194
200
  'cli.shot.canvas': ' canvas {width}x{height}',
@@ -0,0 +1,38 @@
1
+ // The one answer for a public file whose URL carries NO content hash: cacheable, revalidated on
2
+ // every use, and a 304 when the bytes have not moved. `immutable` belongs to a URL that IS its
3
+ // content (islands, styles, `asset()` files) and to nothing else — a file served immutable under a
4
+ // name that outlives its bytes is a file no client refreshes for a year.
5
+
6
+ import type { UltimateRequest } from '@ultimat3/http';
7
+ import { etagMatches } from './runtime-storage';
8
+
9
+ /**
10
+ * The document's own posture (`@ultimat3/render`'s `render-static.ts`), for the same reason: the
11
+ * URL says nothing about the bytes, so a cache may keep them and must ask before reusing them.
12
+ * Written as a header rather than a `CacheHint`, which has no `must-revalidate`.
13
+ */
14
+ export const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
15
+
16
+ /**
17
+ * A strong validator from the bytes — xxHash32, the function `contentHash` applies to a document,
18
+ * taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
19
+ */
20
+ export function revalidatedResponse(
21
+ request: UltimateRequest,
22
+ body: string | Uint8Array,
23
+ contentType: string,
24
+ ): Response {
25
+ // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
26
+ // `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
27
+ const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body);
28
+ const etag = `"${Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0')}"`;
29
+ const headers = {
30
+ 'content-type': contentType,
31
+ 'cache-control': REVALIDATE_CACHE_CONTROL,
32
+ etag,
33
+ };
34
+ if (etagMatches(request.header('if-none-match'), etag)) {
35
+ return new Response(null, { status: 304, headers });
36
+ }
37
+ return new Response(bytes, { headers });
38
+ }
@@ -20,6 +20,7 @@ import { faviconRoute } from './favicon';
20
20
  import { iconPlan, iconRenderer } from './icon-assets';
21
21
  import type { PwaArtifacts } from './pwa-artifacts';
22
22
  import { pwaManifestRoute } from './pwa-artifacts';
23
+ import { revalidatedResponse } from './revalidated-response';
23
24
  import {
24
25
  AUTHORIZED_OBJECT_CACHE,
25
26
  assertReadableKey,
@@ -41,8 +42,10 @@ import { siteAssetTable } from './site-assets';
41
42
  export const MEDIA_BASE_PATH = '/media';
42
43
 
43
44
  /**
44
- * A generated icon and a content-addressed variant answer forever with the same bytes, so the
45
- * immutable hint is a fact about the key. It is NOT a fact about this route — see `mediaCache`.
45
+ * A content-addressed variant answers forever with the same bytes, so the immutable hint is a fact
46
+ * about the key. It is NOT a fact about this route — see `mediaCache`. The generated ICONS are not
47
+ * content-addressed (`/icons/icon-192.png` names a size, not bytes), so they revalidate instead —
48
+ * see `revalidatedResponse`.
46
49
  */
47
50
  const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
48
51
 
@@ -198,8 +201,12 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
198
201
  method: 'GET',
199
202
  path: entry.outputPath,
200
203
  meta: { name: `assets.icon.${entry.spec.filename}`, auth: 'public', tags: ['assets'] },
204
+ // Revalidated, never `immutable` (22.3.2): the file name is the icon's SIZE, so a new
205
+ // `icon.png` shipped under the same URLs and every client that had seen the old one kept it for
206
+ // a year. The manifest and the apple-touch links name these paths, so a hashed URL would move
207
+ // every document and the manifest with it; a 304 is the cheaper fix.
201
208
  handler: async (request: UltimateRequest): Promise<Response> =>
202
- imageResponse(await render(plan, request.pathname), 'image/png', IMMUTABLE_IMAGE),
209
+ revalidatedResponse(request, await render(plan, request.pathname), 'image/png'),
203
210
  }));
204
211
  // The icons above are genuinely public — they are rendered from a file committed in the app, and
205
212
  // an install prompt fetches them before anyone has signed in. `/media` is the opposite: it serves
@@ -25,6 +25,9 @@ export const SHOT_MESSAGE_KEYS = [
25
25
  'cli.shot.ok',
26
26
  'cli.shot.errors',
27
27
  'cli.shot.redirected',
28
+ 'cli.shot.status',
29
+ 'cli.shot.document',
30
+ 'cli.shot.documentUnknown',
28
31
  'cli.shot.picture',
29
32
  'cli.shot.verdict',
30
33
  'cli.shot.server.booted',
@@ -146,6 +149,13 @@ export interface ShotInput {
146
149
  readonly networkDropped: number;
147
150
  /** `null` when the probe could not run or did not parse. Never a zero standing in for unknown. */
148
151
  readonly islands: IslandCount | null;
152
+ /**
153
+ * The document's status as read right after navigation. Only a fallback: the network ring is
154
+ * bounded, so on a page of a few hundred requests the document entry may be gone by capture time.
155
+ */
156
+ readonly landedStatus?: number | null | undefined;
157
+ /** `--expect-status`: the one status this shot is ok with. Absent means any 2xx. */
158
+ readonly expectStatus?: number | undefined;
149
159
  }
150
160
 
151
161
  export interface ShotVerdict extends ShotInput {
@@ -161,10 +171,44 @@ export interface ShotVerdict extends ShotInput {
161
171
  * gating, like a console warning: a missing favicon would otherwise fail every shot of every app.
162
172
  */
163
173
  readonly failed: number;
174
+ /**
175
+ * The HTTP status of the document photographed. `null` when no driver response carried one —
176
+ * "not seen" never gates, like an uncounted island, and is never reported as a 200.
177
+ */
178
+ readonly status: number | null;
179
+ /** The status asked for with `--expect-status`, or `null` for "any 2xx". */
180
+ readonly expectedStatus: number | null;
164
181
  /** What this verdict cannot see, stated every time — a `0` whose blind spots are named. */
165
182
  readonly blind: readonly string[];
166
183
  }
167
184
 
185
+ const withoutFragment = (url: string): string => {
186
+ const hash = url.indexOf('#');
187
+ return hash === -1 ? url : url.slice(0, hash);
188
+ };
189
+
190
+ /**
191
+ * The status of the LAST document response at `url` — the last, because a reload or a redirect
192
+ * back re-requests it and the picture is of the latest. The fragment is dropped: `location.href`
193
+ * keeps it and no request ever carries one.
194
+ */
195
+ export function documentStatus(network: readonly NetworkEntry[], url: string): number | null {
196
+ const target = withoutFragment(url);
197
+ for (let index = network.length - 1; index >= 0; index -= 1) {
198
+ const entry = network[index];
199
+ if (entry === undefined || entry.resourceType !== 'document') continue;
200
+ if (withoutFragment(entry.url) !== target) continue;
201
+ if (entry.status !== undefined) return entry.status;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /** ok for a status: the one expected, or any 2xx when none was. Unseen is not a failure. */
207
+ const statusOk = (status: number | null, expected: number | null): boolean => {
208
+ if (expected !== null) return status === expected;
209
+ return status === null || (status >= 200 && status < 300);
210
+ };
211
+
168
212
  /**
169
213
  * What a shot is blind to, each naming the mechanism rather than apologising. Constant because
170
214
  * these are properties of the browser port, not of a run — and in the artifact because `errors: 0`
@@ -183,7 +227,7 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
183
227
  lines.filter((line) => line.level === level).length;
184
228
 
185
229
  /**
186
- * `ok` is four conditions, and every one is something a picture cannot show: nothing on the page
230
+ * `ok` is five conditions, and every one is something a picture cannot show: nothing on the page
187
231
  * logged an error, nothing THREW, no island's `mount()` REJECTED, and the document photographed is
188
232
  * the route that was asked for.
189
233
  *
@@ -195,16 +239,23 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
195
239
  * an uncounted probe (`null`) out of the verdict — "not counted" is not "none failed".
196
240
  * A redirect is a failure of the CAPTURE rather than of the app: an agent that
197
241
  * photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
242
+ * A fifth (`As of 2026-09-26`): the document answered 2xx, or exactly `--expect-status`. A 404
243
+ * page is clean by every other clause, and `x shot /typo` reported it ok.
198
244
  */
199
245
  export function buildVerdict(input: ShotInput): ShotVerdict {
200
246
  const errors = levelCount(input.console, 'error');
247
+ const status = documentStatus(input.network, input.finalUrl) ?? input.landedStatus ?? null;
248
+ const expectedStatus = input.expectStatus ?? null;
201
249
  return {
202
250
  ...input,
203
251
  ok:
204
252
  errors === 0 &&
205
253
  input.pageErrors.length === 0 &&
206
254
  (input.islands?.failed ?? 0) === 0 &&
207
- input.requestedUrl === input.finalUrl,
255
+ input.requestedUrl === input.finalUrl &&
256
+ statusOk(status, expectedStatus),
257
+ status,
258
+ expectedStatus,
208
259
  redirected: input.requestedUrl !== input.finalUrl,
209
260
  errors,
210
261
  warnings: levelCount(input.console, 'warn'),
@@ -241,6 +292,8 @@ export function verdictJson(verdict: ShotVerdict): JsonValue {
241
292
  requestedUrl: verdict.requestedUrl,
242
293
  finalUrl: verdict.finalUrl,
243
294
  redirected: verdict.redirected,
295
+ status: verdict.status,
296
+ expectedStatus: verdict.expectedStatus,
244
297
  server: verdict.server,
245
298
  capturedAt: verdict.capturedAt,
246
299
  screenshot: verdict.screenshot,
@@ -301,6 +354,9 @@ export function shotLines(artifacts: ShotArtifacts): readonly string[] {
301
354
  const canvas = verdict.canvas;
302
355
  return [
303
356
  msg(`cli.shot.server.${verdict.server}`, { url: verdict.finalUrl }),
357
+ verdict.status === null
358
+ ? msg('cli.shot.documentUnknown')
359
+ : msg('cli.shot.document', { status: verdict.status }),
304
360
  canvas === null
305
361
  ? msg('cli.shot.canvasUnreadable', { bytes: verdict.bytes.byteLength })
306
362
  : msg('cli.shot.canvas', { width: canvas.width, height: canvas.height }),
@@ -338,6 +394,13 @@ export const shotSummary = (verdict: ShotVerdict): string => {
338
394
  if (verdict.redirected) {
339
395
  return msg('cli.shot.redirected', { route: verdict.route, url: verdict.finalUrl });
340
396
  }
397
+ if (!statusOk(verdict.status, verdict.expectedStatus)) {
398
+ return msg('cli.shot.status', {
399
+ route: verdict.route,
400
+ status: verdict.status ?? 'unseen',
401
+ expected: verdict.expectedStatus ?? '2xx',
402
+ });
403
+ }
341
404
  // Ahead of the console count, because a throw is the more severe fact AND the quieter one: an
342
405
  // island that died can log nothing at all, so `errors` would report a clean page.
343
406
  if (verdict.pageErrors.length > 0) {
@@ -23,10 +23,13 @@ export const SW_SCOPE = '/';
23
23
  */
24
24
  export const SW_REGISTER_PATH = '/x-sw-register.js';
25
25
 
26
+ /** How long a returning tab waits between two `sw.js` update checks — five minutes. */
27
+ export const SW_UPDATE_INTERVAL_MS = 300_000;
28
+
26
29
  export interface ServiceWorkerArtifacts {
27
30
  /** `sw.js`, deterministic for identical input. */
28
31
  readonly source: string;
29
- /** `x-sw-register.js`, the four lines that install it. */
32
+ /** `x-sw-register.js`, the lines that install it and look for its successor. */
30
33
  readonly register: string;
31
34
  /** The `<script src>` tag, appended to `PwaArtifacts.head` by every surface that serves it. */
32
35
  readonly head: string;
@@ -133,25 +136,42 @@ const staticAssets = (
133
136
  .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
134
137
 
135
138
  /**
136
- * Four lines, and every one of them earns it. `load` because registration competes with the page's
137
- * own first paint for the same network. `scope: '/'` stated rather than inferred, so a change to
138
- * where the file is served is a build-time refusal (`assertScope`) instead of a worker that
139
- * silently controls a subdirectory. The `catch` because a registration that throws in a browser
140
- * with service workers disabled — an incognito profile, an enterprise policy — must not take an
141
- * otherwise working page down with it.
139
+ * The registration, and every line of it earns its bytes. `load` because registration competes with
140
+ * the page's own first paint for the same network. `scope: '/'` stated rather than inferred, so a
141
+ * change to where the file is served is a build-time refusal (`assertScope`) instead of a worker
142
+ * that silently controls a subdirectory. The `catch` because a registration that throws in a
143
+ * browser with service workers disabled — an incognito profile, an enterprise policy — must not
144
+ * take an otherwise working page down with it.
145
+ *
146
+ * `registration.update()` when a hidden tab comes back, at most once per `SW_UPDATE_INTERVAL_MS`
147
+ * (22.3.2). A browser checks for a new `sw.js` on a NAVIGATION; a tab left open for days makes
148
+ * none, so the new worker was found only on the click that was already answered by the old one. The
149
+ * throttle keeps an alt-tabbing reader from re-downloading the worker on every focus, and the
150
+ * swallowed rejection is the offline case. It never posts `skip-waiting` and never reloads: the
151
+ * worker skips waiting itself (`@ultimat3/pwa`'s install block), and a reload under a reader who is
152
+ * typing is the one thing an update must not do — the next navigation gets the new HTML.
142
153
  *
143
154
  * EXPORTED because a static export has to put these bytes on disk BEFORE it renders, not after.
144
155
  * The file is named by every document and weighed by `measureDocumentJs`, and it used to be
145
156
  * written last, beside `sw.js` — so the measurement read a file that did not exist yet on a clean
146
157
  * output directory, and the PREVIOUS build's copy on a reused one. `sw.js` still comes last, for
147
158
  * the reason its own comment gives (its precache manifest is built from the rendered documents'
148
- * content hashes); this half depends on nothing but two constants, so it can and must come first.
159
+ * content hashes); this half depends on nothing but constants, so it can and must come first.
149
160
  */
150
161
  export const serviceWorkerRegistration = (): string =>
151
162
  `if ('serviceWorker' in navigator) {
152
163
  addEventListener('load', function () {
153
164
  navigator.serviceWorker
154
165
  .register(${JSON.stringify(SERVICE_WORKER_PATH)}, { scope: ${JSON.stringify(SW_SCOPE)} })
166
+ .then(function (registration) {
167
+ var checked = Date.now();
168
+ document.addEventListener('visibilitychange', function () {
169
+ if (document.visibilityState !== 'visible') return;
170
+ if (Date.now() - checked < ${SW_UPDATE_INTERVAL_MS}) return;
171
+ checked = Date.now();
172
+ registration.update().catch(function () {});
173
+ });
174
+ })
155
175
  .catch(function (error) { console.warn('service worker registration failed', error); });
156
176
  });
157
177
  }
package/src/sw-routes.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  // Split from the emitter because mounting is HTTP and generation is a build: `prerender.ts` writes
3
3
  // both artifacts as files and mounts nothing, and it must not have to import a route table to do it.
4
4
 
5
- import type { CacheHint, Route } from '@ultimat3/http';
5
+ import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
6
6
  import { applyCacheHeaders } from '@ultimat3/http';
7
+ import { revalidatedResponse } from './revalidated-response';
7
8
  import type { ServiceWorkerArtifacts } from './sw-artifacts';
8
9
  import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts';
9
10
 
@@ -15,9 +16,6 @@ import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts'
15
16
  */
16
17
  const SW_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
17
18
 
18
- /** Content-addressed in neither path, so the register script gets the favicon's hour. */
19
- const REGISTER_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
20
-
21
19
  /** `/sw.js` and `/x-sw-register.js`, mounted by `x dev` and by the container alike. */
22
20
  export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly Route[] => [
23
21
  {
@@ -42,12 +40,11 @@ export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly
42
40
  method: 'GET',
43
41
  path: SW_REGISTER_PATH,
44
42
  meta: { name: 'pwa.serviceWorkerRegister', auth: 'public' },
45
- handler: () =>
46
- applyCacheHeaders(
47
- new Response(artifacts.register, {
48
- headers: { 'content-type': 'text/javascript; charset=utf-8' },
49
- }),
50
- REGISTER_CACHE,
51
- ),
43
+ // Revalidated on every load, never an hour (22.3.2). The URL carries no content hash — it is
44
+ // named by every document, so hashing it would re-render them all for a four-line script — and
45
+ // at `max-age=3600` a changed register script reached a returning visitor up to an hour late.
46
+ // A strong ETag makes the check a 304. Still an EXTERNAL script: see `SW_REGISTER_PATH`.
47
+ handler: (request: UltimateRequest) =>
48
+ revalidatedResponse(request, artifacts.register, 'text/javascript; charset=utf-8'),
52
49
  },
53
50
  ];
@@ -90,6 +90,19 @@ export const defaultWorkers = (
90
90
  return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
91
91
  };
92
92
 
93
+ /**
94
+ * The width for a parallel suite that SHARES the machine — `x verify` runs the static steps beside
95
+ * `live`, `job`, `e2e` and `eval` (`verify-run.ts`), and each of those scans is a CPU-bound process
96
+ * of its own. `defaultWorkers()`' oversubscription fills a worker's own stalls when nothing else
97
+ * wants the cores; beside six other processes it only multiplies the contention. Measured on
98
+ * notificado.co, 8 vCPU (#537): at 1.5x, `errors` went 1.8s alone → 7.9s in the gate and
99
+ * `boundaries` 2.3s → 9.7s. So: one worker per core, still held to what memory can carry.
100
+ */
101
+ export const sharedWorkers = (
102
+ available: number = availableCpus(),
103
+ freeBytes: number = availableMemory(),
104
+ ): number => Math.min(defaultWorkers(available, freeBytes), Math.max(WORKER_FLOOR, available));
105
+
93
106
  /**
94
107
  * Which types run across worker processes, and why the other two cannot.
95
108
  *
package/src/verify-run.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
6
6
  import { msg } from './messages';
7
7
  import type { CommandResult, Finding, StepResult } from './output';
8
+ import { sharedWorkers } from './test-workers';
8
9
  import {
9
10
  floorRequires,
10
11
  readVerifyFloor,
@@ -40,6 +41,10 @@ export async function runVerify(
40
41
  await pending;
41
42
  pending = undefined;
42
43
  };
44
+ // A parallel suite inside that window shares the cores with the static group, so its DEFAULT
45
+ // width is one per core (`sharedWorkers`); an explicit `--workers` is the caller's and stands.
46
+ const shared: VerifyContext =
47
+ ctx.workers === undefined && beside.length > 0 ? { ...ctx, workers: sharedWorkers() } : ctx;
43
48
  for (const step of selected) {
44
49
  if (beside.includes(step)) continue;
45
50
  if (step.name === SERIAL_SUITES[0]) {
@@ -51,7 +56,8 @@ export async function runVerify(
51
56
  } else if (!SERIAL_SUITES.includes(step.name)) {
52
57
  await join();
53
58
  }
54
- byName.set(step.name, await runStep(step, ctx, floor));
59
+ const inWindow = pending !== undefined && SERIAL_SUITES.includes(step.name);
60
+ byName.set(step.name, await runStep(step, inWindow ? shared : ctx, floor));
55
61
  }
56
62
  await join();
57
63
  // Reported in the declared order, whatever order the steps finished in: the table, `--json` and