@ultimat3/cli 22.3.2 → 22.3.4
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 +28 -28
- package/src/app-load.ts +49 -29
- package/src/dev-route-table.ts +3 -1
- package/src/island-routes.ts +4 -2
- package/src/island-store.ts +31 -4
- package/src/island-styles.ts +12 -1
- package/src/prerender.ts +15 -4
- package/src/pwa-artifacts.ts +78 -15
- package/src/pwa-manifest-locales.ts +147 -0
- package/src/runtime-render.ts +3 -2
- package/src/serve-boot.ts +3 -1
- package/src/style-bundle.ts +23 -1
- package/src/style-routes.ts +4 -2
- package/src/sw-artifacts.ts +26 -70
- package/src/sw-precache-plan.ts +145 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.3.
|
|
3
|
+
"version": "22.3.4",
|
|
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.
|
|
42
|
-
"@ultimat3/admin": "22.3.
|
|
43
|
-
"@ultimat3/ai": "22.3.
|
|
44
|
-
"@ultimat3/auth": "22.3.
|
|
45
|
-
"@ultimat3/cache": "22.3.
|
|
46
|
-
"@ultimat3/core": "22.3.
|
|
47
|
-
"@ultimat3/db": "22.3.
|
|
48
|
-
"@ultimat3/entity": "22.3.
|
|
49
|
-
"@ultimat3/flags": "22.3.
|
|
50
|
-
"@ultimat3/http": "22.3.
|
|
51
|
-
"@ultimat3/i18n": "22.3.
|
|
52
|
-
"@ultimat3/jobs": "22.3.
|
|
53
|
-
"@ultimat3/mail": "22.3.
|
|
54
|
-
"@ultimat3/manifest": "22.3.
|
|
55
|
-
"@ultimat3/mcp": "22.3.
|
|
56
|
-
"@ultimat3/money": "22.3.
|
|
57
|
-
"@ultimat3/notify": "22.3.
|
|
58
|
-
"@ultimat3/policy": "22.3.
|
|
59
|
-
"@ultimat3/pwa": "22.3.
|
|
60
|
-
"@ultimat3/query": "22.3.
|
|
61
|
-
"@ultimat3/realtime": "22.3.
|
|
62
|
-
"@ultimat3/render": "22.3.
|
|
63
|
-
"@ultimat3/schema": "22.3.
|
|
64
|
-
"@ultimat3/seo": "22.3.
|
|
65
|
-
"@ultimat3/storage": "22.3.
|
|
66
|
-
"@ultimat3/testing": "22.3.
|
|
67
|
-
"@ultimat3/time": "22.3.
|
|
41
|
+
"@ultimat3/action": "22.3.4",
|
|
42
|
+
"@ultimat3/admin": "22.3.4",
|
|
43
|
+
"@ultimat3/ai": "22.3.4",
|
|
44
|
+
"@ultimat3/auth": "22.3.4",
|
|
45
|
+
"@ultimat3/cache": "22.3.4",
|
|
46
|
+
"@ultimat3/core": "22.3.4",
|
|
47
|
+
"@ultimat3/db": "22.3.4",
|
|
48
|
+
"@ultimat3/entity": "22.3.4",
|
|
49
|
+
"@ultimat3/flags": "22.3.4",
|
|
50
|
+
"@ultimat3/http": "22.3.4",
|
|
51
|
+
"@ultimat3/i18n": "22.3.4",
|
|
52
|
+
"@ultimat3/jobs": "22.3.4",
|
|
53
|
+
"@ultimat3/mail": "22.3.4",
|
|
54
|
+
"@ultimat3/manifest": "22.3.4",
|
|
55
|
+
"@ultimat3/mcp": "22.3.4",
|
|
56
|
+
"@ultimat3/money": "22.3.4",
|
|
57
|
+
"@ultimat3/notify": "22.3.4",
|
|
58
|
+
"@ultimat3/policy": "22.3.4",
|
|
59
|
+
"@ultimat3/pwa": "22.3.4",
|
|
60
|
+
"@ultimat3/query": "22.3.4",
|
|
61
|
+
"@ultimat3/realtime": "22.3.4",
|
|
62
|
+
"@ultimat3/render": "22.3.4",
|
|
63
|
+
"@ultimat3/schema": "22.3.4",
|
|
64
|
+
"@ultimat3/seo": "22.3.4",
|
|
65
|
+
"@ultimat3/storage": "22.3.4",
|
|
66
|
+
"@ultimat3/testing": "22.3.4",
|
|
67
|
+
"@ultimat3/time": "22.3.4",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -115,6 +115,28 @@ export function resetAppLoad(): void {
|
|
|
115
115
|
routeSources.clear();
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Every module path the app globs match, SORTED. `Bun.Glob` answers in directory order, which is
|
|
120
|
+
* the filesystem's — ext4 hashes names with a per-filesystem seed — so two pods of one image
|
|
121
|
+
* imported the app in two orders, registered its stylesheets in two orders, and served two
|
|
122
|
+
* different `/styles/<hash>.css` for one page (notificado.co, 22.3.2). Import order is the
|
|
123
|
+
* stylesheet cascade's order, so it must be the same everywhere.
|
|
124
|
+
*/
|
|
125
|
+
export async function appModulePaths(root: string): Promise<readonly string[]> {
|
|
126
|
+
// Pattern by pattern, as before — only the order WITHIN a pattern was the filesystem's.
|
|
127
|
+
const found: string[] = [];
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
for (const pattern of APP_GLOBS) {
|
|
130
|
+
const matched: string[] = [];
|
|
131
|
+
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
132
|
+
if (!seen.has(absolute)) matched.push(absolute);
|
|
133
|
+
seen.add(absolute);
|
|
134
|
+
}
|
|
135
|
+
found.push(...matched.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
136
|
+
}
|
|
137
|
+
return found;
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
export async function loadApp(root: string): Promise<LoadedApp> {
|
|
119
141
|
// Before any import: a stylesheet's surface is read below the app root, never off its absolute
|
|
120
142
|
// path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
|
|
@@ -127,36 +149,34 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
127
149
|
const files: string[] = [];
|
|
128
150
|
const findings: Finding[] = [];
|
|
129
151
|
|
|
130
|
-
for (const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
files.push(file);
|
|
157
|
-
const finding = await register(absolute, file, module, snapshot);
|
|
158
|
-
if (finding !== undefined) findings.push(finding);
|
|
152
|
+
for (const absolute of await appModulePaths(root)) {
|
|
153
|
+
// A SEGMENT, never a substring: an app checked out under
|
|
154
|
+
// `~/dev/node_modules-experiments/myapp` answered `includes('node_modules')` for every
|
|
155
|
+
// file it holds, so this loop imported none of them and the app registered nothing.
|
|
156
|
+
// The ROOT-RELATIVE path is tested, never the absolute one: an app checked out under
|
|
157
|
+
// `~/work/my.test.app` matched `.test.` on every file and loaded none of them.
|
|
158
|
+
const file = relative(root, absolute).split(sep).join('/');
|
|
159
|
+
if (hasPathSegment(absolute, 'node_modules') || isTest(file)) continue;
|
|
160
|
+
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// The source is read BEFORE the import, and only on the file's first pass — a rescan of a
|
|
164
|
+
// registered module reads nothing here. A route entry is bound to the bytes the module was
|
|
165
|
+
// evaluated from, and a read AFTER the import cannot know which bytes those were: a save
|
|
166
|
+
// landing between the two bound V1's component to V2's hash, so the next scan saw nothing to
|
|
167
|
+
// do and served V1 until the save after. Read first, the worst case is one re-import the
|
|
168
|
+
// next tick, of a file that did change.
|
|
169
|
+
const snapshot = registered.has(absolute) ? undefined : await Bun.file(absolute).text();
|
|
170
|
+
let module: Record<string, unknown>;
|
|
171
|
+
try {
|
|
172
|
+
module = (await import(absolute)) as Record<string, unknown>;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
findings.push({ ...findingFrom(error), at: file });
|
|
175
|
+
continue;
|
|
159
176
|
}
|
|
177
|
+
files.push(file);
|
|
178
|
+
const finding = await register(absolute, file, module, snapshot);
|
|
179
|
+
if (finding !== undefined) findings.push(finding);
|
|
160
180
|
}
|
|
161
181
|
|
|
162
182
|
files.sort();
|
package/src/dev-route-table.ts
CHANGED
|
@@ -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
|
|
127
|
+
...(pwa === undefined
|
|
128
|
+
? {}
|
|
129
|
+
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
128
130
|
}),
|
|
129
131
|
];
|
|
130
132
|
|
package/src/island-routes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// would be a page that boots in `x dev` and 404s in the image.
|
|
4
4
|
|
|
5
5
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
6
|
-
import { applyCacheHeaders, json } from '@ultimat3/http';
|
|
6
|
+
import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
|
|
7
7
|
import type { IslandBundle } from './island-bundle';
|
|
8
8
|
import { ISLAND_BASE_PATH } from './island-bundle';
|
|
9
9
|
|
|
@@ -28,7 +28,7 @@ export function islandRoutes(source: IslandSource): readonly Route[] {
|
|
|
28
28
|
handler: (request: UltimateRequest): Response => {
|
|
29
29
|
const chunk = source().chunkAt(request.pathname);
|
|
30
30
|
if (chunk === undefined) {
|
|
31
|
-
|
|
31
|
+
const missing = json(
|
|
32
32
|
{
|
|
33
33
|
ok: false,
|
|
34
34
|
error: {
|
|
@@ -45,6 +45,8 @@ export function islandRoutes(source: IslandSource): readonly Route[] {
|
|
|
45
45
|
},
|
|
46
46
|
{ status: 404 },
|
|
47
47
|
);
|
|
48
|
+
// `no-store`: a miss on a content-hashed URL must not be pinned by a CDN or a proxy.
|
|
49
|
+
return applyCacheHeaders(missing, NO_STORE);
|
|
48
50
|
}
|
|
49
51
|
return applyCacheHeaders(
|
|
50
52
|
new Response(chunk.code, { headers: { 'content-type': 'text/javascript' } }),
|
package/src/island-store.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// that names a different set of islands than the app has, or a chunk whose bytes do not hash to
|
|
8
8
|
// what the index recorded is refused, and the boot builds instead — with the reason logged.
|
|
9
9
|
|
|
10
|
-
import { join } from 'node:path'; // why: Bun ships no path-join primitive.
|
|
10
|
+
import { join, relative, sep } from 'node:path'; // why: Bun ships no path-join primitive.
|
|
11
11
|
import { frameworkVersion, logger } from '@ultimat3/core';
|
|
12
|
-
import { contentHash } from '@ultimat3/render/server';
|
|
12
|
+
import { contentHash, loadStylesheet } from '@ultimat3/render/server';
|
|
13
13
|
import type { IslandBundle, IslandChunk } from './island-bundle';
|
|
14
14
|
import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
|
|
15
|
+
import { islandStylesheets } from './island-styles';
|
|
15
16
|
|
|
16
17
|
/** App-root-relative: `COPY . .` carries it into the image with the rest of `.x/`. */
|
|
17
18
|
export const ISLAND_STORE_DIR = '.x/islands';
|
|
@@ -28,6 +29,13 @@ interface StoreIndex {
|
|
|
28
29
|
readonly framework: string;
|
|
29
30
|
readonly bun: string;
|
|
30
31
|
readonly chunks: readonly StoredChunk[];
|
|
32
|
+
/**
|
|
33
|
+
* The stylesheets the island build registered, app-relative and sorted. Registered again at boot
|
|
34
|
+
* from the app's own sources, so a container serving these chunks joins the SAME surface
|
|
35
|
+
* stylesheet a build would — without them an island-only sheet was missing from every pod that
|
|
36
|
+
* read the store, and present on any that rebuilt.
|
|
37
|
+
*/
|
|
38
|
+
readonly stylesheets: readonly string[];
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
const chunkFile = (url: string): string => url.slice(url.lastIndexOf('/') + 1);
|
|
@@ -50,7 +58,16 @@ export async function writeIslandStore(
|
|
|
50
58
|
identity: contentHash(chunk.code),
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
|
-
const
|
|
61
|
+
const stylesheets = islandStylesheets()
|
|
62
|
+
.map((path) => relative(root, path).split(sep).join('/'))
|
|
63
|
+
.filter((path) => !path.startsWith('..'))
|
|
64
|
+
.sort();
|
|
65
|
+
const index: StoreIndex = {
|
|
66
|
+
framework: frameworkVersion(),
|
|
67
|
+
bun: Bun.version,
|
|
68
|
+
chunks,
|
|
69
|
+
stylesheets,
|
|
70
|
+
};
|
|
54
71
|
await Bun.write(join(dir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
|
|
55
72
|
return [...written, `${ISLAND_STORE_DIR}/${INDEX}`];
|
|
56
73
|
}
|
|
@@ -74,7 +91,10 @@ function parseIndex(value: unknown): StoreIndex | undefined {
|
|
|
74
91
|
return undefined;
|
|
75
92
|
parsed.push({ file, moduleId, url, identity });
|
|
76
93
|
}
|
|
77
|
-
|
|
94
|
+
const sheets = record['stylesheets'];
|
|
95
|
+
// Absent in a 22.3.2 store: read as none, and the boot builds when it matters (see below).
|
|
96
|
+
const stylesheets = Array.isArray(sheets) ? sheets.filter(isString) : [];
|
|
97
|
+
return { framework: record['framework'], bun: record['bun'], chunks: parsed, stylesheets };
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
/** A verified store, or the one sentence saying why it cannot be served. */
|
|
@@ -113,6 +133,13 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
|
|
|
113
133
|
bytes: new TextEncoder().encode(code).byteLength,
|
|
114
134
|
});
|
|
115
135
|
}
|
|
136
|
+
// In sorted order, as `island` sheets: `stylesFor` orders those by path, so this is the same
|
|
137
|
+
// surface stylesheet the build that wrote the store joined.
|
|
138
|
+
for (const sheet of index.stylesheets) {
|
|
139
|
+
const source = Bun.file(join(root, sheet));
|
|
140
|
+
if (!(await source.exists())) return { stale: `${sheet}, an island stylesheet, is missing` };
|
|
141
|
+
loadStylesheet(join(root, sheet), await source.text(), 'island');
|
|
142
|
+
}
|
|
116
143
|
return { bundle: islandBundle(chunks) };
|
|
117
144
|
}
|
|
118
145
|
|
package/src/island-styles.ts
CHANGED
|
@@ -16,6 +16,16 @@ import { IslandBuildFailedError } from './errors';
|
|
|
16
16
|
*/
|
|
17
17
|
const STYLESHEET = /\.s?css$/;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Every stylesheet an island build has loaded in this process, absolute. `x build --target docker`
|
|
21
|
+
* writes them into the island store, so a container that serves the stored chunks registers the
|
|
22
|
+
* same sheets a build would have — and every pod of one image joins the same surface stylesheet.
|
|
23
|
+
*/
|
|
24
|
+
const islandSheets = new Set<string>();
|
|
25
|
+
|
|
26
|
+
/** Sorted, so the store index is byte-identical for identical input. */
|
|
27
|
+
export const islandStylesheets = (): readonly string[] => [...islandSheets].sort();
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* `loadStylesheet`, not `compileStylesheet`: compiling alone answers the class names and drops the
|
|
21
31
|
* RULES on the floor, and an island is the one importer a document's own module graph never sees.
|
|
@@ -28,7 +38,8 @@ export const islandStylesPlugin: BunPlugin = {
|
|
|
28
38
|
build.onLoad({ filter: STYLESHEET }, async ({ path }) => {
|
|
29
39
|
const source = await Bun.file(path).text();
|
|
30
40
|
try {
|
|
31
|
-
|
|
41
|
+
islandSheets.add(path);
|
|
42
|
+
return { contents: loadStylesheet(path, source, 'island'), loader: 'js' };
|
|
32
43
|
} catch (error) {
|
|
33
44
|
// A coded failure already names the file and carries a fix — re-wrapping it would bury
|
|
34
45
|
// both. Anything else is rendered through `renderThrowable`, because this is the plugin's
|
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,
|
|
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
|
-
|
|
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
|
|
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 = {
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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:
|
|
185
|
-
head:
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
+
}
|
package/src/runtime-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
package/src/style-bundle.ts
CHANGED
|
@@ -72,15 +72,37 @@ let memo: { readonly revision: number; readonly bundle: StyleBundle } | undefine
|
|
|
72
72
|
export function styleBundle(): StyleBundle {
|
|
73
73
|
const revision = stylesheetsRevision();
|
|
74
74
|
if (memo !== undefined && memo.revision === revision) return memo.bundle;
|
|
75
|
-
const
|
|
75
|
+
const current = styleBundleOf(
|
|
76
76
|
STYLED_SURFACES.map((surface) => ({ surface, css: stylesFor(surface) })).filter(
|
|
77
77
|
(sheet) => sheet.css.length > 0,
|
|
78
78
|
),
|
|
79
79
|
);
|
|
80
|
+
for (const chunk of current.chunks) {
|
|
81
|
+
minted.delete(chunk.url);
|
|
82
|
+
minted.set(chunk.url, chunk);
|
|
83
|
+
}
|
|
84
|
+
while (minted.size > MINTED_KEPT) {
|
|
85
|
+
const oldest = minted.keys().next().value;
|
|
86
|
+
if (oldest === undefined) break;
|
|
87
|
+
minted.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
const bundle: StyleBundle = {
|
|
90
|
+
...current,
|
|
91
|
+
chunkAt: (url: string): StyleChunk | undefined => current.chunkAt(url) ?? minted.get(url),
|
|
92
|
+
};
|
|
80
93
|
memo = { revision, bundle };
|
|
81
94
|
return bundle;
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Every stylesheet this process has minted, newest last, so a URL it once linked keeps answering
|
|
99
|
+
* after the registry moves — a module imported late, an island rebuilt under `x dev`. The worker
|
|
100
|
+
* precached the boot-time URL and a document rendered a minute ago names it; a 404 there is a page
|
|
101
|
+
* with no CSS. Bounded, because `x dev` mints one per edit.
|
|
102
|
+
*/
|
|
103
|
+
const minted = new Map<string, StyleChunk>();
|
|
104
|
+
const MINTED_KEPT = 32;
|
|
105
|
+
|
|
84
106
|
/**
|
|
85
107
|
* Test seam, and the shape `islandBundle` has: a table built from what the caller supplies.
|
|
86
108
|
*
|
package/src/style-routes.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// image.
|
|
5
5
|
|
|
6
6
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
7
|
-
import { applyCacheHeaders, json } from '@ultimat3/http';
|
|
7
|
+
import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
|
|
8
8
|
import type { StyleBundle } from './style-bundle';
|
|
9
9
|
import { STYLE_BASE_PATH } from './style-bundle';
|
|
10
10
|
|
|
@@ -32,7 +32,7 @@ export function styleRoutes(source: StyleSource): readonly Route[] {
|
|
|
32
32
|
handler: (request: UltimateRequest): Response => {
|
|
33
33
|
const chunk = source().chunkAt(request.pathname);
|
|
34
34
|
if (chunk === undefined) {
|
|
35
|
-
|
|
35
|
+
const missing = json(
|
|
36
36
|
{
|
|
37
37
|
ok: false,
|
|
38
38
|
error: {
|
|
@@ -45,6 +45,8 @@ export function styleRoutes(source: StyleSource): readonly Route[] {
|
|
|
45
45
|
},
|
|
46
46
|
{ status: 404 },
|
|
47
47
|
);
|
|
48
|
+
// `no-store`: a miss on a content-hashed URL must not be pinned by a CDN or a proxy.
|
|
49
|
+
return applyCacheHeaders(missing, NO_STORE);
|
|
48
50
|
}
|
|
49
51
|
return applyCacheHeaders(
|
|
50
52
|
new Response(chunk.css, { headers: { 'content-type': 'text/css; charset=utf-8' } }),
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -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
|
|
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';
|
|
@@ -66,75 +72,13 @@ export interface ServiceWorkerInput {
|
|
|
66
72
|
* offline reload that cannot load the boot restores no record and shows the old count.
|
|
67
73
|
*/
|
|
68
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;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
/**
|
|
72
|
-
* One rendered document, as the precache manifest needs it.
|
|
73
|
-
*
|
|
74
|
-
* `revision` is `contentHash(html)` — `@ultimat3/render`'s own, the function that already stamps
|
|
75
|
-
* an ETag — and never the build id. `precache.ts`' header states the rule and nothing kept it:
|
|
76
|
-
* `pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
|
|
77
|
-
* `{"url":"/","revision":"build-aaa","bytes":0}` and two deploys of a byte-identical site
|
|
78
|
-
* re-fetched every precached document. The zero was the second half — `DEFAULT_PRECACHE_WARN_BYTES`
|
|
79
|
-
* is a 5 MB budget over a total that could not count one byte of HTML.
|
|
80
|
-
*/
|
|
81
|
-
export interface RenderedDocument {
|
|
82
|
-
readonly revision: string;
|
|
83
|
-
readonly bytes: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The route table, as the service worker sees it. `api/` is dropped: an API response is a JSON
|
|
88
|
-
* document whose freshness is the app's business, and precaching one serves a stale answer to a
|
|
89
|
-
* client that had a network. Only the fields a browser can act on cross — a descriptor carries
|
|
90
|
-
* budgets and policy flags it has no use for — plus, for a route this build rendered, the content
|
|
91
|
-
* hash and the byte count of the document it produced.
|
|
92
|
-
*/
|
|
93
|
-
const pwaRoutes = (
|
|
94
|
-
routes: readonly RouteDescriptor[],
|
|
95
|
-
documents: ReadonlyMap<string, RenderedDocument>,
|
|
96
|
-
): readonly PwaRoute[] =>
|
|
97
|
-
// `flatMap` rather than `filter().map()`: the filter's predicate does not narrow `surface` for
|
|
98
|
-
// the map that follows it, and `PwaRoute` declares the two navigable surfaces only. A cast would
|
|
99
|
-
// hide the day a fifth surface arrives.
|
|
100
|
-
routes.flatMap((route): readonly PwaRoute[] => {
|
|
101
|
-
// `shared/` is dropped with `api/`, and for a stronger reason: it is not a URL at all — the
|
|
102
|
-
// surface exists so two routes can import one module, and a browser can never navigate to it.
|
|
103
|
-
if (route.surface !== 'site' && route.surface !== 'app') return [];
|
|
104
|
-
// A `Map`, so a route path that happens to spell a prototype member cannot answer with one.
|
|
105
|
-
const document = documents.get(route.path);
|
|
106
|
-
return [
|
|
107
|
-
{
|
|
108
|
-
path: route.path,
|
|
109
|
-
surface: route.surface,
|
|
110
|
-
mode: route.mode,
|
|
111
|
-
offline: route.offline,
|
|
112
|
-
dynamic: route.dynamic,
|
|
113
|
-
// Absent rather than invented for a route no build rendered — an `ssr` page, or one this
|
|
114
|
-
// pass could not produce. `buildPrecacheManifest` then falls back to the build id, which
|
|
115
|
-
// is the honest answer when there are no bytes to hash.
|
|
116
|
-
...(document === undefined ? {} : { revision: document.revision, bytes: document.bytes }),
|
|
117
|
-
},
|
|
118
|
-
];
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Every island chunk and every surface stylesheet, precached. They are content-addressed and
|
|
123
|
-
* served `immutable`, so the revision IS the URL's hash and a byte-identical asset across deploys
|
|
124
|
-
* is never re-downloaded.
|
|
125
|
-
*
|
|
126
|
-
* Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
|
|
127
|
-
* decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
|
|
128
|
-
*/
|
|
129
|
-
const staticAssets = (
|
|
130
|
-
islands: IslandBundle,
|
|
131
|
-
styles: StyleBundle,
|
|
132
|
-
scripts: readonly { readonly url: string; readonly bytes: number }[],
|
|
133
|
-
): readonly PrecacheAsset[] =>
|
|
134
|
-
[...islands.chunks, ...styles.chunks, ...scripts]
|
|
135
|
-
.map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
|
|
136
|
-
.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
137
|
-
|
|
138
82
|
/**
|
|
139
83
|
* The registration, and every line of it earns its bytes. `load` because registration competes with
|
|
140
84
|
* the page's own first paint for the same network. `scope: '/'` stated rather than inferred, so a
|
|
@@ -212,10 +156,13 @@ export function serviceWorkerArtifacts(
|
|
|
212
156
|
// Absent when this pass did not render the fallback (no route serves it — `x doctor` reports
|
|
213
157
|
// that as `X_PWA_NO_OFFLINE_FALLBACK`), and then `@ultimat3/pwa` falls back to the build id.
|
|
214
158
|
const fallbackDocument = documents.get(fallback);
|
|
159
|
+
const locales = input.locales ?? { routed: routedLocales(), fallback: localeConfig().fallback };
|
|
215
160
|
const output = generateServiceWorker(
|
|
216
|
-
pwaRoutes(input.routes, documents),
|
|
161
|
+
pwaRoutes(input.routes, documents, locales),
|
|
217
162
|
{
|
|
218
163
|
scope: SW_SCOPE,
|
|
164
|
+
runtimeAssets: [`${ISLAND_BASE_PATH}/`],
|
|
165
|
+
localePrefixes: localePrefixes(locales),
|
|
219
166
|
swPath: SERVICE_WORKER_PATH,
|
|
220
167
|
...(fallbackDocument === undefined
|
|
221
168
|
? {}
|
|
@@ -228,9 +175,18 @@ export function serviceWorkerArtifacts(
|
|
|
228
175
|
...(pwa.offline.image === null ? {} : { image: pwa.offline.image }),
|
|
229
176
|
...(pwa.offline.font === null ? {} : { font: pwa.offline.font }),
|
|
230
177
|
neverCache: pwa.offline.neverCache,
|
|
178
|
+
personalPages: pwa.offline.personalPages,
|
|
231
179
|
},
|
|
232
180
|
capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
|
|
233
|
-
assets:
|
|
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
|
+
}),
|
|
234
190
|
},
|
|
235
191
|
input.buildId,
|
|
236
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
|
+
}
|