@transclude/core 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/build.js +469 -0
  4. package/bin/check.js +78 -0
  5. package/bin/dev.js +348 -0
  6. package/bin/release.js +176 -0
  7. package/bin/serve.bun.js +15 -0
  8. package/bin/serve.deno.js +15 -0
  9. package/bin/serve.js +12 -0
  10. package/editor/server.js +172 -0
  11. package/editor/vscode/extension.js +49 -0
  12. package/editor/vscode/package.json +32 -0
  13. package/editor/vscode/syntaxes/transclude.injection.json +41 -0
  14. package/package.json +82 -0
  15. package/src/address.js +183 -0
  16. package/src/app.js +492 -0
  17. package/src/cache.js +137 -0
  18. package/src/compiler/bind.js +496 -0
  19. package/src/compiler/codegen.js +1061 -0
  20. package/src/compiler/expr.js +221 -0
  21. package/src/compiler/index.js +964 -0
  22. package/src/compiler/interp.js +82 -0
  23. package/src/compiler/script.js +620 -0
  24. package/src/compiler/shim.js +756 -0
  25. package/src/compiler/sourcemap.js +140 -0
  26. package/src/compiler/types.js +163 -0
  27. package/src/compress.js +104 -0
  28. package/src/cookies.js +157 -0
  29. package/src/csp.js +192 -0
  30. package/src/document.js +604 -0
  31. package/src/extract.js +339 -0
  32. package/src/feed.js +194 -0
  33. package/src/include.js +89 -0
  34. package/src/lookup.js +49 -0
  35. package/src/negotiate.js +95 -0
  36. package/src/plugin.js +423 -0
  37. package/src/pool.js +29 -0
  38. package/src/precache.js +68 -0
  39. package/src/production.js +159 -0
  40. package/src/project.js +110 -0
  41. package/src/proxy.js +319 -0
  42. package/src/public-files.js +77 -0
  43. package/src/rewrite.js +281 -0
  44. package/src/routes.js +199 -0
  45. package/src/runtime/index.js +1345 -0
  46. package/src/server.js +183 -0
  47. package/src/sitemap.js +124 -0
  48. package/src/static-cache.js +170 -0
  49. package/src/typecheck.js +492 -0
  50. package/src/worker.js +87 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Picks a content encoding from an Accept-Encoding header.
