@threadlabs/markupkit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/components/breadcrumbs.html +9 -0
- package/components/nav.html +51 -0
- package/components/pager.html +13 -0
- package/dist/application.d.ts +31 -0
- package/dist/application.d.ts.map +1 -0
- package/dist/application.js +488 -0
- package/dist/application.js.map +1 -0
- package/dist/browser.d.ts +13 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +151 -0
- package/dist/browser.js.map +1 -0
- package/dist/build.d.ts +5 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +351 -0
- package/dist/build.js.map +1 -0
- package/dist/bundle-worker.d.ts +2 -0
- package/dist/bundle-worker.d.ts.map +1 -0
- package/dist/bundle-worker.js +48 -0
- package/dist/bundle-worker.js.map +1 -0
- package/dist/bundle.d.ts +23 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +34 -0
- package/dist/bundle.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +68 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +447 -0
- package/dist/client.js.map +1 -0
- package/dist/component-module.d.ts +20 -0
- package/dist/component-module.d.ts.map +1 -0
- package/dist/component-module.js +82 -0
- package/dist/component-module.js.map +1 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +111 -0
- package/dist/config.js.map +1 -0
- package/dist/csp.d.ts +23 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +91 -0
- package/dist/csp.js.map +1 -0
- package/dist/description.d.ts +45 -0
- package/dist/description.d.ts.map +1 -0
- package/dist/description.js +28 -0
- package/dist/description.js.map +1 -0
- package/dist/document.d.ts +48 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +65 -0
- package/dist/document.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/links.d.ts +8 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +90 -0
- package/dist/links.js.map +1 -0
- package/dist/private-api.d.ts +12 -0
- package/dist/private-api.d.ts.map +1 -0
- package/dist/private-api.js +158 -0
- package/dist/private-api.js.map +1 -0
- package/dist/private-client.d.ts +11 -0
- package/dist/private-client.d.ts.map +1 -0
- package/dist/private-client.js +18 -0
- package/dist/private-client.js.map +1 -0
- package/dist/private-readers.d.ts +26 -0
- package/dist/private-readers.d.ts.map +1 -0
- package/dist/private-readers.js +107 -0
- package/dist/private-readers.js.map +1 -0
- package/dist/provider-preparation.d.ts +20 -0
- package/dist/provider-preparation.d.ts.map +1 -0
- package/dist/provider-preparation.js +82 -0
- package/dist/provider-preparation.js.map +1 -0
- package/dist/providers.d.ts +14 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +127 -0
- package/dist/providers.js.map +1 -0
- package/dist/read-channel.d.ts +30 -0
- package/dist/read-channel.d.ts.map +1 -0
- package/dist/read-channel.js +65 -0
- package/dist/read-channel.js.map +1 -0
- package/dist/resource.d.ts +31 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +117 -0
- package/dist/resource.js.map +1 -0
- package/dist/routes.d.ts +90 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +536 -0
- package/dist/routes.js.map +1 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +221 -0
- package/dist/serve.js.map +1 -0
- package/dist/server.d.ts +211 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +734 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +504 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +80 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
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 { createRenderer, renderComponents, setRenderWorkers } from "@nextwebwg/html-next/server";
|
|
11
|
+
import { MarkupKitError, within } from "./config.js";
|
|
12
|
+
import { policyHeader } from "./csp.js";
|
|
13
|
+
import { authoredHead, documentHTML, escapeHTML, pagePayload, redirectPage } from "./document.js";
|
|
14
|
+
import { matchRoute, parameter, redirectLocation, redirectStatuses } from "./routes.js";
|
|
15
|
+
import { preparationModule } from "./provider-preparation.js";
|
|
16
|
+
import { privateReadBridge } from "./private-readers.js";
|
|
17
|
+
import { privateReadResponse } from "./private-api.js";
|
|
18
|
+
import { serverReaders } from "./private-readers.js";
|
|
19
|
+
export { setRenderWorkers } from "@nextwebwg/html-next/server";
|
|
20
|
+
/** navigation(), navigationTree(), breadcrumbs(), and pager() over routes and each concrete page URL's route. */
|
|
21
|
+
export function createNavigation(base, routes, concrete) {
|
|
22
|
+
const checkedFrom = (from = "/") => {
|
|
23
|
+
if (!/^\/(?:[^/]+\/)*$/.test(from) || from.includes("[") || from.includes("?") || from.includes("#")) {
|
|
24
|
+
throw new MarkupKitError("Navigation from requires a concrete application-relative directory prefix.", from);
|
|
25
|
+
}
|
|
26
|
+
return from;
|
|
27
|
+
};
|
|
28
|
+
const item = (href, route, depth, current) => Object.freeze({
|
|
29
|
+
href, label: route.label ?? decodeURIComponent(href.slice(base.length).split("/").filter(Boolean).at(-1) ?? "Home"),
|
|
30
|
+
current: href === current ? "page" : "false", depth, pageName: route.pageName,
|
|
31
|
+
...(route.groups === undefined ? {} : { group: route.groups.at(-1).label })
|
|
32
|
+
});
|
|
33
|
+
// A page's place in the file tree: each URL segment, with its (group) folders where they sit.
|
|
34
|
+
// depth is the URL segments above the element: a segment's index, or a group's depth.
|
|
35
|
+
const treePath = (href, route) => {
|
|
36
|
+
const path = href.slice(base.length).split("/").filter(Boolean)
|
|
37
|
+
.map((name, i) => ({ name, rank: route.order?.[i] ?? null, depth: i }));
|
|
38
|
+
// Deepest first, so each insertion index is still a count of URL segments.
|
|
39
|
+
for (const group of (route.groups ?? []).toReversed())
|
|
40
|
+
path.splice(group.depth, 0, { name: group.label, rank: group.order, depth: group.depth, group: true });
|
|
41
|
+
return path;
|
|
42
|
+
};
|
|
43
|
+
const navigation = async (query = {}) => {
|
|
44
|
+
const from = checkedFrom(query.from);
|
|
45
|
+
const prefix = base + from.slice(1);
|
|
46
|
+
const depth = from.split("/").filter(Boolean).length;
|
|
47
|
+
// Hidden pages and alias routes stay routable but out of navigation.
|
|
48
|
+
const items = [...await concrete()].filter(([path, route]) => path.startsWith(prefix) && !route.hidden && route.canonical === undefined);
|
|
49
|
+
const paths = new Map(items.map(([href, route]) => [href, treePath(href, route)]));
|
|
50
|
+
items.sort(([a], [b]) => {
|
|
51
|
+
const aParts = paths.get(a);
|
|
52
|
+
const bParts = paths.get(b);
|
|
53
|
+
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
|
|
54
|
+
const aRank = aParts[i].rank;
|
|
55
|
+
const bRank = bParts[i].rank;
|
|
56
|
+
if (aRank !== bRank) {
|
|
57
|
+
if (aRank === null)
|
|
58
|
+
return 1;
|
|
59
|
+
if (bRank === null)
|
|
60
|
+
return -1;
|
|
61
|
+
const difference = BigInt(aRank) - BigInt(bRank);
|
|
62
|
+
if (difference !== 0n)
|
|
63
|
+
return difference < 0n ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
const difference = aParts[i].name.localeCompare(bParts[i].name);
|
|
66
|
+
if (difference !== 0)
|
|
67
|
+
return difference;
|
|
68
|
+
}
|
|
69
|
+
return aParts.length - bParts.length || a.localeCompare(b);
|
|
70
|
+
});
|
|
71
|
+
return Object.freeze(items.map(([href, route]) => item(href, route, href.slice(base.length).split("/").filter(Boolean).length - depth, query.current)));
|
|
72
|
+
};
|
|
73
|
+
// Each existing page from `from` down to the current one, by URL; the current page is included even when hidden.
|
|
74
|
+
const breadcrumbs = async (query = {}) => {
|
|
75
|
+
const from = checkedFrom(query.from);
|
|
76
|
+
const current = query.current;
|
|
77
|
+
if (current === undefined || !current.startsWith(base + from.slice(1)))
|
|
78
|
+
return Object.freeze([]);
|
|
79
|
+
const paths = await concrete();
|
|
80
|
+
const parts = current.slice(base.length).split("/").filter(Boolean);
|
|
81
|
+
const start = from.split("/").filter(Boolean).length;
|
|
82
|
+
const trail = [];
|
|
83
|
+
for (let i = start; i <= parts.length; i++) {
|
|
84
|
+
const href = base + parts.slice(0, i).map(part => part + "/").join("");
|
|
85
|
+
// The page being rendered exists even when no entries() lists it, as for a dynamic page at request time.
|
|
86
|
+
const route = paths.get(href) ?? (i === parts.length ? matchRoute(routes, href, base)?.route : undefined);
|
|
87
|
+
if (route !== undefined && route.canonical === undefined)
|
|
88
|
+
trail.push(item(href, route, i - start, current));
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze(trail);
|
|
91
|
+
};
|
|
92
|
+
// navigation()'s pages, which come in tree order, nested under the folders and groups below `from`.
|
|
93
|
+
const navigationTree = async (query = {}) => {
|
|
94
|
+
const items = await navigation(query);
|
|
95
|
+
const paths = await concrete();
|
|
96
|
+
const fromDepth = checkedFrom(query.from).split("/").filter(Boolean).length;
|
|
97
|
+
const top = [];
|
|
98
|
+
const nodes = new Map();
|
|
99
|
+
for (const item of items) {
|
|
100
|
+
const route = paths.get(item.href);
|
|
101
|
+
let siblings = top;
|
|
102
|
+
let key = "";
|
|
103
|
+
let node;
|
|
104
|
+
// The page's own node is the last element below `from`; `from`'s index page is a top node of its own.
|
|
105
|
+
for (const element of treePath(item.href, route).filter(element => element.depth >= fromDepth)) {
|
|
106
|
+
key += `${element.group ? "(" : "/"}${element.name}\0`;
|
|
107
|
+
node = nodes.get(key);
|
|
108
|
+
if (node === undefined) {
|
|
109
|
+
node = { label: element.group ? element.name : decodeURIComponent(element.name), order: element.rank, children: [] };
|
|
110
|
+
nodes.set(key, node);
|
|
111
|
+
siblings.push(node);
|
|
112
|
+
}
|
|
113
|
+
siblings = node.children;
|
|
114
|
+
}
|
|
115
|
+
if (node === undefined) {
|
|
116
|
+
node = { label: item.label, order: null, children: [] };
|
|
117
|
+
top.unshift(node);
|
|
118
|
+
}
|
|
119
|
+
node.item = item;
|
|
120
|
+
node.route = route;
|
|
121
|
+
}
|
|
122
|
+
const finish = (draft, depth) => {
|
|
123
|
+
const children = draft.children.map(child => finish(child, depth + 1));
|
|
124
|
+
const { item, route } = draft;
|
|
125
|
+
// A folder's index page labels the folder, by mk:label, then its <title>.
|
|
126
|
+
const label = item === undefined ? draft.label : children.length > 0 && route.label === undefined ? route.title ?? draft.label : item.label;
|
|
127
|
+
const current = item?.current ?? "false";
|
|
128
|
+
return Object.freeze({ label, ...(item === undefined ? {} : { href: item.href, pageName: item.pageName }), current,
|
|
129
|
+
open: current === "page" || children.some(child => child.open), depth, order: draft.order, children: Object.freeze(children) });
|
|
130
|
+
};
|
|
131
|
+
return Object.freeze(top.map(draft => finish(draft, 0)));
|
|
132
|
+
};
|
|
133
|
+
// The neighbours of the current page in navigation order.
|
|
134
|
+
const pager = async (query = {}) => {
|
|
135
|
+
const items = await navigation(query);
|
|
136
|
+
const index = items.findIndex(entry => entry.current === "page");
|
|
137
|
+
if (index < 0)
|
|
138
|
+
return Object.freeze({});
|
|
139
|
+
return Object.freeze({ ...(index > 0 ? { previous: items[index - 1] } : {}), ...(index < items.length - 1 ? { next: items[index + 1] } : {}) });
|
|
140
|
+
};
|
|
141
|
+
return { navigation, navigationTree, breadcrumbs, pager };
|
|
142
|
+
}
|
|
143
|
+
/** What a loader's redirect() throws, so render answers with the redirect instead of the page. */
|
|
144
|
+
class Redirect {
|
|
145
|
+
location;
|
|
146
|
+
status;
|
|
147
|
+
constructor(location, status) {
|
|
148
|
+
this.location = location;
|
|
149
|
+
this.status = status;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** What a loader's notFound() throws. */
|
|
153
|
+
class NotFound {
|
|
154
|
+
status = 404;
|
|
155
|
+
}
|
|
156
|
+
/** A loader's props as invocation attributes, in their HTML form, defaults included. */
|
|
157
|
+
function invocationAttributes(definition, result, deferred) {
|
|
158
|
+
const props = result.props ?? {};
|
|
159
|
+
for (const name of Object.keys(props)) {
|
|
160
|
+
if (!Object.hasOwn(definition.contract.props, name))
|
|
161
|
+
throw new MarkupKitError(`Undeclared prop ${name}.`, definition.source.file);
|
|
162
|
+
}
|
|
163
|
+
const attributes = {};
|
|
164
|
+
for (const [name, contract] of Object.entries(definition.contract.props)) {
|
|
165
|
+
if (deferred !== undefined && contract.required && props[name] === undefined && contract.default === undefined)
|
|
166
|
+
deferred.push(name);
|
|
167
|
+
else
|
|
168
|
+
serializePropTarget(contract, props[name], name, definition.contract.tag);
|
|
169
|
+
// The contract target describes lowering onto native DOM. Invocation attributes carry
|
|
170
|
+
// the public prop name; writing the target name here silently loses remapped props.
|
|
171
|
+
const provided = props[name] == null ? contract.default ?? props[name] : props[name];
|
|
172
|
+
const value = provided == null ? null : serializeTypedValue(provided, contract.type);
|
|
173
|
+
if (value !== null)
|
|
174
|
+
attributes[name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()] = value;
|
|
175
|
+
}
|
|
176
|
+
return attributes;
|
|
177
|
+
}
|
|
178
|
+
/** The browser builds the same invocation from a page payload (client.ts). */
|
|
179
|
+
function invocation(tag, attributes, index, child, nested) {
|
|
180
|
+
return `<${tag}${Object.hasOwn(attributes, "id") ? "" : ` id="mk-layer-${index}"`} data-mk-layer="${index}"${nested ? ' slot="page"' : ""}${Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeHTML(value)}"`).join("")}>${child}</${tag}>`;
|
|
181
|
+
}
|
|
182
|
+
/** The request's cookies by name; the first of a repeated name wins, as the most specific path comes first. */
|
|
183
|
+
function parseCookies(header) {
|
|
184
|
+
const cookies = new Map();
|
|
185
|
+
for (const pair of (header ?? "").split(";")) {
|
|
186
|
+
const separator = pair.indexOf("=");
|
|
187
|
+
const name = pair.slice(0, separator).trim();
|
|
188
|
+
if (separator < 0 || name === "" || cookies.has(name))
|
|
189
|
+
continue;
|
|
190
|
+
let value = pair.slice(separator + 1).trim();
|
|
191
|
+
if (value.length > 1 && value.startsWith('"') && value.endsWith('"'))
|
|
192
|
+
value = value.slice(1, -1);
|
|
193
|
+
try {
|
|
194
|
+
value = decodeURIComponent(value);
|
|
195
|
+
}
|
|
196
|
+
catch { /* Kept as sent. */ }
|
|
197
|
+
cookies.set(name, value);
|
|
198
|
+
}
|
|
199
|
+
return cookies;
|
|
200
|
+
}
|
|
201
|
+
/** A private read is a fresh request operation, never a retained server component instance. */
|
|
202
|
+
export function requestReadContext(request, url, base, params, services) {
|
|
203
|
+
return { phase: "request", request, url: new URL(url), base, params, services, signal: request.signal,
|
|
204
|
+
cookies: parseCookies(request.headers.get("cookie")), fetch: globalThis.fetch.bind(globalThis) };
|
|
205
|
+
}
|
|
206
|
+
export const serverFailure = Object.freeze({ status: 500, message: "Internal Server Error" });
|
|
207
|
+
/** The page for a failure without app/error.html, or when it fails too. */
|
|
208
|
+
export function failurePage({ status, message }, scripts, policy) {
|
|
209
|
+
const head = { html: authoredHead + `<title>${escapeHTML(message)}</title>`, scripts, ...(policy === undefined ? {} : { csp: policy }) };
|
|
210
|
+
const body = `<main><h1>${escapeHTML(message)}</h1></main>`;
|
|
211
|
+
return { status, head, body, html: documentHTML(body, head) };
|
|
212
|
+
}
|
|
213
|
+
/** Run the layers' loaders, outermost first, and render their invocations through HTML Next. */
|
|
214
|
+
export async function renderRoute(input) {
|
|
215
|
+
const { base, route, params, url, request, signal, layers, navigation } = input;
|
|
216
|
+
const response = { status: 200, headers: new Headers() };
|
|
217
|
+
let cookies;
|
|
218
|
+
const results = [];
|
|
219
|
+
const attributes = [];
|
|
220
|
+
let parent = Object.freeze({});
|
|
221
|
+
let lang;
|
|
222
|
+
// A pattern's URL with this request's parameters.
|
|
223
|
+
const at = (pattern) => base + pattern.slice(1).split("/").filter(Boolean).map(segment => {
|
|
224
|
+
const value = params[parameter(segment) ?? ""];
|
|
225
|
+
return (value === undefined ? [segment] : [value].flat().map(encodeURIComponent)).map(part => part + "/").join("");
|
|
226
|
+
}).join("");
|
|
227
|
+
// An alias marks its page's own URL current, with the alias's parameters.
|
|
228
|
+
const current = route.canonical === undefined ? url.pathname : at(route.canonical);
|
|
229
|
+
const contextOf = (layer) => {
|
|
230
|
+
const prerendering = (name) => {
|
|
231
|
+
throw new MarkupKitError(`${name} is unavailable while prerendering; <meta name="mk:render" content="server"> renders the page on each request.`, layer.origin);
|
|
232
|
+
};
|
|
233
|
+
return { phase: request === undefined ? "prerender" : "request", url: new URL(url), base, params, parent,
|
|
234
|
+
propNames: Object.freeze(Object.keys(layer.definition.contract.props)),
|
|
235
|
+
fetch: globalThis.fetch, signal,
|
|
236
|
+
navigation: query => navigation.navigation({ current, ...query }),
|
|
237
|
+
navigationTree: query => navigation.navigationTree({ current, ...query }),
|
|
238
|
+
breadcrumbs: query => navigation.breadcrumbs({ current, ...query }),
|
|
239
|
+
pager: query => navigation.pager({ current, ...query }),
|
|
240
|
+
redirect(location, status = 303) {
|
|
241
|
+
if (!redirectStatuses.includes(status))
|
|
242
|
+
throw new MarkupKitError(`Redirect status ${String(status)} must be 301, 302, 303, 307, or 308.`, layer.origin);
|
|
243
|
+
throw new Redirect(redirectLocation(base, location, layer.origin), status);
|
|
244
|
+
},
|
|
245
|
+
notFound() { throw new NotFound(); },
|
|
246
|
+
get request() { return request ?? prerendering("request"); },
|
|
247
|
+
get cookies() { return request === undefined ? prerendering("cookies") : cookies ??= parseCookies(request.headers.get("cookie")); },
|
|
248
|
+
get response() { return request === undefined ? prerendering("response") : response; } };
|
|
249
|
+
};
|
|
250
|
+
// What a loader's or action's redirect() and notFound() answer with.
|
|
251
|
+
const stopped = (error) => {
|
|
252
|
+
if (error instanceof Redirect)
|
|
253
|
+
return { kind: "redirect", location: error.location, status: error.status, headers: response.headers };
|
|
254
|
+
if (error instanceof NotFound)
|
|
255
|
+
return { kind: "notFound", headers: response.headers };
|
|
256
|
+
throw error;
|
|
257
|
+
};
|
|
258
|
+
// The action runs first, so every loader sees what it changed.
|
|
259
|
+
let submitted;
|
|
260
|
+
if (input.action !== undefined) {
|
|
261
|
+
const page = layers.at(-1);
|
|
262
|
+
let result;
|
|
263
|
+
try {
|
|
264
|
+
result = await input.action(contextOf(page));
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
return stopped(error);
|
|
268
|
+
}
|
|
269
|
+
if (result === undefined) {
|
|
270
|
+
// POST/redirect/GET: the page's own URL, without the action's name.
|
|
271
|
+
const location = new URL(url);
|
|
272
|
+
location.search = new URLSearchParams([...location.searchParams].filter(([key]) => !key.startsWith("/"))).toString();
|
|
273
|
+
return { kind: "redirect", location: location.pathname + location.search, status: 303, headers: response.headers };
|
|
274
|
+
}
|
|
275
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) {
|
|
276
|
+
throw new MarkupKitError("An action returns nothing, which redirects to its page, or an object of the page's props.", page.origin);
|
|
277
|
+
}
|
|
278
|
+
submitted = result;
|
|
279
|
+
}
|
|
280
|
+
const required = [];
|
|
281
|
+
for (const [index, layer] of layers.entries()) {
|
|
282
|
+
signal.throwIfAborted();
|
|
283
|
+
const loaded = await layer.module();
|
|
284
|
+
const context = contextOf(layer);
|
|
285
|
+
let result;
|
|
286
|
+
// app/error.html takes the failure's status and message as the props it declares.
|
|
287
|
+
const supplied = input.failure === undefined || layer !== layers.at(-1) ? {} : { props: Object.fromEntries(Object.entries(input.failure)
|
|
288
|
+
.filter(([name]) => Object.hasOwn(layer.definition.contract.props, name))) };
|
|
289
|
+
try {
|
|
290
|
+
result = loaded.load === undefined ? supplied : await loaded.load(context);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return stopped(error);
|
|
294
|
+
}
|
|
295
|
+
if (result === null || typeof result !== "object" || Array.isArray(result))
|
|
296
|
+
throw new MarkupKitError("load() must return a LoaderResult object.", layer.origin);
|
|
297
|
+
if (result.head !== undefined) {
|
|
298
|
+
if (Object.keys(result.head).some(name => name !== "lang")) {
|
|
299
|
+
throw new MarkupKitError("A loader's head sets only lang; a page or layout declares its <title> and <meta> in <defs>, bound to props its loader returns.", layer.origin);
|
|
300
|
+
}
|
|
301
|
+
lang = result.head.lang ?? lang;
|
|
302
|
+
}
|
|
303
|
+
// An action's props are the page's, over its loader's.
|
|
304
|
+
if (submitted !== undefined && layer === layers.at(-1))
|
|
305
|
+
result = { ...result, props: { ...result.props, ...submitted } };
|
|
306
|
+
results.push(result);
|
|
307
|
+
parent = Object.freeze({ ...parent, ...result.data });
|
|
308
|
+
// Checked against the layer's own component, which a prop's diagnostic names.
|
|
309
|
+
const client = input.clientModules?.includes(layer.definition.contract.tag) ?? false;
|
|
310
|
+
const names = input.providers?.[layer.definition.contract.tag] === undefined && !client ? undefined : [];
|
|
311
|
+
attributes.push(invocationAttributes(layer.definition, result, names));
|
|
312
|
+
if (!client && names !== undefined && names.length > 0)
|
|
313
|
+
required.push({ tag: layer.definition.contract.tag, selector: `[data-mk-layer="${index}"]`, names });
|
|
314
|
+
}
|
|
315
|
+
const state = {};
|
|
316
|
+
const props = {};
|
|
317
|
+
const rendered = layers.map((layer, i) => ({ component: layer.definition.contract.tag,
|
|
318
|
+
attributes: attributes[i], props: Object.fromEntries(Object.entries(results[i].props ?? {}).filter(([, value]) => value !== undefined)),
|
|
319
|
+
...(results[i].state === undefined ? {} : { state: results[i].state }) }));
|
|
320
|
+
let body = "";
|
|
321
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
322
|
+
const selector = `[data-mk-layer="${i}"]`;
|
|
323
|
+
const layer = rendered[i];
|
|
324
|
+
if (layer.state !== undefined)
|
|
325
|
+
state[selector] = layer.state;
|
|
326
|
+
props[selector] = Object.fromEntries(Object.keys(layers[i].definition.contract.props).map(name => [name, layer.props[name]]));
|
|
327
|
+
body = invocation(layer.component, layer.attributes, i, body, i > 0);
|
|
328
|
+
}
|
|
329
|
+
// An alias names its page's own URL canonical, and a page in an alias page directory the same page
|
|
330
|
+
// under its directory's own prefix, unless a component on the page declares one.
|
|
331
|
+
const own = route.aliasOf === undefined ? route.canonical === undefined ? undefined : current : at(route.aliasOf);
|
|
332
|
+
const canonical = own !== undefined && !own.includes("[") && !input.definitions.some(definition => definition.head?.some(declaration => declaration.kind === "link" && declaration.attributes.some(attribute => attribute.kind === "literal" && attribute.name === "rel" && attribute.value.trim().toLowerCase() === "canonical")))
|
|
333
|
+
? input.canonicalOrigin + own : undefined;
|
|
334
|
+
const modules = input.providers ?? {};
|
|
335
|
+
const bridge = Object.keys(input.privateReaders ?? {}).length === 0 ? undefined : await privateReadBridge(input.privateReaders, contextOf(layers.at(-1)), input.services);
|
|
336
|
+
let output;
|
|
337
|
+
try {
|
|
338
|
+
output = await (input.render ?? renderComponents)(body, { definitions: input.definitions, url: url.href, state, props, signal,
|
|
339
|
+
...Object.keys(modules).length === 0 ? {} : { prepare: { module: preparationModule,
|
|
340
|
+
...bridge === undefined ? {} : { port: bridge.port }, data: { modules, required, phase: input.request === undefined ? "build" : "request" } } },
|
|
341
|
+
head: authoredHead + (canonical === undefined ? "" : `<link rel="canonical" href="${escapeHTML(canonical)}">`) });
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
bridge?.close();
|
|
345
|
+
}
|
|
346
|
+
signal.throwIfAborted();
|
|
347
|
+
const { policy } = input;
|
|
348
|
+
const head = { html: output.head, ...(lang === undefined ? {} : { lang }), ...(canonical === undefined ? {} : { canonical }),
|
|
349
|
+
...(input.scripts.length === 0 ? {} : { scripts: input.scripts }), ...(policy === undefined ? {} : { csp: policy }) };
|
|
350
|
+
return { kind: "page", status: response.status, headers: response.headers, body: output.html, css: output.css, head, layers: rendered,
|
|
351
|
+
...Object.keys(modules).length === 0 ? {} : { prepared: true } };
|
|
352
|
+
}
|
|
353
|
+
/** A page's answer: a redirect with its redirect page, its document, or for a payload request its payload (404 when it has none). */
|
|
354
|
+
export function pageResponse(page, request, payload, assets = { styles: [], modules: [] }) {
|
|
355
|
+
const headers = new Headers(page.headers);
|
|
356
|
+
const bodyless = request.method === "HEAD";
|
|
357
|
+
if (payload) {
|
|
358
|
+
// Client navigation finds no payload for a redirect or an error, and loads the URL itself.
|
|
359
|
+
const data = page.location === undefined ? pagePayload(page, assets) : undefined;
|
|
360
|
+
headers.set("content-type", "application/json");
|
|
361
|
+
return new Response(data === undefined || bodyless ? null : JSON.stringify(data), { status: data === undefined ? 404 : 200, headers });
|
|
362
|
+
}
|
|
363
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
364
|
+
if (page.head.csp !== undefined)
|
|
365
|
+
headers.set(policyHeader(page.head.csp), page.head.csp.header);
|
|
366
|
+
if (page.location !== undefined) {
|
|
367
|
+
headers.set("location", page.location);
|
|
368
|
+
return new Response(bodyless ? null : redirectPage(page.location).html, { status: page.status, headers });
|
|
369
|
+
}
|
|
370
|
+
return new Response(bodyless ? null : documentHTML(page.body, page.head, assets), { status: page.status, headers });
|
|
371
|
+
}
|
|
372
|
+
const methods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
|
373
|
+
/** Answer a request: its payload URL becomes the page's, then middleware, then redirects, endpoints, and pages. */
|
|
374
|
+
export async function respond(app, request) {
|
|
375
|
+
const url = new URL(request.url);
|
|
376
|
+
const pages = app.base + "_markupkit/pages/";
|
|
377
|
+
let payload = false;
|
|
378
|
+
const page = url.pathname.startsWith(pages) ? /^(.*\/)?payload\.json$/.exec(url.pathname.slice(pages.length)) : null;
|
|
379
|
+
if (page !== null) {
|
|
380
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
381
|
+
return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
|
|
382
|
+
payload = true;
|
|
383
|
+
// Concatenated, so a payload path starting with // stays on this origin and names no page.
|
|
384
|
+
request = new Request(url.origin + app.base + (page[1] ?? "") + url.search, { method: request.method, headers: request.headers, signal: request.signal });
|
|
385
|
+
}
|
|
386
|
+
const answer = (request) => route(app, request, payload);
|
|
387
|
+
if (app.middleware === undefined)
|
|
388
|
+
return answer(request);
|
|
389
|
+
const middleware = await app.middleware();
|
|
390
|
+
const next = async (rewrite) => {
|
|
391
|
+
const answered = await answer(rewrite === undefined ? request : rewrite instanceof Request ? rewrite : new Request(new URL(rewrite, request.url), request));
|
|
392
|
+
// A new Response around the answer, so middleware can set headers even on Response.redirect()'s.
|
|
393
|
+
return new Response(answered.body, answered);
|
|
394
|
+
};
|
|
395
|
+
const response = await middleware(request, next);
|
|
396
|
+
if (!(response instanceof Response))
|
|
397
|
+
throw new MarkupKitError("Middleware must return a Response, such as next()'s.");
|
|
398
|
+
return response;
|
|
399
|
+
}
|
|
400
|
+
async function route(app, request, payload) {
|
|
401
|
+
const url = new URL(request.url);
|
|
402
|
+
const { pathname } = url;
|
|
403
|
+
// A path starting with // would name another origin; no page lives there.
|
|
404
|
+
if (!pathname.startsWith(app.base) || pathname.startsWith("//"))
|
|
405
|
+
return app.notFound(request, payload);
|
|
406
|
+
if (!payload && pathname === app.base + "_markupkit/read" && app.read !== undefined) {
|
|
407
|
+
if (request.method !== "POST")
|
|
408
|
+
return new Response(null, { status: 405, headers: { allow: "POST" } });
|
|
409
|
+
if (!sameOrigin(request))
|
|
410
|
+
return new Response("Cross-origin component reads are refused.", { status: 403 });
|
|
411
|
+
return app.read(request);
|
|
412
|
+
}
|
|
413
|
+
const file = payload ? undefined : app.files?.get(pathname);
|
|
414
|
+
if (file !== undefined) {
|
|
415
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
416
|
+
return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
|
|
417
|
+
return new Response(request.method === "HEAD" ? null : file.contents, { headers: { "content-type": file.type, ...file.headers } });
|
|
418
|
+
}
|
|
419
|
+
// The redirects option precedes pages, with or without the trailing slash.
|
|
420
|
+
const redirect = app.redirects.get(pathname.endsWith("/") ? pathname : pathname + "/");
|
|
421
|
+
if (redirect !== undefined)
|
|
422
|
+
return pageResponse({ ...redirect, body: "", head: { html: "" }, layers: [] }, request, payload);
|
|
423
|
+
// Endpoints answer paths without a trailing slash, which pages redirect from.
|
|
424
|
+
if (!payload && !pathname.endsWith("/")) {
|
|
425
|
+
const endpoint = matchRoute(app.endpoints, pathname, app.base);
|
|
426
|
+
if (endpoint !== undefined)
|
|
427
|
+
return answerEndpoint(app, endpoint.route, endpoint.params, request);
|
|
428
|
+
}
|
|
429
|
+
const matched = matchRoute(app.routes, pathname, app.base);
|
|
430
|
+
if (matched === undefined)
|
|
431
|
+
return app.notFound(request, payload);
|
|
432
|
+
// A POST is a form submission, which a page's actions answer.
|
|
433
|
+
if (!["GET", "HEAD", "POST"].includes(request.method))
|
|
434
|
+
return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
|
|
435
|
+
if (!pathname.endsWith("/"))
|
|
436
|
+
return new Response(null, { status: 308, headers: { location: pathname + "/" + url.search } });
|
|
437
|
+
if (request.method === "POST" && !sameOrigin(request))
|
|
438
|
+
return new Response("Cross-origin form submissions are refused.", { status: 403 });
|
|
439
|
+
return app.page(matched.route, matched.params, request, payload);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Whether a request comes from a page of this site, as Go's CrossOriginProtection decides: by
|
|
443
|
+
* Sec-Fetch-Site, or by Origin from browsers that don't send it. A request with neither isn't a
|
|
444
|
+
* browser's cross-site form, such as one from curl.
|
|
445
|
+
*/
|
|
446
|
+
function sameOrigin(request) {
|
|
447
|
+
const site = request.headers.get("sec-fetch-site");
|
|
448
|
+
if (site !== null)
|
|
449
|
+
return site === "same-origin" || site === "none";
|
|
450
|
+
const origin = request.headers.get("origin");
|
|
451
|
+
// Hosts, not origins: behind a TLS proxy the request's own URL is http.
|
|
452
|
+
return origin === null || URL.parse(origin)?.host === new URL(request.url).host;
|
|
453
|
+
}
|
|
454
|
+
/** The action a page's POST names, `?/name` or `default`, or the answer that refuses it: 405 for a page without actions, 404 for an unknown name. */
|
|
455
|
+
export function pageAction(module, url) {
|
|
456
|
+
const { actions } = module;
|
|
457
|
+
if (actions === undefined)
|
|
458
|
+
return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
|
|
459
|
+
const name = [...url.searchParams.keys()].find(key => key.startsWith("/"))?.slice(1) ?? "default";
|
|
460
|
+
const action = Object.hasOwn(actions, name) ? actions[name] : undefined;
|
|
461
|
+
return typeof action === "function" ? action : new Response(null, { status: 404 });
|
|
462
|
+
}
|
|
463
|
+
/** The build error for actions on a page that prerenders. */
|
|
464
|
+
export function prerenderedActions(pattern, origin) {
|
|
465
|
+
return new MarkupKitError(`The page ${pattern} has actions, which answer requests; <meta name="mk:render" content="server"> renders it on each request.`, origin);
|
|
466
|
+
}
|
|
467
|
+
async function answerEndpoint(app, endpoint, params, request) {
|
|
468
|
+
const module = await app.endpoint(endpoint);
|
|
469
|
+
const method = methods.find(name => name === request.method);
|
|
470
|
+
// HEAD answers with GET's headers unless the file exports its own.
|
|
471
|
+
const handler = method === undefined ? undefined : module[method] ?? (method === "HEAD" ? module.GET : undefined);
|
|
472
|
+
if (typeof handler !== "function") {
|
|
473
|
+
const allow = methods.filter(name => typeof module[name] === "function" || name === "HEAD" && typeof module.GET === "function");
|
|
474
|
+
return new Response(null, { status: 405, headers: { allow: allow.join(", ") } });
|
|
475
|
+
}
|
|
476
|
+
const response = await handler(request, { params });
|
|
477
|
+
if (!(response instanceof Response))
|
|
478
|
+
throw new MarkupKitError(`${method} must return a Response.`, endpoint.file);
|
|
479
|
+
return method === "HEAD" && module.HEAD === undefined ? new Response(null, response) : response;
|
|
480
|
+
}
|
|
481
|
+
export const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
482
|
+
".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
|
|
483
|
+
".webp": "image/webp", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8",
|
|
484
|
+
".xml": "application/xml" };
|
|
485
|
+
/**
|
|
486
|
+
* What a path names in a build's output: a file, a directory's index.html (page), a directory without its
|
|
487
|
+
* trailing slash ("slash"), an undecodable path ("invalid"), or nothing. Nothing outside the output resolves.
|
|
488
|
+
*/
|
|
489
|
+
export async function outputPath(output, base, pathname) {
|
|
490
|
+
if (base !== "/" && pathname === base.slice(0, -1))
|
|
491
|
+
return "slash";
|
|
492
|
+
if (!pathname.startsWith(base))
|
|
493
|
+
return undefined;
|
|
494
|
+
let decoded;
|
|
495
|
+
try {
|
|
496
|
+
decoded = decodeURIComponent(pathname.slice(base.length));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return "invalid";
|
|
500
|
+
}
|
|
501
|
+
const candidate = resolve(output, decoded || ".");
|
|
502
|
+
if (!within(output, candidate))
|
|
503
|
+
return undefined;
|
|
504
|
+
try {
|
|
505
|
+
let file = await realpath(candidate);
|
|
506
|
+
if (!within(output, file))
|
|
507
|
+
return undefined;
|
|
508
|
+
const page = (await stat(file)).isDirectory();
|
|
509
|
+
if (page) {
|
|
510
|
+
if (!pathname.endsWith("/"))
|
|
511
|
+
return "slash";
|
|
512
|
+
file = await realpath(join(file, "index.html"));
|
|
513
|
+
if (!within(output, file))
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
return { file, page };
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code))
|
|
520
|
+
return undefined;
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The application a build's server entry exports: data and module loaders the build wrote, and the
|
|
526
|
+
* output directory two levels above the entry. Prerendered pages and assets come from the output's files.
|
|
527
|
+
*/
|
|
528
|
+
export async function serverApplication(entry, data, modules, options = {}) {
|
|
529
|
+
if (data.version !== 2 && data.version !== 3)
|
|
530
|
+
throw new MarkupKitError(`Unsupported server entry version ${String(data.version)}; rebuild with this MarkupKit.`, entry);
|
|
531
|
+
const output = await realpath(fileURLToPath(new URL("../../", entry)));
|
|
532
|
+
const renderer = createRenderer(options.renderWorkers === undefined ? {} : { workers: options.renderWorkers });
|
|
533
|
+
const privateLifetime = new AbortController();
|
|
534
|
+
const { base, routes } = data;
|
|
535
|
+
const load = async (id) => await modules[id]();
|
|
536
|
+
const privateSources = new Map(Object.entries(data.readers ?? {}).map(([source, reader]) => [source, {
|
|
537
|
+
definition: reader.definition, common: () => load(reader.common),
|
|
538
|
+
readers: async () => serverReaders(await load(reader.server), reader.server),
|
|
539
|
+
}]));
|
|
540
|
+
const catalog = new Map(data.catalog.map(([path, index]) => [path, routes[index]]));
|
|
541
|
+
const navigation = createNavigation(base, routes, () => Promise.resolve(catalog));
|
|
542
|
+
// Files that are never served: the server's own code, and payloads, which answer through their pages.
|
|
543
|
+
const reserved = [join(output, "_markupkit/server"), join(output, "_markupkit/pages")];
|
|
544
|
+
// A prerendered page's file carries the site's policy as a header too, which may hold header-only directives.
|
|
545
|
+
const types = new Map((data.files ?? []).map(file => [join(output, decodeURIComponent(file.pathname.slice(base.length))), file.type]));
|
|
546
|
+
const file = async (path, request, status = 200, init, page = false) => {
|
|
547
|
+
const headers = new Headers(init);
|
|
548
|
+
headers.set("content-type", types.get(path) ?? mime[extname(path)] ?? "application/octet-stream");
|
|
549
|
+
if (page && data.policy !== undefined)
|
|
550
|
+
headers.set(policyHeader(data.policy), data.policy.header);
|
|
551
|
+
// Hashed bundles beside the manifest never change.
|
|
552
|
+
if (/^_markupkit\/[^/]+$/.test(path.slice(output.length + 1).replaceAll("\\", "/")) && !path.endsWith("manifest.json"))
|
|
553
|
+
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
554
|
+
return new Response(request.method === "HEAD" ? null : await readFile(path), { status, headers });
|
|
555
|
+
};
|
|
556
|
+
const missing = (request, headers) => file(join(output, "404.html"), request, 404, headers, true);
|
|
557
|
+
const render = (page, route, params, url, request, signal, failure, action) => renderRoute({ base, canonicalOrigin: data.canonicalOrigin, route, params, url, ...(request === undefined ? {} : { request }), signal, definitions: page.definitions, render: renderer.render,
|
|
558
|
+
providers: Object.fromEntries(Object.entries(page.providers ?? {}).map(([tag, provider]) => [tag, { ...provider, module: new URL(`./${provider.module}.js`, entry).href }])),
|
|
559
|
+
clientModules: page.clientModules ?? [],
|
|
560
|
+
services: options.services,
|
|
561
|
+
privateReaders: Object.fromEntries(Object.entries(page.providers ?? {}).flatMap(([tag, provider]) => {
|
|
562
|
+
const registered = privateSources.get(provider.source);
|
|
563
|
+
return registered === undefined ? [] : [[tag, registered.readers]];
|
|
564
|
+
})),
|
|
565
|
+
scripts: data.scripts, ...(data.policy === undefined ? {} : { policy: data.policy }), navigation, ...(failure === undefined ? {} : { failure }), ...(action === undefined ? {} : { action }),
|
|
566
|
+
layers: page.layers.map(layer => ({ ...layer, module: () => layer.module === undefined ? Promise.resolve({}) : load(layer.module) })) });
|
|
567
|
+
// A failure answers with app/error.html, rendered as the build would render it, and a safe message; the error goes to the console.
|
|
568
|
+
const failed = async (error, request) => {
|
|
569
|
+
if (privateLifetime.signal.aborted)
|
|
570
|
+
throw error;
|
|
571
|
+
if (!request.signal.aborted)
|
|
572
|
+
console.error(error);
|
|
573
|
+
const { pathname } = new URL(request.url);
|
|
574
|
+
// A failed page has no payload, so the browser loads its document, the error page.
|
|
575
|
+
if (pathname.startsWith(base + "_markupkit/pages/"))
|
|
576
|
+
return new Response(null, { status: 500 });
|
|
577
|
+
const builtin = () => pageResponse({ ...failurePage(serverFailure, data.scripts, data.policy), layers: [] }, request, false);
|
|
578
|
+
if (data.error === undefined || pathname.startsWith("//"))
|
|
579
|
+
return builtin();
|
|
580
|
+
try {
|
|
581
|
+
const rendered = await render(data.error, data.error.route, {}, new URL(pathname, request.url), undefined, request.signal, serverFailure);
|
|
582
|
+
return rendered.kind === "page" ? pageResponse({ ...rendered, status: serverFailure.status }, request, false, data.error.assets) : builtin();
|
|
583
|
+
}
|
|
584
|
+
catch (again) {
|
|
585
|
+
// The error page shares the site's layouts, so a failing layout fails it too; the built-in page answers then.
|
|
586
|
+
if (!request.signal.aborted)
|
|
587
|
+
console.error(again);
|
|
588
|
+
return builtin();
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const pipeline = {
|
|
592
|
+
base, routes, endpoints: data.endpoints,
|
|
593
|
+
read(request) {
|
|
594
|
+
request = new Request(request, { signal: AbortSignal.any([request.signal, privateLifetime.signal]) });
|
|
595
|
+
return privateReadResponse(request, base, privateSources, url => requestReadContext(request, url, base, matchRoute(routes, url.pathname, base)?.params ?? {}, options.services));
|
|
596
|
+
},
|
|
597
|
+
redirects: new Map(data.redirects.map(({ from, to, status }) => [from, { location: to, status }])),
|
|
598
|
+
endpoint: route => load(route.module),
|
|
599
|
+
...(data.middleware === undefined ? {} : { middleware: async () => (await load(data.middleware)).default }),
|
|
600
|
+
async page(route, params, request, payload) {
|
|
601
|
+
const server = data.pages[routes.indexOf(route)];
|
|
602
|
+
const url = new URL(request.url);
|
|
603
|
+
// The build refuses actions on a prerendered page.
|
|
604
|
+
let action;
|
|
605
|
+
if (request.method === "POST") {
|
|
606
|
+
const module = server?.layers.at(-1).module;
|
|
607
|
+
const found = pageAction(module === undefined ? {} : await load(module), url);
|
|
608
|
+
if (found instanceof Response)
|
|
609
|
+
return found;
|
|
610
|
+
action = found;
|
|
611
|
+
}
|
|
612
|
+
if (server === undefined) {
|
|
613
|
+
// A prerendered page: its document or payload, if the build wrote one for this URL.
|
|
614
|
+
const found = await outputPath(output, base, payload ? `${base}_markupkit/pages/${url.pathname.slice(base.length)}payload.json` : url.pathname);
|
|
615
|
+
if (typeof found === "object")
|
|
616
|
+
return file(found.file, request, 200, undefined, !payload);
|
|
617
|
+
return payload ? new Response(null, { status: 404 }) : missing(request);
|
|
618
|
+
}
|
|
619
|
+
const rendered = await render(server, route, params, url, request, request.signal, undefined, action);
|
|
620
|
+
if (rendered.kind === "notFound")
|
|
621
|
+
return payload ? new Response(null, { status: 404, headers: rendered.headers }) : missing(request, rendered.headers);
|
|
622
|
+
return pageResponse(rendered.kind === "page" ? rendered : { ...rendered, body: "", head: { html: "" }, layers: [] }, request, payload, server.assets);
|
|
623
|
+
},
|
|
624
|
+
async notFound(request, payload) {
|
|
625
|
+
if (payload)
|
|
626
|
+
return new Response(null, { status: 404 });
|
|
627
|
+
// A directory of public files is served as a static host serves it.
|
|
628
|
+
const found = await outputPath(output, base, new URL(request.url).pathname);
|
|
629
|
+
if (found === "slash")
|
|
630
|
+
return new Response(null, { status: 308, headers: { location: new URL(request.url).pathname + "/" } });
|
|
631
|
+
return typeof found === "object" && found.page && !reserved.some(path => within(path, found.file)) ? file(found.file, request) : missing(request);
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
return {
|
|
635
|
+
base,
|
|
636
|
+
close: () => { privateLifetime.abort(); return renderer.close(); },
|
|
637
|
+
async fetch(request) {
|
|
638
|
+
try {
|
|
639
|
+
// Files answer before middleware: a page's directory, and an endpoint's path, go through it.
|
|
640
|
+
const found = await outputPath(output, base, new URL(request.url).pathname);
|
|
641
|
+
if (typeof found === "object" && !found.page && !reserved.some(path => within(path, found.file))) {
|
|
642
|
+
return request.method === "GET" || request.method === "HEAD" ? await file(found.file, request) : new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
|
|
643
|
+
}
|
|
644
|
+
return await respond(pipeline, request);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
return failed(error, request);
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/** A native Request for a Node request: its method, URL, headers, and body, aborted when the response closes. */
|
|
653
|
+
export function nodeRequest(incoming, outgoing) {
|
|
654
|
+
const controller = new AbortController();
|
|
655
|
+
outgoing.once("close", () => controller.abort());
|
|
656
|
+
const headers = new Headers();
|
|
657
|
+
for (let i = 0; i < incoming.rawHeaders.length; i += 2)
|
|
658
|
+
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
659
|
+
// Vite's development server keeps the path under its base in originalUrl.
|
|
660
|
+
const path = "originalUrl" in incoming && typeof incoming.originalUrl === "string" ? incoming.originalUrl : incoming.url ?? "/";
|
|
661
|
+
const method = incoming.method ?? "GET";
|
|
662
|
+
// ponytail: http and the Host header; a proxy's X-Forwarded-* headers are not read.
|
|
663
|
+
return new Request(`http://${incoming.headers.host ?? "localhost"}${path}`, { method, headers, signal: controller.signal,
|
|
664
|
+
...(method === "GET" || method === "HEAD" ? {} : { body: Readable.toWeb(incoming), duplex: "half" }) });
|
|
665
|
+
}
|
|
666
|
+
/** Write a Response to a Node response, streaming its body. */
|
|
667
|
+
export async function sendResponse(response, outgoing) {
|
|
668
|
+
const headers = {};
|
|
669
|
+
for (const [name, value] of response.headers)
|
|
670
|
+
if (name !== "set-cookie")
|
|
671
|
+
headers[name] = value;
|
|
672
|
+
const cookies = response.headers.getSetCookie();
|
|
673
|
+
if (cookies.length > 0)
|
|
674
|
+
headers["set-cookie"] = cookies;
|
|
675
|
+
outgoing.writeHead(response.status, headers);
|
|
676
|
+
if (response.body === null) {
|
|
677
|
+
outgoing.end();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// A reader that goes away ends the stream; there is no one left to answer.
|
|
681
|
+
await pipeline(Readable.fromWeb(response.body), outgoing).catch(() => { outgoing.destroy(); });
|
|
682
|
+
}
|
|
683
|
+
export async function listen(server, options, base) {
|
|
684
|
+
const host = options.host ?? "127.0.0.1";
|
|
685
|
+
await new Promise((done, reject) => { server.once("error", reject); server.listen(options.port ?? 3000, host, done); });
|
|
686
|
+
const address = server.address();
|
|
687
|
+
if (address === null || typeof address === "string")
|
|
688
|
+
throw new MarkupKitError("Server did not obtain a TCP port.");
|
|
689
|
+
let closing;
|
|
690
|
+
return { url: `http://${host.includes(":") ? `[${host}]` : host}:${address.port}${base}`,
|
|
691
|
+
close: () => closing ??= new Promise((done, reject) => { server.close(error => error ? reject(error) : done()); server.closeIdleConnections(); }) };
|
|
692
|
+
}
|
|
693
|
+
/** Serve a build's server entry on Node: `markupkit start`. */
|
|
694
|
+
export async function startApplication(options = {}) {
|
|
695
|
+
if (options.renderWorkers !== undefined)
|
|
696
|
+
setRenderWorkers(options.renderWorkers);
|
|
697
|
+
const entry = pathToFileURL(resolve(options.outDir ?? "dist", "_markupkit/server/entry.js"));
|
|
698
|
+
try {
|
|
699
|
+
await stat(entry);
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
throw new MarkupKitError("No server entry; run markupkit build first.", fileURLToPath(entry));
|
|
703
|
+
}
|
|
704
|
+
const loaded = await import(entry.href);
|
|
705
|
+
const application = loaded.create === undefined ? loaded.default : await loaded.create(options);
|
|
706
|
+
const server = createHTTPServer((incoming, outgoing) => {
|
|
707
|
+
void (async () => {
|
|
708
|
+
let request;
|
|
709
|
+
try {
|
|
710
|
+
request = nodeRequest(incoming, outgoing);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
outgoing.writeHead(400).end();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
await sendResponse(await application.fetch(request), outgoing);
|
|
717
|
+
})().catch(error => {
|
|
718
|
+
console.error(error);
|
|
719
|
+
if (outgoing.headersSent)
|
|
720
|
+
outgoing.end();
|
|
721
|
+
else
|
|
722
|
+
outgoing.writeHead(500, { "content-type": "text/html; charset=utf-8" }).end(failurePage(serverFailure, []).html);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
try {
|
|
726
|
+
const listening = await listen(server, options, application.base);
|
|
727
|
+
return { url: listening.url, close: async () => { await Promise.all([listening.close(), application.close?.()]); } };
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
await application.close?.();
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
//# sourceMappingURL=server.js.map
|