@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
package/src/app.js ADDED
@@ -0,0 +1,492 @@
1
+ // The production app, with nothing in it that names a runtime.
2
+ //
3
+ // No `node:` imports, and a test checks that across the whole import graph. The
4
+ // moment one appears, this stops working anywhere without a filesystem, and
5
+ // nothing else would notice.
6
+ //
7
+ // What genuinely differs between runtimes is injected: where bytes come from,
8
+ // how to hash them, whether the runtime can compress. Node reads a disk and uses
9
+ // zlib; a runtime with an asset binding reads that and lets the platform compress.
10
+ // Everything below is the same either way.
11
+
12
+ import { cacheKey, createCache, windowOf } from './cache.js';
13
+ import { feed, feedPath, feedType } from './feed.js';
14
+ import { documentStore, PROXY_PATH, proxyHandler } from './proxy.js';
15
+ import { includeContext } from './include.js';
16
+ import { sitemap } from './sitemap.js';
17
+ import { PRECACHE_PATH } from './precache.js';
18
+ import { absoluteFrom } from './document.js';
19
+ import {
20
+ ACTION_METHODS,
21
+ hasRegion,
22
+ methodsOf,
23
+ renderFragment,
24
+ renderRoute,
25
+ responseOf,
26
+ runAction,
27
+ withEnvelope,
28
+ } from './document.js';
29
+ import { pickEncoding } from './negotiate.js';
30
+ import { baseApp, endpointMethods, runEndpoint } from './server.js';
31
+ import { cookiesOf } from './cookies.js';
32
+
33
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
34
+ const REVALIDATE = 'public, max-age=0, must-revalidate';
35
+
36
+ // One per process rather than one per render. It holds no state between calls.
37
+ const encoder = new TextEncoder();
38
+
39
+ /** Below this, the framing costs more than it saves. A 91 byte file gzips to 120. */
40
+ export const COMPRESSIBLE_FLOOR = 512;
41
+
42
+ /**
43
+ * `statics`, `assets`, `notFound` and `errorPage` are bytes from wherever the
44
+ * runtime keeps them; `publicFiles` is a Hono handler or null; `compress` is null
45
+ * when the runtime cannot, in which case bodies go out identity-encoded and the
46
+ * platform in front is welcome to do it instead.
47
+ *
48
+ * `hash` returns a quoted ETag and is awaited, which is not fussiness: Node has a
49
+ * synchronous `createHash` and a runtime with only WebCrypto has an async
50
+ * `subtle.digest`. Awaiting costs nothing on the first and is the only way to
51
+ * accept the second.
52
+ *
53
+ * @param {{ config: object, manifest: object, pages: Record<string, object>,
54
+ * endpoints?: Record<string, object>, statics?: object, assets?: object,
55
+ * notFound?: object|null, errorPage?: object|null, hash: Function,
56
+ * compress?: Function|null, publicFiles?: Function|null,
57
+ * middleware?: Function|null, lookup?: Function|null,
58
+ * precache?: string|null }} options
59
+ * @returns {object} a Hono app, ready to serve
60
+ */
61
+ export function createApp({
62
+ config,
63
+ manifest,
64
+ pages,
65
+ endpoints = {},
66
+ middleware = null,
67
+ statics = { get: () => null },
68
+ assets = { get: () => null },
69
+ publicFiles = null,
70
+ notFound = null,
71
+ errorPage = null,
72
+ hash,
73
+ compress = null,
74
+ // What the build wrote to `dist/static/precache.json`, or null. It is a build
75
+ // artifact: only the build knows an asset's hashed name, and the worker's
76
+ // byte store answers `get` and cannot be enumerated.
77
+ precache = null,
78
+ // Resolving a hostname needs the runtime, and one of the four cannot do it at
79
+ // all, so the servers that can supply this and workerd leaves it out. Without
80
+ // it the proxy's allowlist is the whole defense.
81
+ lookup = null,
82
+ }) {
83
+ const cache = createCache(config.cache);
84
+
85
+ // One resolver for the app, so several pages including the same document read
86
+ // it once. Absent when no host is allowed, and then an external include throws
87
+ // with the reason rather than rendering a hole.
88
+ const include = includeContext({
89
+ config,
90
+ routes: manifest.routes ?? [],
91
+ pageFor: (id) => pages[id],
92
+ lookup,
93
+ });
94
+
95
+ const app = baseApp({
96
+ csrf: config.csrf,
97
+ csp: config.csp,
98
+ trailingSlash: config.trailingSlash,
99
+ publicFiles,
100
+ middleware,
101
+ });
102
+
103
+ // Hashed filenames, so these are safe to cache forever.
104
+ app.get('/assets/*', (c, next) => {
105
+ const asset = assets.get(c.req.path);
106
+ return asset ? send(c, asset, IMMUTABLE) : next();
107
+ });
108
+
109
+ /**
110
+ * What every loader, action and endpoint is handed. `request` is the platform's
111
+ * own `Request` rather than the server's wrapper. The framework should not be
112
+ * the reason an author has to learn a router's API to read a form.
113
+ */
114
+ const contextFor = (route, c, extra = {}) => {
115
+ const response = responseOf();
116
+ return {
117
+ url: c.req.url,
118
+ params: c.req.param(),
119
+ route: { id: route.id, pattern: route.pattern, path: c.req.path },
120
+ request: c.req.raw,
121
+ // The region this request asked for, or null for a whole document. An action
122
+ // needs it to decide whether a redirect is even an answer: post/redirect/get
123
+ // is right for a form, and wrong for a caller that asked for markup.
124
+ fragment: regionFor(route, c),
125
+ action: null,
126
+ response,
127
+ cookies: cookiesOf(c.req.raw, response, config.cookieSecret),
128
+ absolute: absoluteFrom(config.metadataBase, c.req.url),
129
+ revalidateTag: cache.revalidateTag,
130
+ ...extra,
131
+ };
132
+ };
133
+
134
+ const header = config.fragmentHeader ?? null;
135
+
136
+ /**
137
+ * The region this request asked for, or undefined for the whole document.
138
+ *
139
+ * The query parameter is the agreement. It is written out, it can be linked to,
140
+ * and it is strict: an unknown name is a 404, because someone typed it. A
141
+ * header is the opposite: clients send `HX-Target` on every request, including
142
+ * the boosted ones that want a whole document, so a name that is not a region
143
+ * is ignored rather than refused. Guessing wrong there would break more than
144
+ * it fixed.
145
+ */
146
+ function regionOf(route, c) {
147
+ const asked = config.fragmentParam ? c.req.query(config.fragmentParam) : undefined;
148
+ if (asked !== undefined) return asked;
149
+
150
+ if (!header) return undefined;
151
+ const named = c.req.header(header);
152
+ return named && hasRegion(pages[route.id], named) ? named : undefined;
153
+ }
154
+
155
+ /** Same thing, as `ctx.fragment`: null rather than undefined for a document. */
156
+ const regionFor = (route, c) => regionOf(route, c) ?? null;
157
+
158
+ /**
159
+ * A response that could have been a document or a region depending on a header
160
+ * has to say so, or a shared cache will hand one to a client that wanted the
161
+ * other.
162
+ */
163
+ const varyOn = header ? `Accept-Encoding, ${header}` : 'Accept-Encoding';
164
+
165
+ /**
166
+ * Fragments and actions come first, and for every route rather than only the
167
+ * dynamic ones: a page whose document was prerendered still has regions worth
168
+ * asking for and mutations worth accepting, and the prerendered handler below
169
+ * matches on path alone, so it would answer either one with a static document.
170
+ */
171
+ // Before the route table, like the public files, so a `[...path]` catch-all
172
+ // cannot answer for it.
173
+ if (config.sitemap) {
174
+ app.get('/sitemap.xml', async (c) => {
175
+ const page = c.req.query('p');
176
+ const xml = await sitemap(manifest, pages, config.sitemap, page ?? null);
177
+ return c.body(xml, 200, { 'Content-Type': 'application/xml; charset=utf-8' });
178
+ });
179
+ }
180
+
181
+ // A list of what the build produced, for whoever wants to cache it. The
182
+ // framework ships nothing that reads this: a service worker is the app's, the
183
+ // same way a swapper is.
184
+ if (precache) {
185
+ app.get(PRECACHE_PATH, (c) =>
186
+ c.body(precache, 200, {
187
+ 'Content-Type': 'application/json; charset=utf-8',
188
+ // It names the version of every file, so holding it is how a client
189
+ // misses the build that changed them.
190
+ 'Cache-Control': 'no-cache',
191
+ }),
192
+ );
193
+ }
194
+
195
+ if (config.feed) {
196
+ app.get(feedPath(config.feed), async (c) => {
197
+ const xml = await feed(config.feed);
198
+ return c.body(xml, 200, { 'Content-Type': feedType(config.feed) });
199
+ });
200
+ }
201
+
202
+ // Default deny is the config's doing: no `proxy` key, no route at all, so a
203
+ // site that never asked for one cannot be pointed at anything.
204
+ if (config.proxy) {
205
+ const handler = proxyHandler(config.proxy, {
206
+ lookup: config.proxy.lookup ?? lookup ?? null,
207
+ store: documentStore(config.proxy.cache),
208
+ });
209
+ app.get(PROXY_PATH, (c) => handler(c.req.raw));
210
+ }
211
+
212
+ for (const route of manifest.routes ?? []) {
213
+ app.get(route.pattern, async (c, next) => {
214
+ const region = regionOf(route, c);
215
+ if (region === undefined) return next();
216
+
217
+ try {
218
+ const ctx = contextFor(route, c);
219
+ const html = await renderFragment(pages[route.id], ctx, { region: region || null, include });
220
+
221
+ if (html instanceof Response) return withEnvelope(html, ctx);
222
+ if (html === null) return c.text(`no fragment "${region}"`, 404);
223
+ return sendRendered(c, html, ctx);
224
+ } catch (err) {
225
+ return internalError(c, err);
226
+ }
227
+ });
228
+
229
+ app.on(ACTION_METHODS, route.pattern, async (c) => {
230
+ const page = pages[route.id];
231
+ const region = regionOf(route, c);
232
+ try {
233
+ // Before the action, not after: a request nobody can answer should not
234
+ // have mutated anything on its way to saying so.
235
+ if (region !== undefined && !hasRegion(page, region)) {
236
+ return c.text(`no fragment "${region}"`, 404);
237
+ }
238
+
239
+ const acting = contextFor(route, c);
240
+ const outcome = await runAction(page, acting, c.req.method);
241
+ if (!outcome) {
242
+ return c.text(`${c.req.method} not allowed`, 405, { Allow: methodsOf(page).join(', ') });
243
+ }
244
+ if (outcome.response) return withEnvelope(outcome.response, acting);
245
+
246
+ // The render reuses the action's envelope and cookies, so a header it set
247
+ // on the way through is still there when the page comes back instead.
248
+ const ctx = contextFor(route, c, {
249
+ action: outcome.action,
250
+ response: acting.response,
251
+ cookies: acting.cookies,
252
+ });
253
+ const html =
254
+ region === undefined
255
+ ? await renderRoute(page, ctx, {
256
+ clientEntry: route.client,
257
+ stylesheet: manifest.stylesheet,
258
+ csp: config.csp,
259
+ lang: config.lang,
260
+ include,
261
+ })
262
+ : await renderFragment(page, ctx, { region: region || null, include });
263
+
264
+ if (html instanceof Response) return withEnvelope(html, ctx);
265
+ return sendRendered(c, html, ctx);
266
+ } catch (err) {
267
+ return internalError(c, err);
268
+ }
269
+ });
270
+ }
271
+
272
+ // Before the static handler, like fragments and actions: an endpoint's path has
273
+ // no file behind it, but `/api/notes` and a prerendered `/api/notes/index.html`
274
+ // would be indistinguishable to the matcher below.
275
+ for (const route of manifest.endpoints ?? []) {
276
+ app.all(route.pattern, async (c) => {
277
+ const mod = endpoints[route.id];
278
+ try {
279
+ // The same envelope every other path gets. An endpoint that sets a
280
+ // cookie and returns a redirect is an ordinary thing to write, and the
281
+ // `Set-Cookie` was dropped without it.
282
+ const ctx = contextFor(route, c);
283
+ const out = await runEndpoint(mod, ctx, c.req.method);
284
+ if (out) return withEnvelope(out, ctx);
285
+ return c.text(`${c.req.method} not allowed`, 405, {
286
+ Allow: endpointMethods(mod).join(', '),
287
+ });
288
+ } catch (err) {
289
+ return internalError(c, err);
290
+ }
291
+ });
292
+ }
293
+
294
+ // Prerendered pages. /about and /about/ are the same page.
295
+ app.get('*', (c, next) => {
296
+ const page = statics.get(c.req.path);
297
+ return page ? send(c, page, REVALIDATE) : next();
298
+ });
299
+
300
+ /**
301
+ * Every route, not only the ones the build could not enumerate.
302
+ *
303
+ * A prerendered URL never gets here. The static handler above answered it.
304
+ * What does get here is a URL the route matches but `paths` never listed:
305
+ * `/people/nobody`. Leaving those to the not-found handler is what made dev and
306
+ * production disagree. Dev matched the route and rendered the page's own "not
307
+ * found" body with a 200, while production answered the 404 page. Now both render the
308
+ * page, and the page's loader is what decides the status.
309
+ */
310
+ for (const route of manifest.routes ?? []) {
311
+ const window = windowOf(pages[route.id]);
312
+ const preload = preloadHeader(manifest.stylesheet, route.client);
313
+
314
+ app.get(route.pattern, async (c) => {
315
+ try {
316
+ // One render, called by the cache when it needs one and directly when
317
+ // the route has no window. `cacheable` is the same rule the build uses
318
+ // to decide a route can be a file: a 2xx with no header a file could not
319
+ // carry. A `Set-Cookie` in a shared cache is one visitor's session
320
+ // handed to the next.
321
+ let rendered = null;
322
+ const render = async () => {
323
+ const ctx = contextFor(route, c);
324
+ const html = await renderRoute(pages[route.id], ctx, {
325
+ clientEntry: route.client,
326
+ stylesheet: manifest.stylesheet,
327
+ csp: config.csp,
328
+ lang: config.lang,
329
+ include,
330
+ });
331
+
332
+ rendered = { ctx, html };
333
+ const ok = !(html instanceof Response) && ctx.response.status < 300;
334
+ // Three ways a page is not a shared answer: it answered with a
335
+ // Response, it is not 2xx, or it is personal. Personal means a header
336
+ // was written *or* a cookie was read. The second half matters: a page
337
+ // that only reads a cookie and renders a count from it sets no header
338
+ // at all, and holding it would hand one visitor's count to the next.
339
+ const shared = [...ctx.response.headers.keys()].length === 0 && !ctx.cookies.personal;
340
+ return { html, cacheable: ok && shared };
341
+ };
342
+
343
+ if (window) {
344
+ const html = await cache.read(cacheKey(c.req.url), window, render);
345
+
346
+ // A miss renders through the cache, and that render can answer with a
347
+ // `Response`. It was not stored, but it is still the answer.
348
+ if (html instanceof Response) return withEnvelope(html, rendered.ctx);
349
+
350
+ // A hit ran no loader, so there is no envelope to carry. A cached page
351
+ // has none by definition: one with a header was never stored.
352
+ return sendRendered(c, html, rendered?.ctx ?? contextFor(route, c), preload);
353
+ }
354
+
355
+ await render();
356
+ if (rendered.html instanceof Response) return withEnvelope(rendered.html, rendered.ctx);
357
+ return sendRendered(c, rendered.html, rendered.ctx, preload);
358
+ } catch (err) {
359
+ return internalError(c, err);
360
+ }
361
+ });
362
+ }
363
+
364
+ app.notFound((c) => (notFound ? send(c, notFound, REVALIDATE, 404) : c.text('not found', 404)));
365
+
366
+ /**
367
+ * Where a failed request is reported.
368
+ *
369
+ * `console.error` is the default and not much of one: a real site sends this
370
+ * to something that can page a person. `onError` is that seam, and it is given
371
+ * the request as well, because an error with no URL and no method is most of
372
+ * the way to useless.
373
+ *
374
+ * It is called inside a `try`. A reporter that throws would otherwise replace
375
+ * the error being reported, which is the one failure mode a reporting hook
376
+ * must not have.
377
+ */
378
+ function report(err, c) {
379
+ if (typeof config.onError !== 'function') {
380
+ console.error(err);
381
+ return;
382
+ }
383
+ try {
384
+ config.onError(err, { request: c.req.raw, url: c.req.url, method: c.req.method });
385
+ } catch (failed) {
386
+ console.error(err);
387
+ console.error('[transclude] onError itself threw:', failed);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * What a page is going to ask for, said in a header.
393
+ *
394
+ * This is not streaming. Render is `__o += …` to the last component, which is
395
+ * what lets an include resolve before it and a prerendered page stay a file,
396
+ * and making it async would tax every component call for something most pages
397
+ * here do not need. So the body still leaves in one piece.
398
+ *
399
+ * What it does buy: the stylesheet and the client entry come from the route
400
+ * table, not from a loader, so they are known before any loader runs. A proxy
401
+ * that reads this sends a 103 and the browser fetches them while the page is
402
+ * still being made. Cloudflare and Fastly do. A browser reading it directly
403
+ * gets less, since these headers arrive with the body anyway.
404
+ */
405
+ function preloadHeader(stylesheet, clientEntry) {
406
+ const parts = [];
407
+ if (stylesheet) parts.push(`<${stylesheet}>; rel=preload; as=style`);
408
+ if (clientEntry) parts.push(`<${clientEntry}>; rel=preload; as=script; crossorigin`);
409
+ return parts.length ? parts.join(', ') : null;
410
+ }
411
+
412
+ /** Every `catch` above. One place decides what a failed request looks like. */
413
+ function internalError(c, err) {
414
+ report(err, c);
415
+ // No ETag and no Cache-Control: nothing about a failure should be stored or
416
+ // revalidated, and the same bytes would be sent for an unrelated one next time.
417
+ if (!errorPage) return c.text('Internal error', 500);
418
+
419
+ c.header('Cache-Control', 'no-store');
420
+ c.header('Content-Type', errorPage.type);
421
+ return c.body(errorPage.body, 500);
422
+ }
423
+
424
+ /**
425
+ * Sends the best representation the client will accept. `Vary` is not optional
426
+ * here: without it a shared cache would serve one encoding to everyone.
427
+ */
428
+ function send(c, entry, cacheControl, status = 200) {
429
+ // The key list is built once per entry rather than per request. An entry is
430
+ // produced at load time and never changes, so the spread was a fresh array
431
+ // for every hit on the same file.
432
+ entry.encodingList ??= [...entry.encodings.keys()];
433
+ const encoding = pickEncoding(c.req.header('accept-encoding'), entry.encodingList);
434
+ const chosen = encoding ? entry.encodings.get(encoding) : null;
435
+
436
+ const body = chosen?.body ?? entry.body;
437
+ const etag = chosen?.etag ?? entry.etag;
438
+
439
+ c.header('Vary', 'Accept-Encoding');
440
+ c.header('Cache-Control', cacheControl);
441
+ c.header('ETag', etag);
442
+
443
+ if (c.req.header('if-none-match') === etag) return c.body(null, 304);
444
+
445
+ if (chosen) c.header('Content-Encoding', encoding);
446
+ c.header('Content-Type', entry.type);
447
+ c.header('Content-Length', String(body.length));
448
+ return c.body(body, status);
449
+ }
450
+
451
+ /**
452
+ * A response rendered for this request. There is no prebuilt variant to reach
453
+ * for, so the ETag is computed here and the body is compressed on the way out.
454
+ * The conditional check happens first, so a revalidating client pays for a hash
455
+ * and nothing else.
456
+ *
457
+ * `TextEncoder` rather than `Buffer`: the latter is Node's, and this file is not.
458
+ */
459
+ async function sendRendered(c, html, ctx = null, preload = null) {
460
+ // Before the body is even built: a proxy that understands this turns it into
461
+ // a 103, and the browser starts the stylesheet while the loader is still
462
+ // waiting. Set here rather than on `ctx.response`, because a header there is
463
+ // one of the things that makes a page too personal to cache.
464
+ if (preload) c.header('Link', preload);
465
+
466
+ const body = encoder.encode(html);
467
+ const base = await hash(body);
468
+
469
+ const available = compress && body.length >= COMPRESSIBLE_FLOOR ? ['br', 'gzip'] : [];
470
+ const encoding = pickEncoding(c.req.header('accept-encoding'), available);
471
+ const etag = encoding ? `${base.slice(0, -1)}-${encoding}"` : base;
472
+
473
+ c.header('Vary', varyOn);
474
+ c.header('Cache-Control', REVALIDATE);
475
+ c.header('ETag', etag);
476
+
477
+ // Whatever the loaders put on `ctx.response`, after the defaults above so a
478
+ // page can override its own Cache-Control, and before the conditional check
479
+ // so a 304 still carries them.
480
+ for (const [name, value] of ctx?.response?.headers ?? []) c.header(name, value);
481
+
482
+ if (c.req.header('if-none-match') === etag) return c.body(null, 304);
483
+
484
+ const out = encoding ? await compress(body, encoding) : body;
485
+ if (encoding) c.header('Content-Encoding', encoding);
486
+ c.header('Content-Type', 'text/html; charset=utf-8');
487
+ c.header('Content-Length', String(out.length));
488
+ return c.body(out, ctx?.response?.status ?? 200);
489
+ }
490
+
491
+ return app;
492
+ }
package/src/cache.js ADDED
@@ -0,0 +1,137 @@
1
+ // Revalidation: a rendered page held for a while, and refreshed without a build.
2
+ //
3
+ // Between the two things a route could be before: a file written once, or a
4
+ // render on every request. `export const revalidate = 3600` is the middle.
5
+ //
6
+ // Within the window a request is answered from the store and the loader does not
7
+ // run. Past it the *stale* page goes out immediately and a fresh one is rendered
8
+ // behind it, so nobody waits for a re-render. That is the whole point: the cost
9
+ // of being out of date is bounded, and no visitor pays it.
10
+
11
+ /**
12
+ * What a page means by `export const revalidate`, in milliseconds.
13
+ *
14
+ * @param {object|null|undefined} page a compiled page module
15
+ * @returns {number} 0 for a page that is rendered every time
16
+ */
17
+ export function windowOf(page) {
18
+ const value = page?.revalidate;
19
+ if (value === undefined || value === null || value === false) return null;
20
+
21
+ const seconds = typeof value === 'number' ? value : value.seconds;
22
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) {
23
+ throw new Error(
24
+ `[transclude] revalidate must be a number of seconds, or { seconds, tags }, not ` +
25
+ `${JSON.stringify(value)}`,
26
+ );
27
+ }
28
+
29
+ return { seconds, tags: (typeof value === 'object' && value.tags) || [] };
30
+ }
31
+
32
+ /**
33
+ * The default store: a bounded map, right for one server.
34
+ *
35
+ * Bounded because the key carries the query string, so a route reading `?q=`
36
+ * has as many entries as there are searches. Oldest out first, which is not
37
+ * least-recently-used and is enough: an entry that matters is rewritten by its
38
+ * own revalidation and moves back to the end.
39
+ *
40
+ * @param {{ max?: number }} [options]
41
+ * @returns {{ get: Function, set: Function, delete: Function, deleteByTag: Function }}
42
+ */
43
+ export function memoryStore({ max = 1000 } = {}) {
44
+ const entries = new Map();
45
+
46
+ return {
47
+ get: (key) => entries.get(key),
48
+
49
+ set(key, entry) {
50
+ entries.delete(key);
51
+ entries.set(key, entry);
52
+ while (entries.size > max) entries.delete(entries.keys().next().value);
53
+ },
54
+
55
+ delete: (key) => void entries.delete(key),
56
+
57
+ deleteByTag(tag) {
58
+ for (const [key, entry] of entries) {
59
+ if (entry.tags?.includes(tag)) entries.delete(key);
60
+ }
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * One route's cache, wrapped around the render.
67
+ *
68
+ * `render` is called with nothing and returns `{ html, cacheable }`. A page is
69
+ * not cacheable when it answered with a `Response`, when its status is not 2xx,
70
+ * or when a loader put a header on the response: a `Set-Cookie` held in a shared
71
+ * cache is somebody else's session handed to the next visitor. That is the same
72
+ * rule the build uses to decide a route can be a file.
73
+ *
74
+ * @param {object} [store] anything with the `memoryStore` shape
75
+ * @param {{ now?: () => number }} [deps] injected so a test can move time
76
+ * @returns {{ read: Function, revalidateTag: Function, revalidatePath: Function }}
77
+ */
78
+ export function createCache(store = memoryStore(), { now = () => Date.now() } = {}) {
79
+ // One render per key at a time. Without this the first request past the window
80
+ // and every request behind it each start their own.
81
+ const inFlight = new Map();
82
+
83
+ const refresh = async (key, window, render) => {
84
+ if (inFlight.has(key)) return inFlight.get(key);
85
+
86
+ const work = (async () => {
87
+ const result = await render();
88
+ if (result.cacheable) {
89
+ store.set(key, {
90
+ html: result.html,
91
+ tags: window.tags,
92
+ expires: now() + window.seconds * 1000,
93
+ });
94
+ } else {
95
+ // It stopped being cacheable. Holding the last good copy would serve a
96
+ // page the app has decided not to give out.
97
+ store.delete(key);
98
+ }
99
+ return result;
100
+ })().finally(() => inFlight.delete(key));
101
+
102
+ inFlight.set(key, work);
103
+ return work;
104
+ };
105
+
106
+ return {
107
+ /** `null` when the caller should just render, which is every uncached route. */
108
+ async read(key, window, render) {
109
+ if (!window) return null;
110
+
111
+ const hit = store.get(key);
112
+ if (!hit) return refresh(key, window, render).then((result) => result.html);
113
+
114
+ if (hit.expires > now()) return hit.html;
115
+
116
+ // Stale. Answer with it now and rebuild behind the response. A failed
117
+ // rebuild leaves the stale entry in place rather than emptying the cache
118
+ // because one render threw.
119
+ refresh(key, window, render).catch(() => {});
120
+ return hit.html;
121
+ },
122
+
123
+ revalidateTag: (tag) => store.deleteByTag(tag),
124
+ revalidatePath: (key) => store.delete(key),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Path plus query, because a page that reads `?q=` renders differently for each.
130
+ *
131
+ * @param {string} url an absolute URL
132
+ * @returns {string}
133
+ */
134
+ export function cacheKey(url) {
135
+ const { pathname, search } = new URL(url);
136
+ return pathname + search;
137
+ }