@warlock.js/web 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/esm/build/contribution.d.mts +55 -0
- package/esm/build/contribution.mjs +132 -0
- package/esm/build/contribution.mjs.map +1 -0
- package/esm/build/discover-pages.mjs +294 -0
- package/esm/build/discover-pages.mjs.map +1 -0
- package/esm/build/generate-client-registry.mjs +123 -0
- package/esm/build/generate-client-registry.mjs.map +1 -0
- package/esm/build/generate-pages-barrel.mjs +330 -0
- package/esm/build/generate-pages-barrel.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs +212 -0
- package/esm/build/read-route-exports.mjs.map +1 -0
- package/esm/client/build-hydrated-tree.mjs +113 -0
- package/esm/client/build-hydrated-tree.mjs.map +1 -0
- package/esm/client/hydrate-page.mjs +73 -0
- package/esm/client/hydrate-page.mjs.map +1 -0
- package/esm/client/navigation/current-route.d.mts +91 -0
- package/esm/client/navigation/current-route.mjs +66 -0
- package/esm/client/navigation/current-route.mjs.map +1 -0
- package/esm/client/navigation/fetch-page-data.mjs +97 -0
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +196 -0
- package/esm/client/navigation/navigation-root.mjs.map +1 -0
- package/esm/client/navigation/prefetch.mjs +110 -0
- package/esm/client/navigation/prefetch.mjs.map +1 -0
- package/esm/client/navigation/refresh.d.mts +40 -0
- package/esm/client/navigation/refresh.mjs +146 -0
- package/esm/client/navigation/refresh.mjs.map +1 -0
- package/esm/client/navigation/verbs.d.mts +55 -0
- package/esm/client/navigation/verbs.mjs +66 -0
- package/esm/client/navigation/verbs.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +4 -0
- package/esm/client/runtime/index.mjs +4 -0
- package/esm/client/runtime/manifest.d.mts +8 -0
- package/esm/client/runtime/manifest.mjs +77 -0
- package/esm/client/runtime/manifest.mjs.map +1 -0
- package/esm/client/runtime/matcher.d.mts +7 -0
- package/esm/client/runtime/matcher.mjs +169 -0
- package/esm/client/runtime/matcher.mjs.map +1 -0
- package/esm/client/runtime/types.d.mts +22 -0
- package/esm/components/default-app.mjs +32 -0
- package/esm/components/default-app.mjs.map +1 -0
- package/esm/components/document-context.d.mts +1 -0
- package/esm/components/document-context.mjs +24 -0
- package/esm/components/document-context.mjs.map +1 -0
- package/esm/components/head.d.mts +14 -0
- package/esm/components/head.mjs +63 -0
- package/esm/components/head.mjs.map +1 -0
- package/esm/components/link.d.mts +157 -0
- package/esm/components/link.mjs +194 -0
- package/esm/components/link.mjs.map +1 -0
- package/esm/components/scripts.d.mts +14 -0
- package/esm/components/scripts.mjs +21 -0
- package/esm/components/scripts.mjs.map +1 -0
- package/esm/connector/index.d.mts +6 -0
- package/esm/connector/index.mjs +7 -0
- package/esm/context.d.mts +107 -0
- package/esm/hydration/index.d.mts +1 -0
- package/esm/hydration/index.mjs +33 -0
- package/esm/hydration/index.mjs.map +1 -0
- package/esm/hydration-payload.d.mts +1 -0
- package/esm/hydration-payload.mjs +72 -0
- package/esm/hydration-payload.mjs.map +1 -0
- package/esm/index.d.mts +35 -0
- package/esm/index.mjs +12 -0
- package/esm/loaders.d.mts +47 -0
- package/esm/metadata.d.mts +54 -0
- package/esm/props.d.mts +42 -0
- package/esm/route.d.mts +30 -0
- package/esm/routing/compose-route-path.mjs +33 -0
- package/esm/routing/compose-route-path.mjs.map +1 -0
- package/esm/routing/data-request.mjs +52 -0
- package/esm/routing/data-request.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs +78 -0
- package/esm/routing/layout-policy.mjs.map +1 -0
- package/esm/routing/navigator.mjs +21 -0
- package/esm/routing/navigator.mjs.map +1 -0
- package/esm/routing/query-string.d.mts +250 -0
- package/esm/routing/query-string.mjs +340 -0
- package/esm/routing/query-string.mjs.map +1 -0
- package/esm/routing/route-identity.mjs +68 -0
- package/esm/routing/route-identity.mjs.map +1 -0
- package/esm/routing/route-table.d.mts +34 -0
- package/esm/routing/route-table.mjs +182 -0
- package/esm/routing/route-table.mjs.map +1 -0
- package/esm/routing/router-events.d.mts +159 -0
- package/esm/routing/router-events.mjs +83 -0
- package/esm/routing/router-events.mjs.map +1 -0
- package/esm/server/buffered-response.mjs +115 -0
- package/esm/server/buffered-response.mjs.map +1 -0
- package/esm/server/build-hydration-payload.mjs +27 -0
- package/esm/server/build-hydration-payload.mjs.map +1 -0
- package/esm/server/client-asset-url-prefix.d.mts +22 -0
- package/esm/server/client-asset-url-prefix.mjs +23 -0
- package/esm/server/client-asset-url-prefix.mjs.map +1 -0
- package/esm/server/create-page-module-loader.mjs +49 -0
- package/esm/server/create-page-module-loader.mjs.map +1 -0
- package/esm/server/create-page-route-handler.mjs +107 -0
- package/esm/server/create-page-route-handler.mjs.map +1 -0
- package/esm/server/dev-server.mjs +167 -0
- package/esm/server/dev-server.mjs.map +1 -0
- package/esm/server/execute-page-request.d.mts +1 -0
- package/esm/server/execute-page-request.mjs +166 -0
- package/esm/server/execute-page-request.mjs.map +1 -0
- package/esm/server/execute-page-request.types.d.mts +1 -0
- package/esm/server/hydration-client-url.d.mts +37 -0
- package/esm/server/hydration-client-url.mjs +101 -0
- package/esm/server/hydration-client-url.mjs.map +1 -0
- package/esm/server/index.mjs +13 -0
- package/esm/server/install-page-routes-from-manifest.mjs +133 -0
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -0
- package/esm/server/install-page-routes.d.mts +1 -0
- package/esm/server/install-page-routes.mjs +6 -0
- package/esm/server/install-production-page-routes.mjs +55 -0
- package/esm/server/install-production-page-routes.mjs.map +1 -0
- package/esm/server/match-page-route.mjs +48 -0
- package/esm/server/match-page-route.mjs.map +1 -0
- package/esm/server/page-context.d.mts +1 -0
- package/esm/server/page-context.mjs +33 -0
- package/esm/server/page-context.mjs.map +1 -0
- package/esm/server/page-manifest.d.mts +102 -0
- package/esm/server/page-manifest.mjs +31 -0
- package/esm/server/page-manifest.mjs.map +1 -0
- package/esm/server/render-page.mjs +214 -0
- package/esm/server/render-page.mjs.map +1 -0
- package/esm/server/resolve-page-metadata.mjs +33 -0
- package/esm/server/resolve-page-metadata.mjs.map +1 -0
- package/esm/server/resolve-validation-data.mjs +27 -0
- package/esm/server/resolve-validation-data.mjs.map +1 -0
- package/esm/server/settle-page-response.d.mts +1 -0
- package/esm/server/settle-page-response.mjs +90 -0
- package/esm/server/settle-page-response.mjs.map +1 -0
- package/esm/server/stylesheet-urls.mjs +65 -0
- package/esm/server/stylesheet-urls.mjs.map +1 -0
- package/esm/server/web-connector-factory.d.mts +25 -0
- package/esm/server/web-connector-factory.mjs +121 -0
- package/esm/server/web-connector-factory.mjs.map +1 -0
- package/esm/server/web-connector.d.mts +34 -0
- package/esm/server/web-connector.mjs +516 -0
- package/esm/server/web-connector.mjs.map +1 -0
- package/esm/shared.d.mts +22 -0
- package/esm/shared.mjs +223 -0
- package/esm/shared.mjs.map +1 -0
- package/esm/validation.d.mts +31 -0
- package/esm/vite/app-convention-aliases.mjs +43 -0
- package/esm/vite/app-convention-aliases.mjs.map +1 -0
- package/esm/vite/build-client.d.mts +38 -0
- package/esm/vite/build-client.mjs +72 -0
- package/esm/vite/build-client.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +57 -0
- package/esm/vite/gate-a-resolve.mjs +834 -0
- package/esm/vite/gate-a-resolve.mjs.map +1 -0
- package/esm/vite/gate-b-secrets.d.mts +57 -0
- package/esm/vite/gate-b-secrets.mjs +299 -0
- package/esm/vite/gate-b-secrets.mjs.map +1 -0
- package/esm/vite/gate-c-verify.d.mts +118 -0
- package/esm/vite/gate-c-verify.mjs +263 -0
- package/esm/vite/gate-c-verify.mjs.map +1 -0
- package/esm/vite/hydration-entries.d.mts +17 -0
- package/esm/vite/hydration-entries.mjs +45 -0
- package/esm/vite/hydration-entries.mjs.map +1 -0
- package/esm/vite/index.d.mts +126 -0
- package/esm/vite/index.mjs +146 -0
- package/esm/vite/index.mjs.map +1 -0
- package/esm/vite/page-registry-plugin.d.mts +43 -0
- package/esm/vite/page-registry-plugin.mjs +138 -0
- package/esm/vite/page-registry-plugin.mjs.map +1 -0
- package/esm/vite/projection.d.mts +31 -0
- package/esm/vite/projection.mjs +384 -0
- package/esm/vite/projection.mjs.map +1 -0
- package/llms-full.txt +1041 -0
- package/llms.txt +21 -0
- package/package.json +77 -0
- package/skills/add-web-to-an-app/SKILL.md +103 -0
- package/skills/create-a-page/SKILL.md +141 -0
- package/skills/load-page-data/SKILL.md +183 -0
- package/skills/navigate-on-the-client/SKILL.md +182 -0
- package/skills/serve-styles/SKILL.md +145 -0
- package/skills/use-layouts/SKILL.md +121 -0
- package/skills/write-the-root/SKILL.md +131 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//#region ../web/src/server/buffered-response.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runtime brand behind `LoaderShortCircuit` (web/src/context.ts:13-17
|
|
4
|
+
* declares the compile-time half). A loader RETURNS this from
|
|
5
|
+
* `response.redirect()` / `response.notFound()`; the settle stage detects it
|
|
6
|
+
* by the symbol, never by shape.
|
|
7
|
+
*/
|
|
8
|
+
const LOADER_SHORT_CIRCUIT = Symbol("warlock.loader.shortCircuit");
|
|
9
|
+
function isLoaderShortCircuit(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && value[LOADER_SHORT_CIRCUIT] === true;
|
|
11
|
+
}
|
|
12
|
+
function shortCircuitSignal(init) {
|
|
13
|
+
return {
|
|
14
|
+
...init,
|
|
15
|
+
[LOADER_SHORT_CIRCUIT]: true
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function createBufferedResponse() {
|
|
19
|
+
const buffer = {
|
|
20
|
+
headers: [],
|
|
21
|
+
cookies: []
|
|
22
|
+
};
|
|
23
|
+
const response = {
|
|
24
|
+
header(key, value) {
|
|
25
|
+
buffer.headers.push({
|
|
26
|
+
key,
|
|
27
|
+
value
|
|
28
|
+
});
|
|
29
|
+
return response;
|
|
30
|
+
},
|
|
31
|
+
cookie(name, value, options) {
|
|
32
|
+
buffer.cookies.push({
|
|
33
|
+
name,
|
|
34
|
+
value,
|
|
35
|
+
options
|
|
36
|
+
});
|
|
37
|
+
return response;
|
|
38
|
+
},
|
|
39
|
+
setStatusCode(statusCode) {
|
|
40
|
+
buffer.statusCode = statusCode;
|
|
41
|
+
return response;
|
|
42
|
+
},
|
|
43
|
+
/**
|
|
44
|
+
* A redirect is a status AND a `Location`; both belong in the buffer.
|
|
45
|
+
*
|
|
46
|
+
* Writing only `buffer.statusCode` here and carrying the URL out on the
|
|
47
|
+
* branded return value split one answer across two channels, and the
|
|
48
|
+
* emit only ever drained the first: the document path applied
|
|
49
|
+
* `bundle.commit.headers` and never read `bundle.shortCircuit`, so the
|
|
50
|
+
* browser got a bare `302` with an empty body and stayed put
|
|
51
|
+
* (design/loader-endpoint-seam-2026-08-23.md §5).
|
|
52
|
+
*
|
|
53
|
+
* Putting `Location` in the buffer fixes it once, at the declaration,
|
|
54
|
+
* for every consumer: the commit stage's live mirror
|
|
55
|
+
* (`execute-page-request.ts` commitBuffers — which mirrors headers and
|
|
56
|
+
* status only; its cookie half was removed so `Set-Cookie` is not
|
|
57
|
+
* appended twice, and `Location` rides the header half),
|
|
58
|
+
* `bundle.commit.headers`, `finishRender`'s header map, and the handler's
|
|
59
|
+
* `response.headers()` all carry it without any of them learning what a
|
|
60
|
+
* redirect is. The
|
|
61
|
+
* alternative — special-casing `shortCircuit.kind` at the emit — would
|
|
62
|
+
* have to be re-implemented by every emit path that ever exists, which
|
|
63
|
+
* is precisely how this shipped unnoticed the first time.
|
|
64
|
+
*
|
|
65
|
+
* The signal is still returned and still branded: the buffer says what
|
|
66
|
+
* the response should LOOK like, the signal says the pipeline must STOP.
|
|
67
|
+
* Those are different facts and both are still needed.
|
|
68
|
+
*/
|
|
69
|
+
redirect(url, statusCode = 302) {
|
|
70
|
+
buffer.statusCode = statusCode;
|
|
71
|
+
buffer.headers.push({
|
|
72
|
+
key: "Location",
|
|
73
|
+
value: url
|
|
74
|
+
});
|
|
75
|
+
return shortCircuitSignal({
|
|
76
|
+
kind: "redirect",
|
|
77
|
+
statusCode,
|
|
78
|
+
url
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
/** Same shape as `redirect`, at 301 — deliberately not special-cased. */
|
|
82
|
+
permanentRedirect(url) {
|
|
83
|
+
buffer.statusCode = 301;
|
|
84
|
+
buffer.headers.push({
|
|
85
|
+
key: "Location",
|
|
86
|
+
value: url
|
|
87
|
+
});
|
|
88
|
+
return shortCircuitSignal({
|
|
89
|
+
kind: "redirect",
|
|
90
|
+
statusCode: 301,
|
|
91
|
+
url
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
/**
|
|
95
|
+
* The other short-circuit kind. It shares the signal shape but has no
|
|
96
|
+
* URL, so it writes no `Location` — a 404 that redirects is not a thing.
|
|
97
|
+
*/
|
|
98
|
+
notFound(body) {
|
|
99
|
+
buffer.statusCode = 404;
|
|
100
|
+
return shortCircuitSignal({
|
|
101
|
+
kind: "notFound",
|
|
102
|
+
statusCode: 404,
|
|
103
|
+
body
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
response,
|
|
109
|
+
buffer
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
//#endregion
|
|
114
|
+
export { LOADER_SHORT_CIRCUIT, createBufferedResponse, isLoaderShortCircuit };
|
|
115
|
+
//# sourceMappingURL=buffered-response.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buffered-response.mjs","names":[],"sources":["../../../../../../../web/src/server/buffered-response.ts"],"sourcesContent":["import type { LoaderShortCircuit, WebResponse } from \"../context\";\n\n/**\n * The per-loader response facade for pipeline stage 6.\n *\n * Loaders run in PARALLEL and never see the real `Response`: core's\n * `Response.header`/`cookie` write straight through to the fastify reply\n * (core/src/http/response.ts:919-923, :955-967), so three concurrent loaders\n * writing directly would interleave nondeterministically and a discarded\n * layer's writes could never be taken back. Instead each loader gets one of\n * these facades; every write lands in a buffer, and the pipeline's\n * settle/commit stage applies the surviving buffers to\n * the real response root→leaf, per cookie name / header key.\n */\n\nexport type BufferedHeader = {\n key: string;\n value: string;\n};\n\nexport type BufferedCookie = {\n name: string;\n value: unknown;\n options?: Record<string, unknown>;\n};\n\nexport type ResponseBuffer = {\n headers: BufferedHeader[];\n cookies: BufferedCookie[];\n statusCode?: number;\n};\n\n/**\n * Runtime brand behind `LoaderShortCircuit` (web/src/context.ts:13-17\n * declares the compile-time half). A loader RETURNS this from\n * `response.redirect()` / `response.notFound()`; the settle stage detects it\n * by the symbol, never by shape.\n */\nexport const LOADER_SHORT_CIRCUIT = Symbol(\"warlock.loader.shortCircuit\");\n\nexport type LoaderShortCircuitSignal = LoaderShortCircuit & {\n kind: \"redirect\" | \"notFound\";\n statusCode: number;\n url?: string;\n body?: unknown;\n};\n\nexport function isLoaderShortCircuit(value: unknown): value is LoaderShortCircuitSignal {\n return (\n typeof value === \"object\" &&\n value !== null &&\n (value as Record<PropertyKey, unknown>)[LOADER_SHORT_CIRCUIT] === true\n );\n}\n\nfunction shortCircuitSignal(\n init: Omit<LoaderShortCircuitSignal, keyof LoaderShortCircuit>,\n): LoaderShortCircuitSignal {\n return { ...init, [LOADER_SHORT_CIRCUIT]: true } as unknown as LoaderShortCircuitSignal;\n}\n\n/**\n * The loader-facing surface is `WebResponse` (web/src/context.ts:73-88) plus\n * `cookie()`, which the commit contract needs so buffers can be applied\n * per cookie name.\n */\nexport type BufferedWebResponse = WebResponse & {\n cookie(name: string, value: unknown, options?: Record<string, unknown>): BufferedWebResponse;\n};\n\nexport function createBufferedResponse(): {\n response: BufferedWebResponse;\n buffer: ResponseBuffer;\n} {\n const buffer: ResponseBuffer = { headers: [], cookies: [] };\n\n const response: BufferedWebResponse = {\n header(key: string, value: string) {\n buffer.headers.push({ key, value });\n return response;\n },\n\n cookie(name: string, value: unknown, options?: Record<string, unknown>) {\n buffer.cookies.push({ name, value, options });\n return response;\n },\n\n setStatusCode(statusCode: number) {\n buffer.statusCode = statusCode;\n return response;\n },\n\n /**\n * A redirect is a status AND a `Location`; both belong in the buffer.\n *\n * Writing only `buffer.statusCode` here and carrying the URL out on the\n * branded return value split one answer across two channels, and the\n * emit only ever drained the first: the document path applied\n * `bundle.commit.headers` and never read `bundle.shortCircuit`, so the\n * browser got a bare `302` with an empty body and stayed put\n * (design/loader-endpoint-seam-2026-08-23.md §5).\n *\n * Putting `Location` in the buffer fixes it once, at the declaration,\n * for every consumer: the commit stage's live mirror\n * (`execute-page-request.ts` commitBuffers — which mirrors headers and\n * status only; its cookie half was removed so `Set-Cookie` is not\n * appended twice, and `Location` rides the header half),\n * `bundle.commit.headers`, `finishRender`'s header map, and the handler's\n * `response.headers()` all carry it without any of them learning what a\n * redirect is. The\n * alternative — special-casing `shortCircuit.kind` at the emit — would\n * have to be re-implemented by every emit path that ever exists, which\n * is precisely how this shipped unnoticed the first time.\n *\n * The signal is still returned and still branded: the buffer says what\n * the response should LOOK like, the signal says the pipeline must STOP.\n * Those are different facts and both are still needed.\n */\n redirect(url: string, statusCode = 302) {\n buffer.statusCode = statusCode;\n buffer.headers.push({ key: \"Location\", value: url });\n return shortCircuitSignal({ kind: \"redirect\", statusCode, url });\n },\n\n /** Same shape as `redirect`, at 301 — deliberately not special-cased. */\n permanentRedirect(url: string) {\n buffer.statusCode = 301;\n buffer.headers.push({ key: \"Location\", value: url });\n return shortCircuitSignal({ kind: \"redirect\", statusCode: 301, url });\n },\n\n /**\n * The other short-circuit kind. It shares the signal shape but has no\n * URL, so it writes no `Location` — a 404 that redirects is not a thing.\n */\n notFound(body?: unknown) {\n buffer.statusCode = 404;\n return shortCircuitSignal({ kind: \"notFound\", statusCode: 404, body });\n },\n };\n\n return { response, buffer };\n}\n"],"mappings":";;;;;;;AAsCA,MAAa,uBAAuB,OAAO,6BAA6B;AASxE,SAAgB,qBAAqB,OAAmD;CACtF,OACE,OAAO,UAAU,YACjB,UAAU,QACT,MAAuC,0BAA0B;AAEtE;AAEA,SAAS,mBACP,MAC0B;CAC1B,OAAO;EAAE,GAAG;GAAO,uBAAuB;CAAK;AACjD;AAWA,SAAgB,yBAGd;CACA,MAAM,SAAyB;EAAE,SAAS,CAAC;EAAG,SAAS,CAAC;CAAE;CAE1D,MAAM,WAAgC;EACpC,OAAO,KAAa,OAAe;GACjC,OAAO,QAAQ,KAAK;IAAE;IAAK;GAAM,CAAC;GAClC,OAAO;EACT;EAEA,OAAO,MAAc,OAAgB,SAAmC;GACtE,OAAO,QAAQ,KAAK;IAAE;IAAM;IAAO;GAAQ,CAAC;GAC5C,OAAO;EACT;EAEA,cAAc,YAAoB;GAChC,OAAO,aAAa;GACpB,OAAO;EACT;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4BA,SAAS,KAAa,aAAa,KAAK;GACtC,OAAO,aAAa;GACpB,OAAO,QAAQ,KAAK;IAAE,KAAK;IAAY,OAAO;GAAI,CAAC;GACnD,OAAO,mBAAmB;IAAE,MAAM;IAAY;IAAY;GAAI,CAAC;EACjE;;EAGA,kBAAkB,KAAa;GAC7B,OAAO,aAAa;GACpB,OAAO,QAAQ,KAAK;IAAE,KAAK;IAAY,OAAO;GAAI,CAAC;GACnD,OAAO,mBAAmB;IAAE,MAAM;IAAY,YAAY;IAAK;GAAI,CAAC;EACtE;;;;;EAMA,SAAS,MAAgB;GACvB,OAAO,aAAa;GACpB,OAAO,mBAAmB;IAAE,MAAM;IAAY,YAAY;IAAK;GAAK,CAAC;EACvE;CACF;CAEA,OAAO;EAAE;EAAU;CAAO;AAC5B"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region ../web/src/server/build-hydration-payload.ts
|
|
2
|
+
/**
|
|
3
|
+
* Levels without a loader resolve to `undefined`, but the hydration contract
|
|
4
|
+
* requires every data key to be PRESENT.
|
|
5
|
+
*
|
|
6
|
+
* An intentional `null` is preserved — a loader that returned `null` said
|
|
7
|
+
* something, and flattening it would erase that. Only "no data at all" becomes
|
|
8
|
+
* an empty object.
|
|
9
|
+
*/
|
|
10
|
+
function serializableData(data) {
|
|
11
|
+
return data === void 0 ? {} : data;
|
|
12
|
+
}
|
|
13
|
+
function buildHydrationPayload(bundle) {
|
|
14
|
+
return {
|
|
15
|
+
appData: serializableData(bundle.appData),
|
|
16
|
+
layoutData: serializableData(bundle.layoutData),
|
|
17
|
+
pageData: serializableData(bundle.pageData),
|
|
18
|
+
shared: serializableData(bundle.shared),
|
|
19
|
+
params: bundle.route.params,
|
|
20
|
+
...bundle.metadata === void 0 ? {} : { metadata: bundle.metadata },
|
|
21
|
+
name: bundle.route.name
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
export { buildHydrationPayload };
|
|
27
|
+
//# sourceMappingURL=build-hydration-payload.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-hydration-payload.mjs","names":[],"sources":["../../../../../../../web/src/server/build-hydration-payload.ts"],"sourcesContent":["/**\n * The ONE place the hydration payload's shape is decided.\n *\n * Two callers need the identical object and must never drift:\n *\n * `render-page.ts` embeds it in the document as `#__WARLOCK_DATA__`, which\n * is what a FULL page load hands the browser.\n * the `_loader` route returns it as JSON, which is what a CLIENT navigation\n * fetches instead of re-rendering the document.\n *\n * Drift between those two is not a cosmetic bug: the browser builds the same\n * React tree from either source, so a key present on one path and absent on the\n * other produces a page that works when you land on it and breaks when you\n * navigate to it — or the reverse, which is worse, because the first visit is\n * the one everybody tests.\n *\n * Extracted rather than duplicated for exactly that reason. It was previously\n * assembled inline inside the renderer, where the loader route could not reach\n * it without copying five lines that would then be free to diverge.\n */\nimport type { HydrationDocumentPayloadSource } from \"../components/document-context\";\nimport type { PageDataBundle } from \"./execute-page-request\";\n\n/**\n * Levels without a loader resolve to `undefined`, but the hydration contract\n * requires every data key to be PRESENT.\n *\n * An intentional `null` is preserved — a loader that returned `null` said\n * something, and flattening it would erase that. Only \"no data at all\" becomes\n * an empty object.\n */\nfunction serializableData(data: unknown): unknown {\n return data === undefined ? {} : data;\n}\n\nexport function buildHydrationPayload(bundle: PageDataBundle): HydrationDocumentPayloadSource {\n return {\n appData: serializableData(bundle.appData),\n layoutData: serializableData(bundle.layoutData),\n pageData: serializableData(bundle.pageData),\n shared: serializableData(bundle.shared),\n // The server's own match, carried for the same reason `name` is: the params\n // are an ANSWER the router already gave, and re-deriving them in the\n // browser from `location.pathname` would be a second matcher disagreeing\n // with the server about the request it is hydrating. `{}` for a route with\n // no dynamic segments — a real answer, not a missing one.\n params: bundle.route.params,\n // Spread, so \"the page produced no metadata\" is the SAME shape here and on\n // the wire. `metadata: undefined` would be a key in the in-process object\n // and no key at all after `JSON.stringify` — one type, two payload shapes,\n // which is precisely the drift this file exists to prevent. Carried whole:\n // `<Head/>` renders every member of `MetadataOutput`, so anything narrowed\n // out here is a tag the first request has and a navigation does not.\n ...(bundle.metadata === undefined ? {} : { metadata: bundle.metadata }),\n // The matched entry's own name, carried untransformed from stage 1\n // (`bundle.route.name` IS `matched.entry.name`, execute-page-request.ts).\n // The browser reads it to look up the page the server resolved rather than\n // re-matching the pathname — a second matcher can disagree with the server\n // about the very request it is hydrating, and on a client navigation it\n // would be disagreeing about a request the server already answered.\n name: bundle.route.name,\n };\n}\n"],"mappings":";;;;;;;;;AA+BA,SAAS,iBAAiB,MAAwB;CAChD,OAAO,SAAS,SAAY,CAAC,IAAI;AACnC;AAEA,SAAgB,sBAAsB,QAAwD;CAC5F,OAAO;EACL,SAAS,iBAAiB,OAAO,OAAO;EACxC,YAAY,iBAAiB,OAAO,UAAU;EAC9C,UAAU,iBAAiB,OAAO,QAAQ;EAC1C,QAAQ,iBAAiB,OAAO,MAAM;EAMtC,QAAQ,OAAO,MAAM;EAOrB,GAAI,OAAO,aAAa,SAAY,CAAC,IAAI,EAAE,UAAU,OAAO,SAAS;EAOrE,MAAM,OAAO,MAAM;CACrB;AACF"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region ../web/src/server/client-asset-url-prefix.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The SINGLE authority for where built client assets are served.
|
|
4
|
+
*
|
|
5
|
+
* Two halves must agree and only one of them may own the literal:
|
|
6
|
+
* `resolveHydrationClientUrl` GUARANTEES every URL it returns starts with this
|
|
7
|
+
* prefix, and the production static-file route MUST mount `<clientDir>/assets`
|
|
8
|
+
* at exactly this prefix by importing THIS symbol. A second `"/assets"`
|
|
9
|
+
* literal anywhere else is a drift bug: the two copies can be edited apart,
|
|
10
|
+
* and the failure mode is a silent 404 on the hydration script — the page
|
|
11
|
+
* renders, never hydrates, and nothing throws.
|
|
12
|
+
*
|
|
13
|
+
* WHY THE `assets` SUBDIRECTORY AND NOT THE CLIENT ROOT: the client build dir
|
|
14
|
+
* also contains `.vite/manifest.json`, the build→runtime handoff artifact,
|
|
15
|
+
* which must NEVER be publicly served. Mounting the client root would expose
|
|
16
|
+
* it. Mounting only the `assets` subdirectory keeps the manifest unreachable
|
|
17
|
+
* while still serving every hashed artifact the manifest points at.
|
|
18
|
+
*/
|
|
19
|
+
declare const CLIENT_ASSET_URL_PREFIX = "/assets";
|
|
20
|
+
//#endregion
|
|
21
|
+
export { CLIENT_ASSET_URL_PREFIX };
|
|
22
|
+
//# sourceMappingURL=client-asset-url-prefix.d.mts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region ../web/src/server/client-asset-url-prefix.ts
|
|
2
|
+
/**
|
|
3
|
+
* The SINGLE authority for where built client assets are served.
|
|
4
|
+
*
|
|
5
|
+
* Two halves must agree and only one of them may own the literal:
|
|
6
|
+
* `resolveHydrationClientUrl` GUARANTEES every URL it returns starts with this
|
|
7
|
+
* prefix, and the production static-file route MUST mount `<clientDir>/assets`
|
|
8
|
+
* at exactly this prefix by importing THIS symbol. A second `"/assets"`
|
|
9
|
+
* literal anywhere else is a drift bug: the two copies can be edited apart,
|
|
10
|
+
* and the failure mode is a silent 404 on the hydration script — the page
|
|
11
|
+
* renders, never hydrates, and nothing throws.
|
|
12
|
+
*
|
|
13
|
+
* WHY THE `assets` SUBDIRECTORY AND NOT THE CLIENT ROOT: the client build dir
|
|
14
|
+
* also contains `.vite/manifest.json`, the build→runtime handoff artifact,
|
|
15
|
+
* which must NEVER be publicly served. Mounting the client root would expose
|
|
16
|
+
* it. Mounting only the `assets` subdirectory keeps the manifest unreachable
|
|
17
|
+
* while still serving every hashed artifact the manifest points at.
|
|
18
|
+
*/
|
|
19
|
+
const CLIENT_ASSET_URL_PREFIX = "/assets";
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { CLIENT_ASSET_URL_PREFIX };
|
|
23
|
+
//# sourceMappingURL=client-asset-url-prefix.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-asset-url-prefix.mjs","names":[],"sources":["../../../../../../../web/src/server/client-asset-url-prefix.ts"],"sourcesContent":["/**\n * The SINGLE authority for where built client assets are served.\n *\n * Two halves must agree and only one of them may own the literal:\n * `resolveHydrationClientUrl` GUARANTEES every URL it returns starts with this\n * prefix, and the production static-file route MUST mount `<clientDir>/assets`\n * at exactly this prefix by importing THIS symbol. A second `\"/assets\"`\n * literal anywhere else is a drift bug: the two copies can be edited apart,\n * and the failure mode is a silent 404 on the hydration script — the page\n * renders, never hydrates, and nothing throws.\n *\n * WHY THE `assets` SUBDIRECTORY AND NOT THE CLIENT ROOT: the client build dir\n * also contains `.vite/manifest.json`, the build→runtime handoff artifact,\n * which must NEVER be publicly served. Mounting the client root would expose\n * it. Mounting only the `assets` subdirectory keeps the manifest unreachable\n * while still serving every hashed artifact the manifest points at.\n */\nexport const CLIENT_ASSET_URL_PREFIX = \"/assets\";\n"],"mappings":";;;;;;;;;;;;;;;;;;AAiBA,MAAa,0BAA0B"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region ../web/src/server/create-page-module-loader.ts
|
|
2
|
+
/**
|
|
3
|
+
* Asked for a module this build does not contain.
|
|
4
|
+
*
|
|
5
|
+
* Hard failure, deliberately. Every alternative — returning `undefined`, an
|
|
6
|
+
* empty namespace, or falling back to a dynamic import — turns a build that
|
|
7
|
+
* shipped the wrong module table into a page that renders blank, or into a
|
|
8
|
+
* production process reaching for source files that are not deployed. The id
|
|
9
|
+
* is in the message because the whole diagnosis is "which id, and why is it
|
|
10
|
+
* not in the table"; the count is there because zero entries means the build
|
|
11
|
+
* discovered no pages at all, which is a different fault from a mismatch.
|
|
12
|
+
*/
|
|
13
|
+
var PageModuleNotInManifestError = class extends Error {
|
|
14
|
+
constructor(moduleId, knownIdCount) {
|
|
15
|
+
super(`Cannot serve a page module: "${moduleId}" is not in this build's page manifest (${knownIdCount} module(s) available). Module ids are the manifest's \`sourceFile\` values — app-root-relative POSIX paths, extension included — and must match exactly. Re-run \`warlock build\` so the generated \`pages.ts\` barrel matches what is being served.`);
|
|
16
|
+
this.name = "PageModuleNotInManifestError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Build the loader for ONE manifest.
|
|
21
|
+
*
|
|
22
|
+
* Ids are compared by exact string equality against the `sourceFile` each
|
|
23
|
+
* entry carries — no lowercasing, no separator swapping, no extension
|
|
24
|
+
* stripping. Both sides of that comparison come from the same generator
|
|
25
|
+
* (`generatePagesBarrel` writes every `sourceFile` through one
|
|
26
|
+
* app-root-relative POSIX derivation), so any spelling difference is a real
|
|
27
|
+
* disagreement about which file is meant, and a normalizer would only hide it.
|
|
28
|
+
*
|
|
29
|
+
* A layout shared by several pages appears once per chain it belongs to; the
|
|
30
|
+
* generator emits one identifier per layout file, so every occurrence of an id
|
|
31
|
+
* carries the same namespace object and re-registering it is a no-op.
|
|
32
|
+
*/
|
|
33
|
+
function createPageModuleLoader(manifest) {
|
|
34
|
+
const modulesById = /* @__PURE__ */ new Map();
|
|
35
|
+
if (manifest.app) modulesById.set(manifest.app.sourceFile, manifest.app.module);
|
|
36
|
+
for (const page of manifest.pages) {
|
|
37
|
+
modulesById.set(page.sourceFile, page.module);
|
|
38
|
+
for (const layout of page.layouts) modulesById.set(layout.sourceFile, layout.module);
|
|
39
|
+
}
|
|
40
|
+
return async (moduleId) => {
|
|
41
|
+
const module = modulesById.get(moduleId);
|
|
42
|
+
if (module === void 0) throw new PageModuleNotInManifestError(moduleId, modulesById.size);
|
|
43
|
+
return module;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { PageModuleNotInManifestError, createPageModuleLoader };
|
|
49
|
+
//# sourceMappingURL=create-page-module-loader.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-page-module-loader.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-module-loader.ts"],"sourcesContent":["/**\n * The production answer to \"how do I load a page module\".\n *\n * `createPageRouteHandler` takes loading a module as an INPUT\n * ({@link PageModuleLoader}) precisely so the two runtimes can answer it\n * differently. Dev hands it `moduleId => vite.ssrLoadModule(moduleId)`;\n * production has no Vite and nothing to evaluate — the generated `pages.ts`\n * barrel already imported every page, layout and the app root statically, and\n * {@link PageManifest} is the table those namespace objects arrived in. So the\n * production loader does not load anything: it is a LOOKUP over modules that\n * are already in memory, and this file is that lookup and nothing else.\n */\nimport type { PageModuleLoader } from \"./create-page-route-handler\";\nimport type { PageManifest } from \"./page-manifest\";\n\n/**\n * Asked for a module this build does not contain.\n *\n * Hard failure, deliberately. Every alternative — returning `undefined`, an\n * empty namespace, or falling back to a dynamic import — turns a build that\n * shipped the wrong module table into a page that renders blank, or into a\n * production process reaching for source files that are not deployed. The id\n * is in the message because the whole diagnosis is \"which id, and why is it\n * not in the table\"; the count is there because zero entries means the build\n * discovered no pages at all, which is a different fault from a mismatch.\n */\nexport class PageModuleNotInManifestError extends Error {\n public constructor(moduleId: string, knownIdCount: number) {\n super(\n `Cannot serve a page module: \"${moduleId}\" is not in this build's page manifest ` +\n `(${knownIdCount} module(s) available). Module ids are the manifest's \\`sourceFile\\` ` +\n \"values — app-root-relative POSIX paths, extension included — and must match exactly. \" +\n \"Re-run `warlock build` so the generated `pages.ts` barrel matches what is being served.\",\n );\n this.name = \"PageModuleNotInManifestError\";\n }\n}\n\n/**\n * Build the loader for ONE manifest.\n *\n * Ids are compared by exact string equality against the `sourceFile` each\n * entry carries — no lowercasing, no separator swapping, no extension\n * stripping. Both sides of that comparison come from the same generator\n * (`generatePagesBarrel` writes every `sourceFile` through one\n * app-root-relative POSIX derivation), so any spelling difference is a real\n * disagreement about which file is meant, and a normalizer would only hide it.\n *\n * A layout shared by several pages appears once per chain it belongs to; the\n * generator emits one identifier per layout file, so every occurrence of an id\n * carries the same namespace object and re-registering it is a no-op.\n */\nexport function createPageModuleLoader(manifest: PageManifest): PageModuleLoader {\n const modulesById = new Map<string, Record<string, unknown>>();\n\n if (manifest.app) {\n modulesById.set(manifest.app.sourceFile, manifest.app.module);\n }\n\n for (const page of manifest.pages) {\n modulesById.set(page.sourceFile, page.module);\n\n for (const layout of page.layouts) {\n modulesById.set(layout.sourceFile, layout.module);\n }\n }\n\n // `async` so an unknown id surfaces as a rejection rather than a synchronous\n // throw: the handler awaits every module through one `Promise.all`, and a\n // loader that fails both ways depending on the reason is a loader callers\n // have to guard twice.\n return async (moduleId: string) => {\n const module = modulesById.get(moduleId);\n\n if (module === undefined) {\n throw new PageModuleNotInManifestError(moduleId, modulesById.size);\n }\n\n return module;\n };\n}\n"],"mappings":";;;;;;;;;;;;AA0BA,IAAa,+BAAb,cAAkD,MAAM;CACtD,AAAO,YAAY,UAAkB,cAAsB;EACzD,MACE,gCAAgC,SAAS,0CACnC,aAAa,qPAGrB;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,uBAAuB,UAA0C;CAC/E,MAAM,8BAAc,IAAI,IAAqC;CAE7D,IAAI,SAAS,KACX,YAAY,IAAI,SAAS,IAAI,YAAY,SAAS,IAAI,MAAM;CAG9D,KAAK,MAAM,QAAQ,SAAS,OAAO;EACjC,YAAY,IAAI,KAAK,YAAY,KAAK,MAAM;EAE5C,KAAK,MAAM,UAAU,KAAK,SACxB,YAAY,IAAI,OAAO,YAAY,OAAO,MAAM;CAEpD;CAMA,OAAO,OAAO,aAAqB;EACjC,MAAM,SAAS,YAAY,IAAI,QAAQ;EAEvC,IAAI,WAAW,QACb,MAAM,IAAI,6BAA6B,UAAU,YAAY,IAAI;EAGnE,OAAO;CACT;AACF"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER, isDataRequest } from "../routing/data-request.mjs";
|
|
2
|
+
import { buildHydrationPayload } from "./build-hydration-payload.mjs";
|
|
3
|
+
import { renderPageRequest } from "./render-page.mjs";
|
|
4
|
+
|
|
5
|
+
//#region ../web/src/server/create-page-route-handler.ts
|
|
6
|
+
function escapeHtmlAttribute(value) {
|
|
7
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
8
|
+
switch (character) {
|
|
9
|
+
case "&": return "&";
|
|
10
|
+
case "<": return "<";
|
|
11
|
+
case ">": return ">";
|
|
12
|
+
case "\"": return """;
|
|
13
|
+
default: return "'";
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function installHydrationClientModule(html, moduleUrl, nonce) {
|
|
18
|
+
if (moduleUrl === void 0 || html === "") return html;
|
|
19
|
+
const closingBodyIndex = html.lastIndexOf("</body>");
|
|
20
|
+
if (closingBodyIndex === -1) throw new Error("installPageRoutes: cannot install the hydration client module because the rendered document has no closing </body> tag.");
|
|
21
|
+
const script = `<script type="module"${nonce === void 0 ? "" : ` nonce="${escapeHtmlAttribute(nonce)}"`} src="${escapeHtmlAttribute(moduleUrl)}"><\/script>`;
|
|
22
|
+
return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Put the page's stylesheets in `<head>`, so the first paint is styled.
|
|
26
|
+
*
|
|
27
|
+
* Without this the document carries no CSS at all. The stylesheet reaches the
|
|
28
|
+
* browser only because the CLIENT bundle imports it, which means it is applied
|
|
29
|
+
* by JavaScript after the module graph loads — the page renders unstyled first
|
|
30
|
+
* and restyles a moment later. Correct markup, wrong-looking page, and nothing
|
|
31
|
+
* in the console to explain it.
|
|
32
|
+
*
|
|
33
|
+
* A `<link>` in `<head>` is render-blocking, which is exactly what is wanted
|
|
34
|
+
* here: the browser holds the first paint until the CSS is in, so there is no
|
|
35
|
+
* flash rather than a faster ugly one.
|
|
36
|
+
*
|
|
37
|
+
* Inserted before `</head>` rather than after `<head>` so an application's own
|
|
38
|
+
* `<link>`/`<style>` in the root document still comes FIRST and can be
|
|
39
|
+
* overridden by these — matching how the framework's tags are documented to
|
|
40
|
+
* behave, and keeping cascade order predictable.
|
|
41
|
+
*/
|
|
42
|
+
function installStylesheets(html, stylesheetUrls) {
|
|
43
|
+
if (stylesheetUrls.length === 0 || html === "") return html;
|
|
44
|
+
const closingHeadIndex = html.lastIndexOf("</head>");
|
|
45
|
+
if (closingHeadIndex === -1) return html;
|
|
46
|
+
const links = stylesheetUrls.map((url) => `<link rel="stylesheet" href="${escapeHtmlAttribute(url)}">`).join("");
|
|
47
|
+
return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the handler for ONE page route. Per request it loads the App + layout
|
|
51
|
+
* + page triple (concurrently, in that order), renders the URL through
|
|
52
|
+
* `renderPageRequest`, splices in the hydration module, applies the committed
|
|
53
|
+
* cookies and headers, and flushes the document.
|
|
54
|
+
*
|
|
55
|
+
* No try/catch, deliberately: loader/render throws are already absorbed by the
|
|
56
|
+
* pipeline's boundary machinery inside `renderPageRequest`, and anything that
|
|
57
|
+
* escapes (a module-load failure, the missing-`</body>` throw above) belongs to
|
|
58
|
+
* the router's error path — which is exactly where it went before.
|
|
59
|
+
*/
|
|
60
|
+
function createPageRouteHandler(options) {
|
|
61
|
+
const { path, name, appFile, pageFile, layoutFile, loadModule, hydrationClientModuleUrl, stylesheetUrls, applyBufferedCookie } = options;
|
|
62
|
+
return async ({ request, response }) => {
|
|
63
|
+
const [appModule, layoutModule, ownPageModule] = await Promise.all([
|
|
64
|
+
loadModule(appFile),
|
|
65
|
+
layoutFile ? loadModule(layoutFile) : Promise.resolve({}),
|
|
66
|
+
loadModule(pageFile)
|
|
67
|
+
]);
|
|
68
|
+
const routes = [{
|
|
69
|
+
path,
|
|
70
|
+
name,
|
|
71
|
+
triple: {
|
|
72
|
+
app: appModule,
|
|
73
|
+
layout: layoutModule,
|
|
74
|
+
page: ownPageModule
|
|
75
|
+
}
|
|
76
|
+
}];
|
|
77
|
+
const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, void 0));
|
|
78
|
+
const rendered = await renderPageRequest(request.path, {
|
|
79
|
+
routes,
|
|
80
|
+
createHttp: () => ({
|
|
81
|
+
request,
|
|
82
|
+
response
|
|
83
|
+
})
|
|
84
|
+
});
|
|
85
|
+
if (wantsData) {
|
|
86
|
+
for (const cookie of rendered.cookies) applyBufferedCookie(response, cookie);
|
|
87
|
+
response.headers(rendered.headers);
|
|
88
|
+
response.header("Vary", WARLOCK_DATA_REQUEST_HEADER);
|
|
89
|
+
if (rendered.bundle === void 0) {
|
|
90
|
+
response.setContentType(DATA_RESPONSE_CONTENT_TYPE);
|
|
91
|
+
await response.send(JSON.stringify({ error: "not_found" }), rendered.status);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
response.setContentType(DATA_RESPONSE_CONTENT_TYPE);
|
|
95
|
+
await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), rendered.status);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const html = installHydrationClientModule(installStylesheets(rendered.html, stylesheetUrls ?? []), hydrationClientModuleUrl, hydrationClientModuleUrl === void 0 ? void 0 : request.nonce);
|
|
99
|
+
for (const cookie of rendered.cookies) applyBufferedCookie(response, cookie);
|
|
100
|
+
response.headers(rendered.headers);
|
|
101
|
+
await response.html(html, rendered.status);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
export { createPageRouteHandler };
|
|
107
|
+
//# sourceMappingURL=create-page-route-handler.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-page-route-handler.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-route-handler.ts"],"sourcesContent":["/**\r\n * The page handler, as a named seam.\r\n *\r\n * This is the request handler `installPageRoutes` used to inline into its\r\n * `router.get(...)` call (`install-page-routes.ts:236-275` before this\r\n * extraction; the pre-extraction copy is `scratchpad/install-page-routes.ts.orig`).\r\n * The behaviour is unchanged, byte for byte — what changes is that it is now\r\n * a named, exported, independently constructible function instead of a closure\r\n * over eight ambient bindings of `installPageRoutes`.\r\n *\r\n * WHY IT TAKES `loadModule` AND NOT A `ViteDevServer`: loading a module is the\r\n * only capability the handler ever needed, and the two runtimes answer it\r\n * differently — dev goes through Vite's SSR graph\r\n * (`vite.ssrLoadModule`, `install-page-routes.ts:207`), production reads the\r\n * already-built page manifest (`page-manifest.ts`). Taking \"how to load a\r\n * module\" as an INPUT is what lets the same handler serve both, and what lets\r\n * a test construct it with a plain async function — no Vite, no dev server, no\r\n * `app/` directory on disk.\r\n *\r\n * Scope: this file creates a seam and nothing else. It does not implement\r\n * `type: \"page\"` routing, HTML error pages, or any other new capability.\r\n */\r\nimport type { HttpContext, Response } from \"@warlock.js/core\";\r\n\r\nimport {\r\n DATA_RESPONSE_CONTENT_TYPE,\r\n isDataRequest,\r\n WARLOCK_DATA_REQUEST_HEADER,\r\n} from \"../routing/data-request\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport type { PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\r\nimport { renderPageRequest } from \"./render-page\";\r\n\r\n/**\r\n * How the handler obtains a page/layout/app module, by the same id\r\n * (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev\r\n * this is `moduleId => vite.ssrLoadModule(moduleId)`; the connector already\r\n * owns the dev/prod split, so the handler never learns which one it got.\r\n */\r\nexport type PageModuleLoader = (moduleId: string) => Promise<unknown>;\r\n\r\nexport type PageRouteHandlerOptions = {\r\n /** The composed, registered route path — `composeRoutePath`'s output. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The single global app-root file, e.g. `<appSrcRoot>/web/root.tsx`. */\r\n appFile: string;\r\n /** The page module's id. */\r\n pageFile: string;\r\n /** The page's own-directory `layout.tsx`, when it has one. */\r\n layoutFile?: string | undefined;\r\n loadModule: PageModuleLoader;\r\n /** Browser module appended after the server-rendered document. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs for this page, emitted into `<head>` so the FIRST paint is\r\n * styled. Absent or empty means the application has no CSS — it never means\r\n * a stylesheet failed to resolve, which is the build's job to report.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n};\r\n\r\nexport type PageRouteHandler = (context: HttpContext) => Promise<void>;\r\n\r\nfunction escapeHtmlAttribute(value: string): string {\r\n return value.replace(/[&<>\"']/g, (character) => {\r\n switch (character) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\nfunction installHydrationClientModule(\r\n html: string,\r\n moduleUrl: string | undefined,\r\n nonce: string | undefined,\r\n): string {\r\n if (moduleUrl === undefined || html === \"\") return html;\r\n\r\n const closingBodyIndex = html.lastIndexOf(\"</body>\");\r\n if (closingBodyIndex === -1) {\r\n throw new Error(\r\n \"installPageRoutes: cannot install the hydration client module because the rendered document has no closing </body> tag.\",\r\n );\r\n }\r\n\r\n const nonceAttribute = nonce === undefined ? \"\" : ` nonce=\"${escapeHtmlAttribute(nonce)}\"`;\r\n const script = `<script type=\"module\"${nonceAttribute} src=\"${escapeHtmlAttribute(moduleUrl)}\"></script>`;\r\n return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;\r\n}\r\n\r\n/**\r\n * Put the page's stylesheets in `<head>`, so the first paint is styled.\r\n *\r\n * Without this the document carries no CSS at all. The stylesheet reaches the\r\n * browser only because the CLIENT bundle imports it, which means it is applied\r\n * by JavaScript after the module graph loads — the page renders unstyled first\r\n * and restyles a moment later. Correct markup, wrong-looking page, and nothing\r\n * in the console to explain it.\r\n *\r\n * A `<link>` in `<head>` is render-blocking, which is exactly what is wanted\r\n * here: the browser holds the first paint until the CSS is in, so there is no\r\n * flash rather than a faster ugly one.\r\n *\r\n * Inserted before `</head>` rather than after `<head>` so an application's own\r\n * `<link>`/`<style>` in the root document still comes FIRST and can be\r\n * overridden by these — matching how the framework's tags are documented to\r\n * behave, and keeping cascade order predictable.\r\n */\r\nfunction installStylesheets(html: string, stylesheetUrls: readonly string[]): string {\r\n if (stylesheetUrls.length === 0 || html === \"\") return html;\r\n\r\n const closingHeadIndex = html.lastIndexOf(\"</head>\");\r\n\r\n // No `<head>` is not an error the way a missing `</body>` is: a root that\r\n // renders no head is unusual but legal, and losing the stylesheet is a\r\n // cosmetic failure where losing hydration is a broken page. Silently\r\n // dropping it would be the wrong trade the other way, though — so the\r\n // document is left exactly as rendered and the caller's own missing-`</body>`\r\n // check remains the loud one.\r\n if (closingHeadIndex === -1) return html;\r\n\r\n const links = stylesheetUrls\r\n .map((url) => `<link rel=\"stylesheet\" href=\"${escapeHtmlAttribute(url)}\">`)\r\n .join(\"\");\r\n\r\n return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;\r\n}\r\n\r\n/**\r\n * Build the handler for ONE page route. Per request it loads the App + layout\r\n * + page triple (concurrently, in that order), renders the URL through\r\n * `renderPageRequest`, splices in the hydration module, applies the committed\r\n * cookies and headers, and flushes the document.\r\n *\r\n * No try/catch, deliberately: loader/render throws are already absorbed by the\r\n * pipeline's boundary machinery inside `renderPageRequest`, and anything that\r\n * escapes (a module-load failure, the missing-`</body>` throw above) belongs to\r\n * the router's error path — which is exactly where it went before.\r\n */\r\nexport function createPageRouteHandler(options: PageRouteHandlerOptions): PageRouteHandler {\r\n const {\r\n path,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n loadModule,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n } = options;\r\n\r\n return async ({ request, response }: HttpContext) => {\r\n const [appModule, layoutModule, ownPageModule] = await Promise.all([\r\n loadModule(appFile),\r\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\r\n loadModule(pageFile),\r\n ]);\r\n\r\n const triple: PageRouteEntry[\"triple\"] = {\r\n app: appModule as PageTripleModule,\r\n layout: layoutModule as PageTripleModule,\r\n page: ownPageModule as PageTripleModule,\r\n };\r\n\r\n const routes: PageRouteEntry[] = [{ path, name, triple }];\r\n\r\n // A DATA request runs everything above and below this line identically —\r\n // it is the same route, the same match and the same pipeline — and differs\r\n // only in what gets written at the end. Decided here, before the render, so\r\n // the branch is visibly about REPRESENTATION and not about behaviour.\r\n const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\r\n\r\n const rendered = await renderPageRequest(request.path, {\r\n routes,\r\n createHttp: () => ({ request, response }),\r\n });\r\n\r\n if (wantsData) {\r\n // Cookies and headers FIRST, exactly as the document path does below and\r\n // for the same reason: a client navigation must be able to log a user in,\r\n // set a flash cookie or be redirected, and dropping those on this path\r\n // would make a navigation behave differently from a page load of the same\r\n // URL — the one difference this branch is not allowed to introduce.\r\n for (const cookie of rendered.cookies) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n\r\n response.headers(rendered.headers);\r\n\r\n // So a shared cache can never serve a document to a client that asked for\r\n // JSON, or the reverse. See `data-request.ts` on why this stays even\r\n // while page responses are `no-store`.\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n\r\n // `bundle` is absent on exactly one path: nothing matched, so no pipeline\r\n // ran and there is no payload to build. Fastify already matched this\r\n // route to get here, so reaching it means `request.path` did not satisfy\r\n // the entry's own pattern — answered as the 404 it is, rather than\r\n // synthesising an empty payload the client would try to render as a page.\r\n if (rendered.bundle === undefined) {\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify({ error: \"not_found\" }), rendered.status);\r\n\r\n return;\r\n }\r\n\r\n // SERIALIZED HERE, and handed over as a STRING on purpose.\r\n //\r\n // `response.send(object)` runs the body through core's `Response.parse`,\r\n // which recurses the object, calls `toJSON()` on anything that has one\r\n // (assigning `request` onto it as it goes) and rebuilds arrays. That is\r\n // the right behaviour for a controller returning Resources; it is the\r\n // wrong behaviour here, because the DOCUMENT path serializes this exact\r\n // object with a plain `JSON.stringify` into `#__WARLOCK_DATA__`. Routing\r\n // one path through a transformer and not the other is precisely the\r\n // drift `build-hydration-payload.ts` exists to prevent — the browser\r\n // would build one tree on a page load and a different one on a\r\n // navigation to the same URL.\r\n //\r\n // A string body also bypasses `parseBody()` entirely, so the content type\r\n // has to be declared rather than inferred from an object body.\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), rendered.status);\r\n\r\n return;\r\n }\r\n\r\n // Stylesheets first: they go in `<head>`, the hydration module goes before\r\n // `</body>`, and doing the head work on the already-rendered string keeps\r\n // both splices in one place rather than threading CSS through the React\r\n // render just to reach the same bytes.\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n const html = installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n\r\n // THE single site that puts a committed cookie on the wire. The commit\r\n // stage used to mirror the same list onto the live response as well\r\n // (`commitBuffers`, execute-page-request.ts), and because fastify's\r\n // `setCookie` APPENDS rather than sets, every page response carried two\r\n // identical `Set-Cookie` headers — happy path included. The mirror's\r\n // cookie half is gone; this loop is what remains, and it is the right\r\n // one: it runs at stage 10a, after the render, alongside the headers and\r\n // the final status, so it applies the answer the pipeline actually\r\n // settled on rather than the one it had at stage 7.\r\n for (const cookie of rendered.cookies) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n\r\n response.headers(rendered.headers);\r\n\r\n await response.html(html, rendered.status);\r\n };\r\n}\r\n"],"mappings":";;;;;AAoEA,SAAS,oBAAoB,OAAuB;CAClD,OAAO,MAAM,QAAQ,aAAa,cAAc;EAC9C,QAAQ,WAAR;GACE,KAAK,KACH,OAAO;GACT,KAAK,KACH,OAAO;GACT,KAAK,KACH,OAAO;GACT,KAAK,MACH,OAAO;GACT,SACE,OAAO;EACX;CACF,CAAC;AACH;AAEA,SAAS,6BACP,MACA,WACA,OACQ;CACR,IAAI,cAAc,UAAa,SAAS,IAAI,OAAO;CAEnD,MAAM,mBAAmB,KAAK,YAAY,SAAS;CACnD,IAAI,qBAAqB,IACvB,MAAM,IAAI,MACR,yHACF;CAIF,MAAM,SAAS,wBADQ,UAAU,SAAY,KAAK,WAAW,oBAAoB,KAAK,EAAE,GAClC,QAAQ,oBAAoB,SAAS,EAAE;CAC7F,OAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,IAAI,SAAS,KAAK,MAAM,gBAAgB;AAClF;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,mBAAmB,MAAc,gBAA2C;CACnF,IAAI,eAAe,WAAW,KAAK,SAAS,IAAI,OAAO;CAEvD,MAAM,mBAAmB,KAAK,YAAY,SAAS;CAQnD,IAAI,qBAAqB,IAAI,OAAO;CAEpC,MAAM,QAAQ,eACX,KAAK,QAAQ,gCAAgC,oBAAoB,GAAG,EAAE,GAAG,CAAC,CAC1E,KAAK,EAAE;CAEV,OAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,IAAI,QAAQ,KAAK,MAAM,gBAAgB;AACjF;;;;;;;;;;;;AAaA,SAAgB,uBAAuB,SAAoD;CACzF,MAAM,EACJ,MACA,MACA,SACA,UACA,YACA,YACA,0BACA,gBACA,wBACE;CAEJ,OAAO,OAAO,EAAE,SAAS,eAA4B;EACnD,MAAM,CAAC,WAAW,cAAc,iBAAiB,MAAM,QAAQ,IAAI;GACjE,WAAW,OAAO;GAClB,aAAa,WAAW,UAAU,IAAI,QAAQ,QAAQ,CAAC,CAAC;GACxD,WAAW,QAAQ;EACrB,CAAC;EAQD,MAAM,SAA2B,CAAC;GAAE;GAAM;GAAM;IAL9C,KAAK;IACL,QAAQ;IACR,MAAM;GAG6C;EAAE,CAAC;EAMxD,MAAM,YAAY,cAAc,QAAQ,OAAO,6BAA6B,MAAS,CAAC;EAEtF,MAAM,WAAW,MAAM,kBAAkB,QAAQ,MAAM;GACrD;GACA,mBAAmB;IAAE;IAAS;GAAS;EACzC,CAAC;EAED,IAAI,WAAW;GAMb,KAAK,MAAM,UAAU,SAAS,SAC5B,oBAAoB,UAAU,MAAM;GAGtC,SAAS,QAAQ,SAAS,OAAO;GAKjC,SAAS,OAAO,QAAQ,2BAA2B;GAOnD,IAAI,SAAS,WAAW,QAAW;IACjC,SAAS,eAAe,0BAA0B;IAClD,MAAM,SAAS,KAAK,KAAK,UAAU,EAAE,OAAO,YAAY,CAAC,GAAG,SAAS,MAAM;IAE3E;GACF;GAiBA,SAAS,eAAe,0BAA0B;GAClD,MAAM,SAAS,KAAK,KAAK,UAAU,sBAAsB,SAAS,MAAM,CAAC,GAAG,SAAS,MAAM;GAE3F;EACF;EAQA,MAAM,OAAO,6BAFE,mBAAmB,SAAS,MAAM,kBAAkB,CAAC,CAG7D,GACL,0BACA,6BAA6B,SAAY,SAAY,QAAQ,KAC/D;EAWA,KAAK,MAAM,UAAU,SAAS,SAC5B,oBAAoB,UAAU,MAAM;EAGtC,SAAS,QAAQ,SAAS,OAAO;EAEjC,MAAM,SAAS,KAAK,MAAM,SAAS,MAAM;CAC3C;AACF"}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
//#region ../web/src/server/dev-server.ts
|
|
4
|
+
/**
|
|
5
|
+
* Replay a buffered cookie onto the response that will actually be sent.
|
|
6
|
+
*
|
|
7
|
+
* `BufferedCookie.options` is a loosely-typed bag (`Record<string, unknown>`)
|
|
8
|
+
* because the buffer is written by loader code before any response exists; the
|
|
9
|
+
* cast hands it to `Response.cookie()`, which is where the option shape is
|
|
10
|
+
* defined and enforced for every other caller in the framework.
|
|
11
|
+
*/
|
|
12
|
+
function applyBufferedCookie(response, cookie) {
|
|
13
|
+
response.cookie(cookie.name, cookie.value, cookie.options ?? {});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Where a captured transform/resolve failure rides from Vite's connect stack to
|
|
17
|
+
* the Fastify hook that mounted it.
|
|
18
|
+
*
|
|
19
|
+
* A `Symbol.for` key on the raw `IncomingMessage` rather than a `WeakMap`
|
|
20
|
+
* because the two halves live in different modules and are wired at different
|
|
21
|
+
* times; the request object is the only thing they provably share, and the
|
|
22
|
+
* symbol cannot collide with a Vite/Fastify/user property.
|
|
23
|
+
*
|
|
24
|
+
* Exported so a test can stage a captured failure without booting a vite
|
|
25
|
+
* server, and so the two halves cannot drift onto two different keys.
|
|
26
|
+
*/
|
|
27
|
+
const DEV_TRANSFORM_ERROR_BODY = Symbol.for("warlock.web.devTransformErrorBody");
|
|
28
|
+
/**
|
|
29
|
+
* The status a refused module now answers with.
|
|
30
|
+
*
|
|
31
|
+
* NOT the 404 this replaces. That 404 was never a decision about the module —
|
|
32
|
+
* it is what an unmatched URL gets once vite has declined it, and which of the
|
|
33
|
+
* two framework answers you see depends only on whether the app declares a
|
|
34
|
+
* catch-all page: with one (v5/app does — `path: "*"`) the request lands in the
|
|
35
|
+
* page pipeline, matches no route, and `./render-page.ts:604` returns
|
|
36
|
+
* `{ html: "", status: 404 }` for `./create-page-route-handler.ts:147` to write
|
|
37
|
+
* as an empty `text/html` body; without one it is `core/src/router/router.ts:879`.
|
|
38
|
+
* Either way "the module does not exist" is precisely the wrong thing to tell a
|
|
39
|
+
* developer whose module exists and was refused. 500 is the status VITE ITSELF writes for
|
|
40
|
+
* this exact condition when it is not in middleware mode
|
|
41
|
+
* (`node_modules/vite/dist/node/chunks/config.js:9528`), so this adopts that
|
|
42
|
+
* convention rather than inventing a third one.
|
|
43
|
+
*/
|
|
44
|
+
const DEV_TRANSFORM_ERROR_STATUS = 500;
|
|
45
|
+
/**
|
|
46
|
+
* The dev error transport was constructed while the process is hosting a
|
|
47
|
+
* PRODUCTION build. Refused by name at construction rather than degraded,
|
|
48
|
+
* because everything this transport does — file paths, source frames, plugin
|
|
49
|
+
* names — is exactly what a production response must never carry.
|
|
50
|
+
*/
|
|
51
|
+
var DevErrorTransportInProductionError = class extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super("The dev error transport was constructed with `Application.runtimeStrategy === \"production\"`. It exists only to put a Vite transform failure in front of a developer and its response body carries absolute file paths and source frames, so it must never be mounted on a production-hosted server.");
|
|
54
|
+
this.name = "DevErrorTransportInProductionError";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Render a refused module's failure as the plain-text body the browser gets.
|
|
59
|
+
*
|
|
60
|
+
* Formatting is DELEGATED to vite's own exported `buildErrorMessage`, not
|
|
61
|
+
* reimplemented: it is the same function vite uses to print the failure to the
|
|
62
|
+
* terminal, so the text a developer reads in the network panel and the text
|
|
63
|
+
* they read in the terminal cannot drift. Two things are added around it —
|
|
64
|
+
* `error.name`, which vite's terminal path replaces with a fixed
|
|
65
|
+
* "Internal server error:" prefix and which is the single most useful token for
|
|
66
|
+
* a named gate refusal (`ProjectionAmbiguityError`), and the `cause` chain,
|
|
67
|
+
* which vite does not walk.
|
|
68
|
+
*
|
|
69
|
+
* `stripVTControlCharacters` is not optional: `buildErrorMessage` colours its
|
|
70
|
+
* output with picocolors, which is ON whenever the dev server owns a TTY, and
|
|
71
|
+
* raw ANSI escapes in an HTTP body are noise. Vite strips them the same way for
|
|
72
|
+
* the overlay payload (`config.js:9490-9497`).
|
|
73
|
+
*
|
|
74
|
+
* The stack is deliberately omitted (`includeStack: false`). A gate refusal's
|
|
75
|
+
* stack points into the gate, not into the developer's code; the fields that
|
|
76
|
+
* locate the problem — plugin, file, line, source frame — are what
|
|
77
|
+
* `buildErrorMessage` puts there without it.
|
|
78
|
+
*/
|
|
79
|
+
function formatDevTransformError(error, buildErrorMessage) {
|
|
80
|
+
const failure = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
|
|
81
|
+
const lines = [stripVTControlCharacters(buildErrorMessage(failure, [`${failure.name}: ${failure.message}`], false))];
|
|
82
|
+
let cause = failure.cause;
|
|
83
|
+
while (cause instanceof Error) {
|
|
84
|
+
lines.push(` Caused by: ${cause.name}: ${stripVTControlCharacters(cause.message)}`);
|
|
85
|
+
cause = cause.cause;
|
|
86
|
+
}
|
|
87
|
+
return `${lines.join("\n")}\n`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* DEV-ONLY. Capture the transform/resolve failure that vite is about to throw
|
|
91
|
+
* away, so the request that caused it can answer with it.
|
|
92
|
+
*
|
|
93
|
+
* WHY THIS IS A PLUGIN AND NOT A `middlewares.use(...)` CALL — this is the
|
|
94
|
+
* whole defect, and it is an ordering fact, not a style choice:
|
|
95
|
+
*
|
|
96
|
+
* - Vite mounts its own error handler LAST, built as
|
|
97
|
+
* `errorMiddleware(server, !!middlewareMode)`
|
|
98
|
+
* (`node_modules/vite/dist/node/chunks/config.js:25705`).
|
|
99
|
+
* - In middleware mode that `allowNext` flag is `true`, and the handler then
|
|
100
|
+
* logs the error to the TERMINAL and calls `next()` — with no error
|
|
101
|
+
* (`config.js:9525-9527`).
|
|
102
|
+
* - connect only routes an error to a 4-arity handler while an error is in
|
|
103
|
+
* flight (`config.js:10611-10626`), so `next()` clears it: every layer after
|
|
104
|
+
* that point, INCLUDING the `done` callback `./web-connector.ts` hands the
|
|
105
|
+
* stack, is called as if the request had simply gone unhandled. The
|
|
106
|
+
* framework then answers the only way it can for a URL it does not know — a
|
|
107
|
+
* 404, empty (see {@link DEV_TRANSFORM_ERROR_STATUS} for which of the two
|
|
108
|
+
* produces it).
|
|
109
|
+
* - Anything registered with `vite.middlewares.use(...)` after `createServer()`
|
|
110
|
+
* resolves lands AFTER that handler and is therefore unreachable. A
|
|
111
|
+
* `configureServer` POST hook does not: vite runs post hooks at
|
|
112
|
+
* `config.js:25700`, five lines BEFORE it mounts its error handler.
|
|
113
|
+
*
|
|
114
|
+
* So this sits between the failure and vite's logger. It captures, then calls
|
|
115
|
+
* `next(error)` and lets vite's own handler run exactly as before — the
|
|
116
|
+
* terminal message and the `hot.send({ type: "error" })` overlay push
|
|
117
|
+
* (`config.js:9511-9521`) are unchanged. This transport ADDS a reader; it
|
|
118
|
+
* replaces nothing.
|
|
119
|
+
*
|
|
120
|
+
* @param isProductionRuntime the connector's own hosting-mode signal
|
|
121
|
+
* (`./web-connector.ts:122`) — passed in rather than re-derived so there
|
|
122
|
+
* is one definition of "this process is Vite-hosted", not two.
|
|
123
|
+
*/
|
|
124
|
+
function devErrorTransportPlugin(options) {
|
|
125
|
+
const { isProductionRuntime, buildErrorMessage } = options;
|
|
126
|
+
if (isProductionRuntime()) throw new DevErrorTransportInProductionError();
|
|
127
|
+
const capture = (error, request, _response, next) => {
|
|
128
|
+
if (isProductionRuntime()) return next(error);
|
|
129
|
+
request[DEV_TRANSFORM_ERROR_BODY] = formatDevTransformError(error, buildErrorMessage);
|
|
130
|
+
next(error);
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
name: "warlock:dev-error-transport",
|
|
134
|
+
apply: "serve",
|
|
135
|
+
configureServer(server) {
|
|
136
|
+
return () => {
|
|
137
|
+
server.middlewares.use(capture);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Answer the request with the failure {@link devErrorTransportPlugin} captured,
|
|
144
|
+
* if there was one. Returns `false` when there was not, which is the normal
|
|
145
|
+
* case and means "carry on down the framework's own path".
|
|
146
|
+
*
|
|
147
|
+
* Called from the Fastify `onRequest` hook that mounts vite
|
|
148
|
+
* (`./web-connector.ts:321`), in the `done` callback — i.e. at the one moment
|
|
149
|
+
* where connect has finished, vite has declined to answer, and the framework is
|
|
150
|
+
* about to 404. Writing to the raw `ServerResponse` rather than through Fastify
|
|
151
|
+
* is what the mount already does for every response vite serves, so this stays
|
|
152
|
+
* on the same side of the seam.
|
|
153
|
+
*/
|
|
154
|
+
function sendCapturedDevError(request, response) {
|
|
155
|
+
const body = request[DEV_TRANSFORM_ERROR_BODY];
|
|
156
|
+
if (typeof body !== "string") return false;
|
|
157
|
+
if (response.headersSent || response.writableEnded) return false;
|
|
158
|
+
response.statusCode = 500;
|
|
159
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
160
|
+
response.setHeader("cache-control", "no-store");
|
|
161
|
+
response.end(body);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
//#endregion
|
|
166
|
+
export { applyBufferedCookie, devErrorTransportPlugin, sendCapturedDevError };
|
|
167
|
+
//# sourceMappingURL=dev-server.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-server.mjs","names":[],"sources":["../../../../../../../web/src/server/dev-server.ts"],"sourcesContent":["/**\n * Two small pieces of the dev/SSR page plumbing that `./web-connector.ts` wires\n * but deliberately does not own: the buffered-cookie commit, and the DEV-ONLY\n * error transport that stops a refused module from reaching the browser as a\n * bare 404 (see {@link devErrorTransportPlugin}).\n *\n * ---\n *\n * Commits one buffered cookie (`BufferedCookie`, from `./buffered-response.ts`)\n * onto the live Warlock `Response` — the single place in the SSR page pipeline\n * where a cookie the loader buffered turns into a real `Set-Cookie` header.\n *\n * It delegates to core's `Response.cookie()` rather than talking to fastify.\n * That method already owns everything this file used to reimplement: it takes\n * core's own `CookieOptions`, JSON-stringifies the value unless `{ raw: true }`,\n * strips the core-only `raw` flag, and layers the framework's secure-cookie\n * defaults and the `http.cookies.options` config under the per-call options.\n * Going through it means the SSR page path and every ordinary controller emit\n * cookies by the exact same code, so the two cannot drift apart.\n *\n * The helper is passed INTO `installPageRoutes`/`createPageRouteHandler` as an\n * option rather than imported by them, which is what keeps this module out of a\n * cycle with the page-route installer.\n */\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { stripVTControlCharacters } from \"node:util\";\nimport type { Connect, Plugin } from \"vite\";\nimport type { Response } from \"@warlock.js/core\";\nimport type { BufferedCookie } from \"./buffered-response\";\n\n/**\n * Replay a buffered cookie onto the response that will actually be sent.\n *\n * `BufferedCookie.options` is a loosely-typed bag (`Record<string, unknown>`)\n * because the buffer is written by loader code before any response exists; the\n * cast hands it to `Response.cookie()`, which is where the option shape is\n * defined and enforced for every other caller in the framework.\n */\nexport function applyBufferedCookie(response: Response, cookie: BufferedCookie): void {\n response.cookie(cookie.name, cookie.value as never, (cookie.options ?? {}) as never);\n}\n\n/**\n * Where a captured transform/resolve failure rides from Vite's connect stack to\n * the Fastify hook that mounted it.\n *\n * A `Symbol.for` key on the raw `IncomingMessage` rather than a `WeakMap`\n * because the two halves live in different modules and are wired at different\n * times; the request object is the only thing they provably share, and the\n * symbol cannot collide with a Vite/Fastify/user property.\n *\n * Exported so a test can stage a captured failure without booting a vite\n * server, and so the two halves cannot drift onto two different keys.\n */\nexport const DEV_TRANSFORM_ERROR_BODY = Symbol.for(\"warlock.web.devTransformErrorBody\");\n\ntype DevTransformErrorCarrier = { [DEV_TRANSFORM_ERROR_BODY]?: string };\n\n/**\n * The status a refused module now answers with.\n *\n * NOT the 404 this replaces. That 404 was never a decision about the module —\n * it is what an unmatched URL gets once vite has declined it, and which of the\n * two framework answers you see depends only on whether the app declares a\n * catch-all page: with one (v5/app does — `path: \"*\"`) the request lands in the\n * page pipeline, matches no route, and `./render-page.ts:604` returns\n * `{ html: \"\", status: 404 }` for `./create-page-route-handler.ts:147` to write\n * as an empty `text/html` body; without one it is `core/src/router/router.ts:879`.\n * Either way \"the module does not exist\" is precisely the wrong thing to tell a\n * developer whose module exists and was refused. 500 is the status VITE ITSELF writes for\n * this exact condition when it is not in middleware mode\n * (`node_modules/vite/dist/node/chunks/config.js:9528`), so this adopts that\n * convention rather than inventing a third one.\n */\nexport const DEV_TRANSFORM_ERROR_STATUS = 500;\n\n/**\n * `buildErrorMessage` as vite exports it. Declared structurally so this module\n * needs no value import of vite — vite is an optional, dev-only peer and a\n * production install does not carry it.\n */\nexport type BuildErrorMessage = (\n error: Error,\n args?: string[],\n includeStack?: boolean,\n) => string;\n\n/**\n * The dev error transport was constructed while the process is hosting a\n * PRODUCTION build. Refused by name at construction rather than degraded,\n * because everything this transport does — file paths, source frames, plugin\n * names — is exactly what a production response must never carry.\n */\nexport class DevErrorTransportInProductionError extends Error {\n public constructor() {\n super(\n \"The dev error transport was constructed with `Application.runtimeStrategy === \" +\n '\"production\"`. It exists only to put a Vite transform failure in front of a ' +\n \"developer and its response body carries absolute file paths and source frames, \" +\n \"so it must never be mounted on a production-hosted server.\",\n );\n this.name = \"DevErrorTransportInProductionError\";\n }\n}\n\n/**\n * Render a refused module's failure as the plain-text body the browser gets.\n *\n * Formatting is DELEGATED to vite's own exported `buildErrorMessage`, not\n * reimplemented: it is the same function vite uses to print the failure to the\n * terminal, so the text a developer reads in the network panel and the text\n * they read in the terminal cannot drift. Two things are added around it —\n * `error.name`, which vite's terminal path replaces with a fixed\n * \"Internal server error:\" prefix and which is the single most useful token for\n * a named gate refusal (`ProjectionAmbiguityError`), and the `cause` chain,\n * which vite does not walk.\n *\n * `stripVTControlCharacters` is not optional: `buildErrorMessage` colours its\n * output with picocolors, which is ON whenever the dev server owns a TTY, and\n * raw ANSI escapes in an HTTP body are noise. Vite strips them the same way for\n * the overlay payload (`config.js:9490-9497`).\n *\n * The stack is deliberately omitted (`includeStack: false`). A gate refusal's\n * stack points into the gate, not into the developer's code; the fields that\n * locate the problem — plugin, file, line, source frame — are what\n * `buildErrorMessage` puts there without it.\n */\nexport function formatDevTransformError(\n error: unknown,\n buildErrorMessage: BuildErrorMessage,\n): string {\n const failure =\n error instanceof Error ? error : new Error(typeof error === \"string\" ? error : String(error));\n\n const lines = [\n stripVTControlCharacters(\n buildErrorMessage(failure, [`${failure.name}: ${failure.message}`], false),\n ),\n ];\n\n // Walk the `cause` chain. A gate may wrap a parser failure, and the wrapped\n // message is usually the one naming the actual syntax that was refused.\n let cause = (failure as { cause?: unknown }).cause;\n\n while (cause instanceof Error) {\n lines.push(` Caused by: ${cause.name}: ${stripVTControlCharacters(cause.message)}`);\n cause = (cause as { cause?: unknown }).cause;\n }\n\n return `${lines.join(\"\\n\")}\\n`;\n}\n\n/**\n * DEV-ONLY. Capture the transform/resolve failure that vite is about to throw\n * away, so the request that caused it can answer with it.\n *\n * WHY THIS IS A PLUGIN AND NOT A `middlewares.use(...)` CALL — this is the\n * whole defect, and it is an ordering fact, not a style choice:\n *\n * - Vite mounts its own error handler LAST, built as\n * `errorMiddleware(server, !!middlewareMode)`\n * (`node_modules/vite/dist/node/chunks/config.js:25705`).\n * - In middleware mode that `allowNext` flag is `true`, and the handler then\n * logs the error to the TERMINAL and calls `next()` — with no error\n * (`config.js:9525-9527`).\n * - connect only routes an error to a 4-arity handler while an error is in\n * flight (`config.js:10611-10626`), so `next()` clears it: every layer after\n * that point, INCLUDING the `done` callback `./web-connector.ts` hands the\n * stack, is called as if the request had simply gone unhandled. The\n * framework then answers the only way it can for a URL it does not know — a\n * 404, empty (see {@link DEV_TRANSFORM_ERROR_STATUS} for which of the two\n * produces it).\n * - Anything registered with `vite.middlewares.use(...)` after `createServer()`\n * resolves lands AFTER that handler and is therefore unreachable. A\n * `configureServer` POST hook does not: vite runs post hooks at\n * `config.js:25700`, five lines BEFORE it mounts its error handler.\n *\n * So this sits between the failure and vite's logger. It captures, then calls\n * `next(error)` and lets vite's own handler run exactly as before — the\n * terminal message and the `hot.send({ type: \"error\" })` overlay push\n * (`config.js:9511-9521`) are unchanged. This transport ADDS a reader; it\n * replaces nothing.\n *\n * @param isProductionRuntime the connector's own hosting-mode signal\n * (`./web-connector.ts:122`) — passed in rather than re-derived so there\n * is one definition of \"this process is Vite-hosted\", not two.\n */\nexport function devErrorTransportPlugin(options: {\n isProductionRuntime: () => boolean;\n buildErrorMessage: BuildErrorMessage;\n}): Plugin {\n const { isProductionRuntime, buildErrorMessage } = options;\n\n if (isProductionRuntime()) {\n throw new DevErrorTransportInProductionError();\n }\n\n const capture: Connect.ErrorHandleFunction = (error, request, _response, next) => {\n // Re-asserted per request, not just at construction: `runtimeStrategy` is\n // process state and a transport that leaks source frames is not something\n // to hold open on a boot-time reading alone. In production this layer is a\n // pass-through and vite's handler behaves exactly as it does today.\n if (isProductionRuntime()) return next(error);\n\n (request as DevTransformErrorCarrier)[DEV_TRANSFORM_ERROR_BODY] = formatDevTransformError(\n error,\n buildErrorMessage,\n );\n\n next(error);\n };\n\n return {\n name: \"warlock:dev-error-transport\",\n // Belt to the `isProductionRuntime` braces: this plugin has no business in\n // a `vite build` graph either.\n apply: \"serve\",\n configureServer(server) {\n // RETURNING a function is what makes this a POST hook — the ordering the\n // note above depends on. Mounting inline here would land the layer BEFORE\n // vite's transform middleware, where no error has been thrown yet.\n return () => {\n server.middlewares.use(capture);\n };\n },\n };\n}\n\n/**\n * Answer the request with the failure {@link devErrorTransportPlugin} captured,\n * if there was one. Returns `false` when there was not, which is the normal\n * case and means \"carry on down the framework's own path\".\n *\n * Called from the Fastify `onRequest` hook that mounts vite\n * (`./web-connector.ts:321`), in the `done` callback — i.e. at the one moment\n * where connect has finished, vite has declined to answer, and the framework is\n * about to 404. Writing to the raw `ServerResponse` rather than through Fastify\n * is what the mount already does for every response vite serves, so this stays\n * on the same side of the seam.\n */\nexport function sendCapturedDevError(request: IncomingMessage, response: ServerResponse): boolean {\n const body = (request as DevTransformErrorCarrier)[DEV_TRANSFORM_ERROR_BODY];\n\n if (typeof body !== \"string\") return false;\n\n // A middleware further down may have answered already (vite serves plenty of\n // requests itself). Never write twice; the captured body is then just dropped.\n if (response.headersSent || response.writableEnded) return false;\n\n response.statusCode = DEV_TRANSFORM_ERROR_STATUS;\n response.setHeader(\"content-type\", \"text/plain; charset=utf-8\");\n // A refusal is a fact about the CURRENT source. Caching it would survive the\n // edit that fixes it.\n response.setHeader(\"cache-control\", \"no-store\");\n response.end(body);\n\n return true;\n}\n"],"mappings":";;;;;;;;;;;AAsCA,SAAgB,oBAAoB,UAAoB,QAA8B;CACpF,SAAS,OAAO,OAAO,MAAM,OAAO,OAAiB,OAAO,WAAW,CAAC,CAAW;AACrF;;;;;;;;;;;;;AAcA,MAAa,2BAA2B,OAAO,IAAI,mCAAmC;;;;;;;;;;;;;;;;;AAoBtF,MAAa,6BAA6B;;;;;;;AAmB1C,IAAa,qCAAb,cAAwD,MAAM;CAC5D,AAAO,cAAc;EACnB,MACE,uSAIF;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,wBACd,OACA,mBACQ;CACR,MAAM,UACJ,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK,CAAC;CAE9F,MAAM,QAAQ,CACZ,yBACE,kBAAkB,SAAS,CAAC,GAAG,QAAQ,KAAK,IAAI,QAAQ,SAAS,GAAG,KAAK,CAC3E,CACF;CAIA,IAAI,QAAS,QAAgC;CAE7C,OAAO,iBAAiB,OAAO;EAC7B,MAAM,KAAK,gBAAgB,MAAM,KAAK,IAAI,yBAAyB,MAAM,OAAO,GAAG;EACnF,QAAS,MAA8B;CACzC;CAEA,OAAO,GAAG,MAAM,KAAK,IAAI,EAAE;AAC7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,wBAAwB,SAG7B;CACT,MAAM,EAAE,qBAAqB,sBAAsB;CAEnD,IAAI,oBAAoB,GACtB,MAAM,IAAI,mCAAmC;CAG/C,MAAM,WAAwC,OAAO,SAAS,WAAW,SAAS;EAKhF,IAAI,oBAAoB,GAAG,OAAO,KAAK,KAAK;EAE5C,AAAC,QAAqC,4BAA4B,wBAChE,OACA,iBACF;EAEA,KAAK,KAAK;CACZ;CAEA,OAAO;EACL,MAAM;EAGN,OAAO;EACP,gBAAgB,QAAQ;GAItB,aAAa;IACX,OAAO,YAAY,IAAI,OAAO;GAChC;EACF;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,SAA0B,UAAmC;CAChG,MAAM,OAAQ,QAAqC;CAEnD,IAAI,OAAO,SAAS,UAAU,OAAO;CAIrC,IAAI,SAAS,eAAe,SAAS,eAAe,OAAO;CAE3D,SAAS;CACT,SAAS,UAAU,gBAAgB,2BAA2B;CAG9D,SAAS,UAAU,iBAAiB,UAAU;CAC9C,SAAS,IAAI,IAAI;CAEjB,OAAO;AACT"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|