@johnhenry/sitefile 0.0.0-stage → 0.0.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 (174) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +475 -2
  4. package/lib/src/archive-writers.d.ts +31 -0
  5. package/lib/src/archive-writers.js +197 -0
  6. package/lib/src/attest.d.ts +126 -0
  7. package/lib/src/attest.js +220 -0
  8. package/lib/src/bake.d.ts +74 -0
  9. package/lib/src/bake.js +372 -0
  10. package/lib/src/base-image.d.ts +76 -0
  11. package/lib/src/base-image.js +138 -0
  12. package/lib/src/build-args.d.ts +26 -0
  13. package/lib/src/build-args.js +164 -0
  14. package/lib/src/cache-limit.d.ts +54 -0
  15. package/lib/src/cache-limit.js +239 -0
  16. package/lib/src/casefold-data.d.ts +2 -0
  17. package/lib/src/casefold-data.js +217 -0
  18. package/lib/src/cbor.d.ts +39 -0
  19. package/lib/src/cbor.js +174 -0
  20. package/lib/src/checklinks.d.ts +42 -0
  21. package/lib/src/checklinks.js +118 -0
  22. package/lib/src/cli/bake.d.ts +2 -0
  23. package/lib/src/cli/bake.js +179 -0
  24. package/lib/src/cli/build-image.d.ts +21 -0
  25. package/lib/src/cli/build-image.js +42 -0
  26. package/lib/src/cli/cache.d.ts +8 -0
  27. package/lib/src/cli/cache.js +109 -0
  28. package/lib/src/cli/common.d.ts +29 -0
  29. package/lib/src/cli/common.js +100 -0
  30. package/lib/src/cli/git-context.d.ts +18 -0
  31. package/lib/src/cli/git-context.js +48 -0
  32. package/lib/src/cli/images.d.ts +6 -0
  33. package/lib/src/cli/images.js +285 -0
  34. package/lib/src/cli/index.d.ts +10 -0
  35. package/lib/src/cli/index.js +95 -0
  36. package/lib/src/cli/outdated.d.ts +5 -0
  37. package/lib/src/cli/outdated.js +94 -0
  38. package/lib/src/cli/registry.d.ts +2 -0
  39. package/lib/src/cli/registry.js +122 -0
  40. package/lib/src/cli/serve-worker.d.ts +2 -0
  41. package/lib/src/cli/serve-worker.js +42 -0
  42. package/lib/src/cli/servers.d.ts +15 -0
  43. package/lib/src/cli/servers.js +451 -0
  44. package/lib/src/cli/vendor.d.ts +2 -0
  45. package/lib/src/cli/vendor.js +92 -0
  46. package/lib/src/cli/watch.d.ts +26 -0
  47. package/lib/src/cli/watch.js +81 -0
  48. package/lib/src/cli.d.ts +12 -0
  49. package/lib/src/cli.js +486 -0
  50. package/lib/src/csv.d.ts +27 -0
  51. package/lib/src/csv.js +117 -0
  52. package/lib/src/deflate.d.ts +76 -0
  53. package/lib/src/deflate.js +604 -0
  54. package/lib/src/digest.d.ts +3 -0
  55. package/lib/src/digest.js +7 -0
  56. package/lib/src/errors.d.ts +34 -0
  57. package/lib/src/errors.js +20 -0
  58. package/lib/src/executor.d.ts +386 -0
  59. package/lib/src/executor.js +2193 -0
  60. package/lib/src/explain.d.ts +64 -0
  61. package/lib/src/explain.js +445 -0
  62. package/lib/src/export-formats.d.ts +16 -0
  63. package/lib/src/export-formats.js +52 -0
  64. package/lib/src/exporter.d.ts +103 -0
  65. package/lib/src/exporter.js +676 -0
  66. package/lib/src/fingerprint.d.ts +40 -0
  67. package/lib/src/fingerprint.js +168 -0
  68. package/lib/src/frontmatter.d.ts +22 -0
  69. package/lib/src/frontmatter.js +45 -0
  70. package/lib/src/index.d.ts +12 -0
  71. package/lib/src/index.js +12 -0
  72. package/lib/src/language/ast.d.ts +353 -0
  73. package/lib/src/language/ast.js +57 -0
  74. package/lib/src/language/parser.d.ts +3 -0
  75. package/lib/src/language/parser.js +612 -0
  76. package/lib/src/language/scanner.d.ts +110 -0
  77. package/lib/src/language/scanner.js +449 -0
  78. package/lib/src/language/validate.d.ts +13 -0
  79. package/lib/src/language/validate.js +367 -0
  80. package/lib/src/links.d.ts +79 -0
  81. package/lib/src/links.js +583 -0
  82. package/lib/src/lint.d.ts +10 -0
  83. package/lib/src/lint.js +223 -0
  84. package/lib/src/markdown.d.ts +29 -0
  85. package/lib/src/markdown.js +612 -0
  86. package/lib/src/media.d.ts +82 -0
  87. package/lib/src/media.js +650 -0
  88. package/lib/src/memo.d.ts +57 -0
  89. package/lib/src/memo.js +93 -0
  90. package/lib/src/oci.d.ts +178 -0
  91. package/lib/src/oci.js +380 -0
  92. package/lib/src/outdated.d.ts +46 -0
  93. package/lib/src/outdated.js +195 -0
  94. package/lib/src/pages.d.ts +10 -0
  95. package/lib/src/pages.js +54 -0
  96. package/lib/src/paths.d.ts +39 -0
  97. package/lib/src/paths.js +101 -0
  98. package/lib/src/planner.d.ts +297 -0
  99. package/lib/src/planner.js +410 -0
  100. package/lib/src/plugins/cache.d.ts +70 -0
  101. package/lib/src/plugins/cache.js +226 -0
  102. package/lib/src/plugins/exec.d.ts +51 -0
  103. package/lib/src/plugins/exec.js +144 -0
  104. package/lib/src/plugins/host.d.ts +75 -0
  105. package/lib/src/plugins/host.js +277 -0
  106. package/lib/src/plugins/wasm.d.ts +23 -0
  107. package/lib/src/plugins/wasm.js +249 -0
  108. package/lib/src/profile.d.ts +42 -0
  109. package/lib/src/profile.js +68 -0
  110. package/lib/src/ref.d.ts +40 -0
  111. package/lib/src/ref.js +79 -0
  112. package/lib/src/registry-auth.d.ts +32 -0
  113. package/lib/src/registry-auth.js +151 -0
  114. package/lib/src/registry-cache.d.ts +71 -0
  115. package/lib/src/registry-cache.js +333 -0
  116. package/lib/src/registry-ops.d.ts +51 -0
  117. package/lib/src/registry-ops.js +248 -0
  118. package/lib/src/registry.d.ts +109 -0
  119. package/lib/src/registry.js +505 -0
  120. package/lib/src/render.d.ts +134 -0
  121. package/lib/src/render.js +576 -0
  122. package/lib/src/resources/archive.d.ts +11 -0
  123. package/lib/src/resources/archive.js +306 -0
  124. package/lib/src/resources/bundle-data.d.ts +16 -0
  125. package/lib/src/resources/bundle-data.js +50 -0
  126. package/lib/src/resources/convert.d.ts +12 -0
  127. package/lib/src/resources/convert.js +63 -0
  128. package/lib/src/resources/documents.d.ts +35 -0
  129. package/lib/src/resources/documents.js +122 -0
  130. package/lib/src/resources/git.d.ts +103 -0
  131. package/lib/src/resources/git.js +720 -0
  132. package/lib/src/resources/https.d.ts +40 -0
  133. package/lib/src/resources/https.js +256 -0
  134. package/lib/src/resources/ignore.d.ts +33 -0
  135. package/lib/src/resources/ignore.js +119 -0
  136. package/lib/src/resources/local.d.ts +64 -0
  137. package/lib/src/resources/local.js +248 -0
  138. package/lib/src/resources/lockfile.d.ts +52 -0
  139. package/lib/src/resources/lockfile.js +172 -0
  140. package/lib/src/resources/netpolicy.d.ts +6 -0
  141. package/lib/src/resources/netpolicy.js +82 -0
  142. package/lib/src/resources/types.d.ts +63 -0
  143. package/lib/src/resources/types.js +13 -0
  144. package/lib/src/resources/vendor.d.ts +94 -0
  145. package/lib/src/resources/vendor.js +296 -0
  146. package/lib/src/resources/wbn-reader.d.ts +44 -0
  147. package/lib/src/resources/wbn-reader.js +302 -0
  148. package/lib/src/schema.d.ts +25 -0
  149. package/lib/src/schema.js +169 -0
  150. package/lib/src/secrets.d.ts +21 -0
  151. package/lib/src/secrets.js +103 -0
  152. package/lib/src/serve-rules.d.ts +29 -0
  153. package/lib/src/serve-rules.js +122 -0
  154. package/lib/src/server.d.ts +45 -0
  155. package/lib/src/server.js +246 -0
  156. package/lib/src/servers.d.ts +71 -0
  157. package/lib/src/servers.js +139 -0
  158. package/lib/src/site.d.ts +60 -0
  159. package/lib/src/site.js +169 -0
  160. package/lib/src/store.d.ts +151 -0
  161. package/lib/src/store.js +596 -0
  162. package/lib/src/tree.d.ts +50 -0
  163. package/lib/src/tree.js +134 -0
  164. package/lib/src/values.d.ts +7 -0
  165. package/lib/src/values.js +37 -0
  166. package/lib/src/vendor.d.ts +19 -0
  167. package/lib/src/vendor.js +145 -0
  168. package/lib/src/watch.d.ts +44 -0
  169. package/lib/src/watch.js +137 -0
  170. package/lib/src/wbn.d.ts +5 -0
  171. package/lib/src/wbn.js +147 -0
  172. package/lib/src/yaml.d.ts +34 -0
  173. package/lib/src/yaml.js +562 -0
  174. package/package.json +58 -4
