@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/server.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The Hono app both servers start from.
|
|
2
|
+
//
|
|
3
|
+
// One function rather than two `new Hono()` calls, because the order things are
|
|
4
|
+
// registered in *is* the behaviour: a guard registered after the static handler
|
|
5
|
+
// does not guard a prerendered page. Two copies of that order is two servers
|
|
6
|
+
// that disagree, which has happened twice in this codebase already.
|
|
7
|
+
|
|
8
|
+
import { Hono } from 'hono';
|
|
9
|
+
import { csrf } from 'hono/csrf';
|
|
10
|
+
import { headerPolicy } from './csp.js';
|
|
11
|
+
import { trimTrailingSlash } from 'hono/trailing-slash';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `strict: false` so /about and /about/ are the same page.
|
|
15
|
+
*
|
|
16
|
+
* CSRF is on by default and the only middleware that is. This framework's whole
|
|
17
|
+
* form story is `<form method="post">`, and a cross-origin page can post one to
|
|
18
|
+
* you without any of the checks a `fetch` would have to pass. Hono's guard is
|
|
19
|
+
* scoped to exactly that hole: non-GET requests carrying one of the three
|
|
20
|
+
* content types a form element can send. JSON already needs a preflight, so it
|
|
21
|
+
* is not the way in.
|
|
22
|
+
*
|
|
23
|
+
* `middleware` is the app's own `server.js`, and it runs after, so it can add
|
|
24
|
+
* anything and cannot register itself ahead of the guard by mistake.
|
|
25
|
+
*/
|
|
26
|
+
const OPTIONS = new Set(['csrf', 'csp', 'trailingSlash', 'publicFiles', 'middleware']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ csrf?: object|boolean, csp?: object|boolean, trailingSlash?: string,
|
|
30
|
+
* publicFiles?: Function|null, middleware?: Function|null }} [options]
|
|
31
|
+
* @returns {object} a Hono app
|
|
32
|
+
* @throws on a key it does not know
|
|
33
|
+
*/
|
|
34
|
+
export function baseApp(options = {}) {
|
|
35
|
+
const {
|
|
36
|
+
csrf: csrfOption = true,
|
|
37
|
+
csp: cspOption = false,
|
|
38
|
+
trailingSlash = 'never',
|
|
39
|
+
publicFiles = null,
|
|
40
|
+
middleware = null,
|
|
41
|
+
} = options;
|
|
42
|
+
|
|
43
|
+
// A key this does not know is a caller that thinks it configured something.
|
|
44
|
+
// `publicRoot` instead of `publicFiles` was exactly that: dev served no public
|
|
45
|
+
// files at all, production served them, and nothing said why.
|
|
46
|
+
const unknown = Object.keys(options).filter((key) => !OPTIONS.has(key));
|
|
47
|
+
if (unknown.length) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[transclude] baseApp does not know ${unknown.join(', ')}. ` +
|
|
50
|
+
`It takes ${[...OPTIONS].join(', ')}.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (trailingSlash !== 'never' && trailingSlash !== 'ignore') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`[transclude] trailingSlash must be 'never' or 'ignore', not ${JSON.stringify(trailingSlash)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One decision with two halves, which is why it is a single config key rather
|
|
62
|
+
* than a router option plus a middleware the author has to remember to add.
|
|
63
|
+
*
|
|
64
|
+
* `strict: false` does not merely match /about/ as well as /about: it strips
|
|
65
|
+
* the slash from `c.req.path` before any middleware runs. Measured: with it on,
|
|
66
|
+
* `trimTrailingSlash` never fires, because the thing it looks for is gone by
|
|
67
|
+
* the time it looks. The two exclude each other.
|
|
68
|
+
*
|
|
69
|
+
* So 'never' means strict routing plus a 301 to the one URL, and every
|
|
70
|
+
* URL this framework generates is already that form: `routes/about.html` is
|
|
71
|
+
* `/about`. 'ignore' is the loose router, which answers both with 200. Two URLs
|
|
72
|
+
* for one page, and nothing emits <link rel="canonical">.
|
|
73
|
+
*
|
|
74
|
+
* `alwaysRedirect` matters because Hono's default only redirects a request that
|
|
75
|
+
* already 404'd, and a catch-all route answers before it can. `/docs/intro/`
|
|
76
|
+
* would match `/docs/:path{.+}` as `intro/` and return 200, which is the exact
|
|
77
|
+
* duplicate this setting exists to remove. Redirecting ahead of the router also
|
|
78
|
+
* spares a doomed request the route table and every middleware after this one.
|
|
79
|
+
*/
|
|
80
|
+
const app = new Hono({ strict: trailingSlash === 'never' });
|
|
81
|
+
if (trailingSlash === 'never') app.use('*', trimTrailingSlash({ alwaysRedirect: true }));
|
|
82
|
+
|
|
83
|
+
if (csrfOption) app.use('*', csrf(csrfOption === true ? undefined : csrfOption));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The one security header with no judgement in it.
|
|
87
|
+
*
|
|
88
|
+
* Without it a browser may sniff a response's bytes and decide the declared
|
|
89
|
+
* type was wrong, so a text file an app serves can be read as HTML and a
|
|
90
|
+
* `.json` as script. Nothing legitimate depends on sniffing, which is what
|
|
91
|
+
* separates this from `X-Frame-Options` or HSTS: both of those refuse
|
|
92
|
+
* something an app may actually want, so they stay the author's to set.
|
|
93
|
+
*
|
|
94
|
+
* Before everything, so a public file and a prerendered page get it too.
|
|
95
|
+
*/
|
|
96
|
+
app.use('*', async (c, next) => {
|
|
97
|
+
await next();
|
|
98
|
+
c.header('X-Content-Type-Options', 'nosniff');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The half of the policy a `<meta>` cannot carry. It names no hash, so it is
|
|
102
|
+
// the same string for every page and costs a prerendered one nothing. Before
|
|
103
|
+
// anything that answers, so a static file gets it too.
|
|
104
|
+
const header = headerPolicy(cspOption);
|
|
105
|
+
if (header) {
|
|
106
|
+
app.use('*', async (c, next) => {
|
|
107
|
+
await next();
|
|
108
|
+
c.header(header.name, header.value);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (typeof middleware === 'function') middleware(app);
|
|
112
|
+
|
|
113
|
+
// After the app's middleware, so a guard can cover these too, and before the
|
|
114
|
+
// route table, so a real file always beats a `[...path]` catch-all.
|
|
115
|
+
//
|
|
116
|
+
// A handler rather than a directory: what can serve a file is the one thing that
|
|
117
|
+
// genuinely differs between runtimes. Node hands in Hono's `serveStatic`, which
|
|
118
|
+
// does byte ranges off a disk; a runtime with no disk hands in something that
|
|
119
|
+
// reads an asset binding. This file stays free of either.
|
|
120
|
+
if (publicFiles) app.use('*', publicFiles);
|
|
121
|
+
|
|
122
|
+
return app;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Where an app puts its middleware. Relative to `appDir`. */
|
|
126
|
+
export const SERVER_FILE = 'server.js';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An endpoint is a `.js` file in the routes tree: a route with no template, no
|
|
130
|
+
* layout and no regions, which answers with a `Response` of its own.
|
|
131
|
+
*
|
|
132
|
+
* // app/routes/api/notes.js
|
|
133
|
+
* export const GET = () => Response.json(notes);
|
|
134
|
+
* export const DELETE = ({ params }) => { … };
|
|
135
|
+
*
|
|
136
|
+
* Handlers are named for the method, spelled the way HTTP spells it. Uppercase
|
|
137
|
+
* is not decoration: `export const delete` is a syntax error and `DELETE` is not,
|
|
138
|
+
* not. A page's handlers are spelled the same way.
|
|
139
|
+
*
|
|
140
|
+
* Returning a `Response` is required rather than encouraged. There is no
|
|
141
|
+
* template to fall back to, and a handler that returns a bare object has almost
|
|
142
|
+
* certainly forgotten `Response.json`.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} mod the endpoint module
|
|
145
|
+
* @param {object} ctx
|
|
146
|
+
* @param {string} method
|
|
147
|
+
* @returns {Promise<Response|null>} null when the module answers no such verb
|
|
148
|
+
*/
|
|
149
|
+
export async function runEndpoint(mod, ctx, method) {
|
|
150
|
+
const name = method.toUpperCase();
|
|
151
|
+
// Membership, not shape. `mod.HELPERS` may well be a function, and a request
|
|
152
|
+
// naming it would otherwise reach it.
|
|
153
|
+
if (!ENDPOINT_METHODS.includes(name)) return null;
|
|
154
|
+
|
|
155
|
+
const handler = mod?.[name];
|
|
156
|
+
if (typeof handler !== 'function') return null;
|
|
157
|
+
|
|
158
|
+
const out = await handler(ctx);
|
|
159
|
+
if (out instanceof Response) return out;
|
|
160
|
+
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${name} answered with ${out === undefined ? 'nothing' : typeof out}, ` +
|
|
163
|
+
`not a Response. An endpoint has no template to render instead`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The methods an endpoint can answer. `app.all` routes every one of them to it,
|
|
169
|
+
* so this is the list that decides which exports are handlers. The shim reads
|
|
170
|
+
* the same list, or a name would be dispatched and not checked, or checked and
|
|
171
|
+
* never dispatched.
|
|
172
|
+
*/
|
|
173
|
+
export const ENDPOINT_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What an endpoint answers, for an `Allow` header.
|
|
177
|
+
*
|
|
178
|
+
* @param {object|null|undefined} mod
|
|
179
|
+
* @returns {string[]} sorted, for an Allow header
|
|
180
|
+
*/
|
|
181
|
+
export function endpointMethods(mod) {
|
|
182
|
+
return ENDPOINT_METHODS.filter((name) => typeof mod?.[name] === 'function').sort();
|
|
183
|
+
}
|
package/src/sitemap.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// GET /sitemap.xml, from the route table the framework already has.
|
|
2
|
+
//
|
|
3
|
+
// A page route with no parameters is one URL. A parameter route is as many as
|
|
4
|
+
// its `paths` export names, which is the same list the build prerenders, so a
|
|
5
|
+
// route that ships as files is listed without the author repeating it. Anything
|
|
6
|
+
// else (a route with no `paths`, an endpoint, an error page) is not a page a
|
|
7
|
+
// crawler can reach by guessing, so it is left out.
|
|
8
|
+
|
|
9
|
+
/** The protocol's cap for one file. Past it the response is an index of files. */
|
|
10
|
+
const LIMIT = 50000;
|
|
11
|
+
|
|
12
|
+
const escape = (text) =>
|
|
13
|
+
String(text)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
|
|
20
|
+
/** `2026-07-31`, which is what a sitemap wants and what a Date will not give. */
|
|
21
|
+
function day(value) {
|
|
22
|
+
if (!value) return null;
|
|
23
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
24
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const excluded = (path, rules) =>
|
|
28
|
+
rules.some((rule) => (rule instanceof RegExp ? rule.test(path) : rule === path));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every URL the sitemap lists, in route order.
|
|
32
|
+
*
|
|
33
|
+
* `paths()` is the page's own, the one the build calls, so the two cannot
|
|
34
|
+
* disagree about which URLs exist.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} manifest
|
|
37
|
+
* @param {Record<string, object>} pages
|
|
38
|
+
* @param {{ entries?: object[] | (() => object[] | Promise<object[]>),
|
|
39
|
+
* exclude?: string[] }} [config] `entries` may be a function, so an app can
|
|
40
|
+
* build them from its own data rather than list them
|
|
41
|
+
* @returns {Promise<Array<{ path: string, lastmod?: string }>>}
|
|
42
|
+
*/
|
|
43
|
+
export async function sitemapEntries(manifest, pages, { entries = [], exclude = [] } = {}) {
|
|
44
|
+
const found = [];
|
|
45
|
+
|
|
46
|
+
for (const route of manifest.routes ?? []) {
|
|
47
|
+
const page = pages[route.id];
|
|
48
|
+
|
|
49
|
+
if (!route.params.length) {
|
|
50
|
+
found.push({ path: route.pattern });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A parameter route with no `paths` is server-rendered for URLs nobody has
|
|
55
|
+
// listed. Advertising the pattern would advertise `/people/:name`.
|
|
56
|
+
if (typeof page?.paths !== 'function') continue;
|
|
57
|
+
|
|
58
|
+
for (const params of (await page.paths()) ?? []) {
|
|
59
|
+
found.push({
|
|
60
|
+
path: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const extra = typeof entries === 'function' ? ((await entries()) ?? []) : entries;
|
|
66
|
+
const all = [...found, ...extra];
|
|
67
|
+
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
return all.filter((entry) => {
|
|
70
|
+
if (seen.has(entry.path) || excluded(entry.path, exclude)) return false;
|
|
71
|
+
seen.add(entry.path);
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function urlset(entries, hostname) {
|
|
77
|
+
const body = entries
|
|
78
|
+
.map(({ path, lastmod, changefreq, priority }) => {
|
|
79
|
+
const parts = [`<loc>${escape(new URL(path, hostname).href)}</loc>`];
|
|
80
|
+
const when = day(lastmod);
|
|
81
|
+
if (when) parts.push(`<lastmod>${when}</lastmod>`);
|
|
82
|
+
if (changefreq) parts.push(`<changefreq>${escape(changefreq)}</changefreq>`);
|
|
83
|
+
if (priority !== undefined) parts.push(`<priority>${escape(priority)}</priority>`);
|
|
84
|
+
return `<url>${parts.join('')}</url>`;
|
|
85
|
+
})
|
|
86
|
+
.join('\n');
|
|
87
|
+
|
|
88
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function index(count, hostname, limit) {
|
|
92
|
+
const pages = Math.ceil(count / limit);
|
|
93
|
+
const body = Array.from({ length: pages }, (_, i) => {
|
|
94
|
+
const href = new URL(`/sitemap.xml?p=${i}`, hostname).href;
|
|
95
|
+
return `<sitemap><loc>${escape(href)}</loc></sitemap>`;
|
|
96
|
+
}).join('\n');
|
|
97
|
+
|
|
98
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The document for one request.
|
|
103
|
+
*
|
|
104
|
+
* Past the cap the bare path answers with an index and `?p=` answers with a
|
|
105
|
+
* slice, because a file over 50000 URLs is not a sitemap a crawler will read.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} manifest
|
|
108
|
+
* @param {Record<string, object>} pages
|
|
109
|
+
* @param {object} config the `sitemap` block, which has to name a hostname
|
|
110
|
+
* @param {number|null} [page] which sheet, when there are more URLs than one holds
|
|
111
|
+
* @returns {Promise<string>} an XML document
|
|
112
|
+
*/
|
|
113
|
+
export async function sitemap(manifest, pages, config, page = null) {
|
|
114
|
+
const { hostname, limit = LIMIT } = config;
|
|
115
|
+
if (!hostname) throw new Error('[transclude] sitemap needs a hostname');
|
|
116
|
+
|
|
117
|
+
const entries = await sitemapEntries(manifest, pages, config);
|
|
118
|
+
|
|
119
|
+
if (entries.length <= limit) return urlset(entries, hostname);
|
|
120
|
+
if (page === null) return index(entries.length, hostname, limit);
|
|
121
|
+
|
|
122
|
+
const from = Number(page) * limit;
|
|
123
|
+
return urlset(entries.slice(from, from + limit), hostname);
|
|
124
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Built output, held in memory with an ETag per representation.
|
|
2
|
+
//
|
|
3
|
+
// The files never change for the life of the process, because they were produced
|
|
4
|
+
// at build time. The only reason to touch the disk again is if there are more of
|
|
5
|
+
// them than we are willing to hold.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
const TYPES = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.map': 'application/json; charset=utf-8',
|
|
21
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
22
|
+
'.ico': 'image/x-icon',
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.jpg': 'image/jpeg',
|
|
25
|
+
'.webp': 'image/webp',
|
|
26
|
+
'.woff2': 'font/woff2',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} Entry
|
|
31
|
+
* @property {Buffer} body
|
|
32
|
+
* @property {string} etag
|
|
33
|
+
* @property {Map<string, { body: Buffer, etag: string }>} encodings one per content encoding
|
|
34
|
+
* @property {string} type the Content-Type to send
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} Store
|
|
39
|
+
* @property {number} count
|
|
40
|
+
* @property {number} bytes held in memory
|
|
41
|
+
* @property {number} onDisk left on disk because the budget ran out
|
|
42
|
+
* @property {number} encoded how many have a precompressed variant
|
|
43
|
+
* @property {Map<string, Entry|{ file: string }>} entries for the build, which
|
|
44
|
+
* serialises these for a runtime with no filesystem
|
|
45
|
+
* @property {(pathname: string) => Entry|null} get
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prerendered pages, keyed by the URL they stand for.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} dir the directory of built pages
|
|
52
|
+
* @param {{ maxBytes?: number }} [options] how much to hold in memory
|
|
53
|
+
* @returns {Store}
|
|
54
|
+
*/
|
|
55
|
+
export function loadStatic(dir, options = {}) {
|
|
56
|
+
return load(dir, pageUrl, options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build assets, keyed by their path under the output directory.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} dir
|
|
63
|
+
* @param {{ maxBytes?: number }} [options]
|
|
64
|
+
* @returns {Store}
|
|
65
|
+
*/
|
|
66
|
+
export function loadAssets(dir, options = {}) {
|
|
67
|
+
return load(dir, (relative) => `/${relative.split(path.sep).join('/')}`, options);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function load(dir, urlFor, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
71
|
+
const entries = new Map();
|
|
72
|
+
let bytes = 0;
|
|
73
|
+
let onDisk = 0;
|
|
74
|
+
|
|
75
|
+
for (const file of walk(dir)) {
|
|
76
|
+
// .br and .gz are variants of a sibling, not resources of their own.
|
|
77
|
+
if (file.endsWith('.br') || file.endsWith('.gz')) continue;
|
|
78
|
+
|
|
79
|
+
const url = urlFor(path.relative(dir, file));
|
|
80
|
+
if (url === null) continue;
|
|
81
|
+
|
|
82
|
+
const size = fs.statSync(file).size + variantSize(file);
|
|
83
|
+
if (bytes + size > maxBytes) {
|
|
84
|
+
entries.set(url, { file });
|
|
85
|
+
onDisk++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
bytes += size;
|
|
90
|
+
entries.set(url, read(file));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
count: entries.size,
|
|
95
|
+
bytes,
|
|
96
|
+
onDisk,
|
|
97
|
+
/** For the build, which serialises these for runtimes with no filesystem. */
|
|
98
|
+
entries,
|
|
99
|
+
encoded: [...entries.values()].filter((e) => e.encodings?.size).length,
|
|
100
|
+
|
|
101
|
+
/** Entry for a request path, or null. Trailing slashes are the same resource. */
|
|
102
|
+
get(pathname) {
|
|
103
|
+
const clean = pathname.replace(/\/+$/, '') || '/';
|
|
104
|
+
const hit = entries.get(clean);
|
|
105
|
+
if (!hit) return null;
|
|
106
|
+
return hit.body !== undefined ? hit : read(hit.file);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function read(file) {
|
|
112
|
+
const body = fs.readFileSync(file);
|
|
113
|
+
const etag = etagOf(body);
|
|
114
|
+
const encodings = new Map();
|
|
115
|
+
|
|
116
|
+
// Each encoding is a different set of bytes, so it needs its own ETag. Otherwise
|
|
117
|
+
// a shared cache can hand a brotli body to a client that asked for gzip, and
|
|
118
|
+
// nothing shows the mismatch until it fails to decode.
|
|
119
|
+
for (const [encoding, suffix] of [['br', '.br'], ['gzip', '.gz']]) {
|
|
120
|
+
const variant = `${file}${suffix}`;
|
|
121
|
+
if (!fs.existsSync(variant)) continue;
|
|
122
|
+
encodings.set(encoding, {
|
|
123
|
+
body: fs.readFileSync(variant),
|
|
124
|
+
etag: `${etag.slice(0, -1)}-${encoding}"`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { body, etag, encodings, type: TYPES[path.extname(file)] ?? 'application/octet-stream' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A cache key for a body, not a signature.
|
|
133
|
+
*
|
|
134
|
+
* Truncated on purpose: this says whether two responses are the same bytes, and
|
|
135
|
+
* nothing trusts it for anything else. Anything that needs a digest a browser
|
|
136
|
+
* will agree with uses `crypto.subtle`.
|
|
137
|
+
*
|
|
138
|
+
* @param {Buffer|Uint8Array|string} body
|
|
139
|
+
* @returns {string} a quoted ETag
|
|
140
|
+
*/
|
|
141
|
+
export function etagOf(body) {
|
|
142
|
+
return `"${createHash('sha1').update(body).digest('base64url').slice(0, 20)}"`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function variantSize(file) {
|
|
146
|
+
let total = 0;
|
|
147
|
+
for (const suffix of ['.br', '.gz']) {
|
|
148
|
+
const variant = `${file}${suffix}`;
|
|
149
|
+
if (fs.existsSync(variant)) total += fs.statSync(variant).size;
|
|
150
|
+
}
|
|
151
|
+
return total;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `index.html` -> `/`, `people/ada/index.html` -> `/people/ada`, `404.html` -> null. */
|
|
155
|
+
function pageUrl(relative) {
|
|
156
|
+
const posix = relative.split(path.sep).join('/');
|
|
157
|
+
if (posix === 'index.html') return '/';
|
|
158
|
+
if (posix.endsWith('/index.html')) return `/${posix.slice(0, -'/index.html'.length)}`;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function walk(dir, out = []) {
|
|
163
|
+
if (!fs.existsSync(dir)) return out;
|
|
164
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
165
|
+
const full = path.join(dir, entry.name);
|
|
166
|
+
if (entry.isDirectory()) walk(full, out);
|
|
167
|
+
else out.push(full);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|