@ultimat3/cli 22.3.1 → 22.3.3

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.1",
3
+ "version": "22.3.3",
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.1",
42
- "@ultimat3/admin": "22.3.1",
43
- "@ultimat3/ai": "22.3.1",
44
- "@ultimat3/auth": "22.3.1",
45
- "@ultimat3/cache": "22.3.1",
46
- "@ultimat3/core": "22.3.1",
47
- "@ultimat3/db": "22.3.1",
48
- "@ultimat3/entity": "22.3.1",
49
- "@ultimat3/flags": "22.3.1",
50
- "@ultimat3/http": "22.3.1",
51
- "@ultimat3/i18n": "22.3.1",
52
- "@ultimat3/jobs": "22.3.1",
53
- "@ultimat3/mail": "22.3.1",
54
- "@ultimat3/manifest": "22.3.1",
55
- "@ultimat3/mcp": "22.3.1",
56
- "@ultimat3/money": "22.3.1",
57
- "@ultimat3/notify": "22.3.1",
58
- "@ultimat3/policy": "22.3.1",
59
- "@ultimat3/pwa": "22.3.1",
60
- "@ultimat3/query": "22.3.1",
61
- "@ultimat3/realtime": "22.3.1",
62
- "@ultimat3/render": "22.3.1",
63
- "@ultimat3/schema": "22.3.1",
64
- "@ultimat3/seo": "22.3.1",
65
- "@ultimat3/storage": "22.3.1",
66
- "@ultimat3/testing": "22.3.1",
67
- "@ultimat3/time": "22.3.1",
41
+ "@ultimat3/action": "22.3.3",
42
+ "@ultimat3/admin": "22.3.3",
43
+ "@ultimat3/ai": "22.3.3",
44
+ "@ultimat3/auth": "22.3.3",
45
+ "@ultimat3/cache": "22.3.3",
46
+ "@ultimat3/core": "22.3.3",
47
+ "@ultimat3/db": "22.3.3",
48
+ "@ultimat3/entity": "22.3.3",
49
+ "@ultimat3/flags": "22.3.3",
50
+ "@ultimat3/http": "22.3.3",
51
+ "@ultimat3/i18n": "22.3.3",
52
+ "@ultimat3/jobs": "22.3.3",
53
+ "@ultimat3/mail": "22.3.3",
54
+ "@ultimat3/manifest": "22.3.3",
55
+ "@ultimat3/mcp": "22.3.3",
56
+ "@ultimat3/money": "22.3.3",
57
+ "@ultimat3/notify": "22.3.3",
58
+ "@ultimat3/policy": "22.3.3",
59
+ "@ultimat3/pwa": "22.3.3",
60
+ "@ultimat3/query": "22.3.3",
61
+ "@ultimat3/realtime": "22.3.3",
62
+ "@ultimat3/render": "22.3.3",
63
+ "@ultimat3/schema": "22.3.3",
64
+ "@ultimat3/seo": "22.3.3",
65
+ "@ultimat3/storage": "22.3.3",
66
+ "@ultimat3/testing": "22.3.3",
67
+ "@ultimat3/time": "22.3.3",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -124,7 +124,9 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
124
124
  persisted: sync.persisted,
125
125
  themeHead: theme.head,
126
126
  ...(origin === undefined ? {} : { origin }),
127
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
127
+ ...(pwa === undefined
128
+ ? {}
129
+ : { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
128
130
  }),
129
131
  ];
130
132
 
package/src/prerender.ts CHANGED
@@ -29,7 +29,7 @@ import { measurePaths } from './measure-paths';
29
29
  import { measureScope, withAppUrl } from './measure-scope';
30
30
  import { localizedArtifacts } from './prerender-locales';
31
31
  import { clearPrerenderOut } from './prerender-out';
32
- import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
32
+ import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
33
33
  import { routeDocument } from './runtime-render';
34
34
  import { writeSiteAssets } from './site-assets';
35
35
  import { loadSiteSettings, originWarning, publicOrigin } from './site-config';
@@ -249,7 +249,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
249
249
  await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorkerRegistration());
250
250
  }
251
251
  if (pwa !== undefined) {
252
- await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
252
+ // One per routed locale — `/en/manifest.webmanifest` beside the default one — each at the
253
+ // path its own documents link.
254
+ for (const manifest of pwa.manifests) {
255
+ await Bun.write(join(options.out, manifest.path.slice(1)), manifest.body);
256
+ }
253
257
  // And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
254
258
  // `/icons/*` entry would be a 404 in the install prompt — the manifest half of the same
255
259
  // promise `favicon.ico` above keeps. Nothing when the app committed no source icon, which is
@@ -285,7 +289,9 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
285
289
  resolveIsland: (file: string) => islands.resolverFor(file),
286
290
  themeHead: theme.head,
287
291
  origin,
288
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
292
+ ...(pwa === undefined
293
+ ? {}
294
+ : { pwaHead: (locale: string) => pwa.headFor(locale) + (swHead ?? '') }),
289
295
  });
