@meekojs/meeko 0.0.3 → 0.0.4
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/README.md +18 -1
- package/dist/autoimports-ts.d.ts +26 -0
- package/dist/autoimports-ts.js +127 -0
- package/dist/autoimports-ts.js.map +1 -0
- package/dist/autoimports.d.ts +21 -0
- package/dist/autoimports.js +285 -0
- package/dist/autoimports.js.map +1 -0
- package/dist/check.js +35 -23
- package/dist/check.js.map +1 -0
- package/dist/cli.js +33 -4
- package/dist/cli.js.map +1 -0
- package/dist/compiler/import-graph.d.ts +8 -0
- package/dist/compiler/import-graph.js +33 -0
- package/dist/compiler/import-graph.js.map +1 -0
- package/dist/compiler/mapping.js +1 -0
- package/dist/compiler/mapping.js.map +1 -0
- package/dist/compiler/project-root.d.ts +2 -0
- package/dist/compiler/project-root.js +18 -0
- package/dist/compiler/project-root.js.map +1 -0
- package/dist/compiler/sfc.js +1 -0
- package/dist/compiler/sfc.js.map +1 -0
- package/dist/compiler/sourcemap.d.ts +3 -0
- package/dist/compiler/sourcemap.js +75 -0
- package/dist/compiler/sourcemap.js.map +1 -0
- package/dist/compiler/template.js +1 -0
- package/dist/compiler/template.js.map +1 -0
- package/dist/compiler.js +11 -5
- package/dist/compiler.js.map +1 -0
- package/dist/devtools-ui/index.js +1 -0
- package/dist/devtools-ui/index.js.map +1 -0
- package/dist/devtools.js +1 -0
- package/dist/devtools.js.map +1 -0
- package/dist/dom.d.ts +8 -1
- package/dist/dom.js +10 -5
- package/dist/dom.js.map +1 -0
- package/dist/framework/check-app.d.ts +5 -0
- package/dist/framework/check-app.js +59 -0
- package/dist/framework/check-app.js.map +1 -0
- package/dist/framework/client.d.ts +45 -0
- package/dist/framework/client.js +146 -0
- package/dist/framework/client.js.map +1 -0
- package/dist/framework/clients.d.ts +82 -0
- package/dist/framework/clients.js +182 -0
- package/dist/framework/clients.js.map +1 -0
- package/dist/framework/config-type.d.ts +10 -0
- package/dist/framework/config-type.js +4 -0
- package/dist/framework/config-type.js.map +1 -0
- package/dist/framework/config.d.ts +15 -0
- package/dist/framework/config.js +138 -0
- package/dist/framework/config.js.map +1 -0
- package/dist/framework/contracts-generate.d.ts +8 -0
- package/dist/framework/contracts-generate.js +227 -0
- package/dist/framework/contracts-generate.js.map +1 -0
- package/dist/framework/generate.d.ts +7 -0
- package/dist/framework/generate.js +67 -0
- package/dist/framework/generate.js.map +1 -0
- package/dist/framework/init.d.ts +2 -0
- package/dist/framework/init.js +362 -0
- package/dist/framework/init.js.map +1 -0
- package/dist/framework/modules.d.ts +22 -0
- package/dist/framework/modules.js +29 -0
- package/dist/framework/modules.js.map +1 -0
- package/dist/framework/page.d.ts +60 -0
- package/dist/framework/page.js +42 -0
- package/dist/framework/page.js.map +1 -0
- package/dist/framework/prerender.d.ts +14 -0
- package/dist/framework/prerender.js +104 -0
- package/dist/framework/prerender.js.map +1 -0
- package/dist/framework/router.d.ts +39 -0
- package/dist/framework/router.js +173 -0
- package/dist/framework/router.js.map +1 -0
- package/dist/framework/server.d.ts +51 -0
- package/dist/framework/server.js +192 -0
- package/dist/framework/server.js.map +1 -0
- package/dist/framework/socket-contracts-generate.d.ts +5 -0
- package/dist/framework/socket-contracts-generate.js +159 -0
- package/dist/framework/socket-contracts-generate.js.map +1 -0
- package/dist/framework/socket.d.ts +70 -0
- package/dist/framework/socket.js +150 -0
- package/dist/framework/socket.js.map +1 -0
- package/dist/framework/state.d.ts +15 -0
- package/dist/framework/state.js +72 -0
- package/dist/framework/state.js.map +1 -0
- package/dist/framework-server/auto-clients.d.ts +3 -0
- package/dist/framework-server/auto-clients.js +27 -0
- package/dist/framework-server/auto-clients.js.map +1 -0
- package/dist/framework-server/context.d.ts +21 -0
- package/dist/framework-server/context.js +22 -0
- package/dist/framework-server/context.js.map +1 -0
- package/dist/framework-server/contracts.d.ts +64 -0
- package/dist/framework-server/contracts.js +129 -0
- package/dist/framework-server/contracts.js.map +1 -0
- package/dist/framework-server/discovery.d.ts +40 -0
- package/dist/framework-server/discovery.js +252 -0
- package/dist/framework-server/discovery.js.map +1 -0
- package/dist/framework-server/local.d.ts +19 -0
- package/dist/framework-server/local.js +98 -0
- package/dist/framework-server/local.js.map +1 -0
- package/dist/framework-server/node-upgrade.d.ts +23 -0
- package/dist/framework-server/node-upgrade.js +78 -0
- package/dist/framework-server/node-upgrade.js.map +1 -0
- package/dist/framework-server/node-websocket.d.ts +16 -0
- package/dist/framework-server/node-websocket.js +125 -0
- package/dist/framework-server/node-websocket.js.map +1 -0
- package/dist/framework-server/openapi.d.ts +17 -0
- package/dist/framework-server/openapi.js +118 -0
- package/dist/framework-server/openapi.js.map +1 -0
- package/dist/framework-server/session-stores.d.ts +66 -0
- package/dist/framework-server/session-stores.js +96 -0
- package/dist/framework-server/session-stores.js.map +1 -0
- package/dist/framework-server/sessions.d.ts +64 -0
- package/dist/framework-server/sessions.js +136 -0
- package/dist/framework-server/sessions.js.map +1 -0
- package/dist/framework-server/socket.d.ts +83 -0
- package/dist/framework-server/socket.js +161 -0
- package/dist/framework-server/socket.js.map +1 -0
- package/dist/framework-server.d.ts +21 -0
- package/dist/framework-server.js +12 -0
- package/dist/framework-server.js.map +1 -0
- package/dist/framework.d.ts +29 -0
- package/dist/framework.js +13 -0
- package/dist/framework.js.map +1 -0
- package/dist/hmr.js +1 -0
- package/dist/hmr.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -0
- package/dist/init.js +4 -2
- package/dist/init.js.map +1 -0
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.js +52 -0
- package/dist/node.js.map +1 -0
- package/dist/reactivity.d.ts +12 -0
- package/dist/reactivity.js +40 -3
- package/dist/reactivity.js.map +1 -0
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -0
- package/dist/types.js +23 -8
- package/dist/types.js.map +1 -0
- package/dist/vite.js +91 -6
- package/dist/vite.js.map +1 -0
- package/package.json +14 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { serializeJson } from "../server.js";
|
|
5
|
+
import { href, matchRoute } from "./router.js";
|
|
6
|
+
import { createFrameworkHandler } from "./server.js";
|
|
7
|
+
import { discoverConfiguredRoutes } from "./config.js";
|
|
8
|
+
function hasParams(id) {
|
|
9
|
+
return /\[(?:\.\.\.)?[A-Za-z_$][\w$]*\]/.test(id);
|
|
10
|
+
}
|
|
11
|
+
/** Writes only fully resolved, successful HTML responses to the prerender output. */
|
|
12
|
+
export async function prerenderRoutes(options) {
|
|
13
|
+
const outputRoot = resolve(options.outputRoot);
|
|
14
|
+
const origin = options.origin ?? "http://meeko.local";
|
|
15
|
+
const urls = new Map();
|
|
16
|
+
for (const route of options.routes) {
|
|
17
|
+
const sidecar = route.sidecar && options.loadRoute ? await options.loadRoute(route.sidecar) : undefined;
|
|
18
|
+
if (sidecar?.render !== "prerender")
|
|
19
|
+
continue;
|
|
20
|
+
const dynamic = hasParams(route.id);
|
|
21
|
+
const entries = dynamic
|
|
22
|
+
? typeof sidecar?.prerenderEntries === "function" ? await sidecar.prerenderEntries() : sidecar?.prerenderEntries
|
|
23
|
+
: [{}];
|
|
24
|
+
if (!entries?.length)
|
|
25
|
+
throw new Error(`Dynamic prerender route ${route.id} requires prerenderEntries`);
|
|
26
|
+
for (const params of entries) {
|
|
27
|
+
const pathname = href(options.routes, route.id, { params });
|
|
28
|
+
if (matchRoute(options.routes, pathname)?.route !== route) {
|
|
29
|
+
throw new Error(`Prerender URL ${pathname} is shadowed by another route`);
|
|
30
|
+
}
|
|
31
|
+
const previous = urls.get(pathname);
|
|
32
|
+
if (previous)
|
|
33
|
+
throw new Error(`Duplicate prerender URL ${pathname}: ${previous} and ${route.file}`);
|
|
34
|
+
urls.set(pathname, route.file);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const outputs = [];
|
|
38
|
+
await mkdir(outputRoot, { recursive: true });
|
|
39
|
+
for (const pathname of urls.keys()) {
|
|
40
|
+
const request = new Request(new URL(pathname, origin));
|
|
41
|
+
const response = await options.handler(request);
|
|
42
|
+
if (response.status !== 200 || !(response.headers.get("content-type") ?? "").includes("text/html")) {
|
|
43
|
+
throw new Error(`Prerender ${pathname} returned HTTP ${response.status} instead of HTML 200`);
|
|
44
|
+
}
|
|
45
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
46
|
+
const output = resolve(outputRoot, ...parts, "index.html");
|
|
47
|
+
if (!output.startsWith(outputRoot + sep))
|
|
48
|
+
throw new Error(`Prerender path escaped output: ${pathname}`);
|
|
49
|
+
await mkdir(resolve(output, ".."), { recursive: true });
|
|
50
|
+
const html = await response.text();
|
|
51
|
+
await writeFile(output, options.transformHtml ? await options.transformHtml(html) : html, "utf8");
|
|
52
|
+
outputs.push(output);
|
|
53
|
+
}
|
|
54
|
+
await writeFile(join(outputRoot, "manifest.json"), serializeJson({ paths: [...urls.keys()] }) + "\n", "utf8");
|
|
55
|
+
return outputs;
|
|
56
|
+
}
|
|
57
|
+
/** Renders from the production server bundle and client manifest. */
|
|
58
|
+
export async function prerenderProject(projectRoot) {
|
|
59
|
+
const root = resolve(projectRoot);
|
|
60
|
+
const manifestFile = resolve(root, ".meeko/output/client/.vite/manifest.json");
|
|
61
|
+
let manifest;
|
|
62
|
+
try {
|
|
63
|
+
manifest = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error("Client build manifest is missing; run the client build before prerender");
|
|
67
|
+
}
|
|
68
|
+
const entry = manifest["app/entry-client.ts"]?.file;
|
|
69
|
+
if (!entry)
|
|
70
|
+
throw new Error("Client build manifest has no app/entry-client.ts entry");
|
|
71
|
+
const serverBundle = resolve(root, ".meeko/output/server/entry.js");
|
|
72
|
+
let server;
|
|
73
|
+
try {
|
|
74
|
+
server = await import(pathToFileURL(serverBundle).href);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("Server build is missing; run the server build before prerender");
|
|
78
|
+
}
|
|
79
|
+
if (typeof server.loadPage !== "function" || typeof server.loadRoute !== "function") {
|
|
80
|
+
throw new Error("Server build does not expose the Meeko prerender loaders");
|
|
81
|
+
}
|
|
82
|
+
const routes = await discoverConfiguredRoutes(root);
|
|
83
|
+
const loadPage = (file) => server.loadPage(root, file);
|
|
84
|
+
const loadRoute = (file) => server.loadRoute(root, file);
|
|
85
|
+
const handler = createFrameworkHandler({ routes, loadPage, loadRoute, clientEntry: `/${entry}` });
|
|
86
|
+
const portable = routes.map((route) => ({
|
|
87
|
+
id: route.id,
|
|
88
|
+
file: "/" + fileRelative(root, route.file),
|
|
89
|
+
layouts: route.layouts.map((file) => "/" + fileRelative(root, file)),
|
|
90
|
+
...(route.error ? { error: "/" + fileRelative(root, route.error) } : {}),
|
|
91
|
+
}));
|
|
92
|
+
return await prerenderRoutes({
|
|
93
|
+
routes, handler, loadRoute,
|
|
94
|
+
outputRoot: resolve(root, ".meeko/output/prerender"),
|
|
95
|
+
transformHtml: (html) => html.replace("</body>", `<script id="meeko-routes" type="application/json">${serializeJson(portable)}</script></body>`),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function fileRelative(root, file) {
|
|
99
|
+
const absolute = resolve(file);
|
|
100
|
+
if (!absolute.startsWith(root + sep))
|
|
101
|
+
throw new Error(`Route file escaped project: ${file}`);
|
|
102
|
+
return absolute.slice(root.length + 1).replaceAll("\\", "/");
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=prerender.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prerender.js","sourceRoot":"","sources":["../../src/framework/prerender.ts"],"names":[],"mappings":"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","sourcesContent":["import { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { join, resolve, sep } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport { serializeJson } from \"../server.js\";\nimport { href, matchRoute } from \"./router.js\";\nimport type { RouteRecord } from \"./router.js\";\nimport { createFrameworkHandler } from \"./server.js\";\nimport { discoverConfiguredRoutes } from \"./config.js\";\nimport type { PageModule, PageRoute } from \"./server.js\";\n\nexport interface PrerenderOptions {\n readonly routes: readonly RouteRecord[];\n readonly handler: (request: Request) => Promise<Response>;\n readonly loadRoute?: (file: string) => Promise<PageRoute>;\n readonly outputRoot: string;\n readonly origin?: string;\n readonly transformHtml?: (html: string) => string | Promise<string>;\n}\n\nfunction hasParams(id: string): boolean {\n return /\\[(?:\\.\\.\\.)?[A-Za-z_$][\\w$]*\\]/.test(id);\n}\n\n/** Writes only fully resolved, successful HTML responses to the prerender output. */\nexport async function prerenderRoutes(options: PrerenderOptions): Promise<string[]> {\n const outputRoot = resolve(options.outputRoot);\n const origin = options.origin ?? \"http://meeko.local\";\n const urls = new Map<string, string>();\n for (const route of options.routes) {\n const sidecar = route.sidecar && options.loadRoute ? await options.loadRoute(route.sidecar) : undefined;\n if (sidecar?.render !== \"prerender\") continue;\n const dynamic = hasParams(route.id);\n const entries = dynamic\n ? typeof sidecar?.prerenderEntries === \"function\" ? await sidecar.prerenderEntries() : sidecar?.prerenderEntries\n : [{}];\n if (!entries?.length) throw new Error(`Dynamic prerender route ${route.id} requires prerenderEntries`);\n for (const params of entries) {\n const pathname = href(options.routes, route.id, { params });\n if (matchRoute(options.routes, pathname)?.route !== route) {\n throw new Error(`Prerender URL ${pathname} is shadowed by another route`);\n }\n const previous = urls.get(pathname);\n if (previous) throw new Error(`Duplicate prerender URL ${pathname}: ${previous} and ${route.file}`);\n urls.set(pathname, route.file);\n }\n }\n const outputs: string[] = [];\n await mkdir(outputRoot, { recursive: true });\n for (const pathname of urls.keys()) {\n const request = new Request(new URL(pathname, origin));\n const response = await options.handler(request);\n if (response.status !== 200 || !(response.headers.get(\"content-type\") ?? \"\").includes(\"text/html\")) {\n throw new Error(`Prerender ${pathname} returned HTTP ${response.status} instead of HTML 200`);\n }\n const parts = pathname.split(\"/\").filter(Boolean);\n const output = resolve(outputRoot, ...parts, \"index.html\");\n if (!output.startsWith(outputRoot + sep)) throw new Error(`Prerender path escaped output: ${pathname}`);\n await mkdir(resolve(output, \"..\"), { recursive: true });\n const html = await response.text();\n await writeFile(output, options.transformHtml ? await options.transformHtml(html) : html, \"utf8\");\n outputs.push(output);\n }\n await writeFile(join(outputRoot, \"manifest.json\"), serializeJson({ paths: [...urls.keys()] }) + \"\\n\", \"utf8\");\n return outputs;\n}\n\n/** Renders from the production server bundle and client manifest. */\nexport async function prerenderProject(projectRoot: string): Promise<string[]> {\n const root = resolve(projectRoot);\n const manifestFile = resolve(root, \".meeko/output/client/.vite/manifest.json\");\n let manifest: Record<string, { file?: string }>;\n try {\n manifest = JSON.parse(await readFile(manifestFile, \"utf8\")) as Record<string, { file?: string }>;\n } catch {\n throw new Error(\"Client build manifest is missing; run the client build before prerender\");\n }\n const entry = manifest[\"app/entry-client.ts\"]?.file;\n if (!entry) throw new Error(\"Client build manifest has no app/entry-client.ts entry\");\n const serverBundle = resolve(root, \".meeko/output/server/entry.js\");\n let server: {\n loadPage(root: string, file: string): Promise<PageModule>;\n loadRoute(root: string, file: string): Promise<PageRoute>;\n };\n try {\n server = await import(pathToFileURL(serverBundle).href) as typeof server;\n } catch {\n throw new Error(\"Server build is missing; run the server build before prerender\");\n }\n if (typeof server.loadPage !== \"function\" || typeof server.loadRoute !== \"function\") {\n throw new Error(\"Server build does not expose the Meeko prerender loaders\");\n }\n const routes = await discoverConfiguredRoutes(root);\n const loadPage = (file: string) => server.loadPage(root, file);\n const loadRoute = (file: string) => server.loadRoute(root, file);\n const handler = createFrameworkHandler({ routes, loadPage, loadRoute, clientEntry: `/${entry}` });\n const portable = routes.map((route) => ({\n id: route.id,\n file: \"/\" + fileRelative(root, route.file),\n layouts: route.layouts.map((file) => \"/\" + fileRelative(root, file)),\n ...(route.error ? { error: \"/\" + fileRelative(root, route.error) } : {}),\n }));\n return await prerenderRoutes({\n routes, handler, loadRoute,\n outputRoot: resolve(root, \".meeko/output/prerender\"),\n transformHtml: (html) => html.replace(\"</body>\", `<script id=\"meeko-routes\" type=\"application/json\">${serializeJson(portable)}</script></body>`),\n });\n}\n\nfunction fileRelative(root: string, file: string): string {\n const absolute = resolve(file);\n if (!absolute.startsWith(root + sep)) throw new Error(`Route file escaped project: ${file}`);\n return absolute.slice(root.length + 1).replaceAll(\"\\\\\", \"/\");\n}\n"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** A route manifest is shared by the server renderer and browser navigator. */
|
|
2
|
+
export interface RouteRecord {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly file: string;
|
|
5
|
+
readonly layouts: readonly string[];
|
|
6
|
+
readonly error?: string;
|
|
7
|
+
readonly sidecar?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RouteMatch {
|
|
10
|
+
readonly route: RouteRecord;
|
|
11
|
+
readonly params: Readonly<Record<string, string>>;
|
|
12
|
+
}
|
|
13
|
+
/** Validates collisions before sorting static routes ahead of parameters. */
|
|
14
|
+
export declare function createRouteManifest(routes: readonly RouteRecord[]): readonly RouteRecord[];
|
|
15
|
+
/** Resolves a URL against the same manifest used for SSR and client navigation. */
|
|
16
|
+
export declare function matchRoute(routes: readonly RouteRecord[], pathname: string): RouteMatch | undefined;
|
|
17
|
+
/** Builds a URL from a manifest route and rejects absent or extra parameters. */
|
|
18
|
+
export declare function href(routes: readonly RouteRecord[], id: string, options?: {
|
|
19
|
+
params?: Readonly<Record<string, string>>;
|
|
20
|
+
search?: Readonly<Record<string, string | number | boolean | null | undefined>>;
|
|
21
|
+
}): string;
|
|
22
|
+
export interface NavigationContext {
|
|
23
|
+
readonly url: URL;
|
|
24
|
+
readonly match: RouteMatch;
|
|
25
|
+
readonly signal: AbortSignal;
|
|
26
|
+
/** Longest prefix shared with the previous route's layout chain. */
|
|
27
|
+
readonly retainedLayouts: number;
|
|
28
|
+
}
|
|
29
|
+
export interface Navigator {
|
|
30
|
+
navigate(to: string, options?: {
|
|
31
|
+
replace?: boolean;
|
|
32
|
+
}): Promise<boolean>;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The host owns actual DOM outlet rendering. Its callback receives layout
|
|
37
|
+
* identity so it can retain matching layout instances and release exited ones.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createNavigator(routes: readonly RouteRecord[], render: (context: NavigationContext) => Promise<void> | void, browser?: Window): Navigator;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
function segments(id) {
|
|
2
|
+
if (!id.startsWith("/"))
|
|
3
|
+
throw new TypeError(`Route must start with /: ${id}`);
|
|
4
|
+
return id.split("/").slice(1).filter(Boolean).map((part, index, all) => {
|
|
5
|
+
if (/^\[\.\.\.[A-Za-z_$][\w$]*\]$/.test(part)) {
|
|
6
|
+
if (index !== all.length - 1)
|
|
7
|
+
throw new TypeError(`Catch-all must be final: ${id}`);
|
|
8
|
+
return { kind: "rest", value: part.slice(4, -1) };
|
|
9
|
+
}
|
|
10
|
+
if (/^\[[A-Za-z_$][\w$]*\]$/.test(part))
|
|
11
|
+
return { kind: "param", value: part.slice(1, -1) };
|
|
12
|
+
if (part.includes("[") || part.includes("]"))
|
|
13
|
+
throw new TypeError(`Invalid route segment: ${part}`);
|
|
14
|
+
return { kind: "static", value: part };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function rank(id) {
|
|
18
|
+
return segments(id).map((part) => part.kind === "static" ? 3 : part.kind === "param" ? 2 : 1);
|
|
19
|
+
}
|
|
20
|
+
function compareRoute(a, b) {
|
|
21
|
+
const left = rank(a.id);
|
|
22
|
+
const right = rank(b.id);
|
|
23
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
24
|
+
const score = (right[index] ?? 0) - (left[index] ?? 0);
|
|
25
|
+
if (score)
|
|
26
|
+
return score;
|
|
27
|
+
}
|
|
28
|
+
return a.id.localeCompare(b.id);
|
|
29
|
+
}
|
|
30
|
+
function collisionKey(id) {
|
|
31
|
+
return segments(id).map((part) => part.kind === "static" ? part.value : part.kind === "param" ? "[]" : "[...]").join("/");
|
|
32
|
+
}
|
|
33
|
+
function decodePart(part) {
|
|
34
|
+
try {
|
|
35
|
+
const value = decodeURIComponent(part);
|
|
36
|
+
return value.includes("/") || value.includes("\\") || value === "." || value === ".." ? undefined : value;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Validates collisions before sorting static routes ahead of parameters. */
|
|
43
|
+
export function createRouteManifest(routes) {
|
|
44
|
+
const keys = new Map();
|
|
45
|
+
for (const route of routes) {
|
|
46
|
+
const key = collisionKey(route.id);
|
|
47
|
+
const previous = keys.get(key);
|
|
48
|
+
if (previous)
|
|
49
|
+
throw new Error(`Route collision: ${previous} and ${route.file} both resolve to ${key || "/"}`);
|
|
50
|
+
keys.set(key, route.file);
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze([...routes].sort(compareRoute));
|
|
53
|
+
}
|
|
54
|
+
/** Resolves a URL against the same manifest used for SSR and client navigation. */
|
|
55
|
+
export function matchRoute(routes, pathname) {
|
|
56
|
+
if (!pathname.startsWith("/"))
|
|
57
|
+
return undefined;
|
|
58
|
+
const raw = pathname.split("/").slice(1).filter(Boolean);
|
|
59
|
+
const path = raw.map(decodePart);
|
|
60
|
+
if (path.some((part) => part === undefined))
|
|
61
|
+
return undefined;
|
|
62
|
+
for (const route of routes) {
|
|
63
|
+
const pattern = segments(route.id);
|
|
64
|
+
const params = {};
|
|
65
|
+
let index = 0;
|
|
66
|
+
let matched = true;
|
|
67
|
+
for (const part of pattern) {
|
|
68
|
+
if (part.kind === "rest") {
|
|
69
|
+
if (index === path.length) {
|
|
70
|
+
matched = false;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
params[part.value] = path.slice(index).join("/");
|
|
74
|
+
index = path.length;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const value = path[index++];
|
|
78
|
+
if (value === undefined || (part.kind === "static" && part.value !== value)) {
|
|
79
|
+
matched = false;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (part.kind === "param")
|
|
83
|
+
params[part.value] = value;
|
|
84
|
+
}
|
|
85
|
+
if (matched && index === path.length)
|
|
86
|
+
return { route, params };
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Builds a URL from a manifest route and rejects absent or extra parameters. */
|
|
91
|
+
export function href(routes, id, options = {}) {
|
|
92
|
+
if (!routes.some((route) => route.id === id))
|
|
93
|
+
throw new Error(`Unknown route: ${id}`);
|
|
94
|
+
const params = options.params ?? {};
|
|
95
|
+
const used = new Set();
|
|
96
|
+
const path = segments(id).map((part) => {
|
|
97
|
+
if (part.kind === "static")
|
|
98
|
+
return encodeURIComponent(part.value);
|
|
99
|
+
const value = params[part.value];
|
|
100
|
+
if (!value)
|
|
101
|
+
throw new Error(`Missing route parameter: ${part.value}`);
|
|
102
|
+
used.add(part.value);
|
|
103
|
+
if (part.kind === "rest") {
|
|
104
|
+
const pieces = value.split("/");
|
|
105
|
+
if (pieces.some((piece) => !piece || piece === "." || piece === ".."))
|
|
106
|
+
throw new Error(`Invalid catch-all parameter: ${part.value}`);
|
|
107
|
+
return pieces.map(encodeURIComponent).join("/");
|
|
108
|
+
}
|
|
109
|
+
if (value.includes("/") || value === "." || value === "..")
|
|
110
|
+
throw new Error(`Invalid route parameter: ${part.value}`);
|
|
111
|
+
return encodeURIComponent(value);
|
|
112
|
+
});
|
|
113
|
+
for (const key of Object.keys(params))
|
|
114
|
+
if (!used.has(key))
|
|
115
|
+
throw new Error(`Unknown route parameter: ${key}`);
|
|
116
|
+
const url = new URL(path.length ? `/${path.join("/")}` : "/", "http://meeko.local");
|
|
117
|
+
for (const [key, value] of Object.entries(options.search ?? {}))
|
|
118
|
+
if (value !== null && value !== undefined)
|
|
119
|
+
url.searchParams.set(key, String(value));
|
|
120
|
+
return url.pathname + url.search;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The host owns actual DOM outlet rendering. Its callback receives layout
|
|
124
|
+
* identity so it can retain matching layout instances and release exited ones.
|
|
125
|
+
*/
|
|
126
|
+
export function createNavigator(routes, render, browser = window) {
|
|
127
|
+
let current = matchRoute(routes, browser.location.pathname);
|
|
128
|
+
let pending;
|
|
129
|
+
let disposed = false;
|
|
130
|
+
const navigate = async (to, options = {}) => {
|
|
131
|
+
if (disposed)
|
|
132
|
+
throw new Error("Navigator disposed");
|
|
133
|
+
const url = new URL(to, browser.location.href);
|
|
134
|
+
if (url.origin !== browser.location.origin)
|
|
135
|
+
throw new TypeError("External navigation is not a Meeko route");
|
|
136
|
+
const match = matchRoute(routes, url.pathname);
|
|
137
|
+
if (!match)
|
|
138
|
+
return false;
|
|
139
|
+
pending?.abort();
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
pending = controller;
|
|
142
|
+
let retainedLayouts = 0;
|
|
143
|
+
if (current) {
|
|
144
|
+
while (current.route.layouts[retainedLayouts] === match.route.layouts[retainedLayouts]
|
|
145
|
+
&& retainedLayouts < current.route.layouts.length
|
|
146
|
+
&& retainedLayouts < match.route.layouts.length)
|
|
147
|
+
retainedLayouts++;
|
|
148
|
+
}
|
|
149
|
+
await render({ url, match, signal: controller.signal, retainedLayouts });
|
|
150
|
+
if (controller.signal.aborted || disposed)
|
|
151
|
+
return false;
|
|
152
|
+
if (options.replace)
|
|
153
|
+
browser.history.replaceState(null, "", url);
|
|
154
|
+
else
|
|
155
|
+
browser.history.pushState(null, "", url);
|
|
156
|
+
current = match;
|
|
157
|
+
pending = undefined;
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
const onPop = () => {
|
|
161
|
+
void navigate(browser.location.href, { replace: true });
|
|
162
|
+
};
|
|
163
|
+
browser.addEventListener("popstate", onPop);
|
|
164
|
+
return {
|
|
165
|
+
navigate,
|
|
166
|
+
dispose() {
|
|
167
|
+
disposed = true;
|
|
168
|
+
pending?.abort();
|
|
169
|
+
browser.removeEventListener("popstate", onPop);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../../src/framework/router.ts"],"names":[],"mappings":"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","sourcesContent":["/** A route manifest is shared by the server renderer and browser navigator. */\nexport interface RouteRecord {\n readonly id: string;\n readonly file: string;\n readonly layouts: readonly string[];\n readonly error?: string;\n readonly sidecar?: string;\n}\n\nexport interface RouteMatch {\n readonly route: RouteRecord;\n readonly params: Readonly<Record<string, string>>;\n}\n\ntype Segment = { kind: \"static\" | \"param\" | \"rest\"; value: string };\n\nfunction segments(id: string): Segment[] {\n if (!id.startsWith(\"/\")) throw new TypeError(`Route must start with /: ${id}`);\n return id.split(\"/\").slice(1).filter(Boolean).map((part, index, all) => {\n if (/^\\[\\.\\.\\.[A-Za-z_$][\\w$]*\\]$/.test(part)) {\n if (index !== all.length - 1) throw new TypeError(`Catch-all must be final: ${id}`);\n return { kind: \"rest\", value: part.slice(4, -1) };\n }\n if (/^\\[[A-Za-z_$][\\w$]*\\]$/.test(part)) return { kind: \"param\", value: part.slice(1, -1) };\n if (part.includes(\"[\") || part.includes(\"]\")) throw new TypeError(`Invalid route segment: ${part}`);\n return { kind: \"static\", value: part };\n });\n}\n\nfunction rank(id: string): number[] {\n return segments(id).map((part) => part.kind === \"static\" ? 3 : part.kind === \"param\" ? 2 : 1);\n}\n\nfunction compareRoute(a: RouteRecord, b: RouteRecord): number {\n const left = rank(a.id);\n const right = rank(b.id);\n for (let index = 0; index < Math.max(left.length, right.length); index++) {\n const score = (right[index] ?? 0) - (left[index] ?? 0);\n if (score) return score;\n }\n return a.id.localeCompare(b.id);\n}\n\nfunction collisionKey(id: string): string {\n return segments(id).map((part) => part.kind === \"static\" ? part.value : part.kind === \"param\" ? \"[]\" : \"[...]\").join(\"/\");\n}\n\nfunction decodePart(part: string): string | undefined {\n try {\n const value = decodeURIComponent(part);\n return value.includes(\"/\") || value.includes(\"\\\\\") || value === \".\" || value === \"..\" ? undefined : value;\n } catch {\n return undefined;\n }\n}\n\n/** Validates collisions before sorting static routes ahead of parameters. */\nexport function createRouteManifest(routes: readonly RouteRecord[]): readonly RouteRecord[] {\n const keys = new Map<string, string>();\n for (const route of routes) {\n const key = collisionKey(route.id);\n const previous = keys.get(key);\n if (previous) throw new Error(`Route collision: ${previous} and ${route.file} both resolve to ${key || \"/\"}`);\n keys.set(key, route.file);\n }\n return Object.freeze([...routes].sort(compareRoute));\n}\n\n/** Resolves a URL against the same manifest used for SSR and client navigation. */\nexport function matchRoute(routes: readonly RouteRecord[], pathname: string): RouteMatch | undefined {\n if (!pathname.startsWith(\"/\")) return undefined;\n const raw = pathname.split(\"/\").slice(1).filter(Boolean);\n const path = raw.map(decodePart);\n if (path.some((part) => part === undefined)) return undefined;\n for (const route of routes) {\n const pattern = segments(route.id);\n const params: Record<string, string> = {};\n let index = 0;\n let matched = true;\n for (const part of pattern) {\n if (part.kind === \"rest\") {\n if (index === path.length) { matched = false; break; }\n params[part.value] = path.slice(index).join(\"/\");\n index = path.length;\n break;\n }\n const value = path[index++];\n if (value === undefined || (part.kind === \"static\" && part.value !== value)) { matched = false; break; }\n if (part.kind === \"param\") params[part.value] = value;\n }\n if (matched && index === path.length) return { route, params };\n }\n return undefined;\n}\n\n/** Builds a URL from a manifest route and rejects absent or extra parameters. */\nexport function href(routes: readonly RouteRecord[], id: string, options: {\n params?: Readonly<Record<string, string>>;\n search?: Readonly<Record<string, string | number | boolean | null | undefined>>;\n} = {}): string {\n if (!routes.some((route) => route.id === id)) throw new Error(`Unknown route: ${id}`);\n const params = options.params ?? {};\n const used = new Set<string>();\n const path = segments(id).map((part) => {\n if (part.kind === \"static\") return encodeURIComponent(part.value);\n const value = params[part.value];\n if (!value) throw new Error(`Missing route parameter: ${part.value}`);\n used.add(part.value);\n if (part.kind === \"rest\") {\n const pieces = value.split(\"/\");\n if (pieces.some((piece) => !piece || piece === \".\" || piece === \"..\")) throw new Error(`Invalid catch-all parameter: ${part.value}`);\n return pieces.map(encodeURIComponent).join(\"/\");\n }\n if (value.includes(\"/\") || value === \".\" || value === \"..\") throw new Error(`Invalid route parameter: ${part.value}`);\n return encodeURIComponent(value);\n });\n for (const key of Object.keys(params)) if (!used.has(key)) throw new Error(`Unknown route parameter: ${key}`);\n const url = new URL(path.length ? `/${path.join(\"/\")}` : \"/\", \"http://meeko.local\");\n for (const [key, value] of Object.entries(options.search ?? {})) if (value !== null && value !== undefined) url.searchParams.set(key, String(value));\n return url.pathname + url.search;\n}\n\nexport interface NavigationContext {\n readonly url: URL;\n readonly match: RouteMatch;\n readonly signal: AbortSignal;\n /** Longest prefix shared with the previous route's layout chain. */\n readonly retainedLayouts: number;\n}\n\nexport interface Navigator {\n navigate(to: string, options?: { replace?: boolean }): Promise<boolean>;\n dispose(): void;\n}\n\n/**\n * The host owns actual DOM outlet rendering. Its callback receives layout\n * identity so it can retain matching layout instances and release exited ones.\n */\nexport function createNavigator(routes: readonly RouteRecord[], render: (context: NavigationContext) => Promise<void> | void, browser: Window = window): Navigator {\n let current = matchRoute(routes, browser.location.pathname);\n let pending: AbortController | undefined;\n let disposed = false;\n const navigate = async (to: string, options: { replace?: boolean } = {}): Promise<boolean> => {\n if (disposed) throw new Error(\"Navigator disposed\");\n const url = new URL(to, browser.location.href);\n if (url.origin !== browser.location.origin) throw new TypeError(\"External navigation is not a Meeko route\");\n const match = matchRoute(routes, url.pathname);\n if (!match) return false;\n pending?.abort();\n const controller = new AbortController();\n pending = controller;\n let retainedLayouts = 0;\n if (current) {\n while (current.route.layouts[retainedLayouts] === match.route.layouts[retainedLayouts]\n && retainedLayouts < current.route.layouts.length\n && retainedLayouts < match.route.layouts.length) retainedLayouts++;\n }\n await render({ url, match, signal: controller.signal, retainedLayouts });\n if (controller.signal.aborted || disposed) return false;\n if (options.replace) browser.history.replaceState(null, \"\", url);\n else browser.history.pushState(null, \"\", url);\n current = match;\n pending = undefined;\n return true;\n };\n const onPop = () => {\n void navigate(browser.location.href, { replace: true });\n };\n browser.addEventListener(\"popstate\", onPop);\n return {\n navigate,\n dispose() {\n disposed = true;\n pending?.abort();\n browser.removeEventListener(\"popstate\", onPop);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RequestContext } from "../framework-server/context.js";
|
|
2
|
+
import type { ServerFetch } from "../framework-server/context.js";
|
|
3
|
+
import type { PageSchema } from "./page.js";
|
|
4
|
+
import { StateStore } from "./state.js";
|
|
5
|
+
import type { RouteRecord } from "./router.js";
|
|
6
|
+
/** Discovers pages once; the returned manifest is safe to serialize for the browser. */
|
|
7
|
+
export declare function discoverRoutes(projectRoot: string): Promise<readonly RouteRecord[]>;
|
|
8
|
+
export interface PageModule {
|
|
9
|
+
css?: string;
|
|
10
|
+
renderWithState(props: Record<string, unknown>): {
|
|
11
|
+
html: string;
|
|
12
|
+
state: Record<string, unknown>;
|
|
13
|
+
};
|
|
14
|
+
renderToString?(props: Record<string, unknown>): string;
|
|
15
|
+
}
|
|
16
|
+
export interface PageRoute {
|
|
17
|
+
params?: PageSchema;
|
|
18
|
+
search?: PageSchema;
|
|
19
|
+
load?: (context: PageRequestContext) => Promise<unknown> | unknown;
|
|
20
|
+
head?: (context: PageRequestContext & {
|
|
21
|
+
data: unknown;
|
|
22
|
+
}) => {
|
|
23
|
+
title?: string;
|
|
24
|
+
};
|
|
25
|
+
render?: "ssr" | "prerender" | "client";
|
|
26
|
+
prerenderEntries?: readonly Readonly<Record<string, string>>[] | (() => Promise<readonly Readonly<Record<string, string>>[]>);
|
|
27
|
+
}
|
|
28
|
+
export interface PageRequestContext {
|
|
29
|
+
readonly request: Request;
|
|
30
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
31
|
+
readonly search: Readonly<Record<string, unknown>>;
|
|
32
|
+
readonly searchParams: URLSearchParams;
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
/** Public state transferred from this request to its browser app instance. */
|
|
35
|
+
readonly state: StateStore;
|
|
36
|
+
/** Present when the host supplies a per-request context. */
|
|
37
|
+
readonly context?: RequestContext;
|
|
38
|
+
}
|
|
39
|
+
export interface FrameworkHandlerOptions {
|
|
40
|
+
readonly routes: readonly RouteRecord[];
|
|
41
|
+
readonly loadPage: (file: string) => Promise<PageModule>;
|
|
42
|
+
readonly loadRoute?: (file: string) => Promise<PageRoute>;
|
|
43
|
+
readonly createContext?: (request: Request) => RequestContext | Promise<RequestContext>;
|
|
44
|
+
/** Optional in-process dispatcher for automatic API/RPC calls during SSR. */
|
|
45
|
+
readonly serverFetch?: ServerFetch;
|
|
46
|
+
/** Optional host layout renderer; default composition uses <component is="props.outlet"/>. */
|
|
47
|
+
readonly renderLayout?: (file: string, children: string, context: PageRequestContext) => Promise<string> | string;
|
|
48
|
+
readonly clientEntry?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Portable Fetch handler. Node adapters can pass their Request directly. */
|
|
51
|
+
export declare function createFrameworkHandler(options: FrameworkHandlerOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { escapeHtml, serializeJson } from "../server.js";
|
|
4
|
+
import { withRequestContext } from "../framework-server/context.js";
|
|
5
|
+
import { PageInputError, pageSearchObject, validatePageInputs } from "./page.js";
|
|
6
|
+
import { StateStore, withStateStore } from "./state.js";
|
|
7
|
+
import { createRouteManifest, matchRoute } from "./router.js";
|
|
8
|
+
function routeId(relativeFile) {
|
|
9
|
+
const parts = relativeFile.slice(0, -".meeko".length).split(sep);
|
|
10
|
+
if (parts.at(-1) === "index")
|
|
11
|
+
parts.pop();
|
|
12
|
+
const visible = parts.filter((part) => !/^\([^/()]+\)$/.test(part));
|
|
13
|
+
return visible.length ? `/${visible.join("/")}` : "/";
|
|
14
|
+
}
|
|
15
|
+
/** Discovers pages once; the returned manifest is safe to serialize for the browser. */
|
|
16
|
+
export async function discoverRoutes(projectRoot) {
|
|
17
|
+
const pagesRoot = resolve(projectRoot, "app", "pages");
|
|
18
|
+
const files = new Set();
|
|
19
|
+
async function walk(directory) {
|
|
20
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
21
|
+
const path = join(directory, entry.name);
|
|
22
|
+
if (entry.isDirectory())
|
|
23
|
+
await walk(path);
|
|
24
|
+
else if (entry.isFile())
|
|
25
|
+
files.add(path);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await walk(pagesRoot);
|
|
29
|
+
const appShell = resolve(projectRoot, "app", "app.meeko");
|
|
30
|
+
let hasAppShell = false;
|
|
31
|
+
try {
|
|
32
|
+
hasAppShell = (await stat(appShell)).isFile();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== "ENOENT")
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
const routes = [];
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
if (!file.endsWith(".meeko") || basename(file).startsWith("+"))
|
|
41
|
+
continue;
|
|
42
|
+
const directory = dirname(file);
|
|
43
|
+
const layouts = hasAppShell ? [appShell] : [];
|
|
44
|
+
let cursor = pagesRoot;
|
|
45
|
+
const relativeDirectory = relative(pagesRoot, directory);
|
|
46
|
+
if (files.has(join(cursor, "+layout.meeko")))
|
|
47
|
+
layouts.push(join(cursor, "+layout.meeko"));
|
|
48
|
+
if (relativeDirectory)
|
|
49
|
+
for (const segment of relativeDirectory.split(sep)) {
|
|
50
|
+
cursor = join(cursor, segment);
|
|
51
|
+
if (files.has(join(cursor, "+layout.meeko")))
|
|
52
|
+
layouts.push(join(cursor, "+layout.meeko"));
|
|
53
|
+
}
|
|
54
|
+
let error;
|
|
55
|
+
let errorCursor = directory;
|
|
56
|
+
while (errorCursor.startsWith(pagesRoot)) {
|
|
57
|
+
const candidate = join(errorCursor, "+error.meeko");
|
|
58
|
+
if (files.has(candidate)) {
|
|
59
|
+
error = candidate;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (errorCursor === pagesRoot)
|
|
63
|
+
break;
|
|
64
|
+
errorCursor = dirname(errorCursor);
|
|
65
|
+
}
|
|
66
|
+
const sidecar = file.slice(0, -".meeko".length) + ".route.ts";
|
|
67
|
+
routes.push({
|
|
68
|
+
id: routeId(relative(pagesRoot, file)),
|
|
69
|
+
file,
|
|
70
|
+
layouts,
|
|
71
|
+
...(error ? { error } : {}),
|
|
72
|
+
...(files.has(sidecar) ? { sidecar } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return createRouteManifest(routes);
|
|
76
|
+
}
|
|
77
|
+
/** Portable Fetch handler. Node adapters can pass their Request directly. */
|
|
78
|
+
export function createFrameworkHandler(options) {
|
|
79
|
+
return async (request) => {
|
|
80
|
+
if (request.method !== "GET" && request.method !== "HEAD")
|
|
81
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
82
|
+
const url = new URL(request.url);
|
|
83
|
+
const match = matchRoute(options.routes, url.pathname);
|
|
84
|
+
if (!match)
|
|
85
|
+
return new Response("Not Found", { status: 404 });
|
|
86
|
+
const stateStore = new StateStore();
|
|
87
|
+
let requestContext;
|
|
88
|
+
const finish = (response) => {
|
|
89
|
+
if (!requestContext)
|
|
90
|
+
return response;
|
|
91
|
+
const headers = new Headers(response.headers);
|
|
92
|
+
headers.set("x-request-id", requestContext.requestId);
|
|
93
|
+
headers.set("x-meeko-csrf-token", requestContext.session.current?.csrfToken ?? "");
|
|
94
|
+
if (requestContext.session.cookieHeader)
|
|
95
|
+
headers.append("set-cookie", requestContext.session.cookieHeader);
|
|
96
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
requestContext = await options.createContext?.(request);
|
|
100
|
+
if (requestContext && requestContext.request !== request)
|
|
101
|
+
throw new TypeError("Request context belongs to another request");
|
|
102
|
+
const render = async () => {
|
|
103
|
+
const sidecar = match.route.sidecar && options.loadRoute ? await options.loadRoute(match.route.sidecar) : undefined;
|
|
104
|
+
const validated = await validatePageInputs(sidecar, match.params, pageSearchObject(url.searchParams));
|
|
105
|
+
const context = {
|
|
106
|
+
request, params: validated.params, search: validated.search, searchParams: url.searchParams, signal: request.signal, state: stateStore,
|
|
107
|
+
...(requestContext ? { context: requestContext } : {}),
|
|
108
|
+
};
|
|
109
|
+
const data = sidecar?.load ? await sidecar.load(context) : undefined;
|
|
110
|
+
if (data instanceof Response)
|
|
111
|
+
return finish(data);
|
|
112
|
+
const props = {
|
|
113
|
+
params: validated.params,
|
|
114
|
+
search: validated.search,
|
|
115
|
+
...(data === undefined ? {} : { data }),
|
|
116
|
+
};
|
|
117
|
+
let module = await options.loadPage(match.route.file);
|
|
118
|
+
const styles = [];
|
|
119
|
+
if (module.css)
|
|
120
|
+
styles.push(module.css);
|
|
121
|
+
let input = props;
|
|
122
|
+
let rendered;
|
|
123
|
+
if (options.renderLayout) {
|
|
124
|
+
rendered = withStateStore(stateStore, () => module.renderWithState(input));
|
|
125
|
+
for (const layout of [...match.route.layouts].reverse()) {
|
|
126
|
+
const layoutModule = await options.loadPage(layout);
|
|
127
|
+
if (layoutModule.css)
|
|
128
|
+
styles.unshift(layoutModule.css);
|
|
129
|
+
rendered = { html: await options.renderLayout(layout, rendered.html, context), state: rendered.state };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
for (const layout of [...match.route.layouts].reverse()) {
|
|
134
|
+
const child = module;
|
|
135
|
+
const childInput = input;
|
|
136
|
+
const outlet = {
|
|
137
|
+
renderWithState: () => child.renderWithState(childInput),
|
|
138
|
+
renderToString: () => child.renderToString?.(childInput) ?? child.renderWithState(childInput).html,
|
|
139
|
+
hydrateScope: () => { throw new Error("Server outlet cannot hydrate"); },
|
|
140
|
+
};
|
|
141
|
+
module = await options.loadPage(layout);
|
|
142
|
+
if (module.css)
|
|
143
|
+
styles.unshift(module.css);
|
|
144
|
+
input = { outlet };
|
|
145
|
+
}
|
|
146
|
+
rendered = withStateStore(stateStore, () => module.renderWithState(input));
|
|
147
|
+
}
|
|
148
|
+
const head = sidecar?.head?.({ ...context, data });
|
|
149
|
+
const title = head?.title ? `<title>${escapeHtml(head.title)}</title>` : "";
|
|
150
|
+
const styleTags = [...new Set(styles)].map((css) => `<style>${css.replaceAll("<", "\\3C ")}</style>`).join("");
|
|
151
|
+
const payload = serializeJson({ route: match.route.id, props, state: rendered.state, sharedState: stateStore.snapshot() });
|
|
152
|
+
if (request.headers.get("accept")?.split(",").some((value) => value.trim().startsWith("application/meeko+json"))) {
|
|
153
|
+
return finish(new Response(request.method === "HEAD" ? null : payload, {
|
|
154
|
+
status: 200, headers: { "content-type": "application/meeko+json; charset=utf-8", "cache-control": "no-store" },
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
const script = options.clientEntry ? `<script type="module" src="${escapeHtml(options.clientEntry)}"></script>` : "";
|
|
158
|
+
const csrfMeta = requestContext?.session.current ? `<meta name="meeko-csrf-token" content="${escapeHtml(requestContext.session.current.csrfToken)}">` : "";
|
|
159
|
+
const document = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,">${csrfMeta}${styleTags}${title}</head><body><div id="app">${rendered.html}</div><script id="meeko-payload" type="application/json">${payload}</script>${script}</body></html>`;
|
|
160
|
+
return finish(new Response(request.method === "HEAD" ? null : document, {
|
|
161
|
+
status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
162
|
+
}));
|
|
163
|
+
};
|
|
164
|
+
return requestContext ? await withRequestContext(requestContext, options.serverFetch, render) : await render();
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error instanceof Response)
|
|
168
|
+
return finish(error);
|
|
169
|
+
if (error instanceof PageInputError)
|
|
170
|
+
return finish(new Response(error.code, {
|
|
171
|
+
status: 400, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
172
|
+
}));
|
|
173
|
+
if (match.route.error) {
|
|
174
|
+
try {
|
|
175
|
+
const errorPage = await options.loadPage(match.route.error);
|
|
176
|
+
const errorRendered = withStateStore(stateStore, () => errorPage.renderWithState({
|
|
177
|
+
status: 500, message: "Internal Server Error",
|
|
178
|
+
}));
|
|
179
|
+
const document = `<!doctype html><html><head><meta charset="utf-8"><title>Error</title></head><body><div id="app">${errorRendered.html}</div></body></html>`;
|
|
180
|
+
return finish(new Response(request.method === "HEAD" ? null : document, {
|
|
181
|
+
status: 500, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// The error route failed; keep private errors out of the response.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return finish(new Response(request.method === "HEAD" ? null : "Internal Server Error", { status: 500 }));
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=server.js.map
|