@@ -0,0 +1,122 @@
1
+ // `_redirects` and `_headers` as `sitefile serve` reads them (docs/CLI.md, "serve").
2
+ // Ported as the rules section of rust/src/server.rs.
3
+ import { safeSegments } from './server.js';
4
+ const HEADER_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
5
+ /** Headers the server owns; `_headers` cannot set them. */
6
+ const PROTECTED_HEADERS = new Set(['content-length', 'transfer-encoding', 'connection', 'keep-alive', 'etag', 'location']);
7
+ const SERVE_STATUSES = new Set([200, 301, 302, 303, 307, 308, 404]);
8
+ /**
9
+ * Parse `_redirects`: `from to [status[!]]` per line, `#` comments. Supported: exact paths and a
10
+ * trailing `/*` with `:splat` in the target; statuses 301, 302, 303, 307, 308 (redirect), 200
11
+ * (rewrite) and 404. Other lines (placeholders, conditions, query matching) are ignored.
12
+ */
13
+ export function parseRedirectRules(text) {
14
+ const out = [];
15
+ for (const line of text.split('\n')) {
16
+ const t = line.trim();
17
+ if (t === '' || t.startsWith('#'))
18
+ continue;
19
+ const fields = t.split(/[ \t]+/);
20
+ if (fields.length < 2 || fields.length > 3)
21
+ continue;
22
+ const from = fields[0];
23
+ const to = fields[1];
24
+ const st = fields[2] ?? '301';
25
+ const force = st.endsWith('!');
26
+ const code = force ? st.slice(0, -1) : st;
27
+ if (!/^[0-9]{3}$/.test(code) || !SERVE_STATUSES.has(Number(code)))
28
+ continue;
29
+ const status = Number(code);
30
+ if (!/^[\x21-\x7e]+$/.test(to) || !(to.startsWith('/') || /^https?:\/\/[^/]/i.test(to)))
31
+ continue;
32
+ if ((status === 200 || status === 404) && !to.startsWith('/'))
33
+ continue;
34
+ if (!from.startsWith('/'))
35
+ continue;
36
+ const splat = from === '/*' || from.endsWith('/*');
37
+ const prefix = splat ? from.slice(0, -2) : from;
38
+ if (prefix.includes('*') || prefix.split('/').some((seg) => seg.startsWith(':')))
39
+ continue;
40
+ const segs = safeSegments(prefix === '' ? '/' : prefix);
41
+ if (!segs)
42
+ continue;
43
+ const decoded = '/' + segs.join('/');
44
+ out.push({ from: splat && decoded === '/' ? '' : decoded, splat, to, status, force });
45
+ }
46
+ return out;
47
+ }
48
+ /** Parse `_headers`: a path pattern line (`*` matches anything), then indented `Name: value` lines. */
49
+ export function parseHeaderRules(text) {
50
+ const out = [];
51
+ let cur;
52
+ for (const raw of text.split('\n')) {
53
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
54
+ const t = line.trim();
55
+ if (t === '' || t.startsWith('#'))
56
+ continue;
57
+ if (line[0] === ' ' || line[0] === '\t') {
58
+ const i = t.indexOf(':');
59
+ if (!cur || i <= 0)
60
+ continue;
61
+ const name = t.slice(0, i).trim().toLowerCase();
62
+ const value = t.slice(i + 1).trim();
63
+ if (!HEADER_TOKEN.test(name) || !/^[\t\x20-\x7e]*$/.test(value) || PROTECTED_HEADERS.has(name))
64
+ continue;
65
+ cur.headers.push([name, value]);
66
+ continue;
67
+ }
68
+ cur = t.startsWith('/') ? { pattern: t, headers: [] } : undefined;
69
+ if (cur)
70
+ out.push(cur);
71
+ }
72
+ return out;
73
+ }
74
+ /** Glob match where `*` matches any run of characters (including `/`). */
75
+ export function globMatch(pattern, s) {
76
+ let p = 0;
77
+ let i = 0;
78
+ let star = -1;
79
+ let mark = 0;
80
+ while (i < s.length) {
81
+ if (p < pattern.length && pattern[p] !== '*' && pattern[p] === s[i]) {
82
+ p++;
83
+ i++;
84
+ }
85
+ else if (p < pattern.length && pattern[p] === '*') {
86
+ star = p++;
87
+ mark = i;
88
+ }
89
+ else if (star >= 0) {
90
+ p = star + 1;
91
+ i = ++mark;
92
+ }
93
+ else {
94
+ return false;
95
+ }
96
+ }
97
+ while (pattern[p] === '*')
98
+ p++;
99
+ return p === pattern.length;
100
+ }
101
+ /** Every matching block's headers; a name set by several blocks gets its values joined with `, `. */
102
+ export function headersFor(rules, path) {
103
+ const out = {};
104
+ for (const r of rules) {
105
+ if (!globMatch(r.pattern, path))
106
+ continue;
107
+ for (const [k, v] of r.headers)
108
+ out[k] = out[k] === undefined ? v : `${out[k]}, ${v}`;
109
+ }
110
+ return out;
111
+ }
112
+ /** The remainder a rule matches for `path` (`''` for the prefix itself), or undefined. */
113
+ export function matchRedirect(r, path) {
114
+ if (!r.splat)
115
+ return path === r.from ? '' : undefined;
116
+ if (r.from === '')
117
+ return path.slice(1);
118
+ if (path === r.from)
119
+ return '';
120
+ return path.startsWith(r.from + '/') ? path.slice(r.from.length + 1) : undefined;
121
+ }
122
+ //# sourceMappingURL=serve-rules.js.map
@@ -0,0 +1,45 @@
1
+ import http from 'node:http';
2
+ import type { Tree } from './tree.js';
3
+ export interface SiteServerOptions {
4
+ /** Fall back to `/index.html` for unknown extension-less paths (single-page apps). */
5
+ readonly spa?: boolean;
6
+ /** One access-log line per request: `METHOD /path STATUS BYTES MSms`. */
7
+ readonly log?: (line: string) => void;
8
+ /** Sent as `x-sitefile-server` so `servers` can tell its own servers from a reused port. */
9
+ readonly serverId?: string;
10
+ /**
11
+ * `serve --watch`: answer {@link RELOAD_PATH} with an event stream and add
12
+ * {@link RELOAD_SCRIPT} to HTML responses. Never set otherwise, and never written to exports.
13
+ */
14
+ readonly liveReload?: LiveReload;
15
+ }
16
+ /** Reserved for the live-reload event stream while `serve --watch` runs. */
17
+ export declare const RELOAD_PATH = "/__sitefile/reload";
18
+ /** Added to served HTML (before the last `</body>`, else at the end) under `serve --watch`. */
19
+ export declare const RELOAD_SCRIPT = "<script>new EventSource(\"/__sitefile/reload\").onmessage=function(){location.reload()}</script>";
20
+ /** Open live-reload streams; {@link LiveReload.reload} tells every page to reload. */
21
+ export declare class LiveReload {
22
+ private readonly clients;
23
+ /** Hold `res` open as a text/event-stream until the client goes away. */
24
+ attach(res: http.ServerResponse, headers: Record<string, string>): void;
25
+ reload(): void;
26
+ get size(): number;
27
+ }
28
+ /** `html` with {@link RELOAD_SCRIPT} inserted before the last `</body` (ASCII case-insensitive), else appended. */
29
+ export declare function injectReloadScript(html: Uint8Array): Uint8Array;
30
+ /** Decode a request path into safe segments, or undefined when it is malformed or unsafe. */
31
+ export declare function safeSegments(rawPath: string): string[] | undefined;
32
+ /**
33
+ * An HTTP server for an immutable Tree. GET and HEAD only; nothing outside the tree is
34
+ * ever reachable because lookups are exact map keys built from validated segments.
35
+ * Honours `/404.html`, `/_redirects` and `/_headers` (which it never serves), and answers
36
+ * `/about` with `/about.html` when there is no such file or directory.
37
+ */
38
+ export declare function createSiteServer(site: Tree | (() => Tree), options?: SiteServerOptions): http.Server;
39
+ /** Listen on host:port (port 0 = any free port); EADDRINUSE and friends become E_SERVER. */
40
+ export declare function listen(server: http.Server, host: string, port: number): Promise<{
41
+ host: string;
42
+ port: number;
43
+ }>;
44
+ export declare function closeServer(server: http.Server): Promise<void>;
45
+ export declare function formatUrl(host: string, port: number): string;
@@ -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,71 @@
1
+ import { Store } from './store.js';
2
+ import { Tree } from './tree.js';
3
+ /**
4
+ * Detached site servers ("containers"). One state file per server:
5
+ *
6
+ * $SITEFILE_HOME/servers/<id>.json state (this interface)
7
+ * $SITEFILE_HOME/servers/<id>.log stdout/stderr of the server process
8
+ *
9
+ * `serve -d` writes the state, spawns `node serve-worker.js <id>` detached, and the worker
10
+ * fills in `pid`, `host`, `port` and `ready` once it is listening. `servers` never trusts the
11
+ * file alone: a server is `running` only when its pid is alive AND the port answers with
12
+ * this server's `x-sitefile-server` header.
13
+ */
14
+ export interface ServerState {
15
+ readonly id: string;
16
+ readonly name: string;
17
+ /** What the user asked to serve: the image reference as typed, or the directory. */
18
+ readonly image: string;
19
+ readonly source: {
20
+ readonly kind: 'image';
21
+ readonly imageId: string;
22
+ } | {
23
+ readonly kind: 'dir';
24
+ readonly path: string;
25
+ };
26
+ readonly spa: boolean;
27
+ /** The requested bind address (port 0 = pick a free port). */
28
+ readonly bind: {
29
+ readonly host: string;
30
+ readonly port: number;
31
+ };
32
+ readonly host: string;
33
+ readonly port: number;
34
+ readonly pid: number;
35
+ readonly started: string;
36
+ readonly logFile: string;
37
+ readonly ready: boolean;
38
+ readonly error?: string;
39
+ }
40
+ export interface ServerView extends ServerState {
41
+ readonly status: 'running' | 'exited';
42
+ readonly url: string;
43
+ }
44
+ export declare class ServerRegistry {
45
+ readonly store: Store;
46
+ readonly dir: string;
47
+ private constructor();
48
+ static open(store?: Store): Promise<ServerRegistry>;
49
+ statePath(id: string): string;
50
+ logPath(id: string): string;
51
+ read(id: string): Promise<ServerState | undefined>;
52
+ write(state: ServerState): Promise<void>;
53
+ update(id: string, patch: Partial<ServerState>): Promise<ServerState | undefined>;
54
+ remove(id: string): Promise<void>;
55
+ all(): Promise<ServerState[]>;
56
+ /** Find by exact ID, exact name, or unique ID prefix. */
57
+ find(key: string): Promise<ServerState>;
58
+ /** Allocate an ID and validate/choose the name (names are unique among all servers, running or not). */
59
+ allocate(name: string | undefined): Promise<{
60
+ id: string;
61
+ name: string;
62
+ }>;
63
+ view(s: ServerState): Promise<ServerView>;
64
+ }
65
+ export declare function pidAlive(pid: number): boolean;
66
+ /** Alive AND answering on its port with its own id header (guards against pid and port reuse). */
67
+ export declare function isRunning(s: ServerState): Promise<boolean>;
68
+ /** Load a directory into a Tree through the sandboxed local reader; hidden files (except .well-known) are not served. */
69
+ export declare function loadDirectoryTree(dir: string): Promise<Tree>;
70
+ /** The tree a server state serves. */
71
+ export declare function loadServedTree(store: Store, source: ServerState['source']): Promise<Tree>;
@@ -0,0 +1,139 @@
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 { SitefileError } from './errors.js';
6
+ import { LocalContext } from './resources/local.js';
7
+ import { Store } from './store.js';
8
+ import { Tree } from './tree.js';
9
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
10
+ export class ServerRegistry {
11
+ store;
12
+ dir;
13
+ constructor(store) {
14
+ this.store = store;
15
+ this.dir = path.join(store.home, 'servers');
16
+ }
17
+ static async open(store) {
18
+ const reg = new ServerRegistry(store ?? (await Store.open()));
19
+ await fs.mkdir(reg.dir, { recursive: true, mode: 0o700 });
20
+ return reg;
21
+ }
22
+ statePath(id) {
23
+ if (!/^[0-9a-f]{12}$/.test(id))
24
+ throw new SitefileError('E_SERVER', `invalid server id '${id}'`);
25
+ return path.join(this.dir, `${id}.json`);
26
+ }
27
+ logPath(id) {
28
+ return path.join(this.dir, `${id}.log`);
29
+ }
30
+ async read(id) {
31
+ try {
32
+ return JSON.parse(await fs.readFile(this.statePath(id), 'utf8'));
33
+ }
34
+ catch (e) {
35
+ if (e.code === 'ENOENT' || e instanceof SyntaxError)
36
+ return undefined;
37
+ throw e;
38
+ }
39
+ }
40
+ async write(state) {
41
+ const dest = this.statePath(state.id);
42
+ const tmp = `${dest}.${randomBytes(4).toString('hex')}.tmp`;
43
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
44
+ await fs.rename(tmp, dest);
45
+ }
46
+ async update(id, patch) {
47
+ const cur = await this.read(id);
48
+ if (!cur)
49
+ return undefined;
50
+ const next = { ...cur, ...patch };
51
+ await this.write(next);
52
+ return next;
53
+ }
54
+ async remove(id) {
55
+ await fs.rm(this.statePath(id), { force: true });
56
+ await fs.rm(this.logPath(id), { force: true });
57
+ }
58
+ async all() {
59
+ const out = [];
60
+ for (const f of await fs.readdir(this.dir)) {
61
+ const m = /^([0-9a-f]{12})\.json$/.exec(f);
62
+ const s = m ? await this.read(m[1]) : undefined;
63
+ if (s)
64
+ out.push(s);
65
+ }
66
+ return out.sort((a, b) => (a.started < b.started ? -1 : a.started > b.started ? 1 : 0));
67
+ }
68
+ /** Find by exact ID, exact name, or unique ID prefix. */
69
+ async find(key) {
70
+ const all = await this.all();
71
+ const hit = all.find((s) => s.id === key) ?? all.find((s) => s.name === key);
72
+ if (hit)
73
+ return hit;
74
+ const pre = key.length >= 3 ? all.filter((s) => s.id.startsWith(key)) : [];
75
+ if (pre.length === 1)
76
+ return pre[0];
77
+ if (pre.length > 1)
78
+ throw new SitefileError('E_SERVER', `ambiguous server '${key}' matches ${pre.length} servers`);
79
+ throw new SitefileError('E_NOT_FOUND', `no such server: ${key}`);
80
+ }
81
+ /** Allocate an ID and validate/choose the name (names are unique among all servers, running or not). */
82
+ async allocate(name) {
83
+ const all = await this.all();
84
+ if (name !== undefined) {
85
+ if (!NAME_RE.test(name))
86
+ throw new SitefileError('E_USAGE', `invalid server name '${name}' (letters, digits, '_', '.', '-')`);
87
+ if (all.some((s) => s.name === name))
88
+ throw new SitefileError('E_SERVER', `a server named '${name}' already exists; remove it with "sitefile rm ${name}"`);
89
+ }
90
+ const id = randomBytes(6).toString('hex');
91
+ return { id, name: name ?? `site-${id.slice(0, 6)}` };
92
+ }
93
+ async view(s) {
94
+ const running = await isRunning(s);
95
+ return { ...s, status: running ? 'running' : 'exited', url: s.ready ? urlOf(s) : '' };
96
+ }
97
+ }
98
+ function urlOf(s) {
99
+ const shown = s.host === '0.0.0.0' ? '127.0.0.1' : s.host === '::' ? '::1' : s.host;
100
+ return `http://${shown.includes(':') ? `[${shown}]` : shown}:${s.port}/`;
101
+ }
102
+ export function pidAlive(pid) {
103
+ if (!Number.isInteger(pid) || pid <= 0)
104
+ return false;
105
+ try {
106
+ process.kill(pid, 0);
107
+ return true;
108
+ }
109
+ catch (e) {
110
+ return e.code === 'EPERM';
111
+ }
112
+ }
113
+ /** Alive AND answering on its port with its own id header (guards against pid and port reuse). */
114
+ export async function isRunning(s) {
115
+ if (!s.ready || !pidAlive(s.pid))
116
+ return false;
117
+ const host = s.host === '0.0.0.0' ? '127.0.0.1' : s.host === '::' ? '::1' : s.host;
118
+ return new Promise((resolve) => {
119
+ const req = http.request({ host, port: s.port, method: 'HEAD', path: '/', headers: { 'x-sitefile-probe': s.id }, timeout: 1500, agent: false }, (res) => {
120
+ res.resume();
121
+ resolve(res.headers['x-sitefile-server'] === s.id);
122
+ });
123
+ req.on('timeout', () => req.destroy());
124
+ req.on('error', () => resolve(false));
125
+ req.end();
126
+ });
127
+ }
128
+ /** Load a directory into a Tree through the sandboxed local reader; hidden files (except .well-known) are not served. */
129
+ export async function loadDirectoryTree(dir) {
130
+ const ctx = await LocalContext.open(dir, { sitefileignore: false });
131
+ const { tree } = await ctx.readTree('', 'serve');
132
+ const visible = tree.entries().filter(([p]) => p.split('/').every((seg) => !seg.startsWith('.') || seg === '' || seg === '.well-known'));
133
+ return Tree.fromFiles(visible);
134
+ }
135
+ /** The tree a server state serves. */
136
+ export async function loadServedTree(store, source) {
137
+ return source.kind === 'image' ? store.loadTree(source.imageId) : loadDirectoryTree(source.path);
138
+ }
139
+ //# sourceMappingURL=servers.js.map