@jxsuite/compiler 1.4.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.
@@ -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
+ }