@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- 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
|
+
}
|