@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,159 @@
1
+ // The Node wiring for the production app.
2
+ //
3
+ // `app.js` is the app, and it names no runtime. This file is the four things that
4
+ // do: bytes off a disk, a hash from `node:crypto`, compression from `node:zlib`,
5
+ // and Hono's Node `serveStatic` for the public directory. That last one is kept
6
+ // separate because it does byte ranges, which an in-memory map cannot.
7
+ //
8
+ // A runtime with no filesystem writes its own version of this file. It is about
9
+ // thirty lines, and `app.js` does not change.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { publicFiles } from './public-files.js';
15
+ import { createApp } from './app.js';
16
+ import { etagOf, loadAssets, loadStatic } from './static-cache.js';
17
+ import { compressResponse } from './compress.js';
18
+ import { loadProject, portOf } from './project.js';
19
+ import { nodeLookup } from './lookup.js';
20
+
21
+ const { root, config, configFile } = await loadProject();
22
+ const dist = path.join(root, config.outDir);
23
+
24
+ export const port = portOf(config, process.env.PORT);
25
+
26
+ export const noBuild = !fs.existsSync(path.join(dist, 'routes.json'));
27
+
28
+ /**
29
+ * This server reads `dist`, never the source. An edit made since the last build
30
+ * is not visible here, which looks the same as the edit not working, so say so
31
+ * rather than leave it to be found.
32
+ */
33
+ function newestSource() {
34
+ // The app, and the framework wherever it is installed. `fileURLToPath`, not
35
+ // `url.pathname`: a space in the path stays percent-encoded in the latter, and
36
+ // `Atelier%20Dakroub` is not a directory.
37
+ const here = path.dirname(fileURLToPath(import.meta.url));
38
+ const roots = [path.join(root, config.appDir), here];
39
+ let newest = { time: 0, file: null };
40
+
41
+ const walk = (dir) => {
42
+ if (!fs.existsSync(dir)) return;
43
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ const full = path.join(dir, entry.name);
45
+ if (entry.isDirectory()) walk(full);
46
+ else {
47
+ const { mtimeMs } = fs.statSync(full);
48
+ if (mtimeMs > newest.time) newest = { time: mtimeMs, file: full };
49
+ }
50
+ }
51
+ };
52
+
53
+ for (const dir of roots) walk(dir);
54
+ if (fs.existsSync(configFile)) {
55
+ const { mtimeMs } = fs.statSync(configFile);
56
+ if (mtimeMs > newest.time) newest = { time: mtimeMs, file: configFile };
57
+ }
58
+ return newest;
59
+ }
60
+
61
+ const builtAt = noBuild ? 0 : fs.statSync(path.join(dist, 'routes.json')).mtimeMs;
62
+ const newest = newestSource();
63
+ const stale = !noBuild && newest.time > builtAt;
64
+
65
+ const manifest = noBuild
66
+ ? { routes: [], dynamic: [], endpoints: [] }
67
+ : JSON.parse(fs.readFileSync(path.join(dist, 'routes.json'), 'utf8'));
68
+
69
+ const bundle = noBuild
70
+ ? { pages: {}, endpoints: {}, middleware: null }
71
+ : await import(pathToFileURL(path.join(dist, 'server/entry.js')).href);
72
+
73
+ const assets = loadAssets(path.join(dist, 'client'));
74
+ const statics = loadStatic(path.join(dist, 'static'));
75
+
76
+ /** A build artifact under `static/`, as text. Absent until a build writes it. */
77
+ const readText = (name) => {
78
+ const file = path.join(dist, 'static', name);
79
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
80
+ };
81
+
82
+ /** A page the framework reaches for rather than routes to. */
83
+ const readPage = (name) => {
84
+ const file = path.join(dist, 'static', name);
85
+ return fs.existsSync(file) ? readEntry(file) : null;
86
+ };
87
+
88
+ const publicRoot = path.join(dist, 'public');
89
+
90
+ export const app = createApp({
91
+ config,
92
+ lookup: config.proxy ? nodeLookup() : null,
93
+ manifest,
94
+ pages: bundle.pages,
95
+ endpoints: bundle.endpoints ?? {},
96
+ middleware: bundle.middleware ?? null,
97
+ statics,
98
+ assets,
99
+ // `serveStatic` joins `root` onto the request path, so it resolves against the
100
+ // working directory. This file works from its own location so it does not depend
101
+ // on where the process was started.
102
+ publicFiles: fs.existsSync(publicRoot) ? publicFiles(relativeToCwd(publicRoot)) : null,
103
+ notFound: readPage('404.html'),
104
+ errorPage: readPage('500.html'),
105
+ hash: etagOf,
106
+ compress: compressResponse,
107
+ precache: readText('precache.json'),
108
+ });
109
+
110
+ /**
111
+ * What a freshly started server prints. Kept here rather than in an adapter so
112
+ * three of them do not each grow their own copy of it.
113
+ *
114
+ * @param {number} port
115
+ * @returns {void}
116
+ */
117
+ export function summary(port) {
118
+ const kb = (n) => `${Math.round(n / 1024)} KB`;
119
+
120
+ console.log(`http://localhost:${port}`);
121
+ console.log(
122
+ ` prerendered ${statics.count} pages, ${kb(statics.bytes)}` +
123
+ (statics.onDisk ? ` (${statics.onDisk} over budget, read per request)` : ''),
124
+ );
125
+ console.log(` assets ${assets.count} files, ${kb(assets.bytes)}`);
126
+ console.log(
127
+ ` precompressed ${statics.encoded + assets.encoded}/${statics.count + assets.count} resources`,
128
+ );
129
+ console.log(` on demand ${manifest.dynamic.map((r) => r.pattern).join(', ') || 'none'}`);
130
+
131
+ if (stale) {
132
+ const ago = Math.round((newest.time - builtAt) / 1000);
133
+ console.log('');
134
+ const where = path.relative(root, newest.file);
135
+ console.log(` ⚠ ${where} changed ${ago}s after the last build.`);
136
+ console.log(' This server reads dist/, so that edit is not being served.');
137
+ console.log(' Run `npm run build` (or `npm run preview` to do both).');
138
+ }
139
+ }
140
+
141
+ function relativeToCwd(absolute) {
142
+ const relative = path.relative(process.cwd(), absolute);
143
+ return relative === '' ? '.' : relative;
144
+ }
145
+
146
+ function readEntry(file) {
147
+ const body = fs.readFileSync(file);
148
+ const encodings = new Map();
149
+ const etag = etagOf(body);
150
+
151
+ for (const [encoding, suffix] of [['br', '.br'], ['gzip', '.gz']]) {
152
+ if (!fs.existsSync(`${file}${suffix}`)) continue;
153
+ encodings.set(encoding, {
154
+ body: fs.readFileSync(`${file}${suffix}`),
155
+ etag: `${etag.slice(0, -1)}-${encoding}"`,
156
+ });
157
+ }
158
+ return { body, etag, encodings, type: 'text/html; charset=utf-8' };
159
+ }
package/src/project.js ADDED
@@ -0,0 +1,110 @@
1
+ // Where the app is, and what it configured.
2
+ //
3
+ // The framework used to reach two directories up from its own file for both, and
4
+ // import `transclude.config.js` by relative path. That is only true while it sits
5
+ // inside the app it serves. Installed as a package it sits in node_modules, where
6
+ // two directories up is somebody else's package.
7
+ //
8
+ // So the root comes from where the command was run, and the config is loaded from
9
+ // there at run time. Nothing under `framework/` names a path in the app again.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ export const CONFIG_FILE = 'transclude.config.js';
16
+
17
+ /**
18
+ * The port an app listens on when it does not name one.
19
+ *
20
+ * 3000 and 5173 are the two most crowded ports on a developer's machine, and a
21
+ * server someone else started answering yours is a bad half hour. 1960 is the
22
+ * year Project Xanadu began, which is where transclusion comes from.
23
+ */
24
+ export const DEFAULT_PORT = 1960;
25
+
26
+ /**
27
+ * `PORT` beats the config, so a host that assigns one is obeyed without an edit.
28
+ * Dev and production share this, so an app has one port rather than two.
29
+ *
30
+ * @param {object} [config]
31
+ * @param {string|undefined} [env] `PORT`, which wins
32
+ * @returns {number}
33
+ * @throws when the value is set but not a port
34
+ */
35
+ export function portOf(config = {}, env = undefined) {
36
+ const asked = env ?? config.port ?? DEFAULT_PORT;
37
+ const port = Number(asked);
38
+
39
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
40
+ throw new Error(
41
+ `[transclude] port must be a whole number from 1 to 65535, not ${JSON.stringify(asked)}`,
42
+ );
43
+ }
44
+ return port;
45
+ }
46
+
47
+ /**
48
+ * The nearest directory at or above `from` holding the config file.
49
+ *
50
+ * npm runs a script with the package root as the working directory, so the
51
+ * search almost always ends at the first try. It walks up so that running a bin
52
+ * by hand from a subdirectory works too.
53
+ *
54
+ * @param {string} [from]
55
+ * @returns {string} the directory holding transclude.config.js
56
+ */
57
+ export function findRoot(from = process.cwd()) {
58
+ let dir = path.resolve(from);
59
+
60
+ for (;;) {
61
+ if (fs.existsSync(path.join(dir, CONFIG_FILE))) return dir;
62
+ const up = path.dirname(dir);
63
+ if (up === dir) break;
64
+ dir = up;
65
+ }
66
+
67
+ throw new Error(
68
+ `[transclude] no ${CONFIG_FILE} in ${path.resolve(from)} or any directory above it`,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * The root and its config together, because nothing needs one without the other.
74
+ *
75
+ * Imported by URL rather than by path: a space in the project path stays
76
+ * percent-encoded in a bare file path and `Atelier%20Dakroub` is not a directory.
77
+ *
78
+ * @param {string} [from]
79
+ * @returns {Promise<object>} the root, the config and every resolved directory
80
+ */
81
+ export async function loadProject(from = process.cwd()) {
82
+ const root = findRoot(from);
83
+ const file = path.join(root, CONFIG_FILE);
84
+ const { default: config } = await import(pathToFileURL(file).href);
85
+
86
+ if (!config || typeof config !== 'object') {
87
+ throw new Error(`[transclude] ${CONFIG_FILE} must export a config object as its default`);
88
+ }
89
+ assertNoSplitDirs(config, file);
90
+ return { root, config, configFile: file };
91
+ }
92
+
93
+ /**
94
+ * `partialsDir` and `componentsDir` are one `elementsDir` now, and how an
95
+ * element renders is read from the file rather than from where it sits.
96
+ *
97
+ * Left alone, the old keys would be ignored and the app would look in
98
+ * `app/elements/`, find nothing, and every tag would render as an unknown
99
+ * element with no styles and nothing said.
100
+ */
101
+ function assertNoSplitDirs(config, file) {
102
+ const old = ['partialsDir', 'componentsDir'].filter((key) => key in config);
103
+ if (!old.length) return;
104
+
105
+ throw new Error(
106
+ `[transclude] ${file} sets ${old.join(' and ')}, which nothing reads. ` +
107
+ `Put every element in one directory and name it with \`elementsDir\`. ` +
108
+ `A shadow root is opt-in per file now: \`export const shadow = true\`.`,
109
+ );
110
+ }
package/src/proxy.js ADDED
@@ -0,0 +1,319 @@
1
+ // Fetching a foreign document, and answering with one fragment of it.
2
+ //
3
+ // The fragment is resolved here rather than in the browser. That keeps the
4
+ // untrusted markup on this side, keeps sanitizing somewhere it cannot be turned
5
+ // off, and means the page receives the piece it asked for instead of a whole
6
+ // document to sift through.
7
+ //
8
+ // Nothing here imports `node:`. Resolving a hostname to an address is the one
9
+ // check that needs the runtime, so it arrives as a function. See `lookup` below.
10
+
11
+ import { checkUrl } from './address.js';
12
+ import { indexDocument, resolveFragment } from './extract.js';
13
+ import { absolutize, baseOf, sanitize } from './rewrite.js';
14
+ import { parse } from 'parse5';
15
+
16
+ export const PROXY_PATH = '/_transclude/proxy';
17
+
18
+ const DEFAULTS = {
19
+ allow: [],
20
+ maxBytes: 5 * 1024 * 1024,
21
+ timeout: 10_000,
22
+ redirects: 5,
23
+ sanitize: true,
24
+ // What to do with a `style` attribute the source wrote. `<style>` blocks and
25
+ // `<link>` are removed either way, because their rules reach the whole page.
26
+ styles: 'keep',
27
+ cache: 50,
28
+ // How long a held document is used without asking the source anything. Ten
29
+ // fragments off one page during a render should be one request, not ten
30
+ // conditional ones.
31
+ maxAge: 60_000,
32
+ };
33
+
34
+ const STYLE_MODES = new Set(['keep', 'strip']);
35
+
36
+ /**
37
+ * Defaults filled in, and the one value worth checking checked. A misspelled
38
+ * `styles` would keep every style attribute and say nothing, which reads exactly
39
+ * like the setting working.
40
+ */
41
+ function settings(options) {
42
+ const config = { ...DEFAULTS, ...options };
43
+ if (!STYLE_MODES.has(config.styles)) {
44
+ throw new Error(
45
+ `[transclude] proxy.styles is ${JSON.stringify(config.styles)}. It is 'keep' or 'strip'.`,
46
+ );
47
+ }
48
+ return config;
49
+ }
50
+
51
+ /** A refusal that is safe to show, since it repeats only what was asked for. */
52
+ class Refused extends Error {
53
+ constructor(status, message) {
54
+ super(message);
55
+ this.status = status;
56
+ }
57
+ }
58
+
59
+ const HTML = /^(text\/html|application\/xhtml\+xml)/i;
60
+
61
+ /**
62
+ * Follow redirects ourselves so every hop is checked.
63
+ *
64
+ * `redirect: 'follow'` would let the first response send us anywhere, and the
65
+ * check that ran on the URL somebody typed says nothing about where hop three
66
+ * landed. This is the whole reason the fetch is written out by hand.
67
+ */
68
+ async function fetchChecked(url, config, deps) {
69
+ const { fetch: get = globalThis.fetch, lookup = null } = deps;
70
+ let at = url;
71
+
72
+ for (let hop = 0; hop <= config.redirects; hop += 1) {
73
+ const why = checkUrl(at, config);
74
+ if (why) throw new Refused(403, why);
75
+
76
+ if (lookup) {
77
+ const blocked = await lookup(new URL(at).hostname);
78
+ if (blocked) throw new Refused(403, `${new URL(at).hostname} resolves to ${blocked}`);
79
+ }
80
+
81
+ const response = await get(at, {
82
+ redirect: 'manual',
83
+ signal: AbortSignal.timeout(config.timeout),
84
+ headers: { accept: 'text/html' },
85
+ });
86
+
87
+ if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
88
+ at = new URL(response.headers.get('location'), at).href;
89
+ continue;
90
+ }
91
+ return { response, url: at };
92
+ }
93
+
94
+ throw new Refused(502, `more than ${config.redirects} redirects`);
95
+ }
96
+
97
+ /**
98
+ * The body, refused past the cap.
99
+ *
100
+ * `content-length` is a claim, not a promise, so it is used to refuse early and
101
+ * the bytes are counted anyway.
102
+ */
103
+ async function bodyWithin(response, maxBytes) {
104
+ const claimed = Number(response.headers.get('content-length'));
105
+ if (claimed && claimed > maxBytes) throw new Refused(413, `document is larger than ${maxBytes} bytes`);
106
+
107
+ if (!response.body) return await response.text();
108
+
109
+ const reader = response.body.getReader();
110
+ const chunks = [];
111
+ let size = 0;
112
+
113
+ for (;;) {
114
+ const { done, value } = await reader.read();
115
+ if (done) break;
116
+ size += value.byteLength;
117
+ if (size > maxBytes) {
118
+ await reader.cancel();
119
+ throw new Refused(413, `document is larger than ${maxBytes} bytes`);
120
+ }
121
+ chunks.push(value);
122
+ }
123
+
124
+ const joined = new Uint8Array(size);
125
+ let at = 0;
126
+ for (const chunk of chunks) {
127
+ joined.set(chunk, at);
128
+ at += chunk.byteLength;
129
+ }
130
+ return new TextDecoder().decode(joined);
131
+ }
132
+
133
+ /**
134
+ * Parsed documents, by the URL they finally came from.
135
+ *
136
+ * Several fragments usually come from one page, so the parse, the cleaning and
137
+ * the slug table are done once and shared. Keyed by the final URL rather than
138
+ * the requested one, so two requests that redirect to the same place hit.
139
+ *
140
+ * @param {number} [max] how many documents to hold
141
+ * @returns {{ get: Function, set: Function, size: () => number }}
142
+ */
143
+ export function documentStore(max = DEFAULTS.cache) {
144
+ const held = new Map();
145
+
146
+ return {
147
+ get(key) {
148
+ const entry = held.get(key);
149
+ if (!entry) return null;
150
+ // Least recently used goes first, so re-insert on a hit.
151
+ held.delete(key);
152
+ held.set(key, entry);
153
+ return entry;
154
+ },
155
+ set(key, entry) {
156
+ held.delete(key);
157
+ held.set(key, entry);
158
+ while (held.size > max) held.delete(held.keys().next().value);
159
+ },
160
+ get size() {
161
+ return held.size;
162
+ },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * A foreign document, fetched, cleaned and indexed.
168
+ *
169
+ * The order is deliberate: read the base before `<base>` is stripped, strip
170
+ * before rewriting so nothing rewrites a URL on an element about to be removed,
171
+ * and index last so the table never names something the cleaning took out.
172
+ *
173
+ * @param {string} url
174
+ * @param {object} [options] the `proxy` config
175
+ * @param {{ fetch?: Function, store?: object, now?: Function, lookup?: Function }} [deps]
176
+ * @returns {Promise<object>} the indexed document, its base, and what was removed
177
+ */
178
+ export async function readForeign(url, options = {}, deps = {}) {
179
+ const config = settings(options);
180
+ const store = deps.store ?? null;
181
+ const now = deps.now ?? (() => Date.now());
182
+
183
+ const held = store?.get(url) ?? null;
184
+ if (held && now() - held.at < config.maxAge) return held;
185
+
186
+ const revalidate = held
187
+ ? { 'if-none-match': held.etag ?? '', 'if-modified-since': held.lastModified ?? '' }
188
+ : {};
189
+
190
+ const get = deps.fetch ?? globalThis.fetch;
191
+ const wrapped = held
192
+ ? (at, init) =>
193
+ get(at, {
194
+ ...init,
195
+ headers: { ...init.headers, ...Object.fromEntries(Object.entries(revalidate).filter(([, v]) => v)) },
196
+ })
197
+ : get;
198
+
199
+ const { response, url: final } = await fetchChecked(url, config, { ...deps, fetch: wrapped });
200
+
201
+ if (response.status === 304 && held) {
202
+ // Still current, so the parse stands and only its age moves.
203
+ const refreshed = { ...held, at: now() };
204
+ store?.set(url, refreshed);
205
+ return refreshed;
206
+ }
207
+ if (!response.ok) throw new Refused(502, `the source answered ${response.status}`);
208
+
209
+ const type = response.headers.get('content-type') ?? '';
210
+ if (!HTML.test(type)) throw new Refused(415, `the source sent ${type || 'no content type'}`);
211
+
212
+ const html = await bodyWithin(response, config.maxBytes);
213
+ const base = baseOf(html, final);
214
+
215
+ const root = parse(html);
216
+ const removed = config.sanitize ? sanitize(root, { styles: config.styles }) : [];
217
+ absolutize(root, base);
218
+
219
+ const entry = {
220
+ doc: indexDocument(root),
221
+ base,
222
+ removed,
223
+ at: now(),
224
+ etag: response.headers.get('etag'),
225
+ lastModified: response.headers.get('last-modified'),
226
+ };
227
+
228
+ store?.set(final, entry);
229
+ // The requested URL is what the next request will arrive with, so it is held
230
+ // too when a redirect moved us.
231
+ if (final !== url) store?.set(url, entry);
232
+ return entry;
233
+ }
234
+
235
+ /**
236
+ * `GET /_transclude/proxy?url=…&id=…`
237
+ *
238
+ * Answers with the fragment, as markup. A page fetches this from its own origin,
239
+ * which is also what makes it work at all: a cross-origin fetch is refused by
240
+ * the default policy, which names `'self'` and nothing else.
241
+ */
242
+ /**
243
+ * A resolver for `renderRoute`: the markup of one fragment of one URL.
244
+ *
245
+ * Shares a store with nothing else on purpose. Includes are resolved during a
246
+ * render, and holding the parsed document is what makes ten of them off one page
247
+ * cost one read.
248
+ *
249
+ * @param {object} [options]
250
+ * @param {object} [deps]
251
+ * @returns {{ resolve: (url: string, id: string) => Promise<string> }}
252
+ */
253
+ export function includeResolver(options = {}, deps = {}) {
254
+ const config = settings(options);
255
+ const store = deps.store ?? documentStore(config.cache);
256
+
257
+ return {
258
+ resolve: async (url, id) => {
259
+ const entry = await readForeign(url, config, { ...deps, store });
260
+ return resolveFragment(entry.doc, id)?.html ?? null;
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * @param {object} [options]
267
+ * @param {object} [deps]
268
+ * @returns {(request: Request) => Promise<Response>}
269
+ */
270
+ export function proxyHandler(options = {}, deps = {}) {
271
+ const config = settings(options);
272
+ const store = deps.store ?? documentStore(config.cache);
273
+
274
+ return async (request) => {
275
+ const asked = new URL(request.url);
276
+ const url = asked.searchParams.get('url');
277
+ const id = asked.searchParams.get('id');
278
+
279
+ if (!url) return text(400, 'no url');
280
+
281
+ try {
282
+ const entry = await readForeign(url, config, { ...deps, store });
283
+
284
+ // No id is a question about the document rather than a piece of it.
285
+ if (!id) {
286
+ const { listFragments } = await import('./extract.js');
287
+ return json(200, { url, fragments: listFragments(entry.doc) });
288
+ }
289
+
290
+ const found = resolveFragment(entry.doc, id);
291
+ if (!found) return text(404, `no fragment "${id}" at ${url}`);
292
+
293
+ return new Response(found.html, {
294
+ status: 200,
295
+ headers: {
296
+ 'content-type': 'text/html; charset=utf-8',
297
+ // The answer depends entirely on the query, and the source may change
298
+ // under us, so nothing downstream should hold it on its own terms.
299
+ 'cache-control': 'no-store',
300
+ },
301
+ });
302
+ } catch (error) {
303
+ if (error instanceof Refused) return text(error.status, error.message);
304
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
305
+ return text(504, 'the source did not answer in time');
306
+ }
307
+ return text(502, 'the source could not be read');
308
+ }
309
+ };
310
+ }
311
+
312
+ const text = (status, body) =>
313
+ new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
314
+
315
+ const json = (status, body) =>
316
+ new Response(JSON.stringify(body), {
317
+ status,
318
+ headers: { 'content-type': 'application/json; charset=utf-8' },
319
+ });
@@ -0,0 +1,77 @@
1
+ // The author's own files, served from disk.
2
+ //
3
+ // Node only, and deliberately outside the portable core: `app.js` is handed a
4
+ // handler and never builds one, which is what lets a runtime with no filesystem
5
+ // supply its own. Nothing here is reachable from that graph.
6
+ //
7
+ // These are not build output. They can be large, they can be media, and media
8
+ // needs byte ranges, which is why they go through Hono's `serveStatic` rather
9
+ // than the in-memory cache. What that leaves out is a validator: the response
10
+ // carried `Last-Modified` and nothing else, so a browser fell back to guessing
11
+ // how long to hold a favicon.
12
+
13
+ import fs from 'node:fs';
14
+ import { serveStatic } from '@hono/node-server/serve-static';
15
+
16
+ /** Same as the build output's. These change when the author changes them. */
17
+ const REVALIDATE = 'public, max-age=0, must-revalidate';
18
+
19
+ /**
20
+ * Size and modified time, not a hash of the bytes.
21
+ *
22
+ * Hashing is what the in-memory cache does, and it is the wrong trade here: a
23
+ * video would be read in full to answer a request that may only want the first
24
+ * megabyte of it. Weak on purpose, because two files can share a size and a
25
+ * second: it is enough to answer "has this changed", which is all a conditional
26
+ * request asks, and it does not claim the byte-for-byte identity a strong one
27
+ * does.
28
+ *
29
+ * @param {string} file
30
+ * @returns {string}
31
+ */
32
+ function validatorFor(file) {
33
+ const { size, mtimeMs } = fs.statSync(file);
34
+ return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
35
+ }
36
+
37
+ /**
38
+ * A handler for `publicFiles`, with a validator and a 304.
39
+ *
40
+ * `serveStatic` sets the headers and reads no request condition, so the
41
+ * conditional half is done around it. The file it found is the one measured,
42
+ * which is the point when `precompressed` served a `.br`: different bytes are
43
+ * a different entity and must not share an ETag.
44
+ *
45
+ * @param {string} root relative to the working directory
46
+ * @returns {Function} Hono middleware
47
+ */
48
+ export function publicFiles(root) {
49
+ const inner = serveStatic({
50
+ root,
51
+ precompressed: true,
52
+ onFound: (file, c) => {
53
+ c.header('ETag', validatorFor(file));
54
+ c.header('Cache-Control', REVALIDATE);
55
+ },
56
+ });
57
+
58
+ return async (c, next) => {
59
+ // Whatever it gives back is given back. A range is answered by *returning* a
60
+ // Response rather than by setting `c.res`, so swallowing this leaves the
61
+ // context unfinalized and every Range request becomes a 500. That is a
62
+ // difference no unit test here saw: it took a real server and a real
63
+ // `Range` header.
64
+ const answer = await inner(c, next);
65
+ const found = answer ?? c.res;
66
+
67
+ // 206 is left alone. A range was asked for and answered, and a weak
68
+ // validator is not one `If-Range` may be matched against.
69
+ if (!found || found.status !== 200) return answer;
70
+
71
+ const etag = found.headers.get('etag');
72
+ if (etag && c.req.header('if-none-match') === etag) {
73
+ return new Response(null, { status: 304, headers: found.headers });
74
+ }
75
+ return answer;
76
+ };
77
+ }