@nextwebwg/htmlkit 1.0.0-alpha.52 → 1.0.0-alpha.54

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 (54) hide show
  1. package/README.md +9 -6
  2. package/dist/application.d.ts +15 -1
  3. package/dist/application.d.ts.map +1 -1
  4. package/dist/application.js +152 -311
  5. package/dist/application.js.map +1 -1
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +116 -18
  8. package/dist/build.js.map +1 -1
  9. package/dist/bundle-worker.js +17 -7
  10. package/dist/bundle-worker.js.map +1 -1
  11. package/dist/bundle.d.ts +7 -1
  12. package/dist/bundle.d.ts.map +1 -1
  13. package/dist/bundle.js +6 -4
  14. package/dist/bundle.js.map +1 -1
  15. package/dist/cli.js +38 -10
  16. package/dist/cli.js.map +1 -1
  17. package/dist/client.js +11 -11
  18. package/dist/client.js.map +1 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +9 -3
  21. package/dist/config.js.map +1 -1
  22. package/dist/document.d.ts +1 -1
  23. package/dist/document.d.ts.map +1 -1
  24. package/dist/document.js.map +1 -1
  25. package/dist/head.d.ts +19 -2
  26. package/dist/head.d.ts.map +1 -1
  27. package/dist/head.js +26 -21
  28. package/dist/head.js.map +1 -1
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/links.d.ts +8 -0
  34. package/dist/links.d.ts.map +1 -0
  35. package/dist/links.js +90 -0
  36. package/dist/links.js.map +1 -0
  37. package/dist/resource.d.ts +2 -0
  38. package/dist/resource.d.ts.map +1 -1
  39. package/dist/resource.js +8 -3
  40. package/dist/resource.js.map +1 -1
  41. package/dist/routes.d.ts +13 -5
  42. package/dist/routes.d.ts.map +1 -1
  43. package/dist/routes.js +92 -47
  44. package/dist/routes.js.map +1 -1
  45. package/dist/serve.d.ts.map +1 -1
  46. package/dist/serve.js +26 -78
  47. package/dist/serve.js.map +1 -1
  48. package/dist/server.d.ts +162 -0
  49. package/dist/server.d.ts.map +1 -0
  50. package/dist/server.js +599 -0
  51. package/dist/server.js.map +1 -0
  52. package/dist/types.d.ts +92 -35
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +8 -3
package/dist/server.js ADDED
@@ -0,0 +1,599 @@
1
+ // The request-time core, shared by Application.fetch and the built server. Nothing here imports Vite,
2
+ // so a build's server entry runs on Node, Deno, or Bun with only the installed packages.
3
+ import { createServer as createHTTPServer } from "node:http";
4
+ import { readFile, realpath, stat } from "node:fs/promises";
5
+ import { extname, join, resolve } from "node:path";
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { serializePropTarget, serializeTypedValue } from "@nextwebwg/html-next";
10
+ import { renderComponents, setRenderWorkers } from "@nextwebwg/html-next/server";
11
+ import { HtmlKitError, within } from "./config.js";
12
+ import { documentHTML, escapeHTML, pagePayload, redirectPage } from "./document.js";
13
+ import { headCarrier, mergeHead } from "./head.js";
14
+ import { parseFragment } from "parse5";
15
+ import { matchRoute, parameter, redirectLocation, redirectStatuses } from "./routes.js";
16
+ export { setRenderWorkers } from "@nextwebwg/html-next/server";
17
+ /** navigation(), navigationTree(), breadcrumbs(), and pager() over routes and each concrete page URL's route. */
18
+ export function createNavigation(base, routes, concrete) {
19
+ const checkedFrom = (from = "/") => {
20
+ if (!/^\/(?:[^/]+\/)*$/.test(from) || from.includes("[") || from.includes("?") || from.includes("#")) {
21
+ throw new HtmlKitError("Navigation from requires a concrete application-relative directory prefix.", from);
22
+ }
23
+ return from;
24
+ };
25
+ const item = (href, route, depth, current) => Object.freeze({
26
+ href, label: route.label ?? decodeURIComponent(href.slice(base.length).split("/").filter(Boolean).at(-1) ?? "Home"),
27
+ current: href === current ? "page" : "false", depth, pageName: route.pageName,
28
+ ...(route.groups === undefined ? {} : { group: route.groups.at(-1).label })
29
+ });
30
+ // A page's place in the file tree: each URL segment, with its (group) folders where they sit.
31
+ // depth is the URL segments above the element: a segment's index, or a group's depth.
32
+ const treePath = (href, route) => {
33
+ const path = href.slice(base.length).split("/").filter(Boolean)
34
+ .map((name, i) => ({ name, rank: route.order?.[i] ?? null, depth: i }));
35
+ // Deepest first, so each insertion index is still a count of URL segments.
36
+ for (const group of (route.groups ?? []).toReversed())
37
+ path.splice(group.depth, 0, { name: group.label, rank: group.order, depth: group.depth, group: true });
38
+ return path;
39
+ };
40
+ const navigation = async (query = {}) => {
41
+ const from = checkedFrom(query.from);
42
+ const prefix = base + from.slice(1);
43
+ const depth = from.split("/").filter(Boolean).length;
44
+ // Hidden pages and alias routes stay routable but out of navigation.
45
+ const items = [...await concrete()].filter(([path, route]) => path.startsWith(prefix) && !route.hidden && route.canonical === undefined);
46
+ const paths = new Map(items.map(([href, route]) => [href, treePath(href, route)]));
47
+ items.sort(([a], [b]) => {
48
+ const aParts = paths.get(a);
49
+ const bParts = paths.get(b);
50
+ for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
51
+ const aRank = aParts[i].rank;
52
+ const bRank = bParts[i].rank;
53
+ if (aRank !== bRank) {
54
+ if (aRank === null)
55
+ return 1;
56
+ if (bRank === null)
57
+ return -1;
58
+ const difference = BigInt(aRank) - BigInt(bRank);
59
+ if (difference !== 0n)
60
+ return difference < 0n ? -1 : 1;
61
+ }
62
+ const difference = aParts[i].name.localeCompare(bParts[i].name);
63
+ if (difference !== 0)
64
+ return difference;
65
+ }
66
+ return aParts.length - bParts.length || a.localeCompare(b);
67
+ });
68
+ return Object.freeze(items.map(([href, route]) => item(href, route, href.slice(base.length).split("/").filter(Boolean).length - depth, query.current)));
69
+ };
70
+ // Each existing page from `from` down to the current one, by URL; the current page is included even when hidden.
71
+ const breadcrumbs = async (query = {}) => {
72
+ const from = checkedFrom(query.from);
73
+ const current = query.current;
74
+ if (current === undefined || !current.startsWith(base + from.slice(1)))
75
+ return Object.freeze([]);
76
+ const paths = await concrete();
77
+ const parts = current.slice(base.length).split("/").filter(Boolean);
78
+ const start = from.split("/").filter(Boolean).length;
79
+ const trail = [];
80
+ for (let i = start; i <= parts.length; i++) {
81
+ const href = base + parts.slice(0, i).map(part => part + "/").join("");
82
+ // The page being rendered exists even when no entries() lists it, as for a dynamic page at request time.
83
+ const route = paths.get(href) ?? (i === parts.length ? matchRoute(routes, href, base)?.route : undefined);
84
+ if (route !== undefined && route.canonical === undefined)
85
+ trail.push(item(href, route, i - start, current));
86
+ }
87
+ return Object.freeze(trail);
88
+ };
89
+ // navigation()'s pages, which come in tree order, nested under the folders and groups below `from`.
90
+ const navigationTree = async (query = {}) => {
91
+ const items = await navigation(query);
92
+ const paths = await concrete();
93
+ const fromDepth = checkedFrom(query.from).split("/").filter(Boolean).length;
94
+ const top = [];
95
+ const nodes = new Map();
96
+ for (const item of items) {
97
+ const route = paths.get(item.href);
98
+ let siblings = top;
99
+ let key = "";
100
+ let node;
101
+ // The page's own node is the last element below `from`; `from`'s index page is a top node of its own.
102
+ for (const element of treePath(item.href, route).filter(element => element.depth >= fromDepth)) {
103
+ key += `${element.group ? "(" : "/"}${element.name}\0`;
104
+ node = nodes.get(key);
105
+ if (node === undefined) {
106
+ node = { label: element.group ? element.name : decodeURIComponent(element.name), order: element.rank, children: [] };
107
+ nodes.set(key, node);
108
+ siblings.push(node);
109
+ }
110
+ siblings = node.children;
111
+ }
112
+ if (node === undefined) {
113
+ node = { label: item.label, order: null, children: [] };
114
+ top.unshift(node);
115
+ }
116
+ node.item = item;
117
+ node.route = route;
118
+ }
119
+ const finish = (draft, depth) => {
120
+ const children = draft.children.map(child => finish(child, depth + 1));
121
+ const { item, route } = draft;
122
+ // A folder's index page labels the folder, by hk:label, then its <title>.
123
+ const label = item === undefined ? draft.label : children.length > 0 && route.label === undefined ? route.title ?? draft.label : item.label;
124
+ const current = item?.current ?? "false";
125
+ return Object.freeze({ label, ...(item === undefined ? {} : { href: item.href, pageName: item.pageName }), current,
126
+ open: current === "page" || children.some(child => child.open), depth, order: draft.order, children: Object.freeze(children) });
127
+ };
128
+ return Object.freeze(top.map(draft => finish(draft, 0)));
129
+ };
130
+ // The neighbours of the current page in navigation order.
131
+ const pager = async (query = {}) => {
132
+ const items = await navigation(query);
133
+ const index = items.findIndex(entry => entry.current === "page");
134
+ if (index < 0)
135
+ return Object.freeze({});
136
+ return Object.freeze({ ...(index > 0 ? { previous: items[index - 1] } : {}), ...(index < items.length - 1 ? { next: items[index + 1] } : {}) });
137
+ };
138
+ return { navigation, navigationTree, breadcrumbs, pager };
139
+ }
140
+ /** What a loader's redirect() throws, so render answers with the redirect instead of the page. */
141
+ class Redirect {
142
+ location;
143
+ status;
144
+ constructor(location, status) {
145
+ this.location = location;
146
+ this.status = status;
147
+ }
148
+ }
149
+ /** What a loader's notFound() throws. */
150
+ class NotFound {
151
+ status = 404;
152
+ }
153
+ /** A loader's props as invocation attributes, in their HTML form, defaults included. */
154
+ function invocationAttributes(definition, result) {
155
+ const props = result.props ?? {};
156
+ for (const name of Object.keys(props)) {
157
+ if (!Object.hasOwn(definition.contract.props, name))
158
+ throw new HtmlKitError(`Undeclared prop ${name}.`, definition.source.file);
159
+ }
160
+ const attributes = {};
161
+ for (const [name, contract] of Object.entries(definition.contract.props)) {
162
+ serializePropTarget(contract, props[name], name, definition.contract.tag);
163
+ // The contract target describes lowering onto native DOM. Invocation attributes carry
164
+ // the public prop name; writing the target name here silently loses remapped props.
165
+ const provided = props[name] === undefined ? contract.default : props[name];
166
+ const value = provided == null ? null : serializeTypedValue(provided, contract.type);
167
+ if (value !== null)
168
+ attributes[name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()] = value;
169
+ }
170
+ return attributes;
171
+ }
172
+ /** The browser builds the same invocation from a page payload (client.ts). */
173
+ function invocation(tag, attributes, id, child, nested) {
174
+ return `<${tag} id="${id}"${nested ? ' slot="page"' : ""}${Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeHTML(value)}"`).join("")}>${child}</${tag}>`;
175
+ }
176
+ /** The request's cookies by name; the first of a repeated name wins, as the most specific path comes first. */
177
+ function parseCookies(header) {
178
+ const cookies = new Map();
179
+ for (const pair of (header ?? "").split(";")) {
180
+ const separator = pair.indexOf("=");
181
+ const name = pair.slice(0, separator).trim();
182
+ if (separator < 0 || name === "" || cookies.has(name))
183
+ continue;
184
+ let value = pair.slice(separator + 1).trim();
185
+ if (value.length > 1 && value.startsWith('"') && value.endsWith('"'))
186
+ value = value.slice(1, -1);
187
+ try {
188
+ value = decodeURIComponent(value);
189
+ }
190
+ catch { /* Kept as sent. */ }
191
+ cookies.set(name, value);
192
+ }
193
+ return cookies;
194
+ }
195
+ export const serverFailure = Object.freeze({ status: 500, message: "Internal Server Error" });
196
+ /** The page for a failure without app/error.html, or when it fails too. */
197
+ export function failurePage({ status, message }, scripts) {
198
+ const head = { title: message, scripts };
199
+ const body = `<main><h1>${escapeHTML(message)}</h1></main>`;
200
+ return { status, head, body, html: documentHTML(body, head) };
201
+ }
202
+ /** Run the layers' loaders, outermost first, and render their invocations through HTML Next. */
203
+ export async function renderRoute(input) {
204
+ const { base, route, params, url, request, signal, layers, navigation } = input;
205
+ const response = { status: 200, headers: new Headers() };
206
+ let cookies;
207
+ const results = [];
208
+ const attributes = [];
209
+ let parent = Object.freeze({});
210
+ let head = {};
211
+ // An alias marks its page's own URL current, with the alias's parameters.
212
+ const current = route.canonical === undefined ? url.pathname : base + route.canonical.slice(1).split("/").filter(Boolean).map(segment => {
213
+ const value = params[parameter(segment) ?? ""];
214
+ return (value === undefined ? [segment] : [value].flat().map(encodeURIComponent)).map(part => part + "/").join("");
215
+ }).join("");
216
+ for (const layer of layers) {
217
+ signal.throwIfAborted();
218
+ const loaded = await layer.module();
219
+ const prerendering = (name) => {
220
+ throw new HtmlKitError(`${name} is unavailable while prerendering; <meta name="hk:render" content="server"> renders the page on each request.`, layer.origin);
221
+ };
222
+ const context = { phase: request === undefined ? "prerender" : "request", url: new URL(url), base, params, parent,
223
+ propNames: Object.freeze(Object.keys(layer.definition.contract.props)),
224
+ fetch: globalThis.fetch, signal,
225
+ navigation: query => navigation.navigation({ current, ...query }),
226
+ navigationTree: query => navigation.navigationTree({ current, ...query }),
227
+ breadcrumbs: query => navigation.breadcrumbs({ current, ...query }),
228
+ pager: query => navigation.pager({ current, ...query }),
229
+ redirect(location, status = 303) {
230
+ if (!redirectStatuses.includes(status))
231
+ throw new HtmlKitError(`Redirect status ${String(status)} must be 301, 302, 303, 307, or 308.`, layer.origin);
232
+ throw new Redirect(redirectLocation(base, location, layer.origin), status);
233
+ },
234
+ notFound() { throw new NotFound(); },
235
+ get request() { return request ?? prerendering("request"); },
236
+ get cookies() { return request === undefined ? prerendering("cookies") : cookies ??= parseCookies(request.headers.get("cookie")); },
237
+ get response() { return request === undefined ? prerendering("response") : response; } };
238
+ let result;
239
+ // app/error.html takes the failure's status and message as the props it declares.
240
+ const supplied = input.failure === undefined || layer !== layers.at(-1) ? {} : { props: Object.fromEntries(Object.entries(input.failure)
241
+ .filter(([name]) => Object.hasOwn(layer.definition.contract.props, name))) };
242
+ try {
243
+ result = loaded.load === undefined ? supplied : await loaded.load(context);
244
+ }
245
+ catch (error) {
246
+ if (error instanceof Redirect)
247
+ return { kind: "redirect", location: error.location, status: error.status, headers: response.headers };
248
+ if (error instanceof NotFound)
249
+ return { kind: "notFound", headers: response.headers };
250
+ throw error;
251
+ }
252
+ if (result === null || typeof result !== "object" || Array.isArray(result))
253
+ throw new HtmlKitError("load() must return a LoaderResult object.", layer.origin);
254
+ results.push(result);
255
+ parent = Object.freeze({ ...parent, ...result.data });
256
+ // Checked against the layer's own component, which a prop's diagnostic names; its head reuses them.
257
+ attributes.push(invocationAttributes(layer.definition, result));
258
+ }
259
+ const state = {};
260
+ const rendered = layers.map((layer, i) => ({ component: layer.definition.contract.tag,
261
+ attributes: attributes[i], ...(results[i].state === undefined ? {} : { state: results[i].state }) }));
262
+ let body = "";
263
+ for (let i = layers.length - 1; i >= 0; i--) {
264
+ const id = `hk-layer-${i}`;
265
+ const layer = rendered[i];
266
+ if (layer.state !== undefined)
267
+ state[`#${id}`] = layer.state;
268
+ body = invocation(layer.component, layer.attributes, id, body, i > 0);
269
+ }
270
+ // One render for the page and every layer's head: each head is a component after the page, with its layer's props.
271
+ const carriers = layers.map((layer, i) => headCarrier(layer.metadata, layer.definition, `hk-head-${i}`));
272
+ const heads = carriers.flatMap((carrier, i) => carrier === undefined ? [] : [invocation(carrier.contract.tag, attributes[i], `hk-head-${i}`, "", false)]);
273
+ const output = await renderComponents(heads.length === 0 ? body : `${body}<div data-hk-heads>${heads.join("")}</div>`, { definitions: [...input.definitions, ...carriers.filter(carrier => carrier !== undefined)], url: url.href, state });
274
+ signal.throwIfAborted();
275
+ const { page, roots } = splitHeads(output.html, heads.length > 0);
276
+ for (const [i, layer] of layers.entries())
277
+ head = mergeHead(roots.get(`hk-head-${i}`), layer.definition, results[i], head);
278
+ if (input.scripts.length > 0)
279
+ head = { ...head, scripts: input.scripts };
280
+ // An alias names its page's own URL canonical, unless the page names one.
281
+ const elements = head.elements ?? [];
282
+ if (route.canonical !== undefined && !current.includes("[") && !elements.some(element => element.tag === "link" && element.attributes.rel?.toLowerCase().split(/\s+/).includes("canonical"))) {
283
+ head = { ...head, elements: [...elements, { tag: "link", attributes: { rel: "canonical", href: input.canonicalOrigin + current } }] };
284
+ }
285
+ return { kind: "page", status: response.status, headers: response.headers, body: page, css: output.css, head, layers: rendered };
286
+ }
287
+ /** A render's page markup, exactly as rendered, and its head carriers' roots by id. */
288
+ function splitHeads(html, carried) {
289
+ const roots = new Map();
290
+ if (!carried)
291
+ return { page: html, roots };
292
+ const container = parseFragment(html, { sourceCodeLocationInfo: true }).childNodes.findLast(node => "tagName" in node);
293
+ if (container === undefined || !("tagName" in container) || !container.attrs.some(attribute => attribute.name === "data-hk-heads")) {
294
+ throw new HtmlKitError("Head rendering produced no head carriers.");
295
+ }
296
+ for (const node of container.childNodes) {
297
+ const id = "tagName" in node ? node.attrs.find(attribute => attribute.name === "id")?.value : undefined;
298
+ if (id !== undefined)
299
+ roots.set(id, node);
300
+ }
301
+ return { page: html.slice(0, container.sourceCodeLocation.startOffset), roots };
302
+ }
303
+ /** A page's answer: a redirect with its redirect page, its document, or for a payload request its payload (404 when it has none). */
304
+ export function pageResponse(page, request, payload, assets = { styles: [], modules: [] }) {
305
+ const headers = new Headers(page.headers);
306
+ const bodyless = request.method === "HEAD";
307
+ if (payload) {
308
+ // Client navigation finds no payload for a redirect or an error, and loads the URL itself.
309
+ const data = page.location === undefined ? pagePayload(page, assets) : undefined;
310
+ headers.set("content-type", "application/json");
311
+ return new Response(data === undefined || bodyless ? null : JSON.stringify(data), { status: data === undefined ? 404 : 200, headers });
312
+ }
313
+ headers.set("content-type", "text/html; charset=utf-8");
314
+ if (page.location !== undefined) {
315
+ headers.set("location", page.location);
316
+ return new Response(bodyless ? null : redirectPage(page.location).html, { status: page.status, headers });
317
+ }
318
+ return new Response(bodyless ? null : documentHTML(page.body, page.head, assets), { status: page.status, headers });
319
+ }
320
+ const methods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
321
+ /** Answer a request: its payload URL becomes the page's, then middleware, then redirects, endpoints, and pages. */
322
+ export async function respond(app, request) {
323
+ const url = new URL(request.url);
324
+ const pages = app.base + "_htmlkit/pages/";
325
+ let payload = false;
326
+ const page = url.pathname.startsWith(pages) ? /^(.*\/)?payload\.json$/.exec(url.pathname.slice(pages.length)) : null;
327
+ if (page !== null) {
328
+ if (request.method !== "GET" && request.method !== "HEAD")
329
+ return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
330
+ payload = true;
331
+ // Concatenated, so a payload path starting with // stays on this origin and names no page.
332
+ request = new Request(url.origin + app.base + (page[1] ?? "") + url.search, { method: request.method, headers: request.headers, signal: request.signal });
333
+ }
334
+ const answer = (request) => route(app, request, payload);
335
+ if (app.middleware === undefined)
336
+ return answer(request);
337
+ const middleware = await app.middleware();
338
+ const next = async (rewrite) => {
339
+ const answered = await answer(rewrite === undefined ? request : rewrite instanceof Request ? rewrite : new Request(new URL(rewrite, request.url), request));
340
+ // A new Response around the answer, so middleware can set headers even on Response.redirect()'s.
341
+ return new Response(answered.body, answered);
342
+ };
343
+ const response = await middleware(request, next);
344
+ if (!(response instanceof Response))
345
+ throw new HtmlKitError("Middleware must return a Response, such as next()'s.");
346
+ return response;
347
+ }
348
+ async function route(app, request, payload) {
349
+ const url = new URL(request.url);
350
+ const { pathname } = url;
351
+ // A path starting with // would name another origin; no page lives there.
352
+ if (!pathname.startsWith(app.base) || pathname.startsWith("//"))
353
+ return app.notFound(request, payload);
354
+ // The redirects option precedes pages, with or without the trailing slash.
355
+ const redirect = app.redirects.get(pathname.endsWith("/") ? pathname : pathname + "/");
356
+ if (redirect !== undefined)
357
+ return pageResponse({ ...redirect, body: "", head: {}, layers: [] }, request, payload);
358
+ // Endpoints answer paths without a trailing slash, which pages redirect from.
359
+ if (!payload && !pathname.endsWith("/")) {
360
+ const endpoint = matchRoute(app.endpoints, pathname, app.base);
361
+ if (endpoint !== undefined)
362
+ return answerEndpoint(app, endpoint.route, endpoint.params, request);
363
+ }
364
+ const matched = matchRoute(app.routes, pathname, app.base);
365
+ if (matched === undefined)
366
+ return app.notFound(request, payload);
367
+ if (request.method !== "GET" && request.method !== "HEAD")
368
+ return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
369
+ if (!pathname.endsWith("/"))
370
+ return new Response(null, { status: 308, headers: { location: pathname + "/" + url.search } });
371
+ return app.page(matched.route, matched.params, request, payload);
372
+ }
373
+ async function answerEndpoint(app, endpoint, params, request) {
374
+ const module = await app.endpoint(endpoint);
375
+ const method = methods.find(name => name === request.method);
376
+ // HEAD answers with GET's headers unless the file exports its own.
377
+ const handler = method === undefined ? undefined : module[method] ?? (method === "HEAD" ? module.GET : undefined);
378
+ if (typeof handler !== "function") {
379
+ const allow = methods.filter(name => typeof module[name] === "function" || name === "HEAD" && typeof module.GET === "function");
380
+ return new Response(null, { status: 405, headers: { allow: allow.join(", ") } });
381
+ }
382
+ const response = await handler(request, { params });
383
+ if (!(response instanceof Response))
384
+ throw new HtmlKitError(`${method} must return a Response.`, endpoint.file);
385
+ return method === "HEAD" && module.HEAD === undefined ? new Response(null, response) : response;
386
+ }
387
+ export const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
388
+ ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
389
+ ".webp": "image/webp", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8",
390
+ ".xml": "application/xml" };
391
+ /**
392
+ * What a path names in a build's output: a file, a directory's index.html (page), a directory without its
393
+ * trailing slash ("slash"), an undecodable path ("invalid"), or nothing. Nothing outside the output resolves.
394
+ */
395
+ export async function outputPath(output, base, pathname) {
396
+ if (base !== "/" && pathname === base.slice(0, -1))
397
+ return "slash";
398
+ if (!pathname.startsWith(base))
399
+ return undefined;
400
+ let decoded;
401
+ try {
402
+ decoded = decodeURIComponent(pathname.slice(base.length));
403
+ }
404
+ catch {
405
+ return "invalid";
406
+ }
407
+ const candidate = resolve(output, decoded || ".");
408
+ if (!within(output, candidate))
409
+ return undefined;
410
+ try {
411
+ let file = await realpath(candidate);
412
+ if (!within(output, file))
413
+ return undefined;
414
+ const page = (await stat(file)).isDirectory();
415
+ if (page) {
416
+ if (!pathname.endsWith("/"))
417
+ return "slash";
418
+ file = await realpath(join(file, "index.html"));
419
+ if (!within(output, file))
420
+ return undefined;
421
+ }
422
+ return { file, page };
423
+ }
424
+ catch (error) {
425
+ if (["ENOENT", "ENOTDIR"].includes(error.code))
426
+ return undefined;
427
+ throw error;
428
+ }
429
+ }
430
+ /**
431
+ * The application a build's server entry exports: data and module loaders the build wrote, and the
432
+ * output directory two levels above the entry. Prerendered pages and assets come from the output's files.
433
+ */
434
+ export async function serverApplication(entry, data, modules) {
435
+ if (data.version !== 1)
436
+ throw new HtmlKitError(`Unsupported server entry version ${String(data.version)}; rebuild with this HTMLKit.`, entry);
437
+ const output = await realpath(fileURLToPath(new URL("../../", entry)));
438
+ const { base, routes } = data;
439
+ const load = async (id) => await modules[id]();
440
+ const catalog = new Map(data.catalog.map(([path, index]) => [path, routes[index]]));
441
+ const navigation = createNavigation(base, routes, () => Promise.resolve(catalog));
442
+ // Files that are never served: the server's own code, and payloads, which answer through their pages.
443
+ const reserved = [join(output, "_htmlkit/server"), join(output, "_htmlkit/pages")];
444
+ const file = async (path, request, status = 200, init) => {
445
+ const headers = new Headers(init);
446
+ headers.set("content-type", mime[extname(path)] ?? "application/octet-stream");
447
+ // Hashed bundles beside the manifest never change.
448
+ if (/^_htmlkit\/[^/]+$/.test(path.slice(output.length + 1).replaceAll("\\", "/")) && !path.endsWith("manifest.json"))
449
+ headers.set("cache-control", "public, max-age=31536000, immutable");
450
+ return new Response(request.method === "HEAD" ? null : await readFile(path), { status, headers });
451
+ };
452
+ const missing = (request, headers) => file(join(output, "404.html"), request, 404, headers);
453
+ const render = (page, route, params, url, request, signal, failure) => renderRoute({ base, canonicalOrigin: data.canonicalOrigin, route, params, url, ...(request === undefined ? {} : { request }), signal, definitions: page.definitions,
454
+ scripts: data.scripts, navigation, ...(failure === undefined ? {} : { failure }),
455
+ layers: page.layers.map(layer => ({ ...layer, module: () => layer.module === undefined ? Promise.resolve({}) : load(layer.module) })) });
456
+ const html = (body, status) => new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
457
+ // A failure answers with app/error.html, rendered as the build would render it, and a safe message; the error goes to the console.
458
+ const failed = async (error, request) => {
459
+ if (!request.signal.aborted)
460
+ console.error(error);
461
+ const { pathname } = new URL(request.url);
462
+ // A failed page has no payload, so the browser loads its document, the error page.
463
+ if (pathname.startsWith(base + "_htmlkit/pages/"))
464
+ return new Response(null, { status: 500 });
465
+ const builtin = () => html(request.method === "HEAD" ? "" : failurePage(serverFailure, data.scripts).html, 500);
466
+ if (data.error === undefined || pathname.startsWith("//"))
467
+ return builtin();
468
+ try {
469
+ const rendered = await render(data.error, data.error.route, {}, new URL(pathname, request.url), undefined, request.signal, serverFailure);
470
+ return rendered.kind === "page" ? pageResponse({ ...rendered, status: serverFailure.status }, request, false, data.error.assets) : builtin();
471
+ }
472
+ catch (again) {
473
+ // The error page shares the site's layouts, so a failing layout fails it too; the built-in page answers then.
474
+ if (!request.signal.aborted)
475
+ console.error(again);
476
+ return builtin();
477
+ }
478
+ };
479
+ const pipeline = {
480
+ base, routes, endpoints: data.endpoints,
481
+ redirects: new Map(data.redirects.map(({ from, to, status }) => [from, { location: to, status }])),
482
+ endpoint: route => load(route.module),
483
+ ...(data.middleware === undefined ? {} : { middleware: async () => (await load(data.middleware)).default }),
484
+ async page(route, params, request, payload) {
485
+ const server = data.pages[routes.indexOf(route)];
486
+ const url = new URL(request.url);
487
+ if (server === undefined) {
488
+ // A prerendered page: its document or payload, if the build wrote one for this URL.
489
+ const found = await outputPath(output, base, payload ? `${base}_htmlkit/pages/${url.pathname.slice(base.length)}payload.json` : url.pathname);
490
+ if (typeof found === "object")
491
+ return file(found.file, request);
492
+ return payload ? new Response(null, { status: 404 }) : missing(request);
493
+ }
494
+ const rendered = await render(server, route, params, url, request, request.signal);
495
+ if (rendered.kind === "notFound")
496
+ return payload ? new Response(null, { status: 404, headers: rendered.headers }) : missing(request, rendered.headers);
497
+ return pageResponse(rendered.kind === "page" ? rendered : { ...rendered, body: "", head: {}, layers: [] }, request, payload, server.assets);
498
+ },
499
+ async notFound(request, payload) {
500
+ if (payload)
501
+ return new Response(null, { status: 404 });
502
+ // A directory of public files is served as a static host serves it.
503
+ const found = await outputPath(output, base, new URL(request.url).pathname);
504
+ if (found === "slash")
505
+ return new Response(null, { status: 308, headers: { location: new URL(request.url).pathname + "/" } });
506
+ return typeof found === "object" && found.page && !reserved.some(path => within(path, found.file)) ? file(found.file, request) : missing(request);
507
+ },
508
+ };
509
+ return {
510
+ base,
511
+ async fetch(request) {
512
+ try {
513
+ // Files answer before middleware: a page's directory, and an endpoint's path, go through it.
514
+ const found = await outputPath(output, base, new URL(request.url).pathname);
515
+ if (typeof found === "object" && !found.page && !reserved.some(path => within(path, found.file))) {
516
+ return request.method === "GET" || request.method === "HEAD" ? await file(found.file, request) : new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
517
+ }
518
+ return await respond(pipeline, request);
519
+ }
520
+ catch (error) {
521
+ return failed(error, request);
522
+ }
523
+ },
524
+ };
525
+ }
526
+ /** A native Request for a Node request: its method, URL, headers, and body, aborted when the response closes. */
527
+ export function nodeRequest(incoming, outgoing) {
528
+ const controller = new AbortController();
529
+ outgoing.once("close", () => controller.abort());
530
+ const headers = new Headers();
531
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2)
532
+ headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
533
+ // Vite's development server keeps the path under its base in originalUrl.
534
+ const path = "originalUrl" in incoming && typeof incoming.originalUrl === "string" ? incoming.originalUrl : incoming.url ?? "/";
535
+ const method = incoming.method ?? "GET";
536
+ // ponytail: http and the Host header; a proxy's X-Forwarded-* headers are not read.
537
+ return new Request(`http://${incoming.headers.host ?? "localhost"}${path}`, { method, headers, signal: controller.signal,
538
+ ...(method === "GET" || method === "HEAD" ? {} : { body: Readable.toWeb(incoming), duplex: "half" }) });
539
+ }
540
+ /** Write a Response to a Node response, streaming its body. */
541
+ export async function sendResponse(response, outgoing) {
542
+ const headers = {};
543
+ for (const [name, value] of response.headers)
544
+ if (name !== "set-cookie")
545
+ headers[name] = value;
546
+ const cookies = response.headers.getSetCookie();
547
+ if (cookies.length > 0)
548
+ headers["set-cookie"] = cookies;
549
+ outgoing.writeHead(response.status, headers);
550
+ if (response.body === null) {
551
+ outgoing.end();
552
+ return;
553
+ }
554
+ // A reader that goes away ends the stream; there is no one left to answer.
555
+ await pipeline(Readable.fromWeb(response.body), outgoing).catch(() => { outgoing.destroy(); });
556
+ }
557
+ export async function listen(server, options, base) {
558
+ const host = options.host ?? "127.0.0.1";
559
+ await new Promise((done, reject) => { server.once("error", reject); server.listen(options.port ?? 3000, host, done); });
560
+ const address = server.address();
561
+ if (address === null || typeof address === "string")
562
+ throw new HtmlKitError("Server did not obtain a TCP port.");
563
+ return { url: `http://${host.includes(":") ? `[${host}]` : host}:${address.port}${base}`,
564
+ close: () => new Promise((done, reject) => { server.close(error => error ? reject(error) : done()); server.closeIdleConnections(); }) };
565
+ }
566
+ /** Serve a build's server entry on Node: `htmlkit start`. */
567
+ export async function startApplication(options = {}) {
568
+ if (options.renderWorkers !== undefined)
569
+ setRenderWorkers(options.renderWorkers);
570
+ const entry = pathToFileURL(resolve(options.outDir ?? "dist", "_htmlkit/server/entry.js"));
571
+ try {
572
+ await stat(entry);
573
+ }
574
+ catch {
575
+ throw new HtmlKitError("No server entry; run htmlkit build first.", fileURLToPath(entry));
576
+ }
577
+ const application = (await import(entry.href)).default;
578
+ const server = createHTTPServer((incoming, outgoing) => {
579
+ void (async () => {
580
+ let request;
581
+ try {
582
+ request = nodeRequest(incoming, outgoing);
583
+ }
584
+ catch {
585
+ outgoing.writeHead(400).end();
586
+ return;
587
+ }
588
+ await sendResponse(await application.fetch(request), outgoing);
589
+ })().catch(error => {
590
+ console.error(error);
591
+ if (outgoing.headersSent)
592
+ outgoing.end();
593
+ else
594
+ outgoing.writeHead(500, { "content-type": "text/html; charset=utf-8" }).end(failurePage(serverFailure, []).html);
595
+ });
596
+ });
597
+ return listen(server, options, application.base);
598
+ }
599
+ //# sourceMappingURL=server.js.map