3
+ *
4
+ * Getting this wrong is not a missed optimisation, it is a corrupt response: a
5
+ * client that did not ask for brotli must never be handed brotli. So the rules
6
+ * are followed properly: q-values, `*`, and `q=0` as a refusal.
7
+ *
8
+ * @param {string|null|undefined} header the request's Accept-Encoding
9
+ * @param {string[]} available encodings this response actually has
10
+ * @returns {string|null} null means send it unencoded
11
+ */
12
+ export function pickEncoding(header, available = []) {
13
+ if (!available.length) return null;
14
+
15
+ const accepted = parse(header);
16
+ // No header at all means the client stated no preference; sending identity is
17
+ // the only safe reading of that.
18
+ if (!accepted) return null;
19
+
20
+ let best = null;
21
+ for (const encoding of available) {
22
+ const quality = qualityOf(accepted, encoding);
23
+ if (quality <= 0) continue;
24
+ if (!best || quality > best.quality) best = { encoding, quality };
25
+ }
26
+ return best?.encoding ?? null;
27
+ }
28
+
29
+ /**
30
+ * Whether an unencoded response is still allowed.
31
+ *
32
+ * A client can refuse identity with `identity;q=0`, and then a body we have no
33
+ * encoding for cannot be sent at all.
34
+ *
35
+ * @param {string|null|undefined} header
36
+ * @returns {boolean}
37
+ */
38
+ export function identityAcceptable(header) {
39
+ const accepted = parse(header);
40
+ if (!accepted) return true;
41
+ return qualityOf(accepted, 'identity') > 0;
42
+ }
43
+
44
+ /**
45
+ * Parsed headers, keyed by the header itself.
46
+ *
47
+ * Every response negotiates, and clients send a handful of distinct
48
+ * Accept-Encoding strings between them, so the same dozen values are parsed for
49
+ * the life of the process. This was the largest piece of our own code in a
50
+ * profile of the request path.
51
+ *
52
+ * The value is shared, so nothing may write to it. `qualityOf` only reads.
53
+ */
54
+ const parsed = new Map();
55
+ const PARSED_MAX = 64;
56
+
57
+ function parse(header) {
58
+ if (!header || !header.trim()) return null;
59
+
60
+ const held = parsed.get(header);
61
+ if (held !== undefined) return held;
62
+
63
+ const answer = read(header);
64
+ // A client can send anything, so the table is bounded. Oldest out first, which
65
+ // is enough: the values that matter are sent by every request and go back in.
66
+ if (parsed.size >= PARSED_MAX) parsed.delete(parsed.keys().next().value);
67
+ parsed.set(header, answer);
68
+ return answer;
69
+ }
70
+
71
+ function read(header) {
72
+ const entries = new Map();
73
+ for (const part of header.split(',')) {
74
+ const [name, ...params] = part.trim().split(';');
75
+ if (!name) continue;
76
+
77
+ let quality = 1;
78
+ for (const param of params) {
79
+ const [key, value] = param.split('=').map((s) => s.trim());
80
+ if (key === 'q') {
81
+ const q = Number.parseFloat(value);
82
+ quality = Number.isFinite(q) ? q : 0;
83
+ }
84
+ }
85
+ entries.set(name.trim().toLowerCase(), quality);
86
+ }
87
+ return entries.size ? entries : null;
88
+ }
89
+
90
+ function qualityOf(accepted, encoding) {
91
+ if (accepted.has(encoding)) return accepted.get(encoding);
92
+ if (accepted.has('*')) return accepted.get('*');
93
+ // identity is acceptable by default unless something above ruled it out.
94
+ return encoding === 'identity' ? 1 : 0;
95
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,423 @@
1
+ // Vite plugin. Everything is exposed as a virtual module id rather than a real
2
+ // .html path: Vite's own html middleware would otherwise intercept requests for
3
+ // /src/components/user-card.html and serve it as a page.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ compileComponent,
10
+ compileLayout,
11
+ compilePage,
12
+ compileClientEntry,
13
+ compileElementsEntry,
14
+ ELEMENTS_ENTRY,
15
+ splitBlocks,
16
+ usedComponents,
17
+ readFlags,
18
+ } from './compiler/index.js';
19
+ import { resolveRoutesDir, scanRoutes } from './routes.js';
20
+ import { SERVER_FILE } from './server.js';
21
+
22
+ const P_COMPONENT = 'virtual:transclude-component/';
23
+ const P_PAGE = 'virtual:transclude-page/';
24
+ const P_CLIENT = 'virtual:transclude-client/';
25
+ const P_LAYOUT = 'virtual:transclude-layout/';
26
+ const SERVER_ENTRY = 'virtual:transclude-server';
27
+ const LAYOUT_FILE = '_layout.html';
28
+
29
+ const RUNTIME_FILE = fileURLToPath(new URL('./runtime/index.js', import.meta.url));
30
+
31
+ export default function transclude({
32
+ appDir = 'app',
33
+ elementsDir = 'elements',
34
+ routesDir = 'routes',
35
+ fragmentParam = 'fragment',
36
+ watchElements = false,
37
+ } = {}) {
38
+ // Off unless asked for. It puts a script on every page, and it only earns that
39
+ // when swapped-in markup names an element the page did not already render.
40
+ // A page that renders its own elements defines them without this.
41
+ const watching = watchElements === true;
42
+ let root;
43
+ let app;
44
+ let runtime;
45
+ let components = new Map();
46
+ let shadowTags = new Set();
47
+ let pages = new Map();
48
+ let endpoints = new Map();
49
+ let layouts = new Map();
50
+ // Virtual module id -> the .html file it came from, so relative imports inside
51
+ // a <script> block resolve against the author's file rather than nowhere.
52
+ const origin = new Map();
53
+
54
+ const scan = () => {
55
+ // One directory. An element is light unless its own file says otherwise,
56
+ // which is read below rather than taken from where the file sits.
57
+ components = readDir(path.resolve(app, elementsDir));
58
+
59
+ const scanned = scanRoutes(resolveRoutesDir(app, routesDir));
60
+ pages = new Map(
61
+ [...scanned.routes, scanned.notFound, scanned.error]
62
+ .filter(Boolean)
63
+ .map((route) => [route.id, route]),
64
+ );
65
+ endpoints = new Map(scanned.endpoints.map((route) => [route.id, route]));
66
+ // A dash keeps these valid custom element names. That is what makes a light element
67
+ // an undefined custom element rather than an unknown one, and what lets its
68
+ // styles be scoped to its own tag with no class or hash.
69
+ for (const tag of [...components.keys()]) {
70
+ if (!tag.includes('-')) {
71
+ components.delete(tag);
72
+ console.warn(`[transclude] ignoring ${tag}.html. Element names need a dash`);
73
+ }
74
+ }
75
+
76
+ // How a tag renders decides how every file that mentions it compiles, so it
77
+ // has to be known for all of them before any of them is compiled.
78
+ shadowTags = new Set();
79
+ for (const [tag, file] of components) {
80
+ const flags = safely(() => readFlags(read(file), `${tag}.html`));
81
+ if (flags?.shadow) shadowTags.add(tag);
82
+ }
83
+
84
+ layouts = scanLayouts(resolveRoutesDir(app, routesDir));
85
+
86
+ };
87
+
88
+ /**
89
+ * Layouts that wrap a page, outermost first: every _layout.html from the routes
90
+ * root down to the page's own directory.
91
+ */
92
+ const chainFor = (route) => {
93
+ const dirs = path.dirname(route.rel).split(path.sep).filter((d) => d && d !== '.');
94
+ const chain = [];
95
+ for (let i = 0; i <= dirs.length; i++) {
96
+ const id = i === 0 ? 'root' : dirs.slice(0, i).join('-');
97
+ if (layouts.has(id)) chain.push({ id, file: layouts.get(id) });
98
+ }
99
+ return chain;
100
+ };
101
+
102
+ /**
103
+ * Every component the page can end up rendering, including ones only reached
104
+ * through another component: a re-render produces its children's markup too,
105
+ * and those need their definitions to upgrade.
106
+ */
107
+ const componentClosure = (seeds) => {
108
+ const out = new Set();
109
+ const queue = [...seeds];
110
+ while (queue.length) {
111
+ const tag = queue.pop();
112
+ if (out.has(tag) || !components.has(tag)) continue;
113
+ out.add(tag);
114
+ for (const nested of safely(() => usedComponents(read(components.get(tag)), components)) ?? []) {
115
+ queue.push(nested);
116
+ }
117
+ }
118
+
119
+ // A light element with no script has nothing to define. It is markup that was
120
+ // already rendered. A shadow one registers so it can re-render.
121
+ return [...out]
122
+ .filter((tag) => {
123
+ if (shadowTags.has(tag)) return true;
124
+ const blocks = safely(() => splitBlocks(read(components.get(tag))));
125
+ return Boolean(blocks?.client?.some((block) => block.code.trim()));
126
+ })
127
+ .sort();
128
+ };
129
+
130
+ const clientManifest = (route) => {
131
+ const files = [...chainFor(route).map((l) => l.file), route.file];
132
+ const seeds = new Set();
133
+ let hasScript = false;
134
+
135
+ const queue = [...files];
136
+ const visited = new Set();
137
+ while (queue.length) {
138
+ const file = queue.pop();
139
+ if (visited.has(file)) continue;
140
+ visited.add(file);
141
+
142
+ const source = read(file);
143
+ // A light element renders inline, so anything it uses is reached through
144
+ // it and still needs its own definition.
145
+ for (const tag of safely(() => usedComponents(source, components)) ?? []) {
146
+ seeds.add(tag);
147
+ if (!shadowTags.has(tag) && components.has(tag)) queue.push(components.get(tag));
148
+ }
149
+ // The block splitter already separates client <script> from server/props.
150
+ const blocks = safely(() => splitBlocks(source));
151
+ if (blocks?.client?.some((block) => block.code.trim())) hasScript = true;
152
+ }
153
+ const tags = componentClosure(seeds);
154
+ return {
155
+ tags,
156
+ hasScript,
157
+ // Asked by the dev server and by the build, which is the point: two copies
158
+ // of this rule is two servers that disagree about which pages ship JS.
159
+ //
160
+ // Elements to define or script to run, and otherwise nothing at all. The
161
+ // exception is fragments, where any page can be swapped into and needs the
162
+ // loader that defines whatever arrives.
163
+ needed: watching || tags.length > 0 || hasScript,
164
+ };
165
+ };
166
+
167
+ const report = (label, warnings) => {
168
+ for (const w of warnings ?? []) console.warn(`[transclude] ${label}: ${w}`);
169
+ };
170
+
171
+ return {
172
+ name: 'transclude',
173
+ enforce: 'pre',
174
+
175
+ // Read by the build script: it needs the route table and which routes ship
176
+ // client JS before it can decide the rollup inputs.
177
+ api: {
178
+ manifest() {
179
+ if (!root) throw new Error('[transclude] plugin not configured yet');
180
+ const scanned = scanRoutes(resolveRoutesDir(app, routesDir));
181
+ return {
182
+ routes: scanned.routes.map((route) => ({
183
+ id: route.id,
184
+ pattern: route.pattern,
185
+ rel: route.rel,
186
+ params: route.params,
187
+ client: clientManifest(route),
188
+ })),
189
+ // No client entry, no prerendering, no layouts. An endpoint is a route
190
+ // and nothing else.
191
+ endpoints: scanned.endpoints.map((route) => ({
192
+ id: route.id,
193
+ pattern: route.pattern,
194
+ rel: route.rel,
195
+ params: route.params,
196
+ })),
197
+ notFound: scanned.notFound
198
+ ? { id: scanned.notFound.id, rel: scanned.notFound.rel, params: [], client: clientManifest(scanned.notFound) }
199
+ : null,
200
+ error: scanned.error
201
+ ? { id: scanned.error.id, rel: scanned.error.rel, params: [], client: clientManifest(scanned.error) }
202
+ : null,
203
+ };
204
+ },
205
+ configure(config) {
206
+ root = config.root;
207
+ app = path.resolve(root, appDir);
208
+ runtime = '/' + path.relative(root, RUNTIME_FILE).split(path.sep).join('/');
209
+ scan();
210
+ },
211
+ },
212
+
213
+ configResolved(config) {
214
+ root = config.root;
215
+ app = path.resolve(root, appDir);
216
+ runtime = '/' + path.relative(root, RUNTIME_FILE).split(path.sep).join('/');
217
+ scan();
218
+ },
219
+
220
+ resolveId(id, importer) {
221
+ if (id === SERVER_ENTRY || id === ELEMENTS_ENTRY) return '\0' + id;
222
+ if (
223
+ id.startsWith(P_COMPONENT) ||
224
+ id.startsWith(P_PAGE) ||
225
+ id.startsWith(P_CLIENT) ||
226
+ id.startsWith(P_LAYOUT)
227
+ ) {
228
+ return '\0' + id;
229
+ }
230
+ // A virtual module has no directory, so Vite cannot resolve `../data/x.js`
231
+ // on its own. The block was authored in a real file; use that file's dir.
232
+ if (importer?.startsWith('\0virtual:transclude-') && /^\.\.?\//.test(id)) {
233
+ const source = origin.get(importer);
234
+ if (source) return path.resolve(path.dirname(source), id);
235
+ }
236
+ return null;
237
+ },
238
+
239
+ load(id) {
240
+ if (!id.startsWith('\0virtual:transclude-')) return null;
241
+ const virt = id.slice(1);
242
+
243
+ // Every element in the app, not only the ones some page renders: a
244
+ // fragment can name any of them, and which one it names is a runtime fact.
245
+ if (virt === ELEMENTS_ENTRY) return compileElementsEntry(components.keys()).code;
246
+
247
+ // One module that pulls in every page, so the SSR build is a single graph.
248
+ // The app's middleware comes through here too rather than being imported
249
+ // from source at runtime: the production server reads `dist` and nothing
250
+ // else, which is what makes its "your source is newer than this build"
251
+ // warning true.
252
+ if (virt === SERVER_ENTRY) {
253
+ const ids = [...pages.keys()];
254
+ const serverFile = path.resolve(app, SERVER_FILE);
255
+ const hasMiddleware = fs.existsSync(serverFile);
256
+ const specifier = '/' + path.relative(root, serverFile).split(path.sep).join('/');
257
+
258
+ // An endpoint is already a module. It needs no compiling, only pulling
259
+ // into the same graph, so production reads it from `dist` like everything
260
+ // else rather than importing app source at runtime.
261
+ const apiIds = [...endpoints.keys()];
262
+ const apiSpec = (route) =>
263
+ JSON.stringify('/' + path.relative(root, route.file).split(path.sep).join('/'));
264
+
265
+ return `
266
+ ${ids.map((pageId, i) => `import * as __P${i} from ${JSON.stringify(`${P_PAGE}${pageId}`)};`).join('\n')}
267
+ ${apiIds.map((apiId, i) => `import * as __E${i} from ${apiSpec(endpoints.get(apiId))};`).join('\n')}
268
+ ${hasMiddleware ? `import __middleware from ${JSON.stringify(specifier)};` : ''}
269
+
270
+ export const pages = {
271
+ ${ids.map((pageId, i) => ` ${JSON.stringify(pageId)}: __P${i},`).join('\n')}
272
+ };
273
+
274
+ export const endpoints = {
275
+ ${apiIds.map((apiId, i) => ` ${JSON.stringify(apiId)}: __E${i},`).join('\n')}
276
+ };
277
+
278
+ export const middleware = ${hasMiddleware ? '__middleware ?? null' : 'null'};
279
+ `;
280
+ }
281
+
282
+ if (virt.startsWith(P_COMPONENT)) {
283
+ const tag = virt.slice(P_COMPONENT.length);
284
+ const file = components.get(tag);
285
+ if (!file) throw new Error(`[transclude] no element <${tag}> in ${elementsDir}`);
286
+ origin.set(id, file);
287
+ // The donut: a light element's styles stop at any light element nested
288
+ // inside it.
289
+ const inner = [...(safely(() => usedComponents(read(file), components)) ?? [])];
290
+ const nested = inner.filter((child) => !shadowTags.has(child));
291
+ const out = compileComponent(read(file), {
292
+ tag,
293
+ shadow: shadowTags.has(tag),
294
+ components,
295
+ shadowTags,
296
+ runtime,
297
+ filename: tag,
298
+ nested,
299
+ });
300
+ report(tag, out.warnings);
301
+ return out.code;
302
+ }
303
+
304
+ if (virt.startsWith(P_LAYOUT)) {
305
+ const layoutId = virt.slice(P_LAYOUT.length);
306
+ const file = layouts.get(layoutId);
307
+ if (!file) throw new Error(`[transclude] no layout "${layoutId}"`);
308
+ origin.set(id, file);
309
+ const out = compileLayout(read(file), { id: layoutId, components, shadowTags, runtime });
310
+ report(`${layoutId} layout`, out.warnings);
311
+ return out.code;
312
+ }
313
+
314
+ if (virt.startsWith(P_PAGE)) {
315
+ const name = virt.slice(P_PAGE.length);
316
+ const route = pages.get(name);
317
+ if (!route) throw new Error(`[transclude] no page "${name}" in ${routesDir}`);
318
+ origin.set(id, route.file);
319
+ const out = compilePage(read(route.file), {
320
+ components,
321
+ shadowTags,
322
+ runtime,
323
+ filename: name,
324
+ // Absolute, not relative to the project. A bundler composing this map
325
+ // into its own resolves `sources` against the *output* directory, so a
326
+ // repo-relative path came out as `dist/server/app/routes/…` and the
327
+ // stack named whichever file that collided with.
328
+ sourcePath: route.file,
329
+ layouts: chainFor(route),
330
+ client: clientManifest(route),
331
+ });
332
+ report(name, out.warnings);
333
+ // The map goes back with it. Vite composes what a load hook returns; a
334
+ // comment on the code is not read, so a stack named the virtual module.
335
+ return out.map ? { code: out.code, map: out.map } : out.code;
336
+ }
337
+
338
+ const name = virt.slice(P_CLIENT.length);
339
+ const route = pages.get(name);
340
+ if (!route) throw new Error(`[transclude] no page "${name}" in ${routesDir}`);
341
+ origin.set(id, route.file);
342
+ const sources = [
343
+ ...chainFor(route).map((l) => ({ source: read(l.file), filename: `${l.id}/_layout.html` })),
344
+ { source: read(route.file), filename: route.rel },
345
+ ];
346
+ return compileClientEntry(sources, clientManifest(route), {
347
+ runtime,
348
+ elements: watching,
349
+ }).code;
350
+ },
351
+
352
+ configureServer(server) {
353
+ server.watcher.on('all', (_event, file) => {
354
+ if (!file.endsWith('.html')) return;
355
+ if (!file.startsWith(app)) return;
356
+
357
+ scan();
358
+ for (const mod of server.moduleGraph.idToModuleMap.values()) {
359
+ if (mod.id?.startsWith('\0virtual:transclude-')) server.moduleGraph.invalidateModule(mod);
360
+ }
361
+ const hot = server.hot ?? server.ws;
362
+ hot?.send({ type: 'full-reload' });
363
+ });
364
+ },
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Browser URL for a virtual module id.
370
+ *
371
+ * @param {string} page the route id
372
+ * @returns {string} the URL Vite serves its entry from
373
+ */
374
+ export function clientEntryUrl(page) {
375
+ return `/@id/__x00__${P_CLIENT}${page}`;
376
+ }
377
+
378
+ /**
379
+ * @param {string} page
380
+ * @returns {string} the virtual module id for that page
381
+ */
382
+ export function pageModuleId(page) {
383
+ return `${P_PAGE}${page}`;
384
+ }
385
+
386
+ /** `routes/_layout.html` -> "root", `routes/people/_layout.html` -> "people". */
387
+ function scanLayouts(dir, base = dir, out = new Map()) {
388
+ if (!fs.existsSync(dir)) return out;
389
+
390
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
391
+ const full = path.join(dir, entry.name);
392
+ if (entry.isDirectory()) {
393
+ if (!entry.name.startsWith('.')) scanLayouts(full, base, out);
394
+ } else if (entry.name === LAYOUT_FILE) {
395
+ const rel = path.relative(base, dir);
396
+ out.set(rel ? rel.split(path.sep).join('-') : 'root', full);
397
+ }
398
+ }
399
+ return out;
400
+ }
401
+
402
+ function readDir(dir) {
403
+ const map = new Map();
404
+ if (!fs.existsSync(dir)) return map;
405
+ for (const entry of fs.readdirSync(dir)) {
406
+ if (entry.endsWith('.html')) map.set(entry.slice(0, -5), path.join(dir, entry));
407
+ }
408
+ return map;
409
+ }
410
+
411
+ function read(file) {
412
+ return fs.readFileSync(file, 'utf8');
413
+ }
414
+
415
+ // A broken file should not take the whole type pass down with it. The module load
416
+ // for that file will report the real error.
417
+ function safely(fn) {
418
+ try {
419
+ return fn();
420
+ } catch {
421
+ return { kind: 'unknown' };
422
+ }
423
+ }
package/src/pool.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Runs `worker` over `items` with at most `limit` in flight, preserving order.
3
+ *
4
+ * Rendering itself is synchronous and CPU-bound, so this buys nothing on its
5
+ * own. Loaders wait on I/O, and a build of a hundred pages should not wait for a
6
+ * hundred round trips one at a time.
7
+ *
8
+ * @template T, R
9
+ * @param {T[]} items
10
+ * @param {number} limit at least 1, and never more than `items.length`
11
+ * @param {(item: T, index: number) => Promise<R>} worker
12
+ * @returns {Promise<R[]>} in the order `items` were given, not the order they finished
13
+ */
14
+ export async function pool(items, limit, worker) {
15
+ const results = new Array(items.length);
16
+ const width = Math.max(1, Math.min(limit, items.length));
17
+ let next = 0;
18
+
19
+ const runners = Array.from({ length: width }, async () => {
20
+ for (;;) {
21
+ const index = next++;
22
+ if (index >= items.length) return;
23
+ results[index] = await worker(items[index], index);
24
+ }
25
+ });
26
+
27
+ await Promise.all(runners);
28
+ return results;
29
+ }
@@ -0,0 +1,68 @@
1
+ // What the build produced, as a list something else can cache.
2
+ //
3
+ // The framework ships no service worker. This is the same agreement fragments
4
+ // have: the server states a fact and somebody else acts on it. Workbox reads
5
+ // this format directly, and thirty lines of hand-written service worker reads it
6
+ // just as easily.
7
+ //
8
+ // No `node:` imports. The build calls it with maps it already holds, and the
9
+ // servers only ever send the string back.
10
+
11
+ /**
12
+ * A URL and what tells a cache it changed.
13
+ *
14
+ * `revision: null` means the URL is the version: an asset's filename carries its
15
+ * own hash, so the bytes behind it never change and it can be held forever.
16
+ * Everything else keeps a stable URL and different bytes between builds, so it
17
+ * carries its ETag instead. Getting this backwards is how a service worker
18
+ * serves last week's page and cannot be talked out of it.
19
+ *
20
+ * @typedef {{ url: string, revision: string|null }} Entry
21
+ */
22
+
23
+ /**
24
+ * @param {object} sources
25
+ * @param {Iterable<[string, object]>} sources.pages prerendered documents
26
+ * @param {Iterable<[string, object]>} sources.assets hashed build output
27
+ * @param {Iterable<[string, object]>} [sources.files] the author's public files
28
+ * @returns {Entry[]} sorted by URL, so two builds of the same site agree
29
+ */
30
+ export function precacheList({ pages, assets, files = [] }) {
31
+ const entries = [];
32
+
33
+ // An asset's name holds its hash, so it needs no revision and can be cached
34
+ // until the name changes, which is what a new build does.
35
+ for (const [url] of assets) entries.push({ url, revision: null });
36
+
37
+ for (const source of [pages, files]) {
38
+ for (const [url, entry] of source) {
39
+ // Not `?? null`. A missing revision reads as "this URL is immutable", so a
40
+ // page whose ETag went missing would be cached until the visitor cleared
41
+ // it by hand. Refusing is the smaller failure.
42
+ if (!entry.etag) {
43
+ throw new Error(`[transclude] ${url} has no ETag, so nothing can say when it changed.`);
44
+ }
45
+ entries.push({ url, revision: entry.etag });
46
+ }
47
+ }
48
+
49
+ entries.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
50
+ return entries;
51
+ }
52
+
53
+ /**
54
+ * The document served at `/precache.json`.
55
+ *
56
+ * `version` changes when any entry does, so a service worker can name its cache
57
+ * after it and drop the old one on activate without comparing lists.
58
+ *
59
+ * @param {Entry[]} entries
60
+ * @param {string} version
61
+ * @returns {string} JSON, with a trailing newline
62
+ */
63
+ export function precacheDocument(entries, version) {
64
+ return `${JSON.stringify({ version, precache: entries }, null, 2)}\n`;
65
+ }
66
+
67
+ /** Where it is served, and where the build writes it under `static/`. */
68
+ export const PRECACHE_PATH = '/precache.json';