290
296
  const document = (
291
297
  locale: string,
@@ -391,10 +397,15 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
391
397
  // so the precache revision and the HTTP validator can never disagree about one document.
392
398
  // Keyed by the FILLED path, which for a non-dynamic route is the declared one; a dynamic
393
399
  // route is not precached as a single URL anyway (`buildPrecacheManifest` skips it).
394
- documents.set(artifact.path, { revision: artifact.hash, bytes });
395
400
  // Measured from the document that was just written, so the `budgets` step compares a
396
401
  // declared budget against bytes that exist on disk rather than against a graph's estimate.
397
402
  const measured = await measureDocumentJs(artifact.html, options.out);
403
+ // `assets` too: the chunks this page names are the ones the worker precaches with it.
404
+ documents.set(artifact.path, {
405
+ revision: artifact.hash,
406
+ bytes,
407
+ assets: measured.entries.map((entry) => entry.url),
408
+ });
398
409
  const chain = heaviestSource(islands, measured.entries);
399
410
  if (heaviest !== undefined && heaviest.jsBytes >= measured.jsBytes) continue;
400
411
  heaviest = {
@@ -23,7 +23,8 @@ import { existsSync } from 'node:fs';
23
23
  // necessity `favicon.ts` and `runtime-assets.ts` each record for their own root-relative constant.
24
24
  import { join } from 'node:path';
25
25
  import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
26
- import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
26
+ import { localeSegment } from '@ultimat3/core';
27
+ import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
27
28
  import { applyCacheHeaders } from '@ultimat3/http';
28
29
  import {
29
30
  appleTouchLinks,
@@ -35,6 +36,8 @@ import { escapeAttribute } from '@ultimat3/seo';
35
36
  import { APP_CONFIG_EXPORT } from './app-auth';
36
37
  import { APP_CONFIG_FILE } from './app-root';
37
38
  import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
39
+ import type { AppLocales } from './pwa-manifest-locales';
40
+ import { appLocales, localeMembers, spellIn } from './pwa-manifest-locales';
38
41
 
39
42
  /** What a browser fetches from `<link rel="manifest">`. The spec's own extension, not `.json`. */
40
43
  export const WEB_MANIFEST_PATH = '/manifest.webmanifest';
@@ -45,9 +48,18 @@ export const WEB_MANIFEST_PATH = '/manifest.webmanifest';
45
48
  */
46
49
  const MANIFEST_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
47
50
 
51
+ /** One routed locale's manifest: where it is served, its bytes, and the `<head>` that names it. */
52
+ export interface LocalizedManifest {
53
+ readonly locale: string;
54
+ /** `/manifest.webmanifest` for the default locale, `/en/manifest.webmanifest` for `en`. */
55
+ readonly path: string;
56
+ readonly body: string;
57
+ readonly head: string;
58
+ }
59
+
48
60
  /** The two strings every surface needs: the file's bytes, and what `<head>` must carry to name it. */
49
61
  export interface PwaArtifacts {
50
- /** `manifest.webmanifest`, serialized. */
62
+ /** The DEFAULT locale's `manifest.webmanifest`, serialized — `manifests[0].body`. */
51
63
  readonly body: string;
52
64
  /**
53
65
  * `<link rel="manifest">`, both `theme-color` metas, and every apple-touch icon link. One string
@@ -56,6 +68,14 @@ export interface PwaArtifacts {
56
68
  * no manifest is an iOS icon for an app iOS will not add.
57
69
  */
58
70
  readonly head: string;
71
+ /**
72
+ * One manifest per routed locale, default first. Each speaks its own `lang`, starts at its own
73
+ * home and spells its shortcuts in its own locale; one `id` makes them one installed app. There
74
+ * was one manifest, `lang: "en"`, for an `es-co` app with an English half (22.3.2).
75
+ */
76
+ readonly manifests: readonly LocalizedManifest[];
77
+ /** The `<head>` for a document in `locale` — it links that locale's manifest. */
78
+ headFor(locale: string): string;
59
79
  /**
60
80
  * The three `pwa` keys the SERVICE WORKER needs, carried here because this is the one module
61
81
  * that reads an app's config file — `sw-artifacts.ts` needs the route table and the island
@@ -102,6 +122,9 @@ function colorsOf(value: unknown): PwaColors | undefined {
102
122
  interface InstallableApp {
103
123
  readonly name: string;
104
124
  readonly colors: PwaColors;
125
+ /** The block itself, for the manifest members `pwa-manifest-locales.ts` reads per locale. */
126
+ readonly block: Record<string, unknown>;
127
+ readonly locales: AppLocales;
105
128
  readonly offline: PwaOfflineConfig;
106
129
  readonly backgroundSync: boolean;
107
130
  readonly push: boolean;
@@ -121,7 +144,14 @@ async function loadInstallable(root: string): Promise<InstallableApp | undefined
121
144
  const name = text(pwa['name']);
122
145
  const colors = colorsOf(pwa['colors']);
123
146
  if (name === undefined || colors === undefined) return undefined;
124
- return { name, colors, offline: offlineOf(pwa['offline']), ...flags(pwa) };
147
+ return {
148
+ name,
149
+ colors,
150
+ block: pwa,
151
+ locales: appLocales(config),
152
+ offline: offlineOf(pwa['offline']),
153
+ ...flags(pwa),
154
+ };
125
155
  }
126
156
 
127
157
  /**
@@ -142,6 +172,7 @@ function offlineOf(value: unknown): PwaOfflineConfig {
142
172
  neverCache: Array.isArray(patterns)
143
173
  ? patterns.filter((entry): entry is string => typeof entry === 'string')
144
174
  : [],
175
+ personalPages: block['personalPages'] === 'last-member' ? 'last-member' : 'never',
145
176
  };
146
177
  }
147
178
 
@@ -172,23 +203,47 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
172
203
  // own rule forbids. Read at boot, like the rest of this function: adding the file takes effect
173
204
  // on the next start, because the manifest is generated once and served as bytes.
174
205
  const icons = (await hasSourceIcon(root)) ? iconPlan() : undefined;
175
- const result = generateWebManifest({
176
- name: app.name,
177
- tokens: app.colors,
178
- icons: icons?.manifestIcons ?? [],
206
+ const manifests = app.locales.routed.map((locale): LocalizedManifest => {
207
+ const result = generateWebManifest({
208
+ name: app.name,
209
+ tokens: app.colors,
210
+ icons: icons?.manifestIcons ?? [],
211
+ ...localeMembers(app.block, locale, app.locales),
212
+ });
213
+ const path = spellIn(WEB_MANIFEST_PATH, locale, app.locales);
214
+ return {
215
+ locale,
216
+ path,
217
+ body: serializeWebManifest(result.manifest),
218
+ head:
219
+ `<link rel="manifest" href="${escapeAttribute(path)}">` +
220
+ renderThemeColorMeta(result.themeColorMeta) +
221
+ (icons === undefined ? '' : appleTouchLinks(icons)),
222
+ };
179
223
  });
224
+ // `routed` always holds the default first, so this is never undefined; the guard is the type's.
225
+ const primary = manifests[0];
226
+ if (primary === undefined) return undefined;
180
227
  return {
181
228
  offline: app.offline,
182
229
  backgroundSync: app.backgroundSync,
183
230
  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)),
231
+ body: primary.body,
232
+ head: primary.head,
233
+ manifests,
234
+ headFor: (locale: string): string => manifestIn(manifests, locale)?.head ?? primary.head,
189
235
  };
190
236
  }
191
237
 
238
+ /** The manifest `locale` names, by URL segment (`es-CO` is `es-co`), if the app routes it. */
239
+ const manifestIn = (
240
+ manifests: readonly LocalizedManifest[],
241
+ locale: string | undefined,
242
+ ): LocalizedManifest | undefined =>
243
+ locale === undefined
244
+ ? undefined
245
+ : manifests.find((manifest) => localeSegment(manifest.locale) === localeSegment(locale));
246
+
192
247
  /**
193
248
  * The icon bytes a STATIC export has to carry, written under `out`. Answers the paths it wrote.
194
249
  *
@@ -219,10 +274,18 @@ export async function writePwaIcons(root: string, out: string): Promise<readonly
219
274
  export const pwaManifestRoute = (artifacts: PwaArtifacts): Route => ({
220
275
  method: 'GET',
221
276
  path: WEB_MANIFEST_PATH,
222
- meta: { name: 'assets.manifest', auth: 'public', cache: MANIFEST_CACHE, tags: ['assets'] },
223
- handler: async (_request: UltimateRequest): Promise<Response> =>
277
+ // `'path'`: `/en/manifest.webmanifest` reaches this route with its prefix split off and `en` as the
278
+ // locale, and the bare path is the default locale's — never negotiated, so a CDN may cache both.
279
+ meta: {
280
+ name: 'assets.manifest',
281
+ auth: 'public',
282
+ cache: MANIFEST_CACHE,
283
+ tags: ['assets'],
284
+ localeSource: 'path',
285
+ },
286
+ handler: async (_request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
224
287
  applyCacheHeaders(
225
- new Response(artifacts.body, {
288
+ new Response(manifestIn(artifacts.manifests, ctx.locale)?.body ?? artifacts.body, {
226
289
  headers: { 'content-type': 'application/manifest+json; charset=utf-8' },
227
290
  }),
228
291
  MANIFEST_CACHE,
@@ -0,0 +1,147 @@
1
+ // The manifest members an app declares beyond `name` and `colors`, spelled for ONE locale: its
2
+ // `lang`, its `start_url`, its text and its shortcut URLs. `pwa-artifacts.ts` builds one manifest per
3
+ // routed locale from this; the `pwa` block is read structurally, for `colorsOf`'s reason there.
4
+
5
+ import { localeSegment, localizePath } from '@ultimat3/core';
6
+ import type {
7
+ ManifestIcon,
8
+ ManifestScreenshot,
9
+ ManifestShortcut,
10
+ WebManifestInput,
11
+ } from '@ultimat3/pwa';
12
+
13
+ /** The app's locales as `app.config.ts` declares them, default first. */
14
+ export interface AppLocales {
15
+ readonly routed: readonly string[];
16
+ readonly fallback: string;
17
+ }
18
+
19
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
+ typeof value === 'object' && value !== null;
21
+
22
+ const strings = (value: unknown): readonly string[] =>
23
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
24
+
25
+ /**
26
+ * `locales` and `defaultLocale`, read off the config object. The manifest's `lang` was the
27
+ * generator's hardcoded `'en'` for an `es-co` app (notificado.co, 22.3.2). An untyped config with
28
+ * neither is core's own default, `en`.
29
+ */
30
+ export function appLocales(config: Record<string, unknown>): AppLocales {
31
+ const declared = strings(config['locales']);
32
+ const named = config['defaultLocale'];
33
+ const fallback = typeof named === 'string' && named !== '' ? named : (declared[0] ?? 'en');
34
+ const others = declared.filter((locale) => localeSegment(locale) !== localeSegment(fallback));
35
+ return { routed: [fallback, ...others], fallback };
36
+ }
37
+
38
+ /** `path` as `locale` spells it — `/en/panel` — by core's one arithmetic, the one `http` routes. */
39
+ export const spellIn = (path: string, locale: string, locales: AppLocales): string =>
40
+ localizePath(path, locale, locales.routed, locales.fallback);
41
+
42
+ /**
43
+ * `PwaText`: a string is every locale's; a record answers `locale`, then the default locale. A
44
+ * value that is neither — or a record naming neither — is absent, never `'undefined'`.
45
+ */
46
+ function textIn(value: unknown, locale: string, locales: AppLocales): string | undefined {
47
+ if (typeof value === 'string') return value.trim() === '' ? undefined : value;
48
+ if (!isRecord(value)) return undefined;
49
+ for (const key of [locale, locales.fallback]) {
50
+ const entry = value[key];
51
+ if (typeof entry === 'string' && entry.trim() !== '') return entry;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ const PURPOSES = new Set(['any', 'maskable', 'monochrome']);
57
+
58
+ function iconsOf(value: unknown): readonly ManifestIcon[] {
59
+ if (!Array.isArray(value)) return [];
60
+ return value.flatMap((icon): readonly ManifestIcon[] => {
61
+ if (!isRecord(icon)) return [];
62
+ const { src, sizes, type, purpose } = icon;
63
+ if (typeof src !== 'string' || typeof sizes !== 'string' || typeof type !== 'string') return [];
64
+ return [
65
+ {
66
+ src,
67
+ sizes,
68
+ type,
69
+ ...(typeof purpose === 'string' && PURPOSES.has(purpose)
70
+ ? { purpose: purpose as NonNullable<ManifestIcon['purpose']> }
71
+ : {}),
72
+ },
73
+ ];
74
+ });
75
+ }
76
+
77
+ function shortcutsIn(value: unknown, locale: string, locales: AppLocales): ManifestShortcut[] {
78
+ if (!Array.isArray(value)) return [];
79
+ return value.flatMap((shortcut): ManifestShortcut[] => {
80
+ if (!isRecord(shortcut) || typeof shortcut['url'] !== 'string') return [];
81
+ const name = textIn(shortcut['name'], locale, locales);
82
+ if (name === undefined) return [];
83
+ const shortName = textIn(shortcut['shortName'], locale, locales);
84
+ const description = textIn(shortcut['description'], locale, locales);
85
+ const icons = iconsOf(shortcut['icons']);
86
+ return [
87
+ {
88
+ name,
89
+ ...(shortName === undefined ? {} : { short_name: shortName }),
90
+ ...(description === undefined ? {} : { description }),
91
+ url: spellIn(shortcut['url'], locale, locales),
92
+ ...(icons.length === 0 ? {} : { icons }),
93
+ },
94
+ ];
95
+ });
96
+ }
97
+
98
+ function screenshotsIn(value: unknown, locale: string, locales: AppLocales): ManifestScreenshot[] {
99
+ if (!Array.isArray(value)) return [];
100
+ return value.flatMap((shot): ManifestScreenshot[] => {
101
+ if (!isRecord(shot)) return [];
102
+ const src = textIn(shot['src'], locale, locales);
103
+ const { sizes, type, formFactor } = shot;
104
+ if (src === undefined || typeof sizes !== 'string' || typeof type !== 'string') return [];
105
+ const label = textIn(shot['label'], locale, locales);
106
+ return [
107
+ {
108
+ src,
109
+ sizes,
110
+ type,
111
+ ...(formFactor === 'narrow' || formFactor === 'wide' ? { form_factor: formFactor } : {}),
112
+ ...(label === undefined ? {} : { label }),
113
+ },
114
+ ];
115
+ });
116
+ }
117
+
118
+ type LocaleMembers = Pick<
119
+ WebManifestInput,
120
+ 'lang' | 'id' | 'startUrl' | 'description' | 'categories' | 'shortcuts' | 'screenshots'
121
+ >;
122
+
123
+ /**
124
+ * Everything one locale's manifest says beyond name, colours and icons. `id` is the SAME in every
125
+ * locale — the scope unless the app names one — so a browser sees one app installed from either
126
+ * language rather than two; `start_url` is that locale's home.
127
+ */
128
+ export function localeMembers(
129
+ pwa: Record<string, unknown>,
130
+ locale: string,
131
+ locales: AppLocales,
132
+ ): LocaleMembers {
133
+ const description = textIn(pwa['description'], locale, locales);
134
+ const categories = strings(pwa['categories']);
135
+ const shortcuts = shortcutsIn(pwa['shortcuts'], locale, locales);
136
+ const screenshots = screenshotsIn(pwa['screenshots'], locale, locales);
137
+ const id = pwa['id'];
138
+ return {
139
+ lang: locale,
140
+ id: typeof id === 'string' && id.startsWith('/') ? id : '/',
141
+ startUrl: spellIn('/', locale, locales),
142
+ ...(description === undefined ? {} : { description }),
143
+ ...(categories.length === 0 ? {} : { categories }),
144
+ ...(shortcuts.length === 0 ? {} : { shortcuts }),
145
+ ...(screenshots.length === 0 ? {} : { screenshots }),
146
+ };
147
+ }
@@ -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
@@ -88,7 +88,7 @@ export interface DocumentOptions {
88
88
  * projects per-route SEO. Passed through `DocumentOptions` for `resolveIsland`'s reason — the
89
89
  * boot knows it, the renderer cannot ask.
90
90
  */
91
- readonly pwaHead?: string;
91
+ readonly pwaHead?: string | ((locale: string) => string);
92
92
  /**
93
93
  * The no-flash theme `<script>` from `theme-boot.ts`, or absent for a caller that renders no
94
94
  * documents a browser paints. Document-level for `pwaHead`'s reason — the same tag on every page,
@@ -176,7 +176,8 @@ const headFor = async (
176
176
  ),
177
177
  ) +
178
178
  (options.themeHead ?? '') +
179
- (options.pwaHead ?? '')
179
+ // Per locale: each links its own locale's manifest (`PwaArtifacts.headFor`).
180
+ (typeof options.pwaHead === 'function' ? options.pwaHead(meta.locale) : (options.pwaHead ?? ''))
180
181
  );
181
182
  };
182
183
 
package/src/serve-boot.ts CHANGED
@@ -189,7 +189,9 @@ async function webSurface(
189
189
  persisted: sync.persisted,
190
190
  themeHead: theme.head,
191
191
  ...(origin === undefined ? {} : { origin }),
192
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
192
+ ...(pwa === undefined
193
+ ? {}
194
+ : { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
193
195
  // Only when a store was supplied. `createIsrController` defaults to a per-process memory
194
196
  // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
195
197
  // the fleet while the other eleven keep serving the page it just invalidated.
@@ -3,13 +3,19 @@
3
3
  // until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
4
4
  // file, this one needs a booted app and a finished build.
5
5
 
6
- import type { PrecacheAsset, PrecacheManifest, PwaRoute } from '@ultimat3/pwa';
6
+ import { localeConfig, routedLocales } from '@ultimat3/i18n';
7
+ import type { PrecacheManifest } from '@ultimat3/pwa';
7
8
  import { generateServiceWorker } from '@ultimat3/pwa';
8
9
  import type { RouteDescriptor } from '@ultimat3/render';
9
10
  import type { IslandBundle } from './island-bundle';
11
+ import { ISLAND_BASE_PATH } from './island-bundle';
10
12
  import { msg } from './messages';
11
13
  import type { PwaArtifacts } from './pwa-artifacts';
12
14
  import type { StyleBundle } from './style-bundle';
15
+ import type { RenderedDocument, RoutedLocales } from './sw-precache-plan';
16
+ import { localePrefixes, precacheAssets, pwaRoutes } from './sw-precache-plan';
17
+
18
+ export type { RenderedDocument, RoutedLocales } from './sw-precache-plan';
13
19
 
14
20
  /** Root scope, so `/sw.js` and nothing under a directory — `assertScope` refuses the rest. */
15
21
  export const SERVICE_WORKER_PATH = '/sw.js';
@@ -23,10 +29,13 @@ export const SW_SCOPE = '/';
23
29
  */
24
30
  export const SW_REGISTER_PATH = '/x-sw-register.js';
25
31
 
32
+ /** How long a returning tab waits between two `sw.js` update checks — five minutes. */
33
+ export const SW_UPDATE_INTERVAL_MS = 300_000;
34
+
26
35
  export interface ServiceWorkerArtifacts {
27
36
  /** `sw.js`, deterministic for identical input. */
28
37
  readonly source: string;
29
- /** `x-sw-register.js`, the four lines that install it. */
38
+ /** `x-sw-register.js`, the lines that install it and look for its successor. */
30
39
  readonly register: string;
31
40
  /** The `<script src>` tag, appended to `PwaArtifacts.head` by every surface that serves it. */
32
41
  readonly head: string;
@@ -63,95 +72,50 @@ export interface ServiceWorkerInput {
63
72
  * offline reload that cannot load the boot restores no record and shows the old count.
64
73
  */
65
74
  readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
75
+ /**
76
+ * The routed locales, default first. Absent, `@ultimat3/i18n`'s own — the answer the server routes
77
+ * with, set once the app's catalogs are loaded, which every caller has done by now.
78
+ */
79
+ readonly locales?: RoutedLocales;
66
80
  }
67
81
 
68
82
  /**
69
- * One rendered document, as the precache manifest needs it.
70
- *
71
- * `revision` is `contentHash(html)` — `@ultimat3/render`'s own, the function that already stamps
72
- * an ETag — and never the build id. `precache.ts`' header states the rule and nothing kept it:
73
- * `pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
74
- * `{"url":"/","revision":"build-aaa","bytes":0}` and two deploys of a byte-identical site
75
- * re-fetched every precached document. The zero was the second half — `DEFAULT_PRECACHE_WARN_BYTES`
76
- * is a 5 MB budget over a total that could not count one byte of HTML.
77
- */
78
- export interface RenderedDocument {
79
- readonly revision: string;
80
- readonly bytes: number;
81
- }
82
-
83
- /**
84
- * The route table, as the service worker sees it. `api/` is dropped: an API response is a JSON
85
- * document whose freshness is the app's business, and precaching one serves a stale answer to a
86
- * client that had a network. Only the fields a browser can act on cross — a descriptor carries
87
- * budgets and policy flags it has no use for — plus, for a route this build rendered, the content
88
- * hash and the byte count of the document it produced.
89
- */
90
- const pwaRoutes = (
91
- routes: readonly RouteDescriptor[],
92
- documents: ReadonlyMap<string, RenderedDocument>,
93
- ): readonly PwaRoute[] =>
94
- // `flatMap` rather than `filter().map()`: the filter's predicate does not narrow `surface` for
95
- // the map that follows it, and `PwaRoute` declares the two navigable surfaces only. A cast would
96
- // hide the day a fifth surface arrives.
97
- routes.flatMap((route): readonly PwaRoute[] => {
98
- // `shared/` is dropped with `api/`, and for a stronger reason: it is not a URL at all — the
99
- // surface exists so two routes can import one module, and a browser can never navigate to it.
100
- if (route.surface !== 'site' && route.surface !== 'app') return [];
101
- // A `Map`, so a route path that happens to spell a prototype member cannot answer with one.
102
- const document = documents.get(route.path);
103
- return [
104
- {
105
- path: route.path,
106
- surface: route.surface,
107
- mode: route.mode,
108
- offline: route.offline,
109
- dynamic: route.dynamic,
110
- // Absent rather than invented for a route no build rendered — an `ssr` page, or one this
111
- // pass could not produce. `buildPrecacheManifest` then falls back to the build id, which
112
- // is the honest answer when there are no bytes to hash.
113
- ...(document === undefined ? {} : { revision: document.revision, bytes: document.bytes }),
114
- },
115
- ];
116
- });
117
-
118
- /**
119
- * Every island chunk and every surface stylesheet, precached. They are content-addressed and
120
- * served `immutable`, so the revision IS the URL's hash and a byte-identical asset across deploys
121
- * is never re-downloaded.
83
+ * The registration, and every line of it earns its bytes. `load` because registration competes with
84
+ * the page's own first paint for the same network. `scope: '/'` stated rather than inferred, so a
85
+ * change to where the file is served is a build-time refusal (`assertScope`) instead of a worker
86
+ * that silently controls a subdirectory. The `catch` because a registration that throws in a
87
+ * browser with service workers disabled — an incognito profile, an enterprise policy — must not
88
+ * take an otherwise working page down with it.
122
89
  *
123
- * Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
124
- * decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
125
- */
126
- const staticAssets = (
127
- islands: IslandBundle,
128
- styles: StyleBundle,
129
- scripts: readonly { readonly url: string; readonly bytes: number }[],
130
- ): readonly PrecacheAsset[] =>
131
- [...islands.chunks, ...styles.chunks, ...scripts]
132
- .map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
133
- .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
134
-
135
- /**
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.
90
+ * `registration.update()` when a hidden tab comes back, at most once per `SW_UPDATE_INTERVAL_MS`
91
+ * (22.3.2). A browser checks for a new `sw.js` on a NAVIGATION; a tab left open for days makes
92
+ * none, so the new worker was found only on the click that was already answered by the old one. The
93
+ * throttle keeps an alt-tabbing reader from re-downloading the worker on every focus, and the
94
+ * swallowed rejection is the offline case. It never posts `skip-waiting` and never reloads: the
95
+ * worker skips waiting itself (`@ultimat3/pwa`'s install block), and a reload under a reader who is
96
+ * typing is the one thing an update must not do — the next navigation gets the new HTML.
142
97
  *
143
98
  * EXPORTED because a static export has to put these bytes on disk BEFORE it renders, not after.
144
99
  * The file is named by every document and weighed by `measureDocumentJs`, and it used to be
145
100
  * written last, beside `sw.js` — so the measurement read a file that did not exist yet on a clean
146
101
  * output directory, and the PREVIOUS build's copy on a reused one. `sw.js` still comes last, for
147
102
  * 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.
103
+ * content hashes); this half depends on nothing but constants, so it can and must come first.
149
104
  */
150
105
  export const serviceWorkerRegistration = (): string =>
151
106
  `if ('serviceWorker' in navigator) {
152
107
  addEventListener('load', function () {
153
108
  navigator.serviceWorker
154
109
  .register(${JSON.stringify(SERVICE_WORKER_PATH)}, { scope: ${JSON.stringify(SW_SCOPE)} })
110
+ .then(function (registration) {
111
+ var checked = Date.now();
112
+ document.addEventListener('visibilitychange', function () {
113
+ if (document.visibilityState !== 'visible') return;
114
+ if (Date.now() - checked < ${SW_UPDATE_INTERVAL_MS}) return;
115
+ checked = Date.now();
116
+ registration.update().catch(function () {});
117
+ });
118
+ })
155
119
  .catch(function (error) { console.warn('service worker registration failed', error); });
156
120
  });
157
121
  }
@@ -192,10 +156,13 @@ export function serviceWorkerArtifacts(
192
156
  // Absent when this pass did not render the fallback (no route serves it — `x doctor` reports
193
157
  // that as `X_PWA_NO_OFFLINE_FALLBACK`), and then `@ultimat3/pwa` falls back to the build id.
194
158
  const fallbackDocument = documents.get(fallback);
159
+ const locales = input.locales ?? { routed: routedLocales(), fallback: localeConfig().fallback };
195
160
  const output = generateServiceWorker(
196
- pwaRoutes(input.routes, documents),
161
+ pwaRoutes(input.routes, documents, locales),
197
162
  {
198
163
  scope: SW_SCOPE,
164
+ runtimeAssets: [`${ISLAND_BASE_PATH}/`],
165
+ localePrefixes: localePrefixes(locales),
199
166
  swPath: SERVICE_WORKER_PATH,
200
167
  ...(fallbackDocument === undefined
201
168
  ? {}
@@ -208,9 +175,18 @@ export function serviceWorkerArtifacts(
208
175
  ...(pwa.offline.image === null ? {} : { image: pwa.offline.image }),
209
176
  ...(pwa.offline.font === null ? {} : { font: pwa.offline.font }),
210
177
  neverCache: pwa.offline.neverCache,
178
+ personalPages: pwa.offline.personalPages,
211
179
  },
212
180
  capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
213
- assets: staticAssets(input.islands, input.styles, input.scripts ?? []),
181
+ assets: precacheAssets({
182
+ routes: input.routes,
183
+ documents,
184
+ locales,
185
+ islands: input.islands,
186
+ styles: input.styles,
187
+ scripts: input.scripts ?? [],
188
+ fallback,
189
+ }),
214
190
  },
215
191
  input.buildId,
216
192
  );
@@ -0,0 +1,145 @@
1
+ // What the service worker is told about: every navigable route spelled once per routed locale, and
2
+ // the precache narrowed to the island chunks a precached page actually boots. Split from
3
+ // `sw-artifacts.ts`, which composes the worker; this file decides its inputs.
4
+
5
+ import { localeSegment, localizePath } from '@ultimat3/core';
6
+ import type { PrecacheAsset, PwaRoute } from '@ultimat3/pwa';
7
+ import { strategyFor } from '@ultimat3/pwa';
8
+ import type { RouteDescriptor } from '@ultimat3/render';
9
+ import type { IslandBundle } from './island-bundle';
10
+ import type { StyleBundle } from './style-bundle';
11
+
12
+ /**
13
+ * One rendered document, as the precache manifest needs it.
14
+ *
15
+ * `revision` is `contentHash(html)` — `@ultimat3/render`'s own, the function that already stamps
16
+ * an ETag — and never the build id. `precache.ts`' header states the rule and nothing kept it:
17
+ * `pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
18
+ * `{"url":"/","revision":"build-aaa","bytes":0}` and two deploys of a byte-identical site
19
+ * re-fetched every precached document. The zero was the second half — `DEFAULT_PRECACHE_WARN_BYTES`
20
+ * is a 5 MB budget over a total that could not count one byte of HTML.
21
+ */
22
+ export interface RenderedDocument {
23
+ readonly revision: string;
24
+ readonly bytes: number;
25
+ /**
26
+ * The same-origin scripts and island entries the document names (`measureDocumentJs`'s
27
+ * `entries`), so a precached page's islands are precached with it and no other page's are.
28
+ */
29
+ readonly assets?: readonly string[];
30
+ }
31
+
32
+ /** The app's routed locales, default first — `@ultimat3/i18n`'s `routedLocales()` and fallback. */
33
+ export interface RoutedLocales {
34
+ readonly routed: readonly string[];
35
+ readonly fallback: string;
36
+ }
37
+
38
+ type NavigableRoute = RouteDescriptor & { readonly surface: 'site' | 'app' };
39
+
40
+ /** `api/` and `shared/` are no document a browser navigates to. */
41
+ const navigable = (route: RouteDescriptor): route is NavigableRoute =>
42
+ route.surface === 'site' || route.surface === 'app';
43
+
44
+ const spell = (path: string, locale: string, locales: RoutedLocales): string =>
45
+ localizePath(path, locale, locales.routed, locales.fallback);
46
+
47
+ /**
48
+ * The route table, as the service worker sees it — once per routed locale. The server strips a
49
+ * `/en/` prefix before it matches (`@ultimat3/http`'s `locale-prefix.ts`), and the worker matches
50
+ * pathnames: with one spelling per route, no `/en/…` URL had a rule, so the English site was never
51
+ * cached and never offline (notificado.co, 22.3.2). Only the fields a browser can act on cross,
52
+ * plus — for a spelling this build rendered — the content hash and byte count of its document.
53
+ */
54
+ export function pwaRoutes(
55
+ routes: readonly RouteDescriptor[],
56
+ documents: ReadonlyMap<string, RenderedDocument>,
57
+ locales: RoutedLocales,
58
+ ): readonly PwaRoute[] {
59
+ return routes.filter(navigable).flatMap((route) =>
60
+ locales.routed.map((locale): PwaRoute => {
61
+ const path = spell(route.path, locale, locales);
62
+ // A `Map`, so a route path that happens to spell a prototype member cannot answer with one.
63
+ const document = documents.get(path);
64
+ return {
65
+ path,
66
+ surface: route.surface,
67
+ mode: route.mode,
68
+ offline: route.offline,
69
+ dynamic: route.dynamic,
70
+ personal: route.personal,
71
+ // Absent rather than invented for a route no build rendered — an `ssr` page, or one this
72
+ // pass could not produce. `buildPrecacheManifest` then falls back to the build id.
73
+ ...(document === undefined ? {} : { revision: document.revision, bytes: document.bytes }),
74
+ };
75
+ }),
76
+ );
77
+ }
78
+
79
+ /** `['en']` for an `es-co` app routing `es-co` and `en` — the prefixes the default locale has not. */
80
+ export const localePrefixes = (locales: RoutedLocales): readonly string[] =>
81
+ locales.routed
82
+ .map(localeSegment)
83
+ .filter((segment) => segment !== localeSegment(locales.fallback));
84
+
85
+ const precached = (route: PwaRoute): boolean =>
86
+ route.offline === 'precache' && route.dynamic !== true && strategyFor(route) !== 'network-only';
87
+
88
+ export interface PrecacheAssetInput {
89
+ readonly routes: readonly RouteDescriptor[];
90
+ readonly documents: ReadonlyMap<string, RenderedDocument>;
91
+ readonly locales: RoutedLocales;
92
+ readonly islands: IslandBundle;
93
+ readonly styles: StyleBundle;
94
+ readonly scripts: readonly { readonly url: string; readonly bytes: number }[];
95
+ /** The offline document's path: its islands ride the precache whatever its own route says. */
96
+ readonly fallback: string;
97
+ }
98
+
99
+ /**
100
+ * The island chunks a PRECACHED page boots, every surface stylesheet and every framework script.
101
+ * Every island in the app was precached, so a first anonymous visit to a marketing page downloaded
102
+ * the admin, KYC and payment islands too — 43 chunks, ~1 MB on notificado.co (22.3.2). The rest are
103
+ * cached the first time a page asks for one (`ISLAND_BASE_PATH` is a runtime asset prefix).
104
+ *
105
+ * Two readers of "which islands", because only one of them exists per build: the static export
106
+ * rendered the documents and knows the chunk URLs each one names; `x dev` and the container emit
107
+ * the worker at boot, before any page has rendered, and resolve each precached route's declared
108
+ * island `src` against its own file — `island()` is route-module-local by construction.
109
+ *
110
+ * Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
111
+ * decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
112
+ */
113
+ export function precacheAssets(input: PrecacheAssetInput): readonly PrecacheAsset[] {
114
+ const wanted = new Set<string>();
115
+ const paths = new Set(
116
+ pwaRoutes(input.routes, input.documents, input.locales)
117
+ .filter(precached)
118
+ .map((route) => route.path),
119
+ );
120
+ for (const locale of input.locales.routed)
121
+ paths.add(spell(input.fallback, locale, input.locales));
122
+ for (const path of paths) {
123
+ for (const url of input.documents.get(path)?.assets ?? []) wanted.add(url);
124
+ }
125
+ for (const route of input.routes.filter(navigable)) {
126
+ const isFallback = route.path === input.fallback;
127
+ if (!isFallback && !precached(route)) continue;
128
+ const resolve = input.islands.resolverFor(route.file);
129
+ for (const src of route.islandSources) {
130
+ try {
131
+ wanted.add(resolve(src));
132
+ } catch {
133
+ // A src the bundle cannot resolve fails the RENDER that names it, with its own finding;
134
+ // here it only means there is no chunk to precache.
135
+ }
136
+ }
137
+ }
138
+ return [
139
+ ...input.islands.chunks.filter((chunk) => wanted.has(chunk.url)),
140
+ ...input.styles.chunks,
141
+ ...input.scripts,
142
+ ]
143
+ .map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
144
+ .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
145
+ }
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
  ];