@johnhenry/sitefile 0.0.0-stage → 0.0.1
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/CHANGELOG.md +152 -0
- package/LICENSE +21 -0
- package/README.md +513 -2
- package/lib/src/archive-writers.d.ts +31 -0
- package/lib/src/archive-writers.js +197 -0
- package/lib/src/attest.d.ts +126 -0
- package/lib/src/attest.js +220 -0
- package/lib/src/bake.d.ts +74 -0
- package/lib/src/bake.js +372 -0
- package/lib/src/base-image.d.ts +76 -0
- package/lib/src/base-image.js +138 -0
- package/lib/src/build-args.d.ts +26 -0
- package/lib/src/build-args.js +164 -0
- package/lib/src/cache-limit.d.ts +54 -0
- package/lib/src/cache-limit.js +239 -0
- package/lib/src/casefold-data.d.ts +2 -0
- package/lib/src/casefold-data.js +217 -0
- package/lib/src/cbor.d.ts +39 -0
- package/lib/src/cbor.js +174 -0
- package/lib/src/checklinks.d.ts +42 -0
- package/lib/src/checklinks.js +120 -0
- package/lib/src/cli/bake.d.ts +2 -0
- package/lib/src/cli/bake.js +179 -0
- package/lib/src/cli/build-image.d.ts +21 -0
- package/lib/src/cli/build-image.js +42 -0
- package/lib/src/cli/cache.d.ts +8 -0
- package/lib/src/cli/cache.js +119 -0
- package/lib/src/cli/common.d.ts +29 -0
- package/lib/src/cli/common.js +100 -0
- package/lib/src/cli/git-context.d.ts +18 -0
- package/lib/src/cli/git-context.js +48 -0
- package/lib/src/cli/images.d.ts +6 -0
- package/lib/src/cli/images.js +285 -0
- package/lib/src/cli/index.d.ts +10 -0
- package/lib/src/cli/index.js +95 -0
- package/lib/src/cli/network.d.ts +34 -0
- package/lib/src/cli/network.js +56 -0
- package/lib/src/cli/outdated.d.ts +5 -0
- package/lib/src/cli/outdated.js +99 -0
- package/lib/src/cli/registry.d.ts +2 -0
- package/lib/src/cli/registry.js +127 -0
- package/lib/src/cli/serve-worker.d.ts +2 -0
- package/lib/src/cli/serve-worker.js +42 -0
- package/lib/src/cli/servers.d.ts +15 -0
- package/lib/src/cli/servers.js +467 -0
- package/lib/src/cli/vendor.d.ts +2 -0
- package/lib/src/cli/vendor.js +97 -0
- package/lib/src/cli/watch.d.ts +26 -0
- package/lib/src/cli/watch.js +81 -0
- package/lib/src/cli.d.ts +12 -0
- package/lib/src/cli.js +522 -0
- package/lib/src/csv.d.ts +27 -0
- package/lib/src/csv.js +117 -0
- package/lib/src/deflate.d.ts +76 -0
- package/lib/src/deflate.js +604 -0
- package/lib/src/digest.d.ts +3 -0
- package/lib/src/digest.js +7 -0
- package/lib/src/errors.d.ts +56 -0
- package/lib/src/errors.js +29 -0
- package/lib/src/executor.d.ts +393 -0
- package/lib/src/executor.js +2201 -0
- package/lib/src/explain.d.ts +64 -0
- package/lib/src/explain.js +445 -0
- package/lib/src/export-formats.d.ts +23 -0
- package/lib/src/export-formats.js +63 -0
- package/lib/src/exporter.d.ts +103 -0
- package/lib/src/exporter.js +676 -0
- package/lib/src/fingerprint.d.ts +40 -0
- package/lib/src/fingerprint.js +168 -0
- package/lib/src/frontmatter.d.ts +22 -0
- package/lib/src/frontmatter.js +45 -0
- package/lib/src/index.d.ts +13 -0
- package/lib/src/index.js +13 -0
- package/lib/src/language/ast.d.ts +360 -0
- package/lib/src/language/ast.js +57 -0
- package/lib/src/language/parser.d.ts +3 -0
- package/lib/src/language/parser.js +612 -0
- package/lib/src/language/scanner.d.ts +110 -0
- package/lib/src/language/scanner.js +449 -0
- package/lib/src/language/validate.d.ts +13 -0
- package/lib/src/language/validate.js +367 -0
- package/lib/src/links.d.ts +79 -0
- package/lib/src/links.js +583 -0
- package/lib/src/lint.d.ts +10 -0
- package/lib/src/lint.js +223 -0
- package/lib/src/markdown.d.ts +29 -0
- package/lib/src/markdown.js +612 -0
- package/lib/src/media.d.ts +82 -0
- package/lib/src/media.js +650 -0
- package/lib/src/memo.d.ts +57 -0
- package/lib/src/memo.js +93 -0
- package/lib/src/oci.d.ts +178 -0
- package/lib/src/oci.js +380 -0
- package/lib/src/outdated.d.ts +46 -0
- package/lib/src/outdated.js +195 -0
- package/lib/src/pages.d.ts +10 -0
- package/lib/src/pages.js +54 -0
- package/lib/src/paths.d.ts +39 -0
- package/lib/src/paths.js +101 -0
- package/lib/src/planner.d.ts +297 -0
- package/lib/src/planner.js +410 -0
- package/lib/src/plugins/cache.d.ts +96 -0
- package/lib/src/plugins/cache.js +325 -0
- package/lib/src/plugins/exec.d.ts +51 -0
- package/lib/src/plugins/exec.js +144 -0
- package/lib/src/plugins/host.d.ts +75 -0
- package/lib/src/plugins/host.js +277 -0
- package/lib/src/plugins/wasm.d.ts +23 -0
- package/lib/src/plugins/wasm.js +249 -0
- package/lib/src/profile.d.ts +42 -0
- package/lib/src/profile.js +68 -0
- package/lib/src/ref.d.ts +40 -0
- package/lib/src/ref.js +79 -0
- package/lib/src/registry-auth.d.ts +32 -0
- package/lib/src/registry-auth.js +151 -0
- package/lib/src/registry-cache.d.ts +71 -0
- package/lib/src/registry-cache.js +333 -0
- package/lib/src/registry-ops.d.ts +51 -0
- package/lib/src/registry-ops.js +248 -0
- package/lib/src/registry.d.ts +115 -0
- package/lib/src/registry.js +524 -0
- package/lib/src/render.d.ts +134 -0
- package/lib/src/render.js +576 -0
- package/lib/src/resources/archive.d.ts +11 -0
- package/lib/src/resources/archive.js +306 -0
- package/lib/src/resources/bundle-data.d.ts +16 -0
- package/lib/src/resources/bundle-data.js +50 -0
- package/lib/src/resources/convert.d.ts +12 -0
- package/lib/src/resources/convert.js +63 -0
- package/lib/src/resources/documents.d.ts +35 -0
- package/lib/src/resources/documents.js +122 -0
- package/lib/src/resources/git.d.ts +103 -0
- package/lib/src/resources/git.js +720 -0
- package/lib/src/resources/https.d.ts +74 -0
- package/lib/src/resources/https.js +413 -0
- package/lib/src/resources/ignore.d.ts +33 -0
- package/lib/src/resources/ignore.js +119 -0
- package/lib/src/resources/local.d.ts +64 -0
- package/lib/src/resources/local.js +248 -0
- package/lib/src/resources/lockfile.d.ts +52 -0
- package/lib/src/resources/lockfile.js +172 -0
- package/lib/src/resources/netpolicy.d.ts +6 -0
- package/lib/src/resources/netpolicy.js +82 -0
- package/lib/src/resources/network.d.ts +70 -0
- package/lib/src/resources/network.js +304 -0
- package/lib/src/resources/types.d.ts +69 -0
- package/lib/src/resources/types.js +13 -0
- package/lib/src/resources/vendor.d.ts +94 -0
- package/lib/src/resources/vendor.js +296 -0
- package/lib/src/resources/wbn-reader.d.ts +44 -0
- package/lib/src/resources/wbn-reader.js +302 -0
- package/lib/src/schema.d.ts +25 -0
- package/lib/src/schema.js +169 -0
- package/lib/src/secrets.d.ts +21 -0
- package/lib/src/secrets.js +103 -0
- package/lib/src/serve-rules.d.ts +29 -0
- package/lib/src/serve-rules.js +123 -0
- package/lib/src/server.d.ts +45 -0
- package/lib/src/server.js +246 -0
- package/lib/src/servers.d.ts +85 -0
- package/lib/src/servers.js +225 -0
- package/lib/src/site.d.ts +60 -0
- package/lib/src/site.js +169 -0
- package/lib/src/store.d.ts +151 -0
- package/lib/src/store.js +596 -0
- package/lib/src/tree.d.ts +50 -0
- package/lib/src/tree.js +134 -0
- package/lib/src/values.d.ts +7 -0
- package/lib/src/values.js +37 -0
- package/lib/src/vendor.d.ts +19 -0
- package/lib/src/vendor.js +145 -0
- package/lib/src/watch.d.ts +44 -0
- package/lib/src/watch.js +137 -0
- package/lib/src/wbn.d.ts +26 -0
- package/lib/src/wbn.js +241 -0
- package/lib/src/yaml.d.ts +34 -0
- package/lib/src/yaml.js +562 -0
- package/package.json +59 -4
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { SitefileError } from './errors.js';
|
|
3
|
+
import { headersFor, matchRedirect, parseHeaderRules, parseRedirectRules } from './serve-rules.js';
|
|
4
|
+
import { nfc } from './paths.js';
|
|
5
|
+
import { mimeTypeFor } from './wbn.js';
|
|
6
|
+
/** Reserved for the live-reload event stream while `serve --watch` runs. */
|
|
7
|
+
export const RELOAD_PATH = '/__sitefile/reload';
|
|
8
|
+
/** Added to served HTML (before the last `</body>`, else at the end) under `serve --watch`. */
|
|
9
|
+
export const RELOAD_SCRIPT = `<script>new EventSource("${RELOAD_PATH}").onmessage=function(){location.reload()}</script>`;
|
|
10
|
+
/** Open live-reload streams; {@link LiveReload.reload} tells every page to reload. */
|
|
11
|
+
export class LiveReload {
|
|
12
|
+
clients = new Set();
|
|
13
|
+
/** Hold `res` open as a text/event-stream until the client goes away. */
|
|
14
|
+
attach(res, headers) {
|
|
15
|
+
res.writeHead(200, { ...headers, 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
16
|
+
res.write('retry: 1000\n\n');
|
|
17
|
+
this.clients.add(res);
|
|
18
|
+
res.on('close', () => this.clients.delete(res));
|
|
19
|
+
}
|
|
20
|
+
reload() {
|
|
21
|
+
for (const c of this.clients)
|
|
22
|
+
c.write('data: reload\n\n');
|
|
23
|
+
}
|
|
24
|
+
get size() {
|
|
25
|
+
return this.clients.size;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** `html` with {@link RELOAD_SCRIPT} inserted before the last `</body` (ASCII case-insensitive), else appended. */
|
|
29
|
+
export function injectReloadScript(html) {
|
|
30
|
+
const buf = Buffer.from(html.buffer, html.byteOffset, html.byteLength);
|
|
31
|
+
const at = buf.toString('latin1').toLowerCase().lastIndexOf('</body');
|
|
32
|
+
const script = Buffer.from(RELOAD_SCRIPT);
|
|
33
|
+
return at === -1 ? Buffer.concat([buf, script]) : Buffer.concat([buf.subarray(0, at), script, buf.subarray(at)]);
|
|
34
|
+
}
|
|
35
|
+
const NOT_FOUND_PAGE = '<!doctype html><meta charset="utf-8"><title>404 Not Found</title><h1>404 Not Found</h1>\n';
|
|
36
|
+
const BAD_REQUEST_PAGE = '<!doctype html><meta charset="utf-8"><title>400 Bad Request</title><h1>400 Bad Request</h1>\n';
|
|
37
|
+
const METHOD_PAGE = '<!doctype html><meta charset="utf-8"><title>405 Method Not Allowed</title><h1>405 Method Not Allowed</h1>\n';
|
|
38
|
+
/** Decode a request path into safe segments, or undefined when it is malformed or unsafe. */
|
|
39
|
+
export function safeSegments(rawPath) {
|
|
40
|
+
if (!rawPath.startsWith('/') || rawPath.includes('\\') || rawPath.includes('\0'))
|
|
41
|
+
return undefined;
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const raw of rawPath.split('/')) {
|
|
44
|
+
if (raw === '')
|
|
45
|
+
continue;
|
|
46
|
+
let seg;
|
|
47
|
+
try {
|
|
48
|
+
seg = decodeURIComponent(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
// A decoded '/', '\\', NUL, control character, '.' or '..' could change what is read.
|
|
54
|
+
if (seg === '.' || seg === '..' || /[\\/\u0000-\u001f\u007f]/.test(seg))
|
|
55
|
+
return undefined;
|
|
56
|
+
// Tree paths are NFC; a request for an NFD spelling finds the same file.
|
|
57
|
+
out.push(nfc(seg));
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function etagMatches(header, etag) {
|
|
62
|
+
if (header === undefined)
|
|
63
|
+
return false;
|
|
64
|
+
if (header.trim() === '*')
|
|
65
|
+
return true;
|
|
66
|
+
return header.split(',').some((t) => t.trim().replace(/^W\//, '') === etag);
|
|
67
|
+
}
|
|
68
|
+
function printable(s) {
|
|
69
|
+
return s.replace(/[^\x21-\x7e]/g, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`).slice(0, 512);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An HTTP server for an immutable Tree. GET and HEAD only; nothing outside the tree is
|
|
73
|
+
* ever reachable because lookups are exact map keys built from validated segments.
|
|
74
|
+
* Honours `/404.html`, `/_redirects` and `/_headers` (which it never serves), and answers
|
|
75
|
+
* `/about` with `/about.html` when there is no such file or directory.
|
|
76
|
+
*/
|
|
77
|
+
export function createSiteServer(site, options = {}) {
|
|
78
|
+
// A function is read per request (`serve --watch` swaps in each good rebuild).
|
|
79
|
+
const current = typeof site === 'function' ? site : () => site;
|
|
80
|
+
const base = { 'x-content-type-options': 'nosniff', 'cache-control': 'no-cache' };
|
|
81
|
+
if (options.serverId)
|
|
82
|
+
base['x-sitefile-server'] = options.serverId;
|
|
83
|
+
// `_redirects` / `_headers` are parsed once per tree (a watched server gets a new tree per rebuild).
|
|
84
|
+
let rulesTree;
|
|
85
|
+
let rules = { redirects: [], headers: [] };
|
|
86
|
+
const rulesFor = (t) => {
|
|
87
|
+
if (t !== rulesTree) {
|
|
88
|
+
const text = (p) => {
|
|
89
|
+
const f = t.get(p);
|
|
90
|
+
return f ? new TextDecoder().decode(f.bytes) : '';
|
|
91
|
+
};
|
|
92
|
+
rules = { redirects: parseRedirectRules(text('/_redirects')), headers: parseHeaderRules(text('/_headers')) };
|
|
93
|
+
rulesTree = t;
|
|
94
|
+
}
|
|
95
|
+
return rules;
|
|
96
|
+
};
|
|
97
|
+
const handler = (req, res) => {
|
|
98
|
+
const started = process.hrtime.bigint();
|
|
99
|
+
const method = req.method ?? 'GET';
|
|
100
|
+
const url = req.url ?? '/';
|
|
101
|
+
let bytes = 0;
|
|
102
|
+
const finish = (status, headers, body) => {
|
|
103
|
+
const buf = body === undefined ? undefined : typeof body === 'string' ? Buffer.from(body) : body;
|
|
104
|
+
res.writeHead(status, { ...base, ...headers });
|
|
105
|
+
if (buf !== undefined && method !== 'HEAD' && status !== 304) {
|
|
106
|
+
bytes = buf.byteLength;
|
|
107
|
+
res.end(buf);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
res.end();
|
|
111
|
+
}
|
|
112
|
+
const ms = Number((process.hrtime.bigint() - started) / 1000n) / 1000;
|
|
113
|
+
// `servers` probes carry the server's own id; they would only clutter the access log.
|
|
114
|
+
if (options.serverId !== undefined && req.headers['x-sitefile-probe'] === options.serverId)
|
|
115
|
+
return;
|
|
116
|
+
options.log?.(`${method} ${printable(url)} ${status} ${bytes} ${ms.toFixed(1)}ms`);
|
|
117
|
+
};
|
|
118
|
+
const page = (status, html, extra = {}) => finish(status, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html), ...extra }, html);
|
|
119
|
+
if (method !== 'GET' && method !== 'HEAD')
|
|
120
|
+
return page(405, METHOD_PAGE, { allow: 'GET, HEAD' });
|
|
121
|
+
const tree = current();
|
|
122
|
+
const { redirects, headers: headerRules } = rulesFor(tree);
|
|
123
|
+
const isFile = (p) => tree.isFile(p) && p !== '/_redirects' && p !== '/_headers';
|
|
124
|
+
const q = url.indexOf('?');
|
|
125
|
+
const hash = url.indexOf('#');
|
|
126
|
+
const end = Math.min(q === -1 ? url.length : q, hash === -1 ? url.length : hash);
|
|
127
|
+
const rawPath = url.slice(0, end);
|
|
128
|
+
if (options.liveReload && rawPath === RELOAD_PATH) {
|
|
129
|
+
if (method === 'HEAD')
|
|
130
|
+
return finish(200, { 'content-type': 'text/event-stream' }, undefined);
|
|
131
|
+
return options.liveReload.attach(res, base); // not access-logged: one long request per open page
|
|
132
|
+
}
|
|
133
|
+
const search = q === -1 ? '' : url.slice(q, hash === -1 || hash < q ? undefined : hash);
|
|
134
|
+
const segs = safeSegments(rawPath);
|
|
135
|
+
if (!segs)
|
|
136
|
+
return page(400, BAD_REQUEST_PAGE);
|
|
137
|
+
const trailingSlash = rawPath.endsWith('/');
|
|
138
|
+
const vpath = '/' + segs.join('/');
|
|
139
|
+
// `_headers` patterns match the request path (with its trailing slash).
|
|
140
|
+
const custom = headersFor(headerRules, vpath + (trailingSlash && segs.length > 0 ? '/' : ''));
|
|
141
|
+
const sendFile = (status, served) => {
|
|
142
|
+
const file = tree.get(served);
|
|
143
|
+
const type = mimeTypeFor(served);
|
|
144
|
+
const live = options.liveReload !== undefined && type.startsWith('text/html');
|
|
145
|
+
const body = live ? injectReloadScript(file.bytes) : file.bytes;
|
|
146
|
+
if (status !== 200)
|
|
147
|
+
return finish(status, { 'content-type': type, 'content-length': body.byteLength, ...custom }, body);
|
|
148
|
+
// An injected page is a different representation of the file: give it its own ETag.
|
|
149
|
+
const etag = `"${file.digest.replace(/^sha256:/, '')}${live ? '-live' : ''}"`;
|
|
150
|
+
if (etagMatches(req.headers['if-none-match'], etag))
|
|
151
|
+
return finish(304, { ...custom, etag }, undefined);
|
|
152
|
+
finish(200, { 'content-type': type, 'content-length': body.byteLength, ...custom, etag }, body);
|
|
153
|
+
};
|
|
154
|
+
/** The file for a path: exact, `<path>.html` (clean URLs), or `<dir>/index.html` with a trailing slash. */
|
|
155
|
+
const resolve = (p, slash) => {
|
|
156
|
+
if (isFile(p) && !(slash && p !== '/'))
|
|
157
|
+
return p;
|
|
158
|
+
if (!slash && p !== '/' && isFile(`${p}.html`))
|
|
159
|
+
return `${p}.html`;
|
|
160
|
+
if (slash && tree.isDirectory(p)) {
|
|
161
|
+
const index = p === '/' ? '/index.html' : `${p}/index.html`;
|
|
162
|
+
if (isFile(index))
|
|
163
|
+
return index;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
};
|
|
167
|
+
/** Apply the first matching `_redirects` rule (forced ones, or the others); false when none applies. */
|
|
168
|
+
const redirect = (forced) => {
|
|
169
|
+
for (const r of redirects) {
|
|
170
|
+
if (r.force !== forced)
|
|
171
|
+
continue;
|
|
172
|
+
const rest = matchRedirect(r, vpath);
|
|
173
|
+
if (rest === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
const target = r.splat ? r.to.split(':splat').join(rest.split('/').map(encodeURIComponent).join('/')) : r.to;
|
|
176
|
+
if (r.status !== 200 && r.status !== 404) {
|
|
177
|
+
finish(r.status, { location: target.includes('?') ? target : target + search, 'content-length': 0 }, undefined);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
// 200 (rewrite) and 404: serve the target path's file with that status.
|
|
181
|
+
const tq = target.indexOf('?');
|
|
182
|
+
const tpath = tq === -1 ? target : target.slice(0, tq);
|
|
183
|
+
const tsegs = safeSegments(tpath);
|
|
184
|
+
const served = tsegs ? resolve('/' + tsegs.join('/'), tpath.endsWith('/')) : undefined;
|
|
185
|
+
if (served === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
sendFile(r.status, served);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
};
|
|
192
|
+
if (redirect(true))
|
|
193
|
+
return;
|
|
194
|
+
let served = resolve(vpath, trailingSlash);
|
|
195
|
+
if (served === undefined && !trailingSlash && tree.isDirectory(vpath)) {
|
|
196
|
+
const location = '/' + segs.map(encodeURIComponent).join('/') + '/' + search;
|
|
197
|
+
return finish(301, { location, 'content-length': 0 }, undefined);
|
|
198
|
+
}
|
|
199
|
+
if (served === undefined && redirect(false))
|
|
200
|
+
return;
|
|
201
|
+
if (served === undefined && options.spa && !/\.[^/.]+$/.test(segs[segs.length - 1] ?? '') && isFile('/index.html'))
|
|
202
|
+
served = '/index.html';
|
|
203
|
+
if (served !== undefined)
|
|
204
|
+
return sendFile(200, served);
|
|
205
|
+
if (isFile('/404.html'))
|
|
206
|
+
return sendFile(404, '/404.html');
|
|
207
|
+
page(404, NOT_FOUND_PAGE, custom);
|
|
208
|
+
};
|
|
209
|
+
const server = http.createServer(handler);
|
|
210
|
+
server.on('clientError', (_err, socket) => {
|
|
211
|
+
if (socket.writable)
|
|
212
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
|
|
213
|
+
else
|
|
214
|
+
socket.destroy();
|
|
215
|
+
});
|
|
216
|
+
return server;
|
|
217
|
+
}
|
|
218
|
+
/** Listen on host:port (port 0 = any free port); EADDRINUSE and friends become E_SERVER. */
|
|
219
|
+
export function listen(server, host, port) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const onError = (e) => {
|
|
222
|
+
const why = e.code === 'EADDRINUSE'
|
|
223
|
+
? `port ${port} on ${host} is already in use`
|
|
224
|
+
: e.code === 'EACCES'
|
|
225
|
+
? `permission denied binding ${host}:${port}`
|
|
226
|
+
: `cannot listen on ${host}:${port}: ${e.message}`;
|
|
227
|
+
reject(new SitefileError('E_SERVER', why, undefined, { cause: e }));
|
|
228
|
+
};
|
|
229
|
+
server.once('error', onError);
|
|
230
|
+
server.listen(port, host, () => {
|
|
231
|
+
server.off('error', onError);
|
|
232
|
+
resolve({ host, port: server.address().port });
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
export function closeServer(server) {
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
server.close(() => resolve());
|
|
239
|
+
server.closeAllConnections();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
export function formatUrl(host, port) {
|
|
243
|
+
const shown = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
244
|
+
return `http://${shown.includes(':') ? `[${shown}]` : shown}:${port}/`;
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ArchiveLimits } from './resources/types.js';
|
|
2
|
+
import { Store } from './store.js';
|
|
3
|
+
import { Tree } from './tree.js';
|
|
4
|
+
/**
|
|
5
|
+
* Detached site servers ("containers"). One state file per server:
|
|
6
|
+
*
|
|
7
|
+
* $SITEFILE_HOME/servers/<id>.json state (this interface)
|
|
8
|
+
* $SITEFILE_HOME/servers/<id>.log stdout/stderr of the server process
|
|
9
|
+
*
|
|
10
|
+
* `serve -d` writes the state, spawns `node serve-worker.js <id>` detached, and the worker
|
|
11
|
+
* fills in `pid`, `host`, `port` and `ready` once it is listening. `servers` never trusts the
|
|
12
|
+
* file alone: a server is `running` only when its pid is alive AND the port answers with
|
|
13
|
+
* this server's `x-sitefile-server` header.
|
|
14
|
+
*/
|
|
15
|
+
export interface ServerState {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** What the user asked to serve: the image reference as typed, or the directory. */
|
|
19
|
+
readonly image: string;
|
|
20
|
+
readonly source: {
|
|
21
|
+
readonly kind: 'image';
|
|
22
|
+
readonly imageId: string;
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: 'dir';
|
|
25
|
+
readonly path: string;
|
|
26
|
+
}
|
|
27
|
+
/** zip, tar, tar.gz/tgz, a Web Bundle or an OCI image archive; read again on every start, as a directory is. */
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: 'archive';
|
|
30
|
+
readonly path: string;
|
|
31
|
+
};
|
|
32
|
+
readonly spa: boolean;
|
|
33
|
+
/** The requested bind address (port 0 = pick a free port). */
|
|
34
|
+
readonly bind: {
|
|
35
|
+
readonly host: string;
|
|
36
|
+
readonly port: number;
|
|
37
|
+
};
|
|
38
|
+
readonly host: string;
|
|
39
|
+
readonly port: number;
|
|
40
|
+
readonly pid: number;
|
|
41
|
+
readonly started: string;
|
|
42
|
+
readonly logFile: string;
|
|
43
|
+
readonly ready: boolean;
|
|
44
|
+
readonly error?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ServerView extends ServerState {
|
|
47
|
+
readonly status: 'running' | 'exited';
|
|
48
|
+
readonly url: string;
|
|
49
|
+
}
|
|
50
|
+
export declare class ServerRegistry {
|
|
51
|
+
readonly store: Store;
|
|
52
|
+
readonly dir: string;
|
|
53
|
+
private constructor();
|
|
54
|
+
static open(store?: Store): Promise<ServerRegistry>;
|
|
55
|
+
statePath(id: string): string;
|
|
56
|
+
logPath(id: string): string;
|
|
57
|
+
read(id: string): Promise<ServerState | undefined>;
|
|
58
|
+
write(state: ServerState): Promise<void>;
|
|
59
|
+
update(id: string, patch: Partial<ServerState>): Promise<ServerState | undefined>;
|
|
60
|
+
remove(id: string): Promise<void>;
|
|
61
|
+
all(): Promise<ServerState[]>;
|
|
62
|
+
/** Find by exact ID, exact name, or unique ID prefix. */
|
|
63
|
+
find(key: string): Promise<ServerState>;
|
|
64
|
+
/** Allocate an ID and validate/choose the name (names are unique among all servers, running or not). */
|
|
65
|
+
allocate(name: string | undefined): Promise<{
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
}>;
|
|
69
|
+
view(s: ServerState): Promise<ServerView>;
|
|
70
|
+
}
|
|
71
|
+
export declare function pidAlive(pid: number): boolean;
|
|
72
|
+
/** Alive AND answering on its port with its own id header (guards against pid and port reuse). */
|
|
73
|
+
export declare function isRunning(s: ServerState): Promise<boolean>;
|
|
74
|
+
/** Load a directory into a Tree through the sandboxed local reader; hidden files (except .well-known) are not served. */
|
|
75
|
+
export declare function loadDirectoryTree(dir: string): Promise<Tree>;
|
|
76
|
+
/**
|
|
77
|
+
* Load an archive file into a Tree. The format comes from the bytes, never the name: zip, tar, tar.gz,
|
|
78
|
+
* a Web Bundle, or an OCI image archive (a tar holding an OCI layout, as `build -o x.oci.tar` and `save`
|
|
79
|
+
* write it). A zip, tar or Web Bundle is extracted with `limits` (unsafe entries are refused) and served
|
|
80
|
+
* like a directory, without hidden files except `.well-known`; an OCI archive serves its first image's
|
|
81
|
+
* files, like that image from the store, after checking every blob's digest and its site layer against `limits`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function loadArchiveTree(file: string, limits?: ArchiveLimits): Promise<Tree>;
|
|
84
|
+
/** The tree a server state serves. */
|
|
85
|
+
export declare function loadServedTree(store: Store, source: ServerState['source']): Promise<Tree>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { sha256 } from './digest.js';
|
|
6
|
+
import { SitefileError } from './errors.js';
|
|
7
|
+
import { blobPathOf, decodeIndex, extractImageFiles, MEDIA, parseConfig, parseManifest, siteLayerIndex } from './oci.js';
|
|
8
|
+
import { extractArchive } from './resources/archive.js';
|
|
9
|
+
import { LocalContext } from './resources/local.js';
|
|
10
|
+
import { DEFAULT_ARCHIVE_LIMITS } from './resources/types.js';
|
|
11
|
+
import { Store } from './store.js';
|
|
12
|
+
import { Tree } from './tree.js';
|
|
13
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
|
14
|
+
export class ServerRegistry {
|
|
15
|
+
store;
|
|
16
|
+
dir;
|
|
17
|
+
constructor(store) {
|
|
18
|
+
this.store = store;
|
|
19
|
+
this.dir = path.join(store.home, 'servers');
|
|
20
|
+
}
|
|
21
|
+
static async open(store) {
|
|
22
|
+
const reg = new ServerRegistry(store ?? (await Store.open()));
|
|
23
|
+
await fs.mkdir(reg.dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
return reg;
|
|
25
|
+
}
|
|
26
|
+
statePath(id) {
|
|
27
|
+
if (!/^[0-9a-f]{12}$/.test(id))
|
|
28
|
+
throw new SitefileError('E_SERVER', `invalid server id '${id}'`);
|
|
29
|
+
return path.join(this.dir, `${id}.json`);
|
|
30
|
+
}
|
|
31
|
+
logPath(id) {
|
|
32
|
+
return path.join(this.dir, `${id}.log`);
|
|
33
|
+
}
|
|
34
|
+
async read(id) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await fs.readFile(this.statePath(id), 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code === 'ENOENT' || e instanceof SyntaxError)
|
|
40
|
+
return undefined;
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async write(state) {
|
|
45
|
+
const dest = this.statePath(state.id);
|
|
46
|
+
const tmp = `${dest}.${randomBytes(4).toString('hex')}.tmp`;
|
|
47
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
48
|
+
await fs.rename(tmp, dest);
|
|
49
|
+
}
|
|
50
|
+
async update(id, patch) {
|
|
51
|
+
const cur = await this.read(id);
|
|
52
|
+
if (!cur)
|
|
53
|
+
return undefined;
|
|
54
|
+
const next = { ...cur, ...patch };
|
|
55
|
+
await this.write(next);
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
async remove(id) {
|
|
59
|
+
await fs.rm(this.statePath(id), { force: true });
|
|
60
|
+
await fs.rm(this.logPath(id), { force: true });
|
|
61
|
+
}
|
|
62
|
+
async all() {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const f of await fs.readdir(this.dir)) {
|
|
65
|
+
const m = /^([0-9a-f]{12})\.json$/.exec(f);
|
|
66
|
+
const s = m ? await this.read(m[1]) : undefined;
|
|
67
|
+
if (s)
|
|
68
|
+
out.push(s);
|
|
69
|
+
}
|
|
70
|
+
return out.sort((a, b) => (a.started < b.started ? -1 : a.started > b.started ? 1 : 0));
|
|
71
|
+
}
|
|
72
|
+
/** Find by exact ID, exact name, or unique ID prefix. */
|
|
73
|
+
async find(key) {
|
|
74
|
+
const all = await this.all();
|
|
75
|
+
const hit = all.find((s) => s.id === key) ?? all.find((s) => s.name === key);
|
|
76
|
+
if (hit)
|
|
77
|
+
return hit;
|
|
78
|
+
const pre = key.length >= 3 ? all.filter((s) => s.id.startsWith(key)) : [];
|
|
79
|
+
if (pre.length === 1)
|
|
80
|
+
return pre[0];
|
|
81
|
+
if (pre.length > 1)
|
|
82
|
+
throw new SitefileError('E_SERVER', `ambiguous server '${key}' matches ${pre.length} servers`);
|
|
83
|
+
throw new SitefileError('E_NOT_FOUND', `no such server: ${key}`);
|
|
84
|
+
}
|
|
85
|
+
/** Allocate an ID and validate/choose the name (names are unique among all servers, running or not). */
|
|
86
|
+
async allocate(name) {
|
|
87
|
+
const all = await this.all();
|
|
88
|
+
if (name !== undefined) {
|
|
89
|
+
if (!NAME_RE.test(name))
|
|
90
|
+
throw new SitefileError('E_USAGE', `invalid server name '${name}' (letters, digits, '_', '.', '-')`);
|
|
91
|
+
if (all.some((s) => s.name === name))
|
|
92
|
+
throw new SitefileError('E_SERVER', `a server named '${name}' already exists; remove it with "sitefile rm ${name}"`);
|
|
93
|
+
}
|
|
94
|
+
const id = randomBytes(6).toString('hex');
|
|
95
|
+
return { id, name: name ?? `site-${id.slice(0, 6)}` };
|
|
96
|
+
}
|
|
97
|
+
async view(s) {
|
|
98
|
+
const running = await isRunning(s);
|
|
99
|
+
return { ...s, status: running ? 'running' : 'exited', url: s.ready ? urlOf(s) : '' };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function urlOf(s) {
|
|
103
|
+
const shown = s.host === '0.0.0.0' ? '127.0.0.1' : s.host === '::' ? '::1' : s.host;
|
|
104
|
+
return `http://${shown.includes(':') ? `[${shown}]` : shown}:${s.port}/`;
|
|
105
|
+
}
|
|
106
|
+
export function pidAlive(pid) {
|
|
107
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
108
|
+
return false;
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, 0);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return e.code === 'EPERM';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Alive AND answering on its port with its own id header (guards against pid and port reuse). */
|
|
118
|
+
export async function isRunning(s) {
|
|
119
|
+
if (!s.ready || !pidAlive(s.pid))
|
|
120
|
+
return false;
|
|
121
|
+
const host = s.host === '0.0.0.0' ? '127.0.0.1' : s.host === '::' ? '::1' : s.host;
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const req = http.request({ host, port: s.port, method: 'HEAD', path: '/', headers: { 'x-sitefile-probe': s.id }, timeout: 1500, agent: false }, (res) => {
|
|
124
|
+
res.resume();
|
|
125
|
+
resolve(res.headers['x-sitefile-server'] === s.id);
|
|
126
|
+
});
|
|
127
|
+
req.on('timeout', () => req.destroy());
|
|
128
|
+
req.on('error', () => resolve(false));
|
|
129
|
+
req.end();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Paths of `tree` a directory or archive serves: no hidden files except `.well-known`. */
|
|
133
|
+
function visible(tree) {
|
|
134
|
+
return Tree.fromFiles(tree.entries().filter(([p]) => p.split('/').every((seg) => !seg.startsWith('.') || seg === '' || seg === '.well-known')));
|
|
135
|
+
}
|
|
136
|
+
/** Load a directory into a Tree through the sandboxed local reader; hidden files (except .well-known) are not served. */
|
|
137
|
+
export async function loadDirectoryTree(dir) {
|
|
138
|
+
const ctx = await LocalContext.open(dir, { sitefileignore: false });
|
|
139
|
+
const { tree } = await ctx.readTree('', 'serve');
|
|
140
|
+
return visible(tree);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Load an archive file into a Tree. The format comes from the bytes, never the name: zip, tar, tar.gz,
|
|
144
|
+
* a Web Bundle, or an OCI image archive (a tar holding an OCI layout, as `build -o x.oci.tar` and `save`
|
|
145
|
+
* write it). A zip, tar or Web Bundle is extracted with `limits` (unsafe entries are refused) and served
|
|
146
|
+
* like a directory, without hidden files except `.well-known`; an OCI archive serves its first image's
|
|
147
|
+
* files, like that image from the store, after checking every blob's digest and its site layer against `limits`.
|
|
148
|
+
*/
|
|
149
|
+
export async function loadArchiveTree(file, limits = DEFAULT_ARCHIVE_LIMITS) {
|
|
150
|
+
const origin = file;
|
|
151
|
+
const unreadable = () => new SitefileError('E_ARCHIVE', `cannot read '${origin}'`);
|
|
152
|
+
const st = await fs.stat(file).catch((e) => {
|
|
153
|
+
throw e.code === 'ENOENT' ? new SitefileError('E_NOT_FOUND', `'${origin}' does not exist`) : unreadable();
|
|
154
|
+
});
|
|
155
|
+
if (!st.isFile())
|
|
156
|
+
throw new SitefileError('E_TYPE', `'${origin}' is not a file`);
|
|
157
|
+
// The archive is at most its expanded size plus tar framing for every entry.
|
|
158
|
+
if (st.size > limits.maxTotalBytes + (limits.maxEntries + 2) * 4 * 512) {
|
|
159
|
+
throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: archive expands beyond ${limits.maxTotalBytes} bytes`);
|
|
160
|
+
}
|
|
161
|
+
const bytes = new Uint8Array(await fs.readFile(file).catch(() => { throw unreadable(); }));
|
|
162
|
+
const tree = await extractArchive(bytes, origin, limits);
|
|
163
|
+
return tree.get('/oci-layout') ? ociArchiveTree(tree, origin, limits) : visible(tree);
|
|
164
|
+
}
|
|
165
|
+
/** The files of the first image in an extracted OCI layout. */
|
|
166
|
+
async function ociArchiveTree(layout, origin, limits) {
|
|
167
|
+
const bad = (why) => new SitefileError('E_ARCHIVE', `${origin}: ${why}`);
|
|
168
|
+
const blob = (digest, size) => {
|
|
169
|
+
const node = layout.get(blobPathOf(digest));
|
|
170
|
+
if (!node)
|
|
171
|
+
throw bad(`OCI archive has no blob ${digest}`);
|
|
172
|
+
if (sha256(node.bytes) !== digest || node.bytes.byteLength !== size)
|
|
173
|
+
throw bad(`blob ${digest} does not match its digest`);
|
|
174
|
+
return node.bytes;
|
|
175
|
+
};
|
|
176
|
+
const index = layout.get('/index.json');
|
|
177
|
+
if (!index)
|
|
178
|
+
throw bad('OCI archive has no index.json');
|
|
179
|
+
let entries;
|
|
180
|
+
try {
|
|
181
|
+
entries = decodeIndex(index.bytes);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw bad('OCI archive index.json is corrupt');
|
|
185
|
+
}
|
|
186
|
+
const first = entries[0];
|
|
187
|
+
if (!first)
|
|
188
|
+
throw bad('OCI archive holds no image');
|
|
189
|
+
const manifestBytes = blob(first.digest, first.size);
|
|
190
|
+
let m;
|
|
191
|
+
try {
|
|
192
|
+
m = parseManifest(manifestBytes);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw bad("OCI archive's first manifest is not an image manifest");
|
|
196
|
+
}
|
|
197
|
+
const configBytes = blob(m.config.digest, m.config.size);
|
|
198
|
+
let cfg;
|
|
199
|
+
try {
|
|
200
|
+
cfg = parseConfig(configBytes);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw bad("OCI archive's image config is corrupt");
|
|
204
|
+
}
|
|
205
|
+
const layer = m.layers[siteLayerIndex(m, cfg)];
|
|
206
|
+
let layerBytes = new Uint8Array(0); // extractImageFiles reports "not a Sitefile image"
|
|
207
|
+
if (layer && (layer.mediaType === MEDIA.layer || layer.mediaType === MEDIA.layerTar)) {
|
|
208
|
+
layerBytes = blob(layer.digest, layer.size);
|
|
209
|
+
// Hold the site layer to the archive limits before it is fully decoded.
|
|
210
|
+
await extractArchive(layerBytes, origin, limits);
|
|
211
|
+
}
|
|
212
|
+
return extractImageFiles(m, cfg, layerBytes, origin);
|
|
213
|
+
}
|
|
214
|
+
/** The tree a server state serves. */
|
|
215
|
+
export async function loadServedTree(store, source) {
|
|
216
|
+
switch (source.kind) {
|
|
217
|
+
case 'image':
|
|
218
|
+
return store.loadTree(source.imageId);
|
|
219
|
+
case 'dir':
|
|
220
|
+
return loadDirectoryTree(source.path);
|
|
221
|
+
case 'archive':
|
|
222
|
+
return loadArchiveTree(source.path);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=servers.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type SourceSpan } from './errors.js';
|
|
2
|
+
import type { RedirectStatus } from './language/ast.js';
|
|
3
|
+
import type { Tree } from './tree.js';
|
|
4
|
+
/**
|
|
5
|
+
* Check a SITEMAP/FEED `BASE`: an absolute http(s) URL with a host and no credentials,
|
|
6
|
+
* query, fragment, whitespace or control characters. Returns it without trailing slashes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function checkBaseUrl(raw: string, what: string, span: SourceSpan): string;
|
|
9
|
+
/** `<base><path>` with each segment percent-encoded; `…/index.html` becomes its directory URL `…/`. */
|
|
10
|
+
export declare function pageUrl(base: string, path: string): string;
|
|
11
|
+
/** Escape text for XML content and attribute values; characters XML 1.0 forbids become U+FFFD. */
|
|
12
|
+
export declare function xmlEscape(s: string): string;
|
|
13
|
+
/** sitemaps.org XML for every `.html` file in the tree except `/404.html`, sorted by path. */
|
|
14
|
+
export declare function sitemapXml(tree: Tree, base: string): string;
|
|
15
|
+
export interface FeedDate {
|
|
16
|
+
/** RFC 3339 form written to the feed. */
|
|
17
|
+
readonly text: string;
|
|
18
|
+
/** Seconds since the epoch (UTC), for picking the newest entry. */
|
|
19
|
+
readonly seconds: number;
|
|
20
|
+
/** Fractional seconds as 9 digits. */
|
|
21
|
+
readonly fraction: string;
|
|
22
|
+
}
|
|
23
|
+
/** `YYYY-MM-DD` (midnight UTC) or an RFC 3339 date-time; undefined when it is neither. */
|
|
24
|
+
export declare function parseFeedDate(s: string): FeedDate | undefined;
|
|
25
|
+
export interface FeedEntry {
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly date: FeedDate;
|
|
29
|
+
readonly author?: string;
|
|
30
|
+
readonly summary?: string;
|
|
31
|
+
readonly content?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Atom 1.0. The feed's id is its own URL; each entry's id is its link. */
|
|
34
|
+
export declare function atomXml(feed: {
|
|
35
|
+
title: string;
|
|
36
|
+
siteUrl: string;
|
|
37
|
+
selfUrl: string;
|
|
38
|
+
entries: readonly FeedEntry[];
|
|
39
|
+
}): string;
|
|
40
|
+
export interface RedirectRule {
|
|
41
|
+
readonly from: string;
|
|
42
|
+
readonly to: string;
|
|
43
|
+
readonly status: RedirectStatus;
|
|
44
|
+
}
|
|
45
|
+
export interface HeaderRule {
|
|
46
|
+
readonly pattern: string;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly value: string;
|
|
49
|
+
}
|
|
50
|
+
/** Check a REDIRECT source (`from`) or HEADER pattern: an absolute path with no spaces or control characters. */
|
|
51
|
+
export declare function checkSitePath(raw: string, what: string, span: SourceSpan): string;
|
|
52
|
+
/** Check a REDIRECT target: an absolute path or an http(s) URL. */
|
|
53
|
+
export declare function checkRedirectTarget(raw: string, span: SourceSpan): string;
|
|
54
|
+
export declare function checkHeaderName(raw: string, span: SourceSpan): string;
|
|
55
|
+
/** The header value: trimmed of spaces and tabs; non-empty, no control characters other than tab. */
|
|
56
|
+
export declare function checkHeaderValue(raw: string, name: string, span: SourceSpan): string;
|
|
57
|
+
/** `/_redirects` in the Netlify / Cloudflare Pages format: `from to status`, one per line, in source order. */
|
|
58
|
+
export declare function redirectsFile(rules: readonly RedirectRule[]): string;
|
|
59
|
+
/** `/_headers`: one block per path pattern (in order of first use), its headers indented in source order. */
|
|
60
|
+
export declare function headersFile(rules: readonly HeaderRule[]): string;
|