@jxsuite/compiler 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +563 -228
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An optional service worker, and the tombstone that makes turning it off mean something.
|
|
3
|
+
*
|
|
4
|
+
* A service worker is unlike every other output here: it is **sticky**. It survives redeploys, it
|
|
5
|
+
* keeps running against a site that has moved on, and the visitors it breaks are precisely the ones
|
|
6
|
+
* who came before — the people who already trusted the site enough to load it twice. Nothing else
|
|
7
|
+
* the build emits can do that, which is why this one is off by default and why the interesting half
|
|
8
|
+
* of the module is how you get rid of it.
|
|
9
|
+
*
|
|
10
|
+
* Three rules, and each exists because its absence produces a specific failure:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Off by default.** A worker nobody asked for is a caching layer nobody debugged.
|
|
13
|
+
* 2. **HTML is always network-first.** A cache-first worker serves a stale page indefinitely, and the
|
|
14
|
+
* author's next deploy cannot reach the visitor to fix it.
|
|
15
|
+
* 3. **`serviceWorker: false` emits a tombstone** at the same URL: a worker that unregisters itself
|
|
16
|
+
* and deletes every cache it made. Without it, "off" only means "new visitors get nothing" —
|
|
17
|
+
* everyone who already registered keeps the old worker forever, and there is no way to reach
|
|
18
|
+
* them.
|
|
19
|
+
*
|
|
20
|
+
* @docs framework/site/deployment
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import type { ServiceWorkerConfig } from "@jxsuite/schema/types";
|
|
27
|
+
|
|
28
|
+
/** The URL a worker is served from. Fixed: its scope cannot exceed its own path. */
|
|
29
|
+
export const SERVICE_WORKER_PATH = "sw.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one path family safe to serve cache-first.
|
|
33
|
+
*
|
|
34
|
+
* It is the build's only content-addressed output — `variantFilename()` embeds a digest of the
|
|
35
|
+
* source bytes, so a changed image is a changed URL and a cached one can never be wrong. Everything
|
|
36
|
+
* else is named after what it contains rather than after its content (`_headers` says the same in
|
|
37
|
+
* `NEVER_IMMUTABLE`), so caching it first would serve edited components from a stale entry.
|
|
38
|
+
*/
|
|
39
|
+
export const CACHE_FIRST_PREFIX = "/images/_optimized/";
|
|
40
|
+
|
|
41
|
+
export interface ServiceWorkerOutput {
|
|
42
|
+
/** Path relative to `outDir`, or null when nothing should be emitted. */
|
|
43
|
+
path: string | null;
|
|
44
|
+
source: string;
|
|
45
|
+
warnings: string[];
|
|
46
|
+
errors: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The file a site-absolute URL is served from, resolving a directory URL to its index.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} url - Site-absolute, e.g. `/offline/`
|
|
53
|
+
* @param {string} outDir
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function outputFileFor(url: string, outDir: string): string {
|
|
57
|
+
const rel = url.replace(/^\//, "");
|
|
58
|
+
return url.endsWith("/") || rel === "" ? join(outDir, rel, "index.html") : join(outDir, rel);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Normalize the config's two spellings into one shape. */
|
|
62
|
+
export function normalizeServiceWorker(
|
|
63
|
+
value: ServiceWorkerConfig | boolean | undefined,
|
|
64
|
+
): ServiceWorkerConfig | false | null {
|
|
65
|
+
if (value === undefined) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (value === false) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (value === true) {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
return value.enabled === false ? false : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A short, stable id for a worker's configuration.
|
|
79
|
+
*
|
|
80
|
+
* The cache name embeds it so a config change rotates the cache. It deliberately does **not**
|
|
81
|
+
* change per build: HTML is network-first and images are content-addressed, so a content-only
|
|
82
|
+
* deploy needs no rotation — and a per-build name would throw away a warm cache on every deploy for
|
|
83
|
+
* no benefit.
|
|
84
|
+
*/
|
|
85
|
+
function configId(config: ServiceWorkerConfig): string {
|
|
86
|
+
const shape = JSON.stringify({
|
|
87
|
+
fallback: config.offlineFallback ?? null,
|
|
88
|
+
precache: [...(config.precache ?? [])].toSorted(),
|
|
89
|
+
scope: config.scope ?? "/",
|
|
90
|
+
});
|
|
91
|
+
return createHash("sha256").update(shape, "utf8").digest("hex").slice(0, 8);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The worker source for an enabled project.
|
|
96
|
+
*
|
|
97
|
+
* @param {ServiceWorkerConfig} config
|
|
98
|
+
* @returns {ServiceWorkerOutput}
|
|
99
|
+
*/
|
|
100
|
+
export function buildServiceWorker(
|
|
101
|
+
config: ServiceWorkerConfig,
|
|
102
|
+
outDir?: string,
|
|
103
|
+
): ServiceWorkerOutput {
|
|
104
|
+
const warnings: string[] = [];
|
|
105
|
+
const errors: string[] = [];
|
|
106
|
+
const precache = [...(config.precache ?? [])];
|
|
107
|
+
const fallback = config.offlineFallback;
|
|
108
|
+
if (fallback !== undefined && !precache.includes(fallback)) {
|
|
109
|
+
/*
|
|
110
|
+
* A fallback that was never cached cannot be served when the network is gone, which is the
|
|
111
|
+
* only moment it exists for. Adding it is what the author meant; saying so is how they learn
|
|
112
|
+
* the two settings are connected.
|
|
113
|
+
*/
|
|
114
|
+
precache.push(fallback);
|
|
115
|
+
warnings.push(
|
|
116
|
+
`serviceWorker: "${fallback}" is the offline fallback but was not in \`precache\` — ` +
|
|
117
|
+
"added, since a page that was never cached cannot be served offline.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
* A precache URL that does not exist is a **build error**, and the reason is the failure mode:
|
|
123
|
+
* `cache.addAll()` is all-or-nothing, so one unreachable entry rejects the install and the
|
|
124
|
+
* worker never activates at all. The symptom is "the service worker does nothing", with no
|
|
125
|
+
* error anywhere the author will look — which is exactly what happened the first time this was
|
|
126
|
+
* run against a browser.
|
|
127
|
+
*/
|
|
128
|
+
if (outDir !== undefined) {
|
|
129
|
+
for (const url of precache) {
|
|
130
|
+
if (!url.startsWith("/")) {
|
|
131
|
+
errors.push(
|
|
132
|
+
`serviceWorker.precache: "${url}" must be a site-absolute URL starting with /.`,
|
|
133
|
+
);
|
|
134
|
+
} else if (!existsSync(outputFileFor(url, outDir))) {
|
|
135
|
+
errors.push(
|
|
136
|
+
`serviceWorker.precache: "${url}" is not a file this build produced — ` +
|
|
137
|
+
"`cache.addAll()` is all-or-nothing, so one bad entry stops the worker installing.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cache = `jx-${configId(config)}`;
|
|
144
|
+
const source = `/*
|
|
145
|
+
* Generated by @jxsuite/compiler — configure via \`serviceWorker\` in project.json.
|
|
146
|
+
* Set \`"serviceWorker": false\` to replace this with a worker that removes itself.
|
|
147
|
+
*/
|
|
148
|
+
const CACHE = ${JSON.stringify(cache)};
|
|
149
|
+
const PRECACHE = ${JSON.stringify(precache)};
|
|
150
|
+
const CACHE_FIRST = ${JSON.stringify(CACHE_FIRST_PREFIX)};
|
|
151
|
+
const OFFLINE_FALLBACK = ${JSON.stringify(fallback ?? null)};
|
|
152
|
+
|
|
153
|
+
self.addEventListener("install", (event) => {
|
|
154
|
+
// Take over as soon as possible: a half-updated pair of workers is harder to reason about than
|
|
155
|
+
// A single new one, and every response below tolerates a cold cache.
|
|
156
|
+
self.skipWaiting();
|
|
157
|
+
if (PRECACHE.length > 0) {
|
|
158
|
+
/*
|
|
159
|
+
* Fetched one at a time rather than with addAll(), which rejects the whole install if any
|
|
160
|
+
* single request fails — leaving the worker permanently un-activated over one bad URL. The
|
|
161
|
+
* build already checked these against its own output; this covers what it cannot see.
|
|
162
|
+
*/
|
|
163
|
+
event.waitUntil(
|
|
164
|
+
caches.open(CACHE).then((c) =>
|
|
165
|
+
Promise.allSettled(PRECACHE.map((u) => c.add(u))).then((results) => {
|
|
166
|
+
for (const [i, r] of results.entries()) {
|
|
167
|
+
if (r.status === "rejected") console.warn("jx sw: could not precache", PRECACHE[i]);
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
self.addEventListener("activate", (event) => {
|
|
176
|
+
event.waitUntil(
|
|
177
|
+
(async () => {
|
|
178
|
+
// Drop every cache this site made under a different config.
|
|
179
|
+
for (const name of await caches.keys()) {
|
|
180
|
+
if (name.startsWith("jx-") && name !== CACHE) await caches.delete(name);
|
|
181
|
+
}
|
|
182
|
+
await self.clients.claim();
|
|
183
|
+
})(),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
self.addEventListener("fetch", (event) => {
|
|
188
|
+
const { request } = event;
|
|
189
|
+
if (request.method !== "GET") return;
|
|
190
|
+
const url = new URL(request.url);
|
|
191
|
+
if (url.origin !== self.location.origin) return;
|
|
192
|
+
|
|
193
|
+
// Content-addressed output only: the filename embeds a digest, so a hit can never be stale.
|
|
194
|
+
if (url.pathname.startsWith(CACHE_FIRST)) {
|
|
195
|
+
event.respondWith(
|
|
196
|
+
caches.match(request).then(
|
|
197
|
+
(hit) =>
|
|
198
|
+
hit ??
|
|
199
|
+
fetch(request).then((res) => {
|
|
200
|
+
if (res.ok) caches.open(CACHE).then((c) => c.put(request, res.clone()));
|
|
201
|
+
return res;
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* Everything else is network-first, HTML above all. A cache-first worker serves a stale page
|
|
210
|
+
* indefinitely and the next deploy cannot reach the visitor to fix it — the cache is a fallback
|
|
211
|
+
* for a failed request, never a substitute for one.
|
|
212
|
+
*/
|
|
213
|
+
event.respondWith(
|
|
214
|
+
(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(request);
|
|
217
|
+
if (res.ok) {
|
|
218
|
+
const copy = res.clone();
|
|
219
|
+
caches.open(CACHE).then((c) => c.put(request, copy));
|
|
220
|
+
}
|
|
221
|
+
return res;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const hit = await caches.match(request);
|
|
224
|
+
if (hit) return hit;
|
|
225
|
+
if (OFFLINE_FALLBACK && request.mode === "navigate") {
|
|
226
|
+
const page = await caches.match(OFFLINE_FALLBACK);
|
|
227
|
+
if (page) return page;
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
})(),
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
return { errors, path: SERVICE_WORKER_PATH, source, warnings };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The tombstone: a worker whose only job is to stop being one.
|
|
241
|
+
*
|
|
242
|
+
* It has to be served from the **same URL** as the worker it replaces, because that is the only URL
|
|
243
|
+
* the browser will ever check for an update. A visitor who registered the old worker gets this one
|
|
244
|
+
* on their next update check, it unregisters itself, deletes the caches, and reloads its clients
|
|
245
|
+
* onto the live site.
|
|
246
|
+
*
|
|
247
|
+
* @returns {ServiceWorkerOutput}
|
|
248
|
+
*/
|
|
249
|
+
export function tombstoneServiceWorker(): ServiceWorkerOutput {
|
|
250
|
+
const source = `/*
|
|
251
|
+
* Generated by @jxsuite/compiler. This site had a service worker and no longer does.
|
|
252
|
+
*
|
|
253
|
+
* A worker is sticky: removing the file would leave every previous visitor running the old one
|
|
254
|
+
* forever, because a 404 at this URL is not an instruction to stop. This is that instruction.
|
|
255
|
+
*/
|
|
256
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
257
|
+
|
|
258
|
+
self.addEventListener("activate", (event) => {
|
|
259
|
+
event.waitUntil(
|
|
260
|
+
(async () => {
|
|
261
|
+
for (const name of await caches.keys()) {
|
|
262
|
+
if (name.startsWith("jx-")) await caches.delete(name);
|
|
263
|
+
}
|
|
264
|
+
await self.registration.unregister();
|
|
265
|
+
// Reload every open tab so it leaves this worker behind rather than finishing the session
|
|
266
|
+
// Under a controller that has already unregistered.
|
|
267
|
+
for (const client of await self.clients.matchAll({ type: "window" })) {
|
|
268
|
+
client.navigate(client.url);
|
|
269
|
+
}
|
|
270
|
+
})(),
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
`;
|
|
274
|
+
return { errors: [], path: SERVICE_WORKER_PATH, source, warnings: [] };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The inline registration script.
|
|
279
|
+
*
|
|
280
|
+
* Byte-identical on every page of a build, so a strict `script-src` needs exactly one hash for it
|
|
281
|
+
* (site-architecture.md §14.3.1). It registers on `load` rather than immediately: a worker
|
|
282
|
+
* competing with the page's own resources for bandwidth makes the first visit slower, which is the
|
|
283
|
+
* visit that matters most.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} scope
|
|
286
|
+
* @returns {string}
|
|
287
|
+
*/
|
|
288
|
+
export function registrationScript(scope: string): string {
|
|
289
|
+
return (
|
|
290
|
+
`if('serviceWorker' in navigator){addEventListener('load',function(){` +
|
|
291
|
+
`navigator.serviceWorker.register('/${SERVICE_WORKER_PATH}',{scope:${JSON.stringify(scope)}})` +
|
|
292
|
+
`.catch(function(){})})}`
|
|
293
|
+
);
|
|
294
|
+
}
|