@warlock.js/web 5.0.0 → 5.0.2
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/esm/build/contribution.mjs +1 -1
- package/esm/build/discover-pages.mjs +21 -2
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +1 -1
- package/esm/components/document-context.d.mts +7 -1
- package/esm/connector/index.mjs +1 -1
- package/esm/metadata.d.mts +1 -1
- package/esm/routing/compose-route-path.d.mts +29 -0
- package/esm/server/buffered-response.d.mts +58 -0
- package/esm/server/create-page-module-loader.d.mts +36 -0
- package/esm/server/create-page-route-handler.d.mts +31 -0
- package/esm/server/execute-page-request.d.mts +7 -1
- package/esm/server/execute-page-request.mjs +1 -1
- package/esm/server/execute-page-request.types.d.mts +174 -1
- package/esm/server/hydration-client-url.mjs +1 -1
- package/esm/server/index.d.mts +13 -0
- package/esm/server/index.mjs +6 -6
- package/esm/server/install-page-routes-from-manifest.d.mts +44 -0
- package/esm/server/install-page-routes-from-manifest.mjs +1 -1
- package/esm/server/install-page-routes.d.mts +59 -1
- package/esm/server/install-page-routes.mjs +149 -3
- package/esm/server/install-page-routes.mjs.map +1 -0
- package/esm/server/page-context.d.mts +14 -1
- package/esm/server/page-context.mjs +11 -1
- package/esm/server/page-context.mjs.map +1 -1
- package/esm/server/render-page.d.mts +89 -0
- package/esm/server/render-page.mjs +40 -1
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/stylesheet-urls.d.mts +52 -0
- package/esm/server/stylesheet-urls.mjs +64 -2
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/web-connector.d.mts +1 -1
- package/esm/server/web-connector.mjs +30 -7
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared.d.mts +25 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/gate-a-resolve.mjs +2 -2
- package/esm/vite/hydration-entries.mjs +1 -1
- package/package.json +10 -4
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PageManifest } from "./page-manifest.mjs";
|
|
2
|
+
import { BufferedCookie } from "./buffered-response.mjs";
|
|
3
|
+
import { PageRouteHandler, PageRouteHandlerOptions } from "./create-page-route-handler.mjs";
|
|
4
|
+
import { Response, Router } from "@warlock.js/core";
|
|
5
|
+
|
|
6
|
+
//#region ../web/src/server/install-page-routes-from-manifest.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* How a handler is built for one page. Defaults to `createPageRouteHandler`;
|
|
9
|
+
* taking it as an input keeps this module's own job — reading the manifest and
|
|
10
|
+
* registering routes — provable without a render pipeline behind it.
|
|
11
|
+
*/
|
|
12
|
+
type PageRouteHandlerFactory = (options: PageRouteHandlerOptions) => PageRouteHandler;
|
|
13
|
+
type InstalledManifestPageRoute = {
|
|
14
|
+
/** The composed path the route was registered on. */path: string; /** The resolved route name; shared namespace with API routes. */
|
|
15
|
+
name: string; /** The page's manifest `sourceFile`. */
|
|
16
|
+
file: string; /** The layout's manifest `sourceFile`, when the page has one. */
|
|
17
|
+
layoutFile: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
type InstallPageRoutesFromManifestOptions = {
|
|
20
|
+
router: Router; /** The table the generated production barrel provided at import time. */
|
|
21
|
+
manifest: PageManifest; /** Browser module loaded after the server-rendered application and payload. */
|
|
22
|
+
hydrationClientModuleUrl?: string; /** Stylesheet URLs emitted into every page's `<head>`. */
|
|
23
|
+
stylesheetUrls?: readonly string[]; /** Same helper `dev-server.ts` exports — passed in, never imported. */
|
|
24
|
+
applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;
|
|
25
|
+
createHandler?: PageRouteHandlerFactory;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Registers every page the manifest carries into `options.router`.
|
|
29
|
+
*
|
|
30
|
+
* An empty manifest registers nothing and is not an error: "built with web, no
|
|
31
|
+
* pages" is a legal state of a built application, and treating it as a failure
|
|
32
|
+
* would make an empty project unbootable. A manifest that DOES carry pages but
|
|
33
|
+
* no app root is the opposite — every page renders inside the application root,
|
|
34
|
+
* so that combination is a broken table rather than an empty one, and it is
|
|
35
|
+
* refused before any route exists to serve a request with a missing root.
|
|
36
|
+
*
|
|
37
|
+
* Two pages composing to the same path is refused the moment the second one is
|
|
38
|
+
* seen, naming both — a registration-time failure, rather than a route one of
|
|
39
|
+
* them silently loses at runtime.
|
|
40
|
+
*/
|
|
41
|
+
declare function installPageRoutesFromManifest(options: InstallPageRoutesFromManifestOptions): InstalledManifestPageRoute[];
|
|
42
|
+
//#endregion
|
|
43
|
+
export { InstallPageRoutesFromManifestOptions, InstalledManifestPageRoute, PageRouteHandlerFactory, installPageRoutesFromManifest };
|
|
44
|
+
//# sourceMappingURL=install-page-routes-from-manifest.d.mts.map
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { publishRouteTable } from "../routing/route-table.mjs";
|
|
2
|
+
import { createPageModuleLoader } from "./create-page-module-loader.mjs";
|
|
2
3
|
import { composeRoutePath } from "../routing/compose-route-path.mjs";
|
|
3
4
|
import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
|
|
4
5
|
import { canonicalizeRouteExport, deriveFallbackRouteName } from "../routing/route-identity.mjs";
|
|
5
|
-
import { createPageModuleLoader } from "./create-page-module-loader.mjs";
|
|
6
6
|
import { createPageRouteHandler } from "./create-page-route-handler.mjs";
|
|
7
7
|
|
|
8
8
|
//#region ../web/src/server/install-page-routes-from-manifest.ts
|
|
@@ -1 +1,59 @@
|
|
|
1
|
-
|
|
1
|
+
import { composeRoutePath } from "../routing/compose-route-path.mjs";
|
|
2
|
+
import { BufferedCookie } from "./buffered-response.mjs";
|
|
3
|
+
import { PipelineMiddleware } from "./execute-page-request.types.mjs";
|
|
4
|
+
import { Response, Router } from "@warlock.js/core";
|
|
5
|
+
import { ViteDevServer } from "vite";
|
|
6
|
+
|
|
7
|
+
//#region ../web/src/server/install-page-routes.d.ts
|
|
8
|
+
type PageRouteExport = string | {
|
|
9
|
+
path: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
};
|
|
12
|
+
type PageModuleShape = {
|
|
13
|
+
route?: PageRouteExport;
|
|
14
|
+
};
|
|
15
|
+
type InstalledPageRoute = {
|
|
16
|
+
path: string;
|
|
17
|
+
name: string;
|
|
18
|
+
file: string;
|
|
19
|
+
layoutFile: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
type LayoutModuleShape = {
|
|
22
|
+
prefix?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The default export — the thing that puts an element in the document, and
|
|
25
|
+
* therefore the ONLY export that decides whether a layout counts against the
|
|
26
|
+
* single-rendering-layout rule (`../routing/layout-policy.ts`). In dev the
|
|
27
|
+
* module is loaded, so this is a fact rather than a guess.
|
|
28
|
+
*/
|
|
29
|
+
default?: unknown; /** The layout's guards, in the order it declared them. */
|
|
30
|
+
middleware?: readonly PipelineMiddleware[];
|
|
31
|
+
};
|
|
32
|
+
type InstallPageRoutesOptions = {
|
|
33
|
+
router: Router;
|
|
34
|
+
vite: ViteDevServer; /** v5/app/src — pages live under "<appSrcRoot>/app/*\/web/**" and "<appSrcRoot>/web/**". */
|
|
35
|
+
appSrcRoot: string; /** v5/app/src/web/root.tsx — the single global app-root file. */
|
|
36
|
+
appFile: string; /** Browser module loaded after the server-rendered application and payload. */
|
|
37
|
+
hydrationClientModuleUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Stylesheet URLs emitted into every page's `<head>`.
|
|
40
|
+
*
|
|
41
|
+
* In dev these are Vite source URLs; see `devStylesheetUrls` for why they
|
|
42
|
+
* carry `?direct`.
|
|
43
|
+
*/
|
|
44
|
+
stylesheetUrls?: readonly string[]; /** Same helper `dev-server.ts` exports — passed in, not imported, to avoid a dev-server.ts <-> this-file cycle. */
|
|
45
|
+
applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Registers every discoverable page into `options.router`. Throws
|
|
49
|
+
* IMMEDIATELY, naming both files, the moment two pages declare the same
|
|
50
|
+
* `route.path` — a registration-time failure, not a runtime 404 one of them
|
|
51
|
+
* silently loses.
|
|
52
|
+
*
|
|
53
|
+
* Pages with no `route` export are skipped: discovery cannot invent a public
|
|
54
|
+
* URL or route name for an undeclared page.
|
|
55
|
+
*/
|
|
56
|
+
declare function installPageRoutes(options: InstallPageRoutesOptions): Promise<InstalledPageRoute[]>;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { InstallPageRoutesOptions, InstalledPageRoute, LayoutModuleShape, PageModuleShape, PageRouteExport, installPageRoutes };
|
|
59
|
+
//# sourceMappingURL=install-page-routes.d.mts.map
|
|
@@ -1,6 +1,152 @@
|
|
|
1
|
+
import { publishRouteTable } from "../routing/route-table.mjs";
|
|
1
2
|
import { composeRoutePath } from "../routing/compose-route-path.mjs";
|
|
2
|
-
import "../
|
|
3
|
-
import "
|
|
3
|
+
import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
|
|
4
|
+
import { canonicalizeRouteExport, deriveFallbackRouteName } from "../routing/route-identity.mjs";
|
|
5
|
+
import { createPageRouteHandler } from "./create-page-route-handler.mjs";
|
|
6
|
+
import { discoverPageFiles, layoutChainFor, toPosix } from "../build/discover-pages.mjs";
|
|
4
7
|
import path from "node:path";
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
//#region ../web/src/server/install-page-routes.ts
|
|
10
|
+
/**
|
|
11
|
+
* Registers every page {@link discoverPageFiles} finds under `<appSrcRoot>`
|
|
12
|
+
* into Warlock's router (`router.get`, `core/src/router/router.ts:359-361`)
|
|
13
|
+
* so `router.scanDevServer(fastify)` —
|
|
14
|
+
* the sanctioned dev-server dispatch path (server matching is Warlock's
|
|
15
|
+
* router; there is no second server matcher) — picks
|
|
16
|
+
* it up. Replaces the two hand-rolled `fastify.get()` calls this file's
|
|
17
|
+
* sibling, `dev-server.ts`, used to make directly.
|
|
18
|
+
*
|
|
19
|
+
* DELIBERATE EXCEPTION to "web has no core dependency", same
|
|
20
|
+
* reasoning `dev-server.ts`'s own header comment records: this module is not
|
|
21
|
+
* exported from either package barrel and is not part of `web/package.json`'s
|
|
22
|
+
* dependency graph — dev/CLI bootstrap only.
|
|
23
|
+
*
|
|
24
|
+
* Scope note: a page's
|
|
25
|
+
* `route.path` is now composed with the `prefix` export of EVERY `layout.tsx`
|
|
26
|
+
* on its path — outermost first (`composeRoutePath` below) — before
|
|
27
|
+
* registration and before the collision check, so `home.page.tsx`
|
|
28
|
+
* (`path: "/"`, main layout `prefix: "/"`) resolves to `/` and
|
|
29
|
+
* `products.page.tsx` (`path: "/"`, products layout `prefix: "/products"`)
|
|
30
|
+
* resolves to `/products` — no collision. A page with no `layout.tsx` on its
|
|
31
|
+
* path composes against the implicit root prefix `"/"` (e.g. `/contact-us`,
|
|
32
|
+
* `/hydration-demo`, both unaffected by composition).
|
|
33
|
+
*
|
|
34
|
+
* WHICH PAGES EXIST is answered by {@link discoverPageFiles}
|
|
35
|
+
* (`web/src/build/discover-pages.ts`) — the same walk production's build
|
|
36
|
+
* shares — so this file owns no directory-walking of its own and serves the
|
|
37
|
+
* global root (`<appSrcRoot>/web/**`) exactly as it serves a module's
|
|
38
|
+
* (`<appSrcRoot>/app/<module>/web/**`). WHAT ROUTE A PAGE ANSWERS ON stays
|
|
39
|
+
* this file's own job: each page and its nearest layout are still evaluated
|
|
40
|
+
* through Vite (`vite.ssrLoadModule`), never read statically, because a dev
|
|
41
|
+
* page module must be the one Vite serves, warm cache and all.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The page's app-root-relative POSIX source path, e.g.
|
|
45
|
+
* ".../v5/app/src/app/main/web/home.page.tsx" with appSrcRoot
|
|
46
|
+
* ".../v5/app/src" -> "src/app/main/web/home.page.tsx" — the canonical form
|
|
47
|
+
* `deriveFallbackRouteName` (`../routing/route-identity`) requires. The first
|
|
48
|
+
* segment's actual name is arbitrary to that function (it only inspects the
|
|
49
|
+
* segment AFTER it), so `appSrcRoot`'s own basename is used rather than
|
|
50
|
+
* discovering the true app root.
|
|
51
|
+
*/
|
|
52
|
+
function canonicalSourceFileFor(pageFile, appSrcRoot) {
|
|
53
|
+
return `${path.basename(appSrcRoot)}/${toPosix(path.relative(appSrcRoot, pageFile))}`;
|
|
54
|
+
}
|
|
55
|
+
function resolveRoute(routeExport, sourceFile) {
|
|
56
|
+
const canonical = canonicalizeRouteExport(routeExport);
|
|
57
|
+
return {
|
|
58
|
+
path: canonical.path,
|
|
59
|
+
name: canonical.name ?? deriveFallbackRouteName({
|
|
60
|
+
routePath: canonical.path,
|
|
61
|
+
sourceFile
|
|
62
|
+
})
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function resolveLayoutLevel(pageFile, webRoot, loadLayout) {
|
|
66
|
+
const chain = layoutChainFor(pageFile, webRoot);
|
|
67
|
+
const modules = await Promise.all(chain.map(loadLayout));
|
|
68
|
+
const selection = selectPageLayout(chain.map((layout, index) => ({
|
|
69
|
+
layout,
|
|
70
|
+
renders: typeof modules[index].default !== "undefined"
|
|
71
|
+
})));
|
|
72
|
+
if (selection.type === "rejected") throw new NestedLayoutsNotSupportedError(pageFile, selection.layouts);
|
|
73
|
+
return {
|
|
74
|
+
chain,
|
|
75
|
+
layoutFile: selection.type === "selected" ? selection.layout : chain.at(-1),
|
|
76
|
+
prefix: modules.reduce((composed, layoutModule) => composeRoutePath(composed, layoutModule.prefix ?? "/"), "/")
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The layout slot's module for ONE request: the slot host's own namespace, with
|
|
81
|
+
* the whole chain's middleware in place of its own — outermost first, which is
|
|
82
|
+
* the order stage 3 runs the array in (`execute-page-request.ts:519-524`) and
|
|
83
|
+
* the order an outer `optionalAuth` needs in order to have resolved an identity
|
|
84
|
+
* before an inner `gate()` checks it.
|
|
85
|
+
*
|
|
86
|
+
* Loaded per call, not once at install time: a dev layout module must be the
|
|
87
|
+
* one Vite is currently serving, edits and all.
|
|
88
|
+
*/
|
|
89
|
+
async function composeLayoutLevel(level, loadLayout) {
|
|
90
|
+
const modules = await Promise.all(level.chain.map(loadLayout));
|
|
91
|
+
return {
|
|
92
|
+
...modules[level.chain.indexOf(level.layoutFile)],
|
|
93
|
+
middleware: modules.flatMap((layoutModule) => [...layoutModule.middleware ?? []])
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Registers every discoverable page into `options.router`. Throws
|
|
98
|
+
* IMMEDIATELY, naming both files, the moment two pages declare the same
|
|
99
|
+
* `route.path` — a registration-time failure, not a runtime 404 one of them
|
|
100
|
+
* silently loses.
|
|
101
|
+
*
|
|
102
|
+
* Pages with no `route` export are skipped: discovery cannot invent a public
|
|
103
|
+
* URL or route name for an undeclared page.
|
|
104
|
+
*/
|
|
105
|
+
async function installPageRoutes(options) {
|
|
106
|
+
const { router, vite, appSrcRoot, appFile, hydrationClientModuleUrl, stylesheetUrls, applyBufferedCookie } = options;
|
|
107
|
+
const pageFiles = [...discoverPageFiles(appSrcRoot)].sort((left, right) => left.pageFile < right.pageFile ? -1 : left.pageFile > right.pageFile ? 1 : 0);
|
|
108
|
+
const installed = [];
|
|
109
|
+
const fileByPath = /* @__PURE__ */ new Map();
|
|
110
|
+
for (const { pageFile, webRoot } of pageFiles) {
|
|
111
|
+
const pageModule = await vite.ssrLoadModule(pageFile);
|
|
112
|
+
if (pageModule.route === void 0) continue;
|
|
113
|
+
const sourceFile = canonicalSourceFileFor(pageFile, appSrcRoot);
|
|
114
|
+
const { path: routePath, name } = resolveRoute(pageModule.route, sourceFile);
|
|
115
|
+
const loadLayout = (layoutFile) => vite.ssrLoadModule(layoutFile);
|
|
116
|
+
const layoutLevel = await resolveLayoutLevel(pageFile, webRoot, loadLayout);
|
|
117
|
+
const { layoutFile, prefix: layoutPrefix } = layoutLevel;
|
|
118
|
+
const effectivePath = composeRoutePath(layoutPrefix, routePath);
|
|
119
|
+
const existingFile = fileByPath.get(effectivePath);
|
|
120
|
+
if (existingFile) throw new Error(`installPageRoutes: composed route path "${effectivePath}" (layout prefix "${layoutPrefix}" + route.path "${routePath}") is declared by two pages (web/src/server/install-page-routes.ts) — "${existingFile}" and "${pageFile}". Every page's composed route path must be unique.`);
|
|
121
|
+
fileByPath.set(effectivePath, pageFile);
|
|
122
|
+
router.get(effectivePath, createPageRouteHandler({
|
|
123
|
+
path: effectivePath,
|
|
124
|
+
name,
|
|
125
|
+
appFile,
|
|
126
|
+
pageFile,
|
|
127
|
+
layoutFile,
|
|
128
|
+
loadModule: layoutLevel.chain.length > 1 && layoutFile !== void 0 ? (moduleId) => moduleId === layoutFile ? composeLayoutLevel({
|
|
129
|
+
...layoutLevel,
|
|
130
|
+
layoutFile
|
|
131
|
+
}, loadLayout) : vite.ssrLoadModule(moduleId) : (moduleId) => vite.ssrLoadModule(moduleId),
|
|
132
|
+
hydrationClientModuleUrl,
|
|
133
|
+
stylesheetUrls,
|
|
134
|
+
applyBufferedCookie
|
|
135
|
+
}), {
|
|
136
|
+
name,
|
|
137
|
+
isPage: true
|
|
138
|
+
});
|
|
139
|
+
installed.push({
|
|
140
|
+
path: effectivePath,
|
|
141
|
+
name,
|
|
142
|
+
file: pageFile,
|
|
143
|
+
layoutFile
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
publishRouteTable(installed, "installPageRoutes (dev)");
|
|
147
|
+
return installed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
export { installPageRoutes };
|
|
152
|
+
//# sourceMappingURL=install-page-routes.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install-page-routes.mjs","names":[],"sources":["../../../../../../../web/src/server/install-page-routes.ts"],"sourcesContent":["/**\r\n * Registers every page {@link discoverPageFiles} finds under `<appSrcRoot>`\r\n * into Warlock's router (`router.get`, `core/src/router/router.ts:359-361`)\r\n * so `router.scanDevServer(fastify)` —\r\n * the sanctioned dev-server dispatch path (server matching is Warlock's\r\n * router; there is no second server matcher) — picks\r\n * it up. Replaces the two hand-rolled `fastify.get()` calls this file's\r\n * sibling, `dev-server.ts`, used to make directly.\r\n *\r\n * DELIBERATE EXCEPTION to \"web has no core dependency\", same\r\n * reasoning `dev-server.ts`'s own header comment records: this module is not\r\n * exported from either package barrel and is not part of `web/package.json`'s\r\n * dependency graph — dev/CLI bootstrap only.\r\n *\r\n * Scope note: a page's\r\n * `route.path` is now composed with the `prefix` export of EVERY `layout.tsx`\r\n * on its path — outermost first (`composeRoutePath` below) — before\r\n * registration and before the collision check, so `home.page.tsx`\r\n * (`path: \"/\"`, main layout `prefix: \"/\"`) resolves to `/` and\r\n * `products.page.tsx` (`path: \"/\"`, products layout `prefix: \"/products\"`)\r\n * resolves to `/products` — no collision. A page with no `layout.tsx` on its\r\n * path composes against the implicit root prefix `\"/\"` (e.g. `/contact-us`,\r\n * `/hydration-demo`, both unaffected by composition).\r\n *\r\n * WHICH PAGES EXIST is answered by {@link discoverPageFiles}\r\n * (`web/src/build/discover-pages.ts`) — the same walk production's build\r\n * shares — so this file owns no directory-walking of its own and serves the\r\n * global root (`<appSrcRoot>/web/**`) exactly as it serves a module's\r\n * (`<appSrcRoot>/app/<module>/web/**`). WHAT ROUTE A PAGE ANSWERS ON stays\r\n * this file's own job: each page and its nearest layout are still evaluated\r\n * through Vite (`vite.ssrLoadModule`), never read statically, because a dev\r\n * page module must be the one Vite serves, warm cache and all.\r\n */\r\nimport path from \"node:path\";\r\nimport type { ViteDevServer } from \"vite\";\r\nimport { discoverPageFiles, layoutChainFor, toPosix } from \"../build/discover-pages\";\r\nimport { composeRoutePath } from \"../routing/compose-route-path\";\r\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\r\nimport { canonicalizeRouteExport, deriveFallbackRouteName } from \"../routing/route-identity\";\r\nimport { publishRouteTable } from \"../routing/route-table\";\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport { createPageRouteHandler } from \"./create-page-route-handler\";\r\nimport type { PipelineMiddleware } from \"./execute-page-request\";\r\n\r\n/** Re-exported so `web/src/server/index.ts`'s existing barrel export keeps resolving. */\r\nexport { composeRoutePath };\r\n\r\nexport type PageRouteExport = string | { path: string; name?: string };\r\n\r\nexport type PageModuleShape = {\r\n route?: PageRouteExport;\r\n};\r\n\r\nexport type InstalledPageRoute = {\r\n path: string;\r\n name: string;\r\n file: string;\r\n layoutFile: string | undefined;\r\n};\r\n\r\n/**\r\n * The page's app-root-relative POSIX source path, e.g.\r\n * \".../v5/app/src/app/main/web/home.page.tsx\" with appSrcRoot\r\n * \".../v5/app/src\" -> \"src/app/main/web/home.page.tsx\" — the canonical form\r\n * `deriveFallbackRouteName` (`../routing/route-identity`) requires. The first\r\n * segment's actual name is arbitrary to that function (it only inspects the\r\n * segment AFTER it), so `appSrcRoot`'s own basename is used rather than\r\n * discovering the true app root.\r\n */\r\nfunction canonicalSourceFileFor(pageFile: string, appSrcRoot: string): string {\r\n return `${path.basename(appSrcRoot)}/${toPosix(path.relative(appSrcRoot, pageFile))}`;\r\n}\r\n\r\nfunction resolveRoute(\r\n routeExport: PageRouteExport,\r\n sourceFile: string,\r\n): { path: string; name: string } {\r\n const canonical = canonicalizeRouteExport(routeExport);\r\n\r\n return {\r\n path: canonical.path,\r\n name: canonical.name ?? deriveFallbackRouteName({ routePath: canonical.path, sourceFile }),\r\n };\r\n}\r\n\r\nexport type LayoutModuleShape = {\r\n prefix?: string;\r\n /**\r\n * The default export — the thing that puts an element in the document, and\r\n * therefore the ONLY export that decides whether a layout counts against the\r\n * single-rendering-layout rule (`../routing/layout-policy.ts`). In dev the\r\n * module is loaded, so this is a fact rather than a guess.\r\n */\r\n default?: unknown;\r\n /** The layout's guards, in the order it declared them. */\r\n middleware?: readonly PipelineMiddleware[];\r\n};\r\n\r\n/** How this module gets a layout module namespace — `vite.ssrLoadModule`, in practice. */\r\ntype LoadLayout = (layoutFile: string) => Promise<LayoutModuleShape>;\r\n\r\n/**\r\n * The page's layout LEVEL, resolved from its whole chain rather than from the\r\n * one layout nearest to it.\r\n *\r\n * The render pipeline has exactly one layout slot per page\r\n * (`execute-page-request.ts`'s `PageRouteEntry[\"triple\"]`), so the chain has to\r\n * be collapsed into one module before it reaches a handler. Two things collapse\r\n * differently and both matter:\r\n *\r\n * - RENDERING is a selection: at most one layout on the chain may render, and\r\n * the policy picks it. `renders` is read off the loaded module\r\n * (`typeof module.default !== \"undefined\"`), never off the filename — a\r\n * `middleware`-only layout has no default export and is not a wrapper, and\r\n * passing a bare path to `selectPageLayout` would have it read as a rendering\r\n * one, which is the conservative default and the wrong answer here.\r\n * - MIDDLEWARE and PREFIX are compositions: every layout on the path\r\n * contributes, outermost first. A guard on an outer layout that the page's\r\n * own directory knows nothing about is exactly the guard that must still run,\r\n * and a prefix nobody composed is a URL nobody wrote down.\r\n */\r\ntype LayoutLevel = {\r\n /** Every `layout.tsx` from the web root down to the page's directory, outermost first. */\r\n chain: string[];\r\n /**\r\n * The module id the handler's layout slot is registered under, or `undefined`\r\n * when the page has no layout at all: the layout that RENDERS, or — when none\r\n * does — the nearest one, which is the slot dev has always used and so the\r\n * choice that changes nothing but the middleware for a chain with no wrapper\r\n * in it.\r\n */\r\n layoutFile: string | undefined;\r\n /** Every layout's `prefix`, composed outermost first — `discoverPages`' own reduction. */\r\n prefix: string;\r\n};\r\n\r\nasync function resolveLayoutLevel(\r\n pageFile: string,\r\n webRoot: string,\r\n loadLayout: LoadLayout,\r\n): Promise<LayoutLevel> {\r\n const chain = layoutChainFor(pageFile, webRoot);\r\n const modules = await Promise.all(chain.map(loadLayout));\r\n const selection = selectPageLayout(\r\n chain.map((layout, index) => ({\r\n layout,\r\n renders: typeof modules[index].default !== \"undefined\",\r\n })),\r\n );\r\n\r\n if (selection.type === \"rejected\") {\r\n throw new NestedLayoutsNotSupportedError(pageFile, selection.layouts);\r\n }\r\n\r\n return {\r\n chain,\r\n layoutFile: selection.type === \"selected\" ? selection.layout : chain.at(-1),\r\n prefix: modules.reduce(\r\n (composed, layoutModule) => composeRoutePath(composed, layoutModule.prefix ?? \"/\"),\r\n \"/\",\r\n ),\r\n };\r\n}\r\n\r\n/**\r\n * The layout slot's module for ONE request: the slot host's own namespace, with\r\n * the whole chain's middleware in place of its own — outermost first, which is\r\n * the order stage 3 runs the array in (`execute-page-request.ts:519-524`) and\r\n * the order an outer `optionalAuth` needs in order to have resolved an identity\r\n * before an inner `gate()` checks it.\r\n *\r\n * Loaded per call, not once at install time: a dev layout module must be the\r\n * one Vite is currently serving, edits and all.\r\n */\r\nasync function composeLayoutLevel(\r\n level: LayoutLevel & { layoutFile: string },\r\n loadLayout: LoadLayout,\r\n): Promise<LayoutModuleShape> {\r\n const modules = await Promise.all(level.chain.map(loadLayout));\r\n const host = modules[level.chain.indexOf(level.layoutFile)];\r\n\r\n return {\r\n ...host,\r\n middleware: modules.flatMap(layoutModule => [...(layoutModule.middleware ?? [])]),\r\n };\r\n}\r\n\r\nexport type InstallPageRoutesOptions = {\r\n router: Router;\r\n vite: ViteDevServer;\r\n /** v5/app/src — pages live under \"<appSrcRoot>/app/*\\/web/**\" and \"<appSrcRoot>/web/**\". */\r\n appSrcRoot: string;\r\n /** v5/app/src/web/root.tsx — the single global app-root file. */\r\n appFile: string;\r\n /** Browser module loaded after the server-rendered application and payload. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs emitted into every page's `<head>`.\r\n *\r\n * In dev these are Vite source URLs; see `devStylesheetUrls` for why they\r\n * carry `?direct`.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, not imported, to avoid a dev-server.ts <-> this-file cycle. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n};\r\n\r\n/**\r\n * Registers every discoverable page into `options.router`. Throws\r\n * IMMEDIATELY, naming both files, the moment two pages declare the same\r\n * `route.path` — a registration-time failure, not a runtime 404 one of them\r\n * silently loses.\r\n *\r\n * Pages with no `route` export are skipped: discovery cannot invent a public\r\n * URL or route name for an undeclared page.\r\n */\r\nexport async function installPageRoutes(\r\n options: InstallPageRoutesOptions,\r\n): Promise<InstalledPageRoute[]> {\r\n const {\r\n router,\r\n vite,\r\n appSrcRoot,\r\n appFile,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n } = options;\r\n const pageFiles = [...discoverPageFiles(appSrcRoot)].sort((left, right) =>\r\n left.pageFile < right.pageFile ? -1 : left.pageFile > right.pageFile ? 1 : 0,\r\n );\r\n\r\n const installed: InstalledPageRoute[] = [];\r\n const fileByPath = new Map<string, string>();\r\n\r\n for (const { pageFile, webRoot } of pageFiles) {\r\n const pageModule = (await vite.ssrLoadModule(pageFile)) as PageModuleShape;\r\n\r\n if (pageModule.route === undefined) {\r\n continue;\r\n }\r\n\r\n const sourceFile = canonicalSourceFileFor(pageFile, appSrcRoot);\r\n const { path: routePath, name } = resolveRoute(pageModule.route, sourceFile);\r\n\r\n const loadLayout: LoadLayout = layoutFile =>\r\n vite.ssrLoadModule(layoutFile) as Promise<LayoutModuleShape>;\r\n const layoutLevel = await resolveLayoutLevel(pageFile, webRoot, loadLayout);\r\n const { layoutFile, prefix: layoutPrefix } = layoutLevel;\r\n\r\n const effectivePath = composeRoutePath(layoutPrefix, routePath);\r\n\r\n const existingFile = fileByPath.get(effectivePath);\r\n\r\n if (existingFile) {\r\n throw new Error(\r\n `installPageRoutes: composed route path \"${effectivePath}\" (layout ` +\r\n `prefix \"${layoutPrefix}\" + route.path \"${routePath}\") is declared by two ` +\r\n `pages (web/src/server/install-page-routes.ts) — \"${existingFile}\" and ` +\r\n `\"${pageFile}\". Every page's composed route path must be unique.`,\r\n );\r\n }\r\n\r\n fileByPath.set(effectivePath, pageFile);\r\n\r\n router.get(\r\n effectivePath,\r\n // The handler itself is `createPageRouteHandler`\r\n // (`web/src/server/create-page-route-handler.ts`) — a named seam a\r\n // future `type: \"page\"` route can bind to, and testable without a Vite\r\n // server. Vite appears here only as the dev answer to \"how do I load a\r\n // module\"; the handler takes that as an input and knows nothing else\r\n // about it.\r\n createPageRouteHandler({\r\n path: effectivePath,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n // The layout slot's id resolves to the COMPOSED level — every layout's\r\n // middleware, in chain order — and every other id goes straight to\r\n // Vite. A one-layout chain has nothing to compose, so it is left to\r\n // resolve as the exact module Vite hands back, untouched.\r\n loadModule:\r\n layoutLevel.chain.length > 1 && layoutFile !== undefined\r\n ? moduleId =>\r\n moduleId === layoutFile\r\n ? composeLayoutLevel({ ...layoutLevel, layoutFile }, loadLayout)\r\n : vite.ssrLoadModule(moduleId)\r\n : moduleId => vite.ssrLoadModule(moduleId),\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n }),\r\n // `isPage` marks this route as SSR-served. Pages and API routes share one\r\n // router and one route-name namespace, so the router's duplicate-name\r\n // error reads this flag to say which claimant is the page.\r\n { name, isPage: true },\r\n );\r\n\r\n installed.push({ path: effectivePath, name, file: pageFile, layoutFile });\r\n }\r\n\r\n /*\r\n Published from the SAME loop that registered the routes, so `href()` and the\r\n router cannot disagree about where a name points. It happens here rather\r\n than in the caller because a caller that forgets leaves every `<Link>` on\r\n the server throwing at render — and dev republishes on every restart, which\r\n is why the table replaces wholesale instead of merging: a deleted page's\r\n name has to stop resolving.\r\n */\r\n publishRouteTable(installed, \"installPageRoutes (dev)\");\r\n\r\n return installed;\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { PageContextRunner, PipelineStore } from "./execute-page-request.types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../web/src/server/page-context.d.ts
|
|
4
|
+
/** Returns the previous runner so tests can restore it. */
|
|
5
|
+
declare function connectPageContext(runner: PageContextRunner | undefined): PageContextRunner | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Connect an additional shared-module instance that must enter every request.
|
|
8
|
+
* Production has one module graph and needs none; the dev server uses this seam
|
|
9
|
+
* for the Vite SSR graph that evaluates app code.
|
|
10
|
+
*/
|
|
11
|
+
declare function connectPageSharedScope(enter: ((store: PipelineStore) => void) | undefined): ((store: PipelineStore) => void) | undefined;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { connectPageContext, connectPageSharedScope };
|
|
14
|
+
//# sourceMappingURL=page-context.d.mts.map
|
|
@@ -20,6 +20,16 @@ function connectPageContext(runner) {
|
|
|
20
20
|
pageContextRunner = runner;
|
|
21
21
|
return previous;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Connect an additional shared-module instance that must enter every request.
|
|
25
|
+
* Production has one module graph and needs none; the dev server uses this seam
|
|
26
|
+
* for the Vite SSR graph that evaluates app code.
|
|
27
|
+
*/
|
|
28
|
+
function connectPageSharedScope(enter) {
|
|
29
|
+
const previous = additionalSharedScopeEntry;
|
|
30
|
+
additionalSharedScopeEntry = enter;
|
|
31
|
+
return previous;
|
|
32
|
+
}
|
|
23
33
|
function enterAdditionalSharedScope(store) {
|
|
24
34
|
additionalSharedScopeEntry?.(store);
|
|
25
35
|
}
|
|
@@ -29,5 +39,5 @@ function requireRunner() {
|
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
//#endregion
|
|
32
|
-
export { connectPageContext, enterAdditionalSharedScope, requireRunner };
|
|
42
|
+
export { connectPageContext, connectPageSharedScope, enterAdditionalSharedScope, requireRunner };
|
|
33
43
|
//# sourceMappingURL=page-context.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-context.mjs","names":[],"sources":["../../../../../../../web/src/server/page-context.ts"],"sourcesContent":["import type { PageContextRunner, PipelineStore } from \"./execute-page-request.types\";\n\n/**\n * Boot-time wiring for the per-request context, once per process.\n *\n * The pipeline never owns an `AsyncLocalStorage` — it borrows core's. A VALUE\n * import of core from web risks resolving a second core instance, and two\n * copies of core means two stores, so `core` stays a type-only peer and the\n * code that already has `requestContext` in scope hands it over:\n *\n * ```ts\n * connectPageContext(requestContext);\n * connectSharedStore(() => requestContext.getStore());\n * ```\n */\n\nlet pageContextRunner: PageContextRunner | undefined;\nlet additionalSharedScopeEntry: ((store: PipelineStore) => void) | undefined;\n\n/** Returns the previous runner so tests can restore it. */\nexport function connectPageContext(\n runner: PageContextRunner | undefined,\n): PageContextRunner | undefined {\n const previous = pageContextRunner;\n\n pageContextRunner = runner;\n\n return previous;\n}\n\n/**\n * Connect an additional shared-module instance that must enter every request.\n * Production has one module graph and needs none; the dev server uses this seam\n * for the Vite SSR graph that evaluates app code.\n */\nexport function connectPageSharedScope(\n enter: ((store: PipelineStore) => void) | undefined,\n): ((store: PipelineStore) => void) | undefined {\n const previous = additionalSharedScopeEntry;\n\n additionalSharedScopeEntry = enter;\n\n return previous;\n}\n\nexport function enterAdditionalSharedScope(store: PipelineStore): void {\n additionalSharedScopeEntry?.(store);\n}\n\nexport function requireRunner(): PageContextRunner {\n if (!pageContextRunner) {\n throw new Error(\n \"executePageRequest() has no request context connected \" +\n \"(web/src/server/page-context.ts). The pipeline opens the per-request \" +\n \"AsyncLocalStorage frame with CORE's own context — it never owns one \" +\n \"itself. Fix: the server bootstrap must call \" +\n \"connectPageContext(requestContext) (and \" +\n \"connectSharedStore(() => requestContext.getStore())) before any \" +\n \"page request runs.\",\n );\n }\n\n return pageContextRunner;\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,IAAI;AACJ,IAAI;;AAGJ,SAAgB,mBACd,QAC+B;CAC/B,MAAM,WAAW;CAEjB,oBAAoB;CAEpB,OAAO;AACT;
|
|
1
|
+
{"version":3,"file":"page-context.mjs","names":[],"sources":["../../../../../../../web/src/server/page-context.ts"],"sourcesContent":["import type { PageContextRunner, PipelineStore } from \"./execute-page-request.types\";\n\n/**\n * Boot-time wiring for the per-request context, once per process.\n *\n * The pipeline never owns an `AsyncLocalStorage` — it borrows core's. A VALUE\n * import of core from web risks resolving a second core instance, and two\n * copies of core means two stores, so `core` stays a type-only peer and the\n * code that already has `requestContext` in scope hands it over:\n *\n * ```ts\n * connectPageContext(requestContext);\n * connectSharedStore(() => requestContext.getStore());\n * ```\n */\n\nlet pageContextRunner: PageContextRunner | undefined;\nlet additionalSharedScopeEntry: ((store: PipelineStore) => void) | undefined;\n\n/** Returns the previous runner so tests can restore it. */\nexport function connectPageContext(\n runner: PageContextRunner | undefined,\n): PageContextRunner | undefined {\n const previous = pageContextRunner;\n\n pageContextRunner = runner;\n\n return previous;\n}\n\n/**\n * Connect an additional shared-module instance that must enter every request.\n * Production has one module graph and needs none; the dev server uses this seam\n * for the Vite SSR graph that evaluates app code.\n */\nexport function connectPageSharedScope(\n enter: ((store: PipelineStore) => void) | undefined,\n): ((store: PipelineStore) => void) | undefined {\n const previous = additionalSharedScopeEntry;\n\n additionalSharedScopeEntry = enter;\n\n return previous;\n}\n\nexport function enterAdditionalSharedScope(store: PipelineStore): void {\n additionalSharedScopeEntry?.(store);\n}\n\nexport function requireRunner(): PageContextRunner {\n if (!pageContextRunner) {\n throw new Error(\n \"executePageRequest() has no request context connected \" +\n \"(web/src/server/page-context.ts). The pipeline opens the per-request \" +\n \"AsyncLocalStorage frame with CORE's own context — it never owns one \" +\n \"itself. Fix: the server bootstrap must call \" +\n \"connectPageContext(requestContext) (and \" +\n \"connectSharedStore(() => requestContext.getStore())) before any \" +\n \"page request runs.\",\n );\n }\n\n return pageContextRunner;\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,IAAI;AACJ,IAAI;;AAGJ,SAAgB,mBACd,QAC+B;CAC/B,MAAM,WAAW;CAEjB,oBAAoB;CAEpB,OAAO;AACT;;;;;;AAOA,SAAgB,uBACd,OAC8C;CAC9C,MAAM,WAAW;CAEjB,6BAA6B;CAE7B,OAAO;AACT;AAEA,SAAgB,2BAA2B,OAA4B;CACrE,6BAA6B,KAAK;AACpC;AAEA,SAAgB,gBAAmC;CACjD,IAAI,CAAC,mBACH,MAAM,IAAI,MACR,uWAOF;CAGF,OAAO;AACT"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { PAYLOAD_SCRIPT_ID, escapePayload } from "../components/document-context.mjs";
|
|
2
|
+
import { BufferedCookie } from "./buffered-response.mjs";
|
|
3
|
+
import { ExecutePageRequestOptions, PageDataBundle, PageRouteEntry } from "./execute-page-request.types.mjs";
|
|
4
|
+
//#region ../web/src/server/render-page.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Pipeline stages 9–10: RENDER the page tree from the
|
|
7
|
+
* data bundle stages 1–8 produced, then return finalized { html, status,
|
|
8
|
+
* headers, cookies }. Stage 10 happens at the CALL SITE in two halves —
|
|
9
|
+
* 10a the caller applies status + headers (the single live-response write,
|
|
10
|
+
* after render, before anything flushes), 10b it flushes
|
|
11
|
+
* the document. Nothing in this module writes the live response. It never
|
|
12
|
+
* re-runs any earlier stage — `renderPage` calls `executePageRequest` and
|
|
13
|
+
* everything here consumes its bundle as-is.
|
|
14
|
+
*
|
|
15
|
+
* `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is
|
|
16
|
+
* the production orchestrator AND the test helper. Because a loader IS a
|
|
17
|
+
* controller, `renderPage("products.details", { params: { id: "42" } })`
|
|
18
|
+
* returns `{ html, status, headers, data }` in one call — asserting a page's
|
|
19
|
+
* data and its response headers is a unit test, no browser, no server boot.
|
|
20
|
+
*/
|
|
21
|
+
type PageRoutesRegistry = {
|
|
22
|
+
routes: readonly PageRouteEntry[]; /** Same contract as ExecutePageRequestOptions["createHttp"]. */
|
|
23
|
+
createHttp: ExecutePageRequestOptions["createHttp"];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Boot-time wiring so `renderPage(name, options)` can resolve a route NAME
|
|
27
|
+
* without each call site carrying the manifest. Returns the previous registry
|
|
28
|
+
* so tests can restore it. A per-call `routes`/`createHttp` override wins.
|
|
29
|
+
*/
|
|
30
|
+
declare function connectPageRoutes(registry: PageRoutesRegistry | undefined): PageRoutesRegistry | undefined;
|
|
31
|
+
type RenderPageOptions = {
|
|
32
|
+
params?: Record<string, string>;
|
|
33
|
+
query?: Record<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* Impersonation for tests: assigned to `request.user` right after the
|
|
36
|
+
* request pair is constructed — `user` is a plain public property on core's
|
|
37
|
+
* Request (core/src/http/request.ts:92) and this is exactly the write auth
|
|
38
|
+
* middleware would have performed.
|
|
39
|
+
*/
|
|
40
|
+
as?: unknown; /** Per-call overrides of the connected registry (tests, mostly). */
|
|
41
|
+
routes?: readonly PageRouteEntry[];
|
|
42
|
+
createHttp?: ExecutePageRequestOptions["createHttp"];
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* `renderPageRequest` takes the URL itself, so `params`/`query` (the
|
|
46
|
+
* name-based sugar buildUrl consumes) have no meaning here — everything else
|
|
47
|
+
* is the same seam.
|
|
48
|
+
*/
|
|
49
|
+
type RenderPageRequestOptions = Omit<RenderPageOptions, "params" | "query">;
|
|
50
|
+
type RenderedPage = {
|
|
51
|
+
/** The full document ("" when the pipeline short-circuited before render). */html: string;
|
|
52
|
+
status: number; /** Committed response headers, lowercased key → value. */
|
|
53
|
+
headers: Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Committed cookies in commit order, attribute-faithful: each entry carries
|
|
56
|
+
* the loader's raw value (pre-serialization) AND its options
|
|
57
|
+
* (`httpOnly`/`secure`/`sameSite`/`path`/`expires`/…). Never flattened to a
|
|
58
|
+
* name→value map — a map cannot express the attributes, and a Set-Cookie
|
|
59
|
+
* built without them is a security defect, not a convenience.
|
|
60
|
+
*/
|
|
61
|
+
cookies: BufferedCookie[];
|
|
62
|
+
/**
|
|
63
|
+
* The PAGE loader's data — `data.product.name` reads as the dx story
|
|
64
|
+
* writes it. `unknown`: the pipeline never checks a loader's return shape.
|
|
65
|
+
*/
|
|
66
|
+
data: unknown;
|
|
67
|
+
/**
|
|
68
|
+
* The full stages-1–8 bundle, for assertions beyond the page's own data.
|
|
69
|
+
* Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,
|
|
70
|
+
* so no pipeline ran and there is no bundle — the 404 answer stands alone.
|
|
71
|
+
* `renderPage` always carries one (its no-match throws instead).
|
|
72
|
+
*/
|
|
73
|
+
bundle: PageDataBundle | undefined;
|
|
74
|
+
};
|
|
75
|
+
declare function renderPage(routeName: string, options?: RenderPageOptions): Promise<RenderedPage>;
|
|
76
|
+
/**
|
|
77
|
+
* The URL-based sibling of `renderPage` — the production render surface: a
|
|
78
|
+
* real HTTP server has a URL, not a route name. The url goes STRAIGHT to
|
|
79
|
+
* executePageRequest's stage-1 matcher (no buildUrl), then the same shared
|
|
80
|
+
* tail renders and emits.
|
|
81
|
+
*
|
|
82
|
+
* No-match here is NOT the manifest bug renderPage throws on: an arbitrary
|
|
83
|
+
* URL matching no route is a legitimate 404, and a server must ANSWER it —
|
|
84
|
+
* `{ html: "", status: 404 }` with an undefined `bundle` (see RenderedPage).
|
|
85
|
+
*/
|
|
86
|
+
declare function renderPageRequest(url: string, options?: RenderPageRequestOptions): Promise<RenderedPage>;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { PageRoutesRegistry, RenderPageOptions, RenderPageRequestOptions, RenderedPage, connectPageRoutes, renderPage, renderPageRequest };
|
|
89
|
+
//# sourceMappingURL=render-page.d.mts.map
|
|
@@ -7,6 +7,16 @@ import { createElement } from "react";
|
|
|
7
7
|
|
|
8
8
|
//#region ../web/src/server/render-page.ts
|
|
9
9
|
let pageRoutesRegistry;
|
|
10
|
+
/**
|
|
11
|
+
* Boot-time wiring so `renderPage(name, options)` can resolve a route NAME
|
|
12
|
+
* without each call site carrying the manifest. Returns the previous registry
|
|
13
|
+
* so tests can restore it. A per-call `routes`/`createHttp` override wins.
|
|
14
|
+
*/
|
|
15
|
+
function connectPageRoutes(registry) {
|
|
16
|
+
const previous = pageRoutesRegistry;
|
|
17
|
+
pageRoutesRegistry = registry;
|
|
18
|
+
return previous;
|
|
19
|
+
}
|
|
10
20
|
function requireRegistry(options) {
|
|
11
21
|
const routes = options.routes ?? pageRoutesRegistry?.routes;
|
|
12
22
|
const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;
|
|
@@ -16,6 +26,17 @@ function requireRegistry(options) {
|
|
|
16
26
|
createHttp
|
|
17
27
|
};
|
|
18
28
|
}
|
|
29
|
+
function buildUrl(entry, params, query) {
|
|
30
|
+
const path = entry.path.split("/").map((segment) => {
|
|
31
|
+
if (!segment.startsWith(":")) return segment;
|
|
32
|
+
const name = segment.slice(1);
|
|
33
|
+
const value = params[name];
|
|
34
|
+
if (value === void 0) throw new Error(`renderPage("${entry.name}"): route path "${entry.path}" needs param "${name}" and the call did not provide it (web/src/server/render-page.ts). Fix: pass it in \`params: { ${name}: … }\`.`);
|
|
35
|
+
return encodeURIComponent(value);
|
|
36
|
+
}).join("/");
|
|
37
|
+
const queryString = new URLSearchParams(query).toString();
|
|
38
|
+
return queryString ? `${path}?${queryString}` : path;
|
|
39
|
+
}
|
|
19
40
|
/**
|
|
20
41
|
* The framework-owned terminal boundary (P1 §4: designation falls back to
|
|
21
42
|
* `app` even when no level exports one — "the framework owns a root
|
|
@@ -179,6 +200,24 @@ async function finishRender(triple, bundle, documentSlots) {
|
|
|
179
200
|
bundle
|
|
180
201
|
};
|
|
181
202
|
}
|
|
203
|
+
async function renderPage(routeName, options = {}) {
|
|
204
|
+
const registry = requireRegistry(options);
|
|
205
|
+
const entry = registry.routes.find((candidate) => candidate.name === routeName);
|
|
206
|
+
if (!entry) {
|
|
207
|
+
const known = registry.routes.map((candidate) => `"${candidate.name}"`).join(", ");
|
|
208
|
+
throw new Error(`renderPage("${routeName}"): no route with that name (web/src/server/render-page.ts). Known route names: ${known}. Fix: use a name from the manifest, or connect the manifest that declares this one.`);
|
|
209
|
+
}
|
|
210
|
+
const url = buildUrl(entry, options.params ?? {}, options.query ?? {});
|
|
211
|
+
const { state, createHttp } = capturingCreateHttp(registry, options.as);
|
|
212
|
+
const rendered = await executePageRequest({
|
|
213
|
+
url,
|
|
214
|
+
routes: registry.routes,
|
|
215
|
+
createHttp,
|
|
216
|
+
finish: (bundle) => finishRender(entry.triple, bundle, documentSlotsFrom(state.captured))
|
|
217
|
+
});
|
|
218
|
+
if (!rendered) throw new Error(`renderPage("${routeName}"): the built URL "${url}" did not match stage 1 (web/src/server/render-page.ts). The name resolved but the matcher disagreed — that is a manifest bug, not a caller bug.`);
|
|
219
|
+
return rendered;
|
|
220
|
+
}
|
|
182
221
|
/**
|
|
183
222
|
* The URL-based sibling of `renderPage` — the production render surface: a
|
|
184
223
|
* real HTTP server has a URL, not a route name. The url goes STRAIGHT to
|
|
@@ -210,5 +249,5 @@ async function renderPageRequest(url, options = {}) {
|
|
|
210
249
|
}
|
|
211
250
|
|
|
212
251
|
//#endregion
|
|
213
|
-
export { renderPageRequest };
|
|
252
|
+
export { connectPageRoutes, renderPage, renderPageRequest };
|
|
214
253
|
//# sourceMappingURL=render-page.mjs.map
|