@warlock.js/web 5.5.0 → 5.7.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 +53 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs +1 -1
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +130 -208
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +7 -5
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs +1 -1
- package/esm/build/public-files.mjs +30 -2
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/fetch-page-data.mjs +3 -10
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +24 -104
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs +19 -10
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +13 -1
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +9 -0
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +18 -12
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +91 -35
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +21 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +3 -3
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +4 -3
- package/esm/server/index.mjs +6 -6
- package/esm/server/install-page-routes-from-manifest.d.mts +10 -1
- package/esm/server/install-page-routes-from-manifest.mjs +61 -58
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +24 -26
- package/esm/server/install-page-routes.mjs +149 -60
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +6 -10
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -0
- package/esm/server/not-found-page.mjs +8 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-module-shapes.d.mts +24 -0
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +26 -2
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +13 -49
- package/esm/server/render-page.mjs +14 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/resolve-validation-data.mjs +1 -1
- package/esm/server/resolve-validation-data.mjs.map +1 -1
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs +78 -19
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +25 -322
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/validation.d.mts +12 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +32 -71
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +6 -8
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +564 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +354 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"not-found-page.mjs","names":[],"sources":["../../../../../../../web/src/server/not-found-page.ts"],"sourcesContent":["/**\r\n * THE NOT-FOUND PATH — the one route in the application that answers for URLs\r\n * nobody declared.\r\n *\r\n * An application gets it by writing `404.page.tsx` anywhere under a web root.\r\n * The file is named for the status it answers with, not for a concept\r\n * (\"not-found\"), because `404` is the string people actually search for, and\r\n * the `*.page.tsx` suffix is what makes it a page in the first place.\r\n *\r\n * ── THE RULE THIS FILE EXISTS FOR ────────────────────────────────────────────\r\n *\r\n * Pages and API routes share ONE route namespace and ONE router. So a catch-all\r\n * page route sees every unmatched request in the process, including\r\n * `GET /api/uzers` — and if it renders a document for that, a `fetch()` gets\r\n * `<!doctype html>` back and dies inside `response.json()` with a SyntaxError\r\n * pointing at the parser instead of at the typo. That failure is expensive\r\n * precisely because the error names nothing near its cause.\r\n *\r\n * Because pages and API share one namespace there is no path-prefix rule\r\n * available: `/anything` may legitimately be either. The discriminator is\r\n * therefore the `Accept` header, and it is stated as a narrow permission rather\r\n * than a guess — the document is the exception, JSON is the default:\r\n *\r\n * A request renders the not-found PAGE only if\r\n * 1. its method is GET or HEAD, and\r\n * 2. `text/html` appears EXPLICITLY in its `Accept` header.\r\n *\r\n * (1) is not a heuristic about browsers. Pages are registered with\r\n * `router.get` and nothing else — every page route in this codebase is\r\n * installed by `installPageRoutes` / `installPageRoutesFromManifest`, both\r\n * of which call `router.get`. A `POST` therefore cannot have been meant\r\n * for a page, by construction. HEAD rides along because Fastify answers it\r\n * from the GET route.\r\n *\r\n * (2) is EXPLICIT and the word is load-bearing. A wildcard does NOT count:\r\n * `* /*` — what a bare `fetch()` sends — is not a request for a document,\r\n * it is the absence of a preference, and `text/*` claims a family rather\r\n * than the type. Only the literal `text/html` media range, with a non-zero\r\n * `q`, opens the page path. A browser address-bar navigation always sends\r\n * an explicit `text/html`; a `fetch()` that has not asked for one never\r\n * does. So the mistyped `/api/...` in a `fetch()` keeps its JSON body and\r\n * dies at the typo rather than inside `response.json()`.\r\n *\r\n * WHAT THE RULE CANNOT DECIDE, and does not pretend to: a browser navigating to\r\n * a typo'd API URL asks for `text/html`, and so is answered with the document.\r\n * Nothing in that request distinguishes it from a typo'd page URL — same verb,\r\n * same header, same absence of a match — so the rule does not guess. The status\r\n * is 404 either way, which is the part machines read.\r\n *\r\n * ── DEPENDENCY NOTE ──────────────────────────────────────────────────────────\r\n * This module has one runtime asset dependency: the framework fallback's\r\n * stylesheet, read as a plain string constant from\r\n * `./framework-default-not-found-stylesheet` and turned into a data URL at\r\n * render time — never a static-asset import, which the production server\r\n * build refuses to compile (see that module's own comment). `../build/discover-pages`\r\n * imports the filename constant and identity helpers from here so build\r\n * discovery and both installers cannot disagree about what a not-found page\r\n * is. No renderer or application runtime is imported, so that edge cannot\r\n * drag the render pipeline into the build.\r\n */\r\nimport type { HttpContext } from \"@warlock.js/core\";\r\nimport type { PageRouteHandler } from \"./create-page-route-handler\";\r\nimport { buildFrameworkDefaultNotFoundStylesheetUrl } from \"./framework-default-not-found-stylesheet\";\r\n\r\n/**\r\n * The one filename that makes a page THE not-found page.\r\n *\r\n * `404.page.tsx`, not `not-found.page.tsx`: it keeps the `*.page.tsx`\r\n * convention every other page follows, and `404` is the token a developer\r\n * greps for when a URL answers with one.\r\n */\r\nexport const NOT_FOUND_PAGE_FILENAME = \"404.page.tsx\";\r\n\r\n/**\r\n * The path the not-found route is registered on — find-my-way's and Fastify's\r\n * catch-all, and the same literal core's own dev dispatcher registers\r\n * (`core/src/router/router.ts`, `server.route({ url: \"*\" })`).\r\n *\r\n * A catch-all has the LOWEST matching priority in both routers, so every\r\n * declared page and every declared API route still wins on its own path; this\r\n * route is only ever reached because nothing else claimed the URL.\r\n */\r\nexport const NOT_FOUND_ROUTE_PATH = \"*\";\r\n\r\n/**\r\n * The reserved route name the not-found page is registered under.\r\n *\r\n * Namespaced under `warlock.` because it is the framework's route rather than\r\n * the application's, and because the router's name namespace is shared with API\r\n * routes — an application that takes this name gets core's duplicate-name error,\r\n * which is the loud answer, not a silent overwrite.\r\n *\r\n * It is deliberately NOT published into the route table (`href()` / `<Link>`):\r\n * the not-found page has no URL of its own to link to.\r\n */\r\nexport const NOT_FOUND_ROUTE_NAME = \"warlock.not-found\";\r\n\r\n/** True when `sourceFile`'s basename is exactly {@link NOT_FOUND_PAGE_FILENAME}. */\r\nexport function isNotFoundPageFile(sourceFile: string): boolean {\r\n const separator = Math.max(sourceFile.lastIndexOf(\"/\"), sourceFile.lastIndexOf(\"\\\\\"));\r\n\r\n return sourceFile.slice(separator + 1) === NOT_FOUND_PAGE_FILENAME;\r\n}\r\n\r\n/**\r\n * Raised when more than one `404.page.tsx` exists.\r\n *\r\n * There is exactly one not-found route in a process, so a second file is not a\r\n * per-module override — it is two files claiming one route, with the winner\r\n * decided by directory-walk order. Both are named because the fix is to delete\r\n * one and the operator has to know which two are in play.\r\n */\r\nexport class DuplicateNotFoundPageError extends Error {\r\n public constructor(public readonly pageFiles: readonly string[]) {\r\n super(\r\n `Two or more not-found pages were found: ${pageFiles.map((file) => `\"${file}\"`).join(\", \")}. ` +\r\n `An application has exactly one \\`${NOT_FOUND_PAGE_FILENAME}\\` — it answers every ` +\r\n \"unmatched page URL in the process, so a second one would silently never render. \" +\r\n \"Keep one and delete the rest.\",\r\n );\r\n this.name = \"DuplicateNotFoundPageError\";\r\n }\r\n}\r\n\r\n/**\r\n * Raised when `404.page.tsx` declares a `route` export.\r\n *\r\n * The not-found page has no URL of its own — it is reached by NOT matching. A\r\n * `route` export on it reads like a promise that `/404` is browsable, and it is\r\n * not: the installers register this file on the catch-all and nowhere else. So\r\n * the export is refused rather than ignored, because a declaration the framework\r\n * silently drops is worse than one it rejects.\r\n */\r\nexport class NotFoundPageDeclaresRouteError extends Error {\r\n public constructor(public readonly pageFile: string) {\r\n super(\r\n `\"${pageFile}\" is the not-found page but declares a \\`route\\` export. ` +\r\n `\\`${NOT_FOUND_PAGE_FILENAME}\\` has no URL of its own — it answers every page URL that ` +\r\n \"matched nothing, and is never registered at a path of its own. Remove the `route` \" +\r\n \"export; to serve a browsable page at a fixed path, use a normal `*.page.tsx`.\",\r\n );\r\n this.name = \"NotFoundPageDeclaresRouteError\";\r\n }\r\n}\r\n\r\n/** The shape this module reads off a registered route — core's `Route`, narrowed. */\r\nexport type RegisteredRouteShape = {\r\n path: string;\r\n isPage?: boolean;\r\n};\r\n\r\n/**\r\n * The media range the not-found PAGE is gated on. Compared literally: a request\r\n * either named this exact type or it did not.\r\n */\r\nconst HTML_MEDIA_TYPE = \"text/html\";\r\n\r\n/**\r\n * True when `text/html` is named EXPLICITLY in an `Accept` header — the whole\r\n * discriminator, in one predicate.\r\n *\r\n * Wildcards are refused on purpose. `* /*` is what `fetch()` and `curl` send\r\n * when the caller expressed no preference at all, and `text/*` names a family;\r\n * neither is a request for a document, and treating either as one is what makes\r\n * a mistyped `/api/...` answer HTML to a JSON parser.\r\n *\r\n * `q=0` is honoured because it is the header's own way of saying \"not this\r\n * one\" — `Accept: text/html;q=0, application/json` is a client refusing the\r\n * document, and reading it as a request for one would be reading the header\r\n * backwards. Any other `q`, present or absent, counts.\r\n */\r\nexport function acceptsHtmlExplicitly(accept: string | undefined): boolean {\r\n if (!accept) return false;\r\n\r\n for (const entry of accept.split(\",\")) {\r\n const [rawType, ...parameters] = entry.split(\";\");\r\n\r\n if (rawType.trim().toLowerCase() !== HTML_MEDIA_TYPE) continue;\r\n\r\n const quality = parameters\r\n .map((parameter) => parameter.trim().toLowerCase())\r\n .find((parameter) => parameter.startsWith(\"q=\"));\r\n\r\n // A malformed `q` is not a refusal — only an explicit zero is.\r\n if (quality !== undefined && Number.parseFloat(quality.slice(2)) === 0) continue;\r\n\r\n return true;\r\n }\r\n\r\n return false;\r\n}\r\n\r\nexport type UnmatchedRequestKind = \"page\" | \"api\";\r\n\r\n/**\r\n * The rule, in one function — see this file's header for why it is these two\r\n * conditions and why the second one refuses wildcards.\r\n */\r\nexport function classifyUnmatchedRequest(input: {\r\n method: string;\r\n accept: string | undefined;\r\n}): UnmatchedRequestKind {\r\n const method = input.method.toUpperCase();\r\n\r\n // Pages are installed with `router.get`, so only these two verbs can ever\r\n // have been asking for one.\r\n if (method !== \"GET\" && method !== \"HEAD\") return \"api\";\r\n\r\n return acceptsHtmlExplicitly(input.accept) ? \"page\" : \"api\";\r\n}\r\n\r\n/**\r\n * The document served when the application ships no `404.page.tsx`.\r\n *\r\n * Deliberately a STRING, not a React render: it must survive the case where the\r\n * application root, a layout or the page module is exactly what is broken, and\r\n * a default that can itself fail is not a default. Its stylesheet is embedded in\r\n * the document as a data URL, and it carries no hydration script, so serving the\r\n * fallback requires no application render and no follow-up fetch.\r\n *\r\n * It answers 404 like the real page does, because the status is the part that\r\n * search engines, caches and monitoring read; a framework default that soft-404s\r\n * would teach every un-customised application to lie.\r\n */\r\nfunction resolveDefaultNotFoundDocumentLocale(locale: unknown): {\r\n lang: string;\r\n dir: \"ltr\" | \"rtl\";\r\n} {\r\n const fallback = { lang: \"en\", dir: \"ltr\" } as const;\r\n\r\n if (typeof locale !== \"string\" || locale.trim() === \"\") return fallback;\r\n\r\n // Locale negotiation is normally complete by this point, but the final 404\r\n // must also survive a partial or unusual JavaScript runtime. Keep this local:\r\n // it is a document-specific last line of defence, not a public locale API.\r\n try {\r\n const Locale = Intl.Locale;\r\n\r\n if (typeof Locale !== \"function\") return fallback;\r\n\r\n const resolvedLocale = new Locale(locale);\r\n const lang = resolvedLocale.toString();\r\n const textInfo = (resolvedLocale as Intl.Locale & { textInfo?: unknown }).textInfo;\r\n\r\n if (typeof textInfo !== \"object\" || textInfo === null) return fallback;\r\n\r\n const direction = (textInfo as { direction?: unknown }).direction;\r\n\r\n if (direction !== \"ltr\" && direction !== \"rtl\") return fallback;\r\n\r\n return { lang, dir: direction };\r\n } catch {\r\n return fallback;\r\n }\r\n}\r\n\r\nexport function frameworkDefaultNotFoundDocument(locale?: string): string {\r\n const { lang, dir } = resolveDefaultNotFoundDocumentLocale(locale);\r\n\r\n return `<!doctype html>\r\n<html lang=\"${lang}\" dir=\"${dir}\">\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n <meta name=\"robots\" content=\"noindex\">\r\n <title>404 | Warlock</title>\r\n <link rel=\"stylesheet\" href=\"${buildFrameworkDefaultNotFoundStylesheetUrl()}\">\r\n</head>\r\n<body>\r\n <main>\r\n <div class=\"rule\" aria-hidden=\"true\"></div>\r\n <h1>404</h1>\r\n <p dir=\"auto\">This page is outside the spellbook.</p>\r\n <a href=\"/\" dir=\"auto\">Return home</a>\r\n </main>\r\n</body>\r\n</html>`;\r\n}\r\n\r\nexport type NotFoundRouteHandlerOptions = {\r\n /**\r\n * The application's `404.page.tsx`, already built into a page handler by\r\n * whichever installer owns module loading — `undefined` when the application\r\n * ships no such file, which is what selects\r\n * {@link frameworkDefaultNotFoundDocument}.\r\n *\r\n * Taking a built handler rather than a module keeps this file out of the\r\n * render pipeline entirely: dev hands over a Vite-backed handler, production a\r\n * manifest-backed one, and neither difference is visible here.\r\n */\r\n renderPage?: PageRouteHandler;\r\n};\r\n\r\n/**\r\n * The handler registered on the catch-all.\r\n *\r\n * Three answers, in this order, and the order is the safety property: the API\r\n * check runs BEFORE anything can render, so no request that the rule calls an\r\n * API request can reach a React render even if the page module is broken.\r\n */\r\nexport function createNotFoundRouteHandler(options: NotFoundRouteHandlerOptions): PageRouteHandler {\r\n const { renderPage } = options;\r\n\r\n return async (context: HttpContext) => {\r\n const { request, response } = context;\r\n // A catch-all 404 must never become a cached answer for a later route.\r\n // Set this before classifying so it also travels with an application's\r\n // custom 404 page, whose rendering remains otherwise entirely its own.\r\n response.header(\"Cache-Control\", \"no-store\");\r\n // Node lowercases header names and collapses a repeated `Accept` into an\r\n // array; both forms are read, so a duplicated header cannot silently mean\r\n // \"no preference\".\r\n const accept = request.header(\"accept\");\r\n\r\n if (\r\n classifyUnmatchedRequest({\r\n method: request.method,\r\n accept: Array.isArray(accept) ? accept.join(\",\") : accept,\r\n }) === \"api\"\r\n ) {\r\n // The same body core's own dev dispatcher writes for an unmatched route\r\n // (`core/src/router/router.ts`), so an API 404 reads identically whether\r\n // it fell through to core or was declined here — and identically in\r\n // development and in production, which it previously was not.\r\n await response.send(\r\n { error: \"Route not found\", path: request.path, method: request.method },\r\n 404,\r\n );\r\n\r\n return;\r\n }\r\n\r\n if (renderPage === undefined) {\r\n await response.html(frameworkDefaultNotFoundDocument(request.locale), 404);\r\n\r\n return;\r\n }\r\n\r\n return renderPage(context);\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"not-found-page.mjs","names":[],"sources":["../../../../../../../web/src/server/not-found-page.ts"],"sourcesContent":["/**\r\n * THE NOT-FOUND PATH — the one route in the application that answers for URLs\r\n * nobody declared.\r\n *\r\n * An application gets it by writing `404.page.tsx` anywhere under a web root.\r\n * The file is named for the status it answers with, not for a concept\r\n * (\"not-found\"), because `404` is the string people actually search for, and\r\n * the `*.page.tsx` suffix is what makes it a page in the first place.\r\n *\r\n * ── THE RULE THIS FILE EXISTS FOR ────────────────────────────────────────────\r\n *\r\n * Pages and API routes share ONE route namespace and ONE router. So a catch-all\r\n * page route sees every unmatched request in the process, including\r\n * `GET /api/uzers` — and if it renders a document for that, a `fetch()` gets\r\n * `<!doctype html>` back and dies inside `response.json()` with a SyntaxError\r\n * pointing at the parser instead of at the typo. That failure is expensive\r\n * precisely because the error names nothing near its cause.\r\n *\r\n * Because pages and API share one namespace there is no path-prefix rule\r\n * available: `/anything` may legitimately be either. The discriminator is\r\n * therefore the `Accept` header, and it is stated as a narrow permission rather\r\n * than a guess — the document is the exception, JSON is the default:\r\n *\r\n * A request renders the not-found PAGE only if\r\n * 1. its method is GET or HEAD, and\r\n * 2. `text/html` appears EXPLICITLY in its `Accept` header.\r\n *\r\n * (1) is not a heuristic about browsers. Pages are registered with\r\n * `router.get` and nothing else — every page route in this codebase is\r\n * installed by `installPageRoutes` / `installPageRoutesFromManifest`, both\r\n * of which call `router.get`. A `POST` therefore cannot have been meant\r\n * for a page, by construction. HEAD rides along because Fastify answers it\r\n * from the GET route.\r\n *\r\n * (2) is EXPLICIT and the word is load-bearing. A wildcard does NOT count:\r\n * `* /*` — what a bare `fetch()` sends — is not a request for a document,\r\n * it is the absence of a preference, and `text/*` claims a family rather\r\n * than the type. Only the literal `text/html` media range, with a non-zero\r\n * `q`, opens the page path. A browser address-bar navigation always sends\r\n * an explicit `text/html`; a `fetch()` that has not asked for one never\r\n * does. So the mistyped `/api/...` in a `fetch()` keeps its JSON body and\r\n * dies at the typo rather than inside `response.json()`.\r\n *\r\n * WHAT THE RULE CANNOT DECIDE, and does not pretend to: a browser navigating to\r\n * a typo'd API URL asks for `text/html`, and so is answered with the document.\r\n * Nothing in that request distinguishes it from a typo'd page URL — same verb,\r\n * same header, same absence of a match — so the rule does not guess. The status\r\n * is 404 either way, which is the part machines read.\r\n *\r\n * ── DEPENDENCY NOTE ──────────────────────────────────────────────────────────\r\n * This module has one runtime asset dependency: the framework fallback's\r\n * stylesheet, read as a plain string constant from\r\n * `./framework-default-not-found-stylesheet` and turned into a data URL at\r\n * render time — never a static-asset import, which the production server\r\n * build refuses to compile (see that module's own comment). `../build/discover-pages`\r\n * imports the filename constant and identity helpers from here so build\r\n * discovery and both installers cannot disagree about what a not-found page\r\n * is. No renderer or application runtime is imported, so that edge cannot\r\n * drag the render pipeline into the build.\r\n */\r\nimport type { HttpContext } from \"@warlock.js/core\";\r\nimport type { PageRouteHandler } from \"./create-page-route-handler\";\r\nimport type { Router } from \"@warlock.js/core\";\r\nimport { buildFrameworkDefaultNotFoundStylesheetUrl } from \"./framework-default-not-found-stylesheet\";\r\n\r\n/**\r\n * The one filename that makes a page THE not-found page.\r\n *\r\n * `404.page.tsx`, not `not-found.page.tsx`: it keeps the `*.page.tsx`\r\n * convention every other page follows, and `404` is the token a developer\r\n * greps for when a URL answers with one.\r\n */\r\nexport const NOT_FOUND_PAGE_FILENAME = \"404.page.tsx\";\r\n\r\n/**\r\n * The path the not-found route is registered on — find-my-way's and Fastify's\r\n * catch-all, and the same literal core's own dev dispatcher registers\r\n * (`core/src/router/router.ts`, `server.route({ url: \"*\" })`).\r\n *\r\n * A catch-all has the LOWEST matching priority in both routers, so every\r\n * declared page and every declared API route still wins on its own path; this\r\n * route is only ever reached because nothing else claimed the URL.\r\n */\r\nexport const NOT_FOUND_ROUTE_PATH = \"*\";\r\n\r\n/**\r\n * The reserved route name the not-found page is registered under.\r\n *\r\n * Namespaced under `warlock.` because it is the framework's route rather than\r\n * the application's, and because the router's name namespace is shared with API\r\n * routes — an application that takes this name gets core's duplicate-name error,\r\n * which is the loud answer, not a silent overwrite.\r\n *\r\n * It is deliberately NOT published into the route table (`href()` / `<Link>`):\r\n * the not-found page has no URL of its own to link to.\r\n */\r\nexport const NOT_FOUND_ROUTE_NAME = \"warlock.not-found\";\r\n\r\n/** True when `sourceFile`'s basename is exactly {@link NOT_FOUND_PAGE_FILENAME}. */\r\nexport function isNotFoundPageFile(sourceFile: string): boolean {\r\n const separator = Math.max(sourceFile.lastIndexOf(\"/\"), sourceFile.lastIndexOf(\"\\\\\"));\r\n\r\n return sourceFile.slice(separator + 1) === NOT_FOUND_PAGE_FILENAME;\r\n}\r\n\r\n/**\r\n * Raised when more than one `404.page.tsx` exists.\r\n *\r\n * There is exactly one not-found route in a process, so a second file is not a\r\n * per-module override — it is two files claiming one route, with the winner\r\n * decided by directory-walk order. Both are named because the fix is to delete\r\n * one and the operator has to know which two are in play.\r\n */\r\nexport class DuplicateNotFoundPageError extends Error {\r\n public constructor(public readonly pageFiles: readonly string[]) {\r\n super(\r\n `Two or more not-found pages were found: ${pageFiles.map((file) => `\"${file}\"`).join(\", \")}. ` +\r\n `An application has exactly one \\`${NOT_FOUND_PAGE_FILENAME}\\` — it answers every ` +\r\n \"unmatched page URL in the process, so a second one would silently never render. \" +\r\n \"Keep one and delete the rest.\",\r\n );\r\n this.name = \"DuplicateNotFoundPageError\";\r\n }\r\n}\r\n\r\n/**\r\n * Raised when `404.page.tsx` declares a `route` export.\r\n *\r\n * The not-found page has no URL of its own — it is reached by NOT matching. A\r\n * `route` export on it reads like a promise that `/404` is browsable, and it is\r\n * not: the installers register this file on the catch-all and nowhere else. So\r\n * the export is refused rather than ignored, because a declaration the framework\r\n * silently drops is worse than one it rejects.\r\n */\r\nexport class NotFoundPageDeclaresRouteError extends Error {\r\n public constructor(public readonly pageFile: string) {\r\n super(\r\n `\"${pageFile}\" is the not-found page but declares a \\`route\\` export. ` +\r\n `\\`${NOT_FOUND_PAGE_FILENAME}\\` has no URL of its own — it answers every page URL that ` +\r\n \"matched nothing, and is never registered at a path of its own. Remove the `route` \" +\r\n \"export; to serve a browsable page at a fixed path, use a normal `*.page.tsx`.\",\r\n );\r\n this.name = \"NotFoundPageDeclaresRouteError\";\r\n }\r\n}\r\n\r\n/** The shape this module reads off a registered route — core's `Route`, narrowed. */\r\nexport type RegisteredRouteShape = {\r\n path: string;\r\n isPage?: boolean;\r\n};\r\n\r\n/**\r\n * The media range the not-found PAGE is gated on. Compared literally: a request\r\n * either named this exact type or it did not.\r\n */\r\nconst HTML_MEDIA_TYPE = \"text/html\";\r\n\r\n/**\r\n * True when `text/html` is named EXPLICITLY in an `Accept` header — the whole\r\n * discriminator, in one predicate.\r\n *\r\n * Wildcards are refused on purpose. `* /*` is what `fetch()` and `curl` send\r\n * when the caller expressed no preference at all, and `text/*` names a family;\r\n * neither is a request for a document, and treating either as one is what makes\r\n * a mistyped `/api/...` answer HTML to a JSON parser.\r\n *\r\n * `q=0` is honoured because it is the header's own way of saying \"not this\r\n * one\" — `Accept: text/html;q=0, application/json` is a client refusing the\r\n * document, and reading it as a request for one would be reading the header\r\n * backwards. Any other `q`, present or absent, counts.\r\n */\r\nexport function acceptsHtmlExplicitly(accept: string | undefined): boolean {\r\n if (!accept) return false;\r\n\r\n for (const entry of accept.split(\",\")) {\r\n const [rawType, ...parameters] = entry.split(\";\");\r\n\r\n if (rawType.trim().toLowerCase() !== HTML_MEDIA_TYPE) continue;\r\n\r\n const quality = parameters\r\n .map((parameter) => parameter.trim().toLowerCase())\r\n .find((parameter) => parameter.startsWith(\"q=\"));\r\n\r\n // A malformed `q` is not a refusal — only an explicit zero is.\r\n if (quality !== undefined && Number.parseFloat(quality.slice(2)) === 0) continue;\r\n\r\n return true;\r\n }\r\n\r\n return false;\r\n}\r\n\r\nexport type UnmatchedRequestKind = \"page\" | \"api\";\r\n\r\n/**\r\n * The rule, in one function — see this file's header for why it is these two\r\n * conditions and why the second one refuses wildcards.\r\n */\r\nexport function classifyUnmatchedRequest(input: {\r\n method: string;\r\n accept: string | undefined;\r\n}): UnmatchedRequestKind {\r\n const method = input.method.toUpperCase();\r\n\r\n // Pages are installed with `router.get`, so only these two verbs can ever\r\n // have been asking for one.\r\n if (method !== \"GET\" && method !== \"HEAD\") return \"api\";\r\n\r\n return acceptsHtmlExplicitly(input.accept) ? \"page\" : \"api\";\r\n}\r\n\r\n/**\r\n * The document served when the application ships no `404.page.tsx`.\r\n *\r\n * Deliberately a STRING, not a React render: it must survive the case where the\r\n * application root, a layout or the page module is exactly what is broken, and\r\n * a default that can itself fail is not a default. Its stylesheet is embedded in\r\n * the document as a data URL, and it carries no hydration script, so serving the\r\n * fallback requires no application render and no follow-up fetch.\r\n *\r\n * It answers 404 like the real page does, because the status is the part that\r\n * search engines, caches and monitoring read; a framework default that soft-404s\r\n * would teach every un-customised application to lie.\r\n */\r\nfunction resolveDefaultNotFoundDocumentLocale(locale: unknown): {\r\n lang: string;\r\n dir: \"ltr\" | \"rtl\";\r\n} {\r\n const fallback = { lang: \"en\", dir: \"ltr\" } as const;\r\n\r\n if (typeof locale !== \"string\" || locale.trim() === \"\") return fallback;\r\n\r\n // Locale negotiation is normally complete by this point, but the final 404\r\n // must also survive a partial or unusual JavaScript runtime. Keep this local:\r\n // it is a document-specific last line of defence, not a public locale API.\r\n try {\r\n const Locale = Intl.Locale;\r\n\r\n if (typeof Locale !== \"function\") return fallback;\r\n\r\n const resolvedLocale = new Locale(locale);\r\n const lang = resolvedLocale.toString();\r\n const textInfo = (resolvedLocale as Intl.Locale & { textInfo?: unknown }).textInfo;\r\n\r\n if (typeof textInfo !== \"object\" || textInfo === null) return fallback;\r\n\r\n const direction = (textInfo as { direction?: unknown }).direction;\r\n\r\n if (direction !== \"ltr\" && direction !== \"rtl\") return fallback;\r\n\r\n return { lang, dir: direction };\r\n } catch {\r\n return fallback;\r\n }\r\n}\r\n\r\nexport function frameworkDefaultNotFoundDocument(locale?: string): string {\r\n const { lang, dir } = resolveDefaultNotFoundDocumentLocale(locale);\r\n\r\n return `<!doctype html>\r\n<html lang=\"${lang}\" dir=\"${dir}\">\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n <meta name=\"robots\" content=\"noindex\">\r\n <title>404 | Warlock</title>\r\n <link rel=\"stylesheet\" href=\"${buildFrameworkDefaultNotFoundStylesheetUrl()}\">\r\n</head>\r\n<body>\r\n <main>\r\n <div class=\"rule\" aria-hidden=\"true\"></div>\r\n <h1>404</h1>\r\n <p dir=\"auto\">This page is outside the spellbook.</p>\r\n <a href=\"/\" dir=\"auto\">Return home</a>\r\n </main>\r\n</body>\r\n</html>`;\r\n}\r\n\r\nexport type NotFoundRouteHandlerOptions = {\r\n /**\r\n * The application's `404.page.tsx`, already built into a page handler by\r\n * whichever installer owns module loading — `undefined` when the application\r\n * ships no such file, which is what selects\r\n * {@link frameworkDefaultNotFoundDocument}.\r\n *\r\n * Taking a built handler rather than a module keeps this file out of the\r\n * render pipeline entirely: dev hands over a Vite-backed handler, production a\r\n * manifest-backed one, and neither difference is visible here.\r\n */\r\n renderPage?: PageRouteHandler;\r\n};\r\n\r\n/**\r\n * The handler registered on the catch-all.\r\n *\r\n * Three answers, in this order, and the order is the safety property: the API\r\n * check runs BEFORE anything can render, so no request that the rule calls an\r\n * API request can reach a React render even if the page module is broken.\r\n */\r\nexport function createNotFoundRouteHandler(options: NotFoundRouteHandlerOptions): PageRouteHandler {\r\n const { renderPage } = options;\r\n\r\n return async (context: HttpContext) => {\r\n const { request, response } = context;\r\n // A catch-all 404 must never become a cached answer for a later route.\r\n // Set this before classifying so it also travels with an application's\r\n // custom 404 page, whose rendering remains otherwise entirely its own.\r\n response.header(\"Cache-Control\", \"no-store\");\r\n // Node lowercases header names and collapses a repeated `Accept` into an\r\n // array; both forms are read, so a duplicated header cannot silently mean\r\n // \"no preference\".\r\n const accept = request.header(\"accept\");\r\n\r\n if (\r\n classifyUnmatchedRequest({\r\n method: request.method,\r\n accept: Array.isArray(accept) ? accept.join(\",\") : accept,\r\n }) === \"api\"\r\n ) {\r\n // The same body core's own dev dispatcher writes for an unmatched route\r\n // (`core/src/router/router.ts`), so an API 404 reads identically whether\r\n // it fell through to core or was declined here — and identically in\r\n // development and in production, which it previously was not.\r\n await response.send(\r\n { error: \"Route not found\", path: request.path, method: request.method },\r\n 404,\r\n );\r\n\r\n return;\r\n }\r\n\r\n if (renderPage === undefined) {\r\n await response.html(frameworkDefaultNotFoundDocument(request.locale), 404);\r\n\r\n return;\r\n }\r\n\r\n return renderPage(context);\r\n };\r\n}\r\n\r\n/** Registers the invariant catch-all route both page installers share. */\r\nexport function registerNotFoundPageRoute(options: {\r\n router: Router;\r\n renderPage?: PageRouteHandler;\r\n}): void {\r\n options.router.get(\r\n NOT_FOUND_ROUTE_PATH,\r\n createNotFoundRouteHandler({ renderPage: options.renderPage }),\r\n { name: NOT_FOUND_ROUTE_NAME, isPage: true },\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-file-change.mjs","names":[],"sources":["../../../../../../../web/src/server/page-file-change.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport const PAGE_FILE_SUFFIX = \".page.tsx\";\n\nexport type PageFileChanges = {\n added: string[];\n removed: string[];\n inspectionNeeded: string[];\n};\n\nexport type PageFileChangeOptions = {\n appRoot: string;\n appSrcRoot: string;\n installedPageFiles: readonly string[];\n fileExists?: (file: string) => boolean;\n};\n\nfunction isFile(file: string): boolean {\n try {\n return fs.statSync(file).isFile();\n } catch {\n return false;\n }\n}\n\nfunction normalizePath(appRoot: string, file: string): string {\n return path.resolve(appRoot, file.replace(/[\\\\/]+/g, path.sep));\n}\n\nfunction pathKey(file: string): string {\n const normalized = file.replace(/\\\\/g, \"/\");\n\n return process.platform === \"win32\" ? normalized.toLowerCase() : normalized;\n}\n\nexport function isPageFilePath(file: string, appSrcRoot: string): boolean {\n if (!file.endsWith(PAGE_FILE_SUFFIX)) {\n return false;\n }\n\n const relative = path.relative(appSrcRoot, file);\n\n return (\n relative !== \"\" &&\n !relative.startsWith(`..${path.sep}`) &&\n relative !== \"..\" &&\n !path.isAbsolute(relative) &&\n relative.split(path.sep)[0] === \"web\"\n );\n}\n\n/**\n * Layouts contribute to every descendant page's derived route identity, but\n * never own a route themselves. Keep this predicate aligned with\n * `page-registry-plugin.ts` so a layout lifecycle event re-derives the whole\n * page table rather than being discarded as an ordinary Vite update.\n */\nexport function isPageLayoutFilePath(file: string, appSrcRoot: string): boolean {\n const relative = path.relative(appSrcRoot, file);\n const base = path.basename(file);\n\n return (\n relative !== \"\" &&\n !relative.startsWith(`..${path.sep}`) &&\n relative !== \"..\" &&\n !path.isAbsolute(relative) &&\n relative.split(path.sep)[0] === \"web\" &&\n (base === \"layout.ts\" || base === \"layout.tsx\" || /\\.layout\\.tsx?$/.test(base))\n );\n}\n\n/** Error boundaries participate in reload tracking but never in router ownership. */\nexport function isErrorPageFilePath(file: string): boolean {\n return path.basename(file) === \"error.page.tsx\";\n}\n\nexport function classifyPageFileChanges(\n changedFiles: readonly string[],\n options: PageFileChangeOptions,\n): PageFileChanges {\n const pageRoot = path.resolve(options.appRoot, options.appSrcRoot);\n const installed = new Set(\n options.installedPageFiles.map((file) => pathKey(normalizePath(options.appRoot, file))),\n );\n const classified: PageFileChanges = { added: [], removed: [], inspectionNeeded: [] };\n const seen = new Set<string>();\n const fileExists = options.fileExists ?? isFile;\n\n for (const changedFile of changedFiles) {\n const file = normalizePath(options.appRoot, changedFile);\n const key = pathKey(file);\n\n if (seen.has(key)) {\n continue;\n }\n\n seen.add(key);\n\n // A layout has no individual router owner. Its addition, removal, or\n // in-place edit can change every descendant page's prefix/layout chain, so\n // force the existing full page-table derivation transaction. `added` is\n // deliberately the transaction's \"replacement required\" bucket here;\n // it does not claim the layout itself owns a newly-added route.\n if (isPageLayoutFilePath(file, pageRoot)) {\n (fileExists(file) ? classified.added : classified.removed).push(file);\n continue;\n }\n\n if (!isPageFilePath(file, pageRoot)) {\n continue;\n }\n\n const isInstalled = installed.has(key);\n const isErrorPage = isErrorPageFilePath(file);\n if (!fileExists(file)) {\n if (isInstalled || isErrorPage) {\n classified.removed.push(file);\n }\n continue;\n }\n\n if (isInstalled || isErrorPage) {\n classified.inspectionNeeded.push(file);\n } else {\n classified.added.push(file);\n }\n }\n\n return classified;\n}\n\nexport function hasPageFileChanges(changes: PageFileChanges): boolean {\n return (\n changes.added.length > 0
|
|
1
|
+
{"version":3,"file":"page-file-change.mjs","names":[],"sources":["../../../../../../../web/src/server/page-file-change.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport const PAGE_FILE_SUFFIX = \".page.tsx\";\n\nexport type PageFileChanges = {\n added: string[];\n removed: string[];\n inspectionNeeded: string[];\n};\n\nexport type PageFileChangeOptions = {\n appRoot: string;\n appSrcRoot: string;\n installedPageFiles: readonly string[];\n fileExists?: (file: string) => boolean;\n};\n\nfunction isFile(file: string): boolean {\n try {\n return fs.statSync(file).isFile();\n } catch {\n return false;\n }\n}\n\nfunction normalizePath(appRoot: string, file: string): string {\n return path.resolve(appRoot, file.replace(/[\\\\/]+/g, path.sep));\n}\n\nfunction pathKey(file: string): string {\n const normalized = file.replace(/\\\\/g, \"/\");\n\n return process.platform === \"win32\" ? normalized.toLowerCase() : normalized;\n}\n\nexport function isPageFilePath(file: string, appSrcRoot: string): boolean {\n if (!file.endsWith(PAGE_FILE_SUFFIX)) {\n return false;\n }\n\n const relative = path.relative(appSrcRoot, file);\n\n return (\n relative !== \"\" &&\n !relative.startsWith(`..${path.sep}`) &&\n relative !== \"..\" &&\n !path.isAbsolute(relative) &&\n relative.split(path.sep)[0] === \"web\"\n );\n}\n\n/**\n * Layouts contribute to every descendant page's derived route identity, but\n * never own a route themselves. Keep this predicate aligned with\n * `page-registry-plugin.ts` so a layout lifecycle event re-derives the whole\n * page table rather than being discarded as an ordinary Vite update.\n */\nexport function isPageLayoutFilePath(file: string, appSrcRoot: string): boolean {\n const relative = path.relative(appSrcRoot, file);\n const base = path.basename(file);\n\n return (\n relative !== \"\" &&\n !relative.startsWith(`..${path.sep}`) &&\n relative !== \"..\" &&\n !path.isAbsolute(relative) &&\n relative.split(path.sep)[0] === \"web\" &&\n (base === \"layout.ts\" || base === \"layout.tsx\" || /\\.layout\\.tsx?$/.test(base))\n );\n}\n\n/** Error boundaries participate in reload tracking but never in router ownership. */\nexport function isErrorPageFilePath(file: string): boolean {\n return path.basename(file) === \"error.page.tsx\";\n}\n\nexport function classifyPageFileChanges(\n changedFiles: readonly string[],\n options: PageFileChangeOptions,\n): PageFileChanges {\n const pageRoot = path.resolve(options.appRoot, options.appSrcRoot);\n const installed = new Set(\n options.installedPageFiles.map((file) => pathKey(normalizePath(options.appRoot, file))),\n );\n const classified: PageFileChanges = { added: [], removed: [], inspectionNeeded: [] };\n const seen = new Set<string>();\n const fileExists = options.fileExists ?? isFile;\n\n for (const changedFile of changedFiles) {\n const file = normalizePath(options.appRoot, changedFile);\n const key = pathKey(file);\n\n if (seen.has(key)) {\n continue;\n }\n\n seen.add(key);\n\n // A layout has no individual router owner. Its addition, removal, or\n // in-place edit can change every descendant page's prefix/layout chain, so\n // force the existing full page-table derivation transaction. `added` is\n // deliberately the transaction's \"replacement required\" bucket here;\n // it does not claim the layout itself owns a newly-added route.\n if (isPageLayoutFilePath(file, pageRoot)) {\n (fileExists(file) ? classified.added : classified.removed).push(file);\n continue;\n }\n\n if (!isPageFilePath(file, pageRoot)) {\n continue;\n }\n\n const isInstalled = installed.has(key);\n const isErrorPage = isErrorPageFilePath(file);\n if (!fileExists(file)) {\n if (isInstalled || isErrorPage) {\n classified.removed.push(file);\n }\n continue;\n }\n\n if (isInstalled || isErrorPage) {\n classified.inspectionNeeded.push(file);\n } else {\n classified.added.push(file);\n }\n }\n\n return classified;\n}\n\nexport function hasPageFileChanges(changes: PageFileChanges): boolean {\n return (\n changes.added.length > 0 || changes.removed.length > 0 || changes.inspectionNeeded.length > 0\n );\n}\n"],"mappings":";;;;AAGA,MAAa,mBAAmB;AAehC,SAAS,OAAO,MAAuB;CACrC,IAAI;EACF,OAAO,GAAG,SAAS,IAAI,CAAC,CAAC,OAAO;CAClC,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,cAAc,SAAiB,MAAsB;CAC5D,OAAO,KAAK,QAAQ,SAAS,KAAK,QAAQ,WAAW,KAAK,GAAG,CAAC;AAChE;AAEA,SAAS,QAAQ,MAAsB;CACrC,MAAM,aAAa,KAAK,QAAQ,OAAO,GAAG;CAE1C,OAAO,QAAQ,aAAa,UAAU,WAAW,YAAY,IAAI;AACnE;AAEA,SAAgB,eAAe,MAAc,YAA6B;CACxE,IAAI,CAAC,KAAK,oBAAyB,GACjC,OAAO;CAGT,MAAM,WAAW,KAAK,SAAS,YAAY,IAAI;CAE/C,OACE,aAAa,MACb,CAAC,SAAS,WAAW,KAAK,KAAK,KAAK,KACpC,aAAa,QACb,CAAC,KAAK,WAAW,QAAQ,KACzB,SAAS,MAAM,KAAK,GAAG,CAAC,CAAC,OAAO;AAEpC;;;;;;;AAQA,SAAgB,qBAAqB,MAAc,YAA6B;CAC9E,MAAM,WAAW,KAAK,SAAS,YAAY,IAAI;CAC/C,MAAM,OAAO,KAAK,SAAS,IAAI;CAE/B,OACE,aAAa,MACb,CAAC,SAAS,WAAW,KAAK,KAAK,KAAK,KACpC,aAAa,QACb,CAAC,KAAK,WAAW,QAAQ,KACzB,SAAS,MAAM,KAAK,GAAG,CAAC,CAAC,OAAO,UAC/B,SAAS,eAAe,SAAS,gBAAgB,kBAAkB,KAAK,IAAI;AAEjF;;AAGA,SAAgB,oBAAoB,MAAuB;CACzD,OAAO,KAAK,SAAS,IAAI,MAAM;AACjC;AAEA,SAAgB,wBACd,cACA,SACiB;CACjB,MAAM,WAAW,KAAK,QAAQ,QAAQ,SAAS,QAAQ,UAAU;CACjE,MAAM,YAAY,IAAI,IACpB,QAAQ,mBAAmB,KAAK,SAAS,QAAQ,cAAc,QAAQ,SAAS,IAAI,CAAC,CAAC,CACxF;CACA,MAAM,aAA8B;EAAE,OAAO,CAAC;EAAG,SAAS,CAAC;EAAG,kBAAkB,CAAC;CAAE;CACnF,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,aAAa,QAAQ,cAAc;CAEzC,KAAK,MAAM,eAAe,cAAc;EACtC,MAAM,OAAO,cAAc,QAAQ,SAAS,WAAW;EACvD,MAAM,MAAM,QAAQ,IAAI;EAExB,IAAI,KAAK,IAAI,GAAG,GACd;EAGF,KAAK,IAAI,GAAG;EAOZ,IAAI,qBAAqB,MAAM,QAAQ,GAAG;GACxC,CAAC,WAAW,IAAI,IAAI,WAAW,QAAQ,WAAW,QAAO,CAAE,KAAK,IAAI;GACpE;EACF;EAEA,IAAI,CAAC,eAAe,MAAM,QAAQ,GAChC;EAGF,MAAM,cAAc,UAAU,IAAI,GAAG;EACrC,MAAM,cAAc,oBAAoB,IAAI;EAC5C,IAAI,CAAC,WAAW,IAAI,GAAG;GACrB,IAAI,eAAe,aACjB,WAAW,QAAQ,KAAK,IAAI;GAE9B;EACF;EAEA,IAAI,eAAe,aACjB,WAAW,iBAAiB,KAAK,IAAI;OAErC,WAAW,MAAM,KAAK,IAAI;CAE9B;CAEA,OAAO;AACT;AAEA,SAAgB,mBAAmB,SAAmC;CACpE,OACE,QAAQ,MAAM,SAAS,KAAK,QAAQ,QAAQ,SAAS,KAAK,QAAQ,iBAAiB,SAAS;AAEhG"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PageCacheOptIn } from "../routing/route-identity.mjs";
|
|
2
|
+
import { PipelineLoader, PipelineMiddleware } from "./execute-page-request.types.mjs";
|
|
3
|
+
//#region ../web/src/server/page-module-shapes.d.ts
|
|
4
|
+
/** A page declares either a bare path or a path plus an explicit route name. */
|
|
5
|
+
type PageRouteExport = string | {
|
|
6
|
+
path: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
cache?: PageCacheOptIn;
|
|
9
|
+
};
|
|
10
|
+
/** The only export the page installers read off a page module namespace. */
|
|
11
|
+
type PageModuleShape = {
|
|
12
|
+
route?: PageRouteExport;
|
|
13
|
+
};
|
|
14
|
+
/** The exports the page installers read off a layout module namespace. */
|
|
15
|
+
type LayoutModuleShape = {
|
|
16
|
+
/** Universal registration hook; invoked on this real namespace, never a composed wrapper. */register?: () => unknown;
|
|
17
|
+
prefix?: string; /** The default export decides whether this layout renders. */
|
|
18
|
+
default?: unknown; /** The layout's guards, in declaration order. */
|
|
19
|
+
middleware?: readonly PipelineMiddleware[];
|
|
20
|
+
loader?: PipelineLoader;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { LayoutModuleShape, PageModuleShape, PageRouteExport };
|
|
24
|
+
//# sourceMappingURL=page-module-shapes.d.mts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isNotFoundPageFile } from "./not-found-page.mjs";
|
|
2
|
-
import { resolvePageRouteIdentity } from "
|
|
2
|
+
import { resolvePageRouteIdentity } from "../routing/route-identity.mjs";
|
|
3
|
+
import { filesystemPageFileFor } from "./install-page-routes.mjs";
|
|
3
4
|
import { isErrorPageFilePath } from "./page-file-change.mjs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
|
|
@@ -43,8 +44,8 @@ async function pageRoutesNeedReplacement(changes, options) {
|
|
|
43
44
|
replace = true;
|
|
44
45
|
continue;
|
|
45
46
|
}
|
|
46
|
-
const next = resolvePageRouteIdentity(pageModule.route, file, options.appSrcRoot);
|
|
47
|
-
if (next.
|
|
47
|
+
const next = resolvePageRouteIdentity(pageModule.route, filesystemPageFileFor(file, options.appSrcRoot), file);
|
|
48
|
+
if (next.path !== installed.declaredPath || next.name !== installed.name) replace = true;
|
|
48
49
|
}
|
|
49
50
|
return replace;
|
|
50
51
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-route-reload.mjs","names":[],"sources":["../../../../../../../web/src/server/page-route-reload.ts"],"sourcesContent":["import path from \"node:path\";\nimport
|
|
1
|
+
{"version":3,"file":"page-route-reload.mjs","names":[],"sources":["../../../../../../../web/src/server/page-route-reload.ts"],"sourcesContent":["import path from \"node:path\";\nimport { resolvePageRouteIdentity } from \"../routing/route-identity\";\nimport type { Router } from \"@warlock.js/core\";\nimport type { ViteDevServer } from \"vite\";\nimport {\n filesystemPageFileFor,\n type InstalledPageRoute,\n type PageModuleShape,\n} from \"./install-page-routes\";\nimport { isNotFoundPageFile } from \"./not-found-page\";\nimport type { PageFileChanges } from \"./page-file-change\";\nimport { isErrorPageFilePath } from \"./page-file-change\";\n\nfunction pathKey(file: string): string {\n const normalized = path.resolve(file).replace(/\\\\/g, \"/\");\n\n return process.platform === \"win32\" ? normalized.toLowerCase() : normalized;\n}\n\n/**\n * Every real page file currently represented in the route table. This includes\n * a custom `404.page.tsx`, which is intentionally absent from the public page\n * table because it has no linkable URL of its own.\n */\nexport function registeredPageFiles(\n routes: ReturnType<Router[\"list\"]>,\n appSrcRoot: string,\n): string[] {\n const sourceRoot = path.dirname(appSrcRoot);\n\n return [\n ...new Set(\n routes\n .filter((route) => route.isPage && route.sourceFile && !route.sourceFile.startsWith(\"\\0\"))\n .map((route) => path.resolve(sourceRoot, route.sourceFile)),\n ),\n ];\n}\n\n/** Exact route-source owners replaced by a live page-table transaction. */\nexport function pageRouteSourceFiles(routes: ReturnType<Router[\"list\"]>): string[] {\n return [\n ...new Set(\n routes.filter((route) => route.isPage && route.sourceFile).map((route) => route.sourceFile),\n ),\n ];\n}\n\n/**\n * Inspect only in-place page edits. Vite's SSR graph is invalidated explicitly\n * before evaluation because core's file watcher and Vite's watcher have no\n * ordering contract. Component-only edits compare equal and stay in Fast\n * Refresh; route/name changes request an atomic table replacement.\n */\nexport async function pageRoutesNeedReplacement(\n changes: PageFileChanges,\n options: {\n vite: ViteDevServer;\n appSrcRoot: string;\n installedPages: readonly InstalledPageRoute[];\n },\n): Promise<boolean> {\n let replace = changes.added.length > 0 || changes.removed.length > 0;\n\n const installedByFile = new Map(\n options.installedPages.map((page) => [pathKey(page.file), page] as const),\n );\n\n for (const file of changes.inspectionNeeded) {\n // An error boundary has no installed router route to compare against. Its\n // namespace is projected into every client composition, so any edit must\n // replace the generated table; do not load it here just to inspect it.\n if (isErrorPageFilePath(file)) return true;\n\n options.vite.environments.ssr.moduleGraph.onFileChange(file);\n const pageModule = (await options.vite.ssrLoadModule(file)) as PageModuleShape;\n\n // A valid 404 page has no route identity. Its component body remains Vite\n // HMR territory; adding an illegal route export must still enter the\n // transaction so the installer can reject it without touching live routes.\n if (isNotFoundPageFile(file)) {\n if (pageModule.route !== undefined) replace = true;\n continue;\n }\n\n const installed = installedByFile.get(pathKey(file));\n if (installed === undefined) {\n replace = true;\n continue;\n }\n\n const next = resolvePageRouteIdentity(\n pageModule.route,\n filesystemPageFileFor(file, options.appSrcRoot),\n file,\n );\n if (next.path !== installed.declaredPath || next.name !== installed.name) {\n replace = true;\n }\n }\n\n return replace;\n}\n"],"mappings":";;;;;;;AAaA,SAAS,QAAQ,MAAsB;CACrC,MAAM,aAAa,KAAK,QAAQ,IAAI,CAAC,CAAC,QAAQ,OAAO,GAAG;CAExD,OAAO,QAAQ,aAAa,UAAU,WAAW,YAAY,IAAI;AACnE;;;;;;AAOA,SAAgB,oBACd,QACA,YACU;CACV,MAAM,aAAa,KAAK,QAAQ,UAAU;CAE1C,OAAO,CACL,GAAG,IAAI,IACL,OACG,QAAQ,UAAU,MAAM,UAAU,MAAM,cAAc,CAAC,MAAM,WAAW,WAAW,IAAI,CAAC,CAAC,CACzF,KAAK,UAAU,KAAK,QAAQ,YAAY,MAAM,UAAU,CAAC,CAC9D,CACF;AACF;;AAGA,SAAgB,qBAAqB,QAA8C;CACjF,OAAO,CACL,GAAG,IAAI,IACL,OAAO,QAAQ,UAAU,MAAM,UAAU,MAAM,UAAU,CAAC,CAAC,KAAK,UAAU,MAAM,UAAU,CAC5F,CACF;AACF;;;;;;;AAQA,eAAsB,0BACpB,SACA,SAKkB;CAClB,IAAI,UAAU,QAAQ,MAAM,SAAS,KAAK,QAAQ,QAAQ,SAAS;CAEnE,MAAM,kBAAkB,IAAI,IAC1B,QAAQ,eAAe,KAAK,SAAS,CAAC,QAAQ,KAAK,IAAI,GAAG,IAAI,CAAU,CAC1E;CAEA,KAAK,MAAM,QAAQ,QAAQ,kBAAkB;EAI3C,IAAI,oBAAoB,IAAI,GAAG,OAAO;EAEtC,QAAQ,KAAK,aAAa,IAAI,YAAY,aAAa,IAAI;EAC3D,MAAM,aAAc,MAAM,QAAQ,KAAK,cAAc,IAAI;EAKzD,IAAI,mBAAmB,IAAI,GAAG;GAC5B,IAAI,WAAW,UAAU,QAAW,UAAU;GAC9C;EACF;EAEA,MAAM,YAAY,gBAAgB,IAAI,QAAQ,IAAI,CAAC;EACnD,IAAI,cAAc,QAAW;GAC3B,UAAU;GACV;EACF;EAEA,MAAM,OAAO,yBACX,WAAW,OACX,sBAAsB,MAAM,QAAQ,UAAU,GAC9C,IACF;EACA,IAAI,KAAK,SAAS,UAAU,gBAAgB,KAAK,SAAS,UAAU,MAClE,UAAU;CAEd;CAEA,OAAO;AACT"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { collectPublicFilesSync } from "../build/public-files.mjs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
3
4
|
|
|
4
5
|
//#region ../web/src/server/register-production-public-files.ts
|
|
5
6
|
var InvalidProductionPublicFileError = class extends Error {
|
|
@@ -34,9 +35,32 @@ function assertRelativePublicFile(publicFile) {
|
|
|
34
35
|
if (publicFile.length === 0 || publicFile.startsWith("/") || publicFile.includes("\\") || segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) throw new InvalidProductionPublicFileError(publicFile);
|
|
35
36
|
return segments;
|
|
36
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Report the `public/` files present on disk that the build manifest did not
|
|
40
|
+
* record — i.e. files added after the last `warlock build`. They are not
|
|
41
|
+
* registered (the manifest, not the disk, is what production serves — see
|
|
42
|
+
* the module-level note on {@link PUBLIC_FILE_CACHE_MAX_AGE_SECONDS}'s
|
|
43
|
+
* neighbours), so they will 404 until the app is rebuilt. A missing
|
|
44
|
+
* `publicRoot` is the ordinary no-public-directory case, not staleness, and
|
|
45
|
+
* {@link collectPublicFilesSync} already reports it as `[]`.
|
|
46
|
+
*/
|
|
47
|
+
function warnIfPublicBuildIsStale(publicRoot, publicFiles, warn) {
|
|
48
|
+
let onDisk;
|
|
49
|
+
try {
|
|
50
|
+
onDisk = collectPublicFilesSync(publicRoot);
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const recorded = new Set(publicFiles);
|
|
55
|
+
const staleFiles = onDisk.filter((file) => !recorded.has(file));
|
|
56
|
+
if (staleFiles.length === 0) return;
|
|
57
|
+
warn(`[warlock:web] public/ is stale: the last \`warlock build\` did not see these files, so they will 404 in production until the app is rebuilt:
|
|
58
|
+
${staleFiles.map((file) => ` - ${file}`).join("\n")}\nRun \`warlock build\` again to include them.`);
|
|
59
|
+
}
|
|
37
60
|
/** Register the exact app-public files recorded by the successful build. */
|
|
38
|
-
function registerProductionPublicFiles(router, clientDir, publicFiles) {
|
|
61
|
+
function registerProductionPublicFiles(router, clientDir, publicFiles, warn = console.warn) {
|
|
39
62
|
const publicRoot = path.join(clientDir, "public");
|
|
63
|
+
warnIfPublicBuildIsStale(publicRoot, publicFiles, warn);
|
|
40
64
|
for (const publicFile of publicFiles) {
|
|
41
65
|
const absoluteFile = path.join(publicRoot, ...assertRelativePublicFile(publicFile));
|
|
42
66
|
let stat;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"register-production-public-files.mjs","names":[],"sources":["../../../../../../../web/src/server/register-production-public-files.ts"],"sourcesContent":["import fs from \"node:fs\";\r\nimport path from \"node:path\";\r\nimport type { Router } from \"@warlock.js/core\";\r\n\r\nexport class InvalidProductionPublicFileError extends Error {\r\n public constructor(publicFile: string) {\r\n super(\r\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\r\n \"the build manifest must contain a non-empty relative POSIX path with no \" +\r\n \"traversal segments.\",\r\n );\r\n this.name = \"InvalidProductionPublicFileError\";\r\n }\r\n}\r\n\r\nexport class MissingProductionPublicFileError extends Error {\r\n public constructor(publicFile: string, absoluteFile: string) {\r\n super(\r\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\r\n \"the successful build manifest recorded it, but \" +\r\n `${JSON.stringify(absoluteFile)} is missing or not a file. ` +\r\n \"Run `warlock build` again to replace the incomplete artifact.\",\r\n );\r\n this.name = \"MissingProductionPublicFileError\";\r\n }\r\n}\r\n\r\n/**\r\n * Seconds, not milliseconds — `Router.file`'s `cacheTime` is the response's\r\n * own `Cache-Control: max-age` value (`core/src/http/response.ts`), unlike\r\n * `@fastify/static`'s millisecond `maxAge`.\r\n *\r\n * These files are copied verbatim from `app/public` at build time: the URL is\r\n * the developer's chosen filename, not a content hash, so a rebuild can change\r\n * a file's bytes without changing its URL. `immutable` would tell the browser\r\n * to skip revalidation forever, which is wrong here — this is a plain\r\n * `max-age`, so a stale copy is served for at most this long and then\r\n * revalidated. Five minutes bounds staleness after a deploy to something a\r\n * developer would not notice, without paying a revalidation round trip on\r\n * every load the way `max-age=0` does.\r\n */\r\nconst PUBLIC_FILE_CACHE_MAX_AGE_SECONDS = 300;\r\n\r\nfunction assertRelativePublicFile(publicFile: string): string[] {\r\n const segments = publicFile.split(\"/\");\r\n\r\n if (\r\n publicFile.length === 0 ||\r\n publicFile.startsWith(\"/\") ||\r\n publicFile.includes(\"\\\\\") ||\r\n segments.some((segment) => segment.length === 0 || segment === \".\" || segment === \"..\")\r\n ) {\r\n throw new InvalidProductionPublicFileError(publicFile);\r\n }\r\n\r\n return segments;\r\n}\r\n\r\n/** Register the exact app-public files recorded by the successful build. */\r\nexport function registerProductionPublicFiles(\r\n router: Router,\r\n clientDir: string,\r\n publicFiles: readonly string[],\r\n): void {\r\n const publicRoot = path.join(clientDir, \"public\");\r\n\r\n for (const publicFile of publicFiles) {\r\n const absoluteFile = path.join(publicRoot, ...assertRelativePublicFile(publicFile));\r\n\r\n let stat: fs.Stats;\r\n\r\n try {\r\n stat = fs.statSync(absoluteFile);\r\n } catch {\r\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\r\n }\r\n\r\n if (!stat.isFile()) {\r\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\r\n }\r\n\r\n router.file(`/${publicFile}`, absoluteFile, PUBLIC_FILE_CACHE_MAX_AGE_SECONDS);\r\n }\r\n}\r\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"register-production-public-files.mjs","names":[],"sources":["../../../../../../../web/src/server/register-production-public-files.ts"],"sourcesContent":["import fs from \"node:fs\";\r\nimport path from \"node:path\";\r\nimport type { Router } from \"@warlock.js/core\";\r\nimport { collectPublicFilesSync } from \"../build/public-files\";\r\n\r\nexport class InvalidProductionPublicFileError extends Error {\r\n public constructor(publicFile: string) {\r\n super(\r\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\r\n \"the build manifest must contain a non-empty relative POSIX path with no \" +\r\n \"traversal segments.\",\r\n );\r\n this.name = \"InvalidProductionPublicFileError\";\r\n }\r\n}\r\n\r\nexport class MissingProductionPublicFileError extends Error {\r\n public constructor(publicFile: string, absoluteFile: string) {\r\n super(\r\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\r\n \"the successful build manifest recorded it, but \" +\r\n `${JSON.stringify(absoluteFile)} is missing or not a file. ` +\r\n \"Run `warlock build` again to replace the incomplete artifact.\",\r\n );\r\n this.name = \"MissingProductionPublicFileError\";\r\n }\r\n}\r\n\r\n/**\r\n * Seconds, not milliseconds — `Router.file`'s `cacheTime` is the response's\r\n * own `Cache-Control: max-age` value (`core/src/http/response.ts`), unlike\r\n * `@fastify/static`'s millisecond `maxAge`.\r\n *\r\n * These files are copied verbatim from `app/public` at build time: the URL is\r\n * the developer's chosen filename, not a content hash, so a rebuild can change\r\n * a file's bytes without changing its URL. `immutable` would tell the browser\r\n * to skip revalidation forever, which is wrong here — this is a plain\r\n * `max-age`, so a stale copy is served for at most this long and then\r\n * revalidated. Five minutes bounds staleness after a deploy to something a\r\n * developer would not notice, without paying a revalidation round trip on\r\n * every load the way `max-age=0` does.\r\n */\r\nconst PUBLIC_FILE_CACHE_MAX_AGE_SECONDS = 300;\r\n\r\nfunction assertRelativePublicFile(publicFile: string): string[] {\r\n const segments = publicFile.split(\"/\");\r\n\r\n if (\r\n publicFile.length === 0 ||\r\n publicFile.startsWith(\"/\") ||\r\n publicFile.includes(\"\\\\\") ||\r\n segments.some((segment) => segment.length === 0 || segment === \".\" || segment === \"..\")\r\n ) {\r\n throw new InvalidProductionPublicFileError(publicFile);\r\n }\r\n\r\n return segments;\r\n}\r\n\r\n/**\r\n * Report the `public/` files present on disk that the build manifest did not\r\n * record — i.e. files added after the last `warlock build`. They are not\r\n * registered (the manifest, not the disk, is what production serves — see\r\n * the module-level note on {@link PUBLIC_FILE_CACHE_MAX_AGE_SECONDS}'s\r\n * neighbours), so they will 404 until the app is rebuilt. A missing\r\n * `publicRoot` is the ordinary no-public-directory case, not staleness, and\r\n * {@link collectPublicFilesSync} already reports it as `[]`.\r\n */\r\nfunction warnIfPublicBuildIsStale(\r\n publicRoot: string,\r\n publicFiles: readonly string[],\r\n warn: (message: string) => void,\r\n): void {\r\n let onDisk: string[];\r\n\r\n try {\r\n onDisk = collectPublicFilesSync(publicRoot);\r\n } catch {\r\n return;\r\n }\r\n\r\n const recorded = new Set(publicFiles);\r\n const staleFiles = onDisk.filter((file) => !recorded.has(file));\r\n\r\n if (staleFiles.length === 0) return;\r\n\r\n const named = staleFiles.map((file) => ` - ${file}`).join(\"\\n\");\r\n\r\n warn(\r\n \"[warlock:web] public/ is stale: the last `warlock build` did not see these files, \" +\r\n \"so they will 404 in production until the app is rebuilt:\\n\" +\r\n `${named}\\n` +\r\n \"Run `warlock build` again to include them.\",\r\n );\r\n}\r\n\r\n/** Register the exact app-public files recorded by the successful build. */\r\nexport function registerProductionPublicFiles(\r\n router: Router,\r\n clientDir: string,\r\n publicFiles: readonly string[],\r\n warn: (message: string) => void = console.warn,\r\n): void {\r\n const publicRoot = path.join(clientDir, \"public\");\r\n\r\n warnIfPublicBuildIsStale(publicRoot, publicFiles, warn);\r\n\r\n for (const publicFile of publicFiles) {\r\n const absoluteFile = path.join(publicRoot, ...assertRelativePublicFile(publicFile));\r\n\r\n let stat: fs.Stats;\r\n\r\n try {\r\n stat = fs.statSync(absoluteFile);\r\n } catch {\r\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\r\n }\r\n\r\n if (!stat.isFile()) {\r\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\r\n }\r\n\r\n router.file(`/${publicFile}`, absoluteFile, PUBLIC_FILE_CACHE_MAX_AGE_SECONDS);\r\n }\r\n}\r\n"],"mappings":";;;;;AAKA,IAAa,mCAAb,cAAsD,MAAM;CAC1D,AAAO,YAAY,YAAoB;EACrC,MACE,0CAA0C,KAAK,UAAU,UAAU,EAAE,8FAGvE;EACA,KAAK,OAAO;CACd;AACF;AAEA,IAAa,mCAAb,cAAsD,MAAM;CAC1D,AAAO,YAAY,YAAoB,cAAsB;EAC3D,MACE,0CAA0C,KAAK,UAAU,UAAU,EAAE,mDAEhE,KAAK,UAAU,YAAY,EAAE,2FAEpC;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,MAAM,oCAAoC;AAE1C,SAAS,yBAAyB,YAA8B;CAC9D,MAAM,WAAW,WAAW,MAAM,GAAG;CAErC,IACE,WAAW,WAAW,KACtB,WAAW,WAAW,GAAG,KACzB,WAAW,SAAS,IAAI,KACxB,SAAS,MAAM,YAAY,QAAQ,WAAW,KAAK,YAAY,OAAO,YAAY,IAAI,GAEtF,MAAM,IAAI,iCAAiC,UAAU;CAGvD,OAAO;AACT;;;;;;;;;;AAWA,SAAS,yBACP,YACA,aACA,MACM;CACN,IAAI;CAEJ,IAAI;EACF,SAAS,uBAAuB,UAAU;CAC5C,QAAQ;EACN;CACF;CAEA,MAAM,WAAW,IAAI,IAAI,WAAW;CACpC,MAAM,aAAa,OAAO,QAAQ,SAAS,CAAC,SAAS,IAAI,IAAI,CAAC;CAE9D,IAAI,WAAW,WAAW,GAAG;CAI7B,KACE;EAHY,WAAW,KAAK,SAAS,OAAO,MAAM,CAAC,CAAC,KAAK,IAKhD,EAAE,+CAEb;AACF;;AAGA,SAAgB,8BACd,QACA,WACA,aACA,OAAkC,QAAQ,MACpC;CACN,MAAM,aAAa,KAAK,KAAK,WAAW,QAAQ;CAEhD,yBAAyB,YAAY,aAAa,IAAI;CAEtD,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,eAAe,KAAK,KAAK,YAAY,GAAG,yBAAyB,UAAU,CAAC;EAElF,IAAI;EAEJ,IAAI;GACF,OAAO,GAAG,SAAS,YAAY;EACjC,QAAQ;GACN,MAAM,IAAI,iCAAiC,YAAY,YAAY;EACrE;EAEA,IAAI,CAAC,KAAK,OAAO,GACf,MAAM,IAAI,iCAAiC,YAAY,YAAY;EAGrE,OAAO,KAAK,IAAI,cAAc,cAAc,iCAAiC;CAC/E;AACF"}
|
|
@@ -1,49 +1,16 @@
|
|
|
1
1
|
import { PAYLOAD_SCRIPT_ID, escapePayload } from "../components/document-context.mjs";
|
|
2
2
|
import { BufferedCookie } from "./settle-page-response.mjs";
|
|
3
|
-
import { ExecutePageRequestOptions, PageDataBundle, PageRouteEntry } from "./execute-page-request.types.mjs";
|
|
3
|
+
import { ExecutePageRequestOptions, PageDataBundle, PageRouteEntry, PageRouteMatch } from "./execute-page-request.types.mjs";
|
|
4
4
|
import { ErrorPageModuleLoader } from "./error-page.mjs";
|
|
5
5
|
import { Request, Response } from "@warlock.js/core";
|
|
6
6
|
|
|
7
7
|
//#region ../web/src/server/render-page.d.ts
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* 10a the caller applies status + headers (the single live-response write,
|
|
13
|
-
* after render, before anything flushes), 10b it flushes
|
|
14
|
-
* the document. Nothing in this module writes the live response. It never
|
|
15
|
-
* re-runs any earlier stage — `renderPage` calls `executePageRequest` and
|
|
16
|
-
* everything here consumes its bundle as-is.
|
|
17
|
-
*
|
|
18
|
-
* `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is
|
|
19
|
-
* the production orchestrator AND the test helper. Because a loader IS a
|
|
20
|
-
* controller, `renderPage("products.details", { params: { id: "42" } })`
|
|
21
|
-
* returns `{ html, status, headers, data }` in one call — asserting a page's
|
|
22
|
-
* data and its response headers is a unit test, no browser, no server boot.
|
|
23
|
-
*/
|
|
24
|
-
type PageRoutesRegistry = {
|
|
25
|
-
routes: readonly PageRouteEntry[]; /** Same contract as ExecutePageRequestOptions["createHttp"]. */
|
|
26
|
-
createHttp: ExecutePageRequestOptions["createHttp"];
|
|
27
|
-
};
|
|
28
|
-
/**
|
|
29
|
-
* Boot-time wiring so `renderPage(name, options)` can resolve a route NAME
|
|
30
|
-
* without each call site carrying the manifest. Returns the previous registry
|
|
31
|
-
* so tests can restore it. A per-call `routes`/`createHttp` override wins.
|
|
32
|
-
*/
|
|
33
|
-
declare function connectPageRoutes(registry: PageRoutesRegistry | undefined): PageRoutesRegistry | undefined;
|
|
34
|
-
type RenderPageOptions = {
|
|
35
|
-
params?: Record<string, string>;
|
|
36
|
-
query?: Record<string, string>; /** Per-call overrides of the connected registry (tests, mostly). */
|
|
37
|
-
routes?: readonly PageRouteEntry[];
|
|
38
|
-
createHttp?: ExecutePageRequestOptions["createHttp"]; /** Loaded only after the ordinary boundary chain has been exhausted. */
|
|
8
|
+
type RenderPageRequestOptions = {
|
|
9
|
+
routes: readonly PageRouteEntry[]; /** Core's already-resolved HTTP route, when this is serving a live request. */
|
|
10
|
+
matched?: Pick<PageRouteMatch, "entry" | "params">;
|
|
11
|
+
createHttp: ExecutePageRequestOptions["createHttp"]; /** Loaded only after the ordinary boundary chain has been exhausted. */
|
|
39
12
|
loadErrorPage?: ErrorPageModuleLoader;
|
|
40
13
|
};
|
|
41
|
-
/**
|
|
42
|
-
* `renderPageRequest` takes the URL itself, so `params`/`query` (the
|
|
43
|
-
* name-based sugar buildUrl consumes) have no meaning here — everything else
|
|
44
|
-
* is the same seam.
|
|
45
|
-
*/
|
|
46
|
-
type RenderPageRequestOptions = Omit<RenderPageOptions, "params" | "query">;
|
|
47
14
|
type RenderedPage = {
|
|
48
15
|
/** The full document ("" when the pipeline short-circuited before render). */html: string;
|
|
49
16
|
status: number; /** Committed response headers, lowercased key → value. */
|
|
@@ -58,22 +25,19 @@ type RenderedPage = {
|
|
|
58
25
|
* The full stages-1–8 bundle, for assertions beyond the page's own data.
|
|
59
26
|
* Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,
|
|
60
27
|
* so no pipeline ran and there is no bundle — the 404 answer stands alone.
|
|
61
|
-
* `renderPage` always carries one (its no-match throws instead).
|
|
62
28
|
*/
|
|
63
29
|
bundle: PageDataBundle | undefined;
|
|
64
30
|
};
|
|
65
|
-
declare function renderPage(routeName: string, options?: RenderPageOptions): Promise<RenderedPage | Response>;
|
|
66
31
|
/**
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* tail renders and emits.
|
|
32
|
+
* The production render surface: a real HTTP server has a URL. The url goes
|
|
33
|
+
* STRAIGHT to executePageRequest's stage-1 matcher, then the shared tail
|
|
34
|
+
* (`finishRender`) renders and emits.
|
|
71
35
|
*
|
|
72
|
-
* No-match
|
|
73
|
-
*
|
|
74
|
-
*
|
|
36
|
+
* No-match is not thrown on: an arbitrary URL matching no route is a
|
|
37
|
+
* legitimate 404, and a server must ANSWER it — `{ html: "", status: 404 }`
|
|
38
|
+
* with an undefined `bundle` (see RenderedPage).
|
|
75
39
|
*/
|
|
76
|
-
declare function renderPageRequest(url: string, options
|
|
40
|
+
declare function renderPageRequest(url: string, options: RenderPageRequestOptions): Promise<RenderedPage | Response>;
|
|
77
41
|
//#endregion
|
|
78
|
-
export {
|
|
42
|
+
export { RenderPageRequestOptions, RenderedPage, renderPageRequest };
|
|
79
43
|
//# sourceMappingURL=render-page.d.mts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LocaleProvider } from "../localization.mjs";
|
|
2
2
|
import { DocumentContext, PAYLOAD_SCRIPT_ID, escapePayload } from "../components/document-context.mjs";
|
|
3
3
|
import { markNonHydrating } from "./page-render-bundle.mjs";
|
|
4
|
-
import { registerModules } from "../
|
|
4
|
+
import { registerModules } from "../register-modules.mjs";
|
|
5
5
|
import DefaultApp from "../components/default-app.mjs";
|
|
6
6
|
import { buildHydrationPayload } from "./build-hydration-payload.mjs";
|
|
7
7
|
import { ERROR_PAGE_METADATA } from "./resolve-page-metadata.mjs";
|
|
@@ -22,37 +22,14 @@ function committedHeaders(bundle) {
|
|
|
22
22
|
function committedCookies(bundle) {
|
|
23
23
|
return bundle.commit?.cookies ?? [];
|
|
24
24
|
}
|
|
25
|
-
let pageRoutesRegistry;
|
|
26
|
-
/**
|
|
27
|
-
* Boot-time wiring so `renderPage(name, options)` can resolve a route NAME
|
|
28
|
-
* without each call site carrying the manifest. Returns the previous registry
|
|
29
|
-
* so tests can restore it. A per-call `routes`/`createHttp` override wins.
|
|
30
|
-
*/
|
|
31
|
-
function connectPageRoutes(registry) {
|
|
32
|
-
const previous = pageRoutesRegistry;
|
|
33
|
-
pageRoutesRegistry = registry;
|
|
34
|
-
return previous;
|
|
35
|
-
}
|
|
36
25
|
function requireRegistry(options) {
|
|
37
|
-
const routes = options
|
|
38
|
-
|
|
39
|
-
if (!routes || !createHttp) throw new Error("renderPage()/renderPageRequest() has no route registry connected (web/src/server/render-page.ts). Both resolve against the page manifest, which the server bootstrap owns. Fix: call connectPageRoutes({ routes, createHttp }) at boot (tests: in beforeAll), or pass { routes, createHttp } to this call.");
|
|
26
|
+
const { routes, createHttp } = options;
|
|
27
|
+
if (!routes || !createHttp) throw new Error("renderPageRequest() has no route registry (web/src/server/render-page.ts). It resolves against the page manifest, which the server bootstrap owns. Fix: pass { routes, createHttp } to this call.");
|
|
40
28
|
return {
|
|
41
29
|
routes,
|
|
42
30
|
createHttp
|
|
43
31
|
};
|
|
44
32
|
}
|
|
45
|
-
function buildUrl(entry, params, query) {
|
|
46
|
-
const path = entry.path.split("/").map((segment) => {
|
|
47
|
-
if (!segment.startsWith(":")) return segment;
|
|
48
|
-
const name = segment.slice(1);
|
|
49
|
-
const value = params[name];
|
|
50
|
-
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}: … }\`.`);
|
|
51
|
-
return encodeURIComponent(value);
|
|
52
|
-
}).join("/");
|
|
53
|
-
const queryString = new URLSearchParams(query).toString();
|
|
54
|
-
return queryString ? `${path}?${queryString}` : path;
|
|
55
|
-
}
|
|
56
33
|
/**
|
|
57
34
|
* The framework-owned terminal boundary (P1 §4: designation falls back to
|
|
58
35
|
* `app` even when no level exports one — "the framework owns a root
|
|
@@ -205,7 +182,7 @@ async function finishRender(triple, bundle, documentSlots, response, loadErrorPa
|
|
|
205
182
|
if (!loadErrorPage) return void 0;
|
|
206
183
|
const props = {
|
|
207
184
|
error: thrown,
|
|
208
|
-
status: 500
|
|
185
|
+
status: currentError?.statusCode ?? 500
|
|
209
186
|
};
|
|
210
187
|
const module = await loadErrorPage();
|
|
211
188
|
registerModules([module]);
|
|
@@ -240,7 +217,7 @@ async function finishRender(triple, bundle, documentSlots, response, loadErrorPa
|
|
|
240
217
|
}
|
|
241
218
|
currentError = buildErrorRecord(thrown, designateBoundary(currentError?.boundary.boundaryLevel === "layout" ? "app" : currentError ? "layout" : "page", triple));
|
|
242
219
|
}
|
|
243
|
-
const status = currentError ? 500 : bundle.commit?.statusCode ?? 200;
|
|
220
|
+
const status = currentError ? currentError.statusCode ?? 500 : bundle.commit?.statusCode ?? 200;
|
|
244
221
|
return {
|
|
245
222
|
html: emitDocument(body),
|
|
246
223
|
status,
|
|
@@ -329,40 +306,22 @@ async function renderPageFailure(options) {
|
|
|
329
306
|
bundle
|
|
330
307
|
};
|
|
331
308
|
}
|
|
332
|
-
async function renderPage(routeName, options = {}) {
|
|
333
|
-
const registry = requireRegistry(options);
|
|
334
|
-
const entry = registry.routes.find((candidate) => candidate.name === routeName);
|
|
335
|
-
if (!entry) {
|
|
336
|
-
const known = registry.routes.map((candidate) => `"${candidate.name}"`).join(", ");
|
|
337
|
-
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.`);
|
|
338
|
-
}
|
|
339
|
-
const url = buildUrl(entry, options.params ?? {}, options.query ?? {});
|
|
340
|
-
const { state, createHttp } = capturingCreateHttp(registry);
|
|
341
|
-
const rendered = await executePageRequest({
|
|
342
|
-
url,
|
|
343
|
-
routes: registry.routes,
|
|
344
|
-
createHttp,
|
|
345
|
-
finish: (bundle) => finishRender(entry.triple, bundle, documentSlotsFrom(state.captured), state.captured.response, options.loadErrorPage)
|
|
346
|
-
});
|
|
347
|
-
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.`);
|
|
348
|
-
return rendered;
|
|
349
|
-
}
|
|
350
309
|
/**
|
|
351
|
-
* The
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* tail renders and emits.
|
|
310
|
+
* The production render surface: a real HTTP server has a URL. The url goes
|
|
311
|
+
* STRAIGHT to executePageRequest's stage-1 matcher, then the shared tail
|
|
312
|
+
* (`finishRender`) renders and emits.
|
|
355
313
|
*
|
|
356
|
-
* No-match
|
|
357
|
-
*
|
|
358
|
-
*
|
|
314
|
+
* No-match is not thrown on: an arbitrary URL matching no route is a
|
|
315
|
+
* legitimate 404, and a server must ANSWER it — `{ html: "", status: 404 }`
|
|
316
|
+
* with an undefined `bundle` (see RenderedPage).
|
|
359
317
|
*/
|
|
360
|
-
async function renderPageRequest(url, options
|
|
318
|
+
async function renderPageRequest(url, options) {
|
|
361
319
|
const registry = requireRegistry(options);
|
|
362
320
|
const { state, createHttp } = capturingCreateHttp(registry);
|
|
363
321
|
const rendered = await executePageRequest({
|
|
364
322
|
url,
|
|
365
323
|
routes: registry.routes,
|
|
324
|
+
matched: options.matched,
|
|
366
325
|
createHttp,
|
|
367
326
|
finish: (bundle) => finishRender(state.match.entry.triple, bundle, documentSlotsFrom(state.captured), state.captured.response, options.loadErrorPage)
|
|
368
327
|
});
|
|
@@ -378,5 +337,5 @@ async function renderPageRequest(url, options = {}) {
|
|
|
378
337
|
}
|
|
379
338
|
|
|
380
339
|
//#endregion
|
|
381
|
-
export {
|
|
340
|
+
export { renderPageFailure, renderPageRequest };
|
|
382
341
|
//# sourceMappingURL=render-page.mjs.map
|