@warlock.js/web 5.0.1 → 5.1.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 +76 -0
- package/esm/build/contribution.d.mts +10 -0
- package/esm/build/contribution.mjs +36 -0
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +226 -12
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +43 -6
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
- package/esm/metadata.d.mts +14 -0
- package/esm/metadata.mjs +45 -0
- package/esm/metadata.mjs.map +1 -0
- package/esm/routing/url-fragment.mjs +120 -0
- package/esm/routing/url-fragment.mjs.map +1 -0
- package/esm/server/create-page-route-handler.d.mts +27 -0
- package/esm/server/create-page-route-handler.mjs +12 -10
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -1
- package/esm/server/index.mjs +2 -1
- package/esm/server/install-page-routes-from-manifest.mjs +23 -1
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +4 -2
- package/esm/server/install-page-routes.mjs +33 -5
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs +6 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +126 -0
- package/esm/server/not-found-page.mjs +157 -0
- package/esm/server/not-found-page.mjs.map +1 -0
- package/esm/server/web-connector-factory.mjs +2 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +123 -5
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +8 -4
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +211 -0
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/llms-full.txt +33 -5
- package/llms.txt +4 -2
- package/package.json +3 -3
- package/skills/create-a-page/SKILL.md +18 -2
- package/skills/navigate-on-the-client/SKILL.md +2 -0
- package/skills/use-layouts/SKILL.md +6 -0
- package/skills/write-the-root/SKILL.md +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"install-page-routes.mjs","names":[],"sources":["../../../../../../../web/src/server/install-page-routes.ts"],"sourcesContent":["/**\r\n * Registers every page {@link discoverPageFiles} finds under `<appSrcRoot>`\r\n * into Warlock's router (`router.get`, `core/src/router/router.ts:359-361`)\r\n * so `router.scanDevServer(fastify)` —\r\n * the sanctioned dev-server dispatch path (server matching is Warlock's\r\n * router; there is no second server matcher) — picks\r\n * it up. Replaces the two hand-rolled `fastify.get()` calls this file's\r\n * sibling, `dev-server.ts`, used to make directly.\r\n *\r\n * DELIBERATE EXCEPTION to \"web has no core dependency\", same\r\n * reasoning `dev-server.ts`'s own header comment records: this module is not\r\n * exported from either package barrel and is not part of `web/package.json`'s\r\n * dependency graph — dev/CLI bootstrap only.\r\n *\r\n * Scope note: a page's\r\n * `route.path` is now composed with the `prefix` export of EVERY `layout.tsx`\r\n * on its path — outermost first (`composeRoutePath` below) — before\r\n * registration and before the collision check, so `home.page.tsx`\r\n * (`path: \"/\"`, main layout `prefix: \"/\"`) resolves to `/` and\r\n * `products.page.tsx` (`path: \"/\"`, products layout `prefix: \"/products\"`)\r\n * resolves to `/products` — no collision. A page with no `layout.tsx` on its\r\n * path composes against the implicit root prefix `\"/\"` (e.g. `/contact-us`,\r\n * `/hydration-demo`, both unaffected by composition).\r\n *\r\n * WHICH PAGES EXIST is answered by {@link discoverPageFiles}\r\n * (`web/src/build/discover-pages.ts`) — the same walk production's build\r\n * shares — so this file owns no directory-walking of its own and serves the\r\n * global root (`<appSrcRoot>/web/**`) exactly as it serves a module's\r\n * (`<appSrcRoot>/app/<module>/web/**`). WHAT ROUTE A PAGE ANSWERS ON stays\r\n * this file's own job: each page and its nearest layout are still evaluated\r\n * through Vite (`vite.ssrLoadModule`), never read statically, because a dev\r\n * page module must be the one Vite serves, warm cache and all.\r\n */\r\nimport path from \"node:path\";\r\nimport type { ViteDevServer } from \"vite\";\r\nimport { discoverPageFiles, layoutChainFor, toPosix } from \"../build/discover-pages\";\r\nimport { composeRoutePath } from \"../routing/compose-route-path\";\r\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\r\nimport { canonicalizeRouteExport, deriveFallbackRouteName } from \"../routing/route-identity\";\r\nimport { publishRouteTable } from \"../routing/route-table\";\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport { createPageRouteHandler } from \"./create-page-route-handler\";\r\nimport type { PipelineMiddleware } from \"./execute-page-request\";\r\n\r\n/** Re-exported so `web/src/server/index.ts`'s existing barrel export keeps resolving. */\r\nexport { composeRoutePath };\r\n\r\nexport type PageRouteExport = string | { path: string; name?: string };\r\n\r\nexport type PageModuleShape = {\r\n route?: PageRouteExport;\r\n};\r\n\r\nexport type InstalledPageRoute = {\r\n path: string;\r\n name: string;\r\n file: string;\r\n layoutFile: string | undefined;\r\n};\r\n\r\n/**\r\n * The page's app-root-relative POSIX source path, e.g.\r\n * \".../v5/app/src/app/main/web/home.page.tsx\" with appSrcRoot\r\n * \".../v5/app/src\" -> \"src/app/main/web/home.page.tsx\" — the canonical form\r\n * `deriveFallbackRouteName` (`../routing/route-identity`) requires. The first\r\n * segment's actual name is arbitrary to that function (it only inspects the\r\n * segment AFTER it), so `appSrcRoot`'s own basename is used rather than\r\n * discovering the true app root.\r\n */\r\nfunction canonicalSourceFileFor(pageFile: string, appSrcRoot: string): string {\r\n return `${path.basename(appSrcRoot)}/${toPosix(path.relative(appSrcRoot, pageFile))}`;\r\n}\r\n\r\nfunction resolveRoute(\r\n routeExport: PageRouteExport,\r\n sourceFile: string,\r\n): { path: string; name: string } {\r\n const canonical = canonicalizeRouteExport(routeExport);\r\n\r\n return {\r\n path: canonical.path,\r\n name: canonical.name ?? deriveFallbackRouteName({ routePath: canonical.path, sourceFile }),\r\n };\r\n}\r\n\r\nexport type LayoutModuleShape = {\r\n prefix?: string;\r\n /**\r\n * The default export — the thing that puts an element in the document, and\r\n * therefore the ONLY export that decides whether a layout counts against the\r\n * single-rendering-layout rule (`../routing/layout-policy.ts`). In dev the\r\n * module is loaded, so this is a fact rather than a guess.\r\n */\r\n default?: unknown;\r\n /** The layout's guards, in the order it declared them. */\r\n middleware?: readonly PipelineMiddleware[];\r\n};\r\n\r\n/** How this module gets a layout module namespace — `vite.ssrLoadModule`, in practice. */\r\ntype LoadLayout = (layoutFile: string) => Promise<LayoutModuleShape>;\r\n\r\n/**\r\n * The page's layout LEVEL, resolved from its whole chain rather than from the\r\n * one layout nearest to it.\r\n *\r\n * The render pipeline has exactly one layout slot per page\r\n * (`execute-page-request.ts`'s `PageRouteEntry[\"triple\"]`), so the chain has to\r\n * be collapsed into one module before it reaches a handler. Two things collapse\r\n * differently and both matter:\r\n *\r\n * - RENDERING is a selection: at most one layout on the chain may render, and\r\n * the policy picks it. `renders` is read off the loaded module\r\n * (`typeof module.default !== \"undefined\"`), never off the filename — a\r\n * `middleware`-only layout has no default export and is not a wrapper, and\r\n * passing a bare path to `selectPageLayout` would have it read as a rendering\r\n * one, which is the conservative default and the wrong answer here.\r\n * - MIDDLEWARE and PREFIX are compositions: every layout on the path\r\n * contributes, outermost first. A guard on an outer layout that the page's\r\n * own directory knows nothing about is exactly the guard that must still run,\r\n * and a prefix nobody composed is a URL nobody wrote down.\r\n */\r\ntype LayoutLevel = {\r\n /** Every `layout.tsx` from the web root down to the page's directory, outermost first. */\r\n chain: string[];\r\n /**\r\n * The module id the handler's layout slot is registered under, or `undefined`\r\n * when the page has no layout at all: the layout that RENDERS, or — when none\r\n * does — the nearest one, which is the slot dev has always used and so the\r\n * choice that changes nothing but the middleware for a chain with no wrapper\r\n * in it.\r\n */\r\n layoutFile: string | undefined;\r\n /** Every layout's `prefix`, composed outermost first — `discoverPages`' own reduction. */\r\n prefix: string;\r\n};\r\n\r\nasync function resolveLayoutLevel(\r\n pageFile: string,\r\n webRoot: string,\r\n loadLayout: LoadLayout,\r\n): Promise<LayoutLevel> {\r\n const chain = layoutChainFor(pageFile, webRoot);\r\n const modules = await Promise.all(chain.map(loadLayout));\r\n const selection = selectPageLayout(\r\n chain.map((layout, index) => ({\r\n layout,\r\n renders: typeof modules[index].default !== \"undefined\",\r\n })),\r\n );\r\n\r\n if (selection.type === \"rejected\") {\r\n throw new NestedLayoutsNotSupportedError(pageFile, selection.layouts);\r\n }\r\n\r\n return {\r\n chain,\r\n layoutFile: selection.type === \"selected\" ? selection.layout : chain.at(-1),\r\n prefix: modules.reduce(\r\n (composed, layoutModule) => composeRoutePath(composed, layoutModule.prefix ?? \"/\"),\r\n \"/\",\r\n ),\r\n };\r\n}\r\n\r\n/**\r\n * The layout slot's module for ONE request: the slot host's own namespace, with\r\n * the whole chain's middleware in place of its own — outermost first, which is\r\n * the order stage 3 runs the array in (`execute-page-request.ts:519-524`) and\r\n * the order an outer `optionalAuth` needs in order to have resolved an identity\r\n * before an inner `gate()` checks it.\r\n *\r\n * Loaded per call, not once at install time: a dev layout module must be the\r\n * one Vite is currently serving, edits and all.\r\n */\r\nasync function composeLayoutLevel(\r\n level: LayoutLevel & { layoutFile: string },\r\n loadLayout: LoadLayout,\r\n): Promise<LayoutModuleShape> {\r\n const modules = await Promise.all(level.chain.map(loadLayout));\r\n const host = modules[level.chain.indexOf(level.layoutFile)];\r\n\r\n return {\r\n ...host,\r\n middleware: modules.flatMap(layoutModule => [...(layoutModule.middleware ?? [])]),\r\n };\r\n}\r\n\r\nexport type InstallPageRoutesOptions = {\r\n router: Router;\r\n vite: ViteDevServer;\r\n /** v5/app/src — pages live under \"<appSrcRoot>/app/*\\/web/**\" and \"<appSrcRoot>/web/**\". */\r\n appSrcRoot: string;\r\n /** v5/app/src/web/root.tsx — the single global app-root file. */\r\n appFile: string;\r\n /** Browser module loaded after the server-rendered application and payload. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs emitted into every page's `<head>`.\r\n *\r\n * In dev these are Vite source URLs; see `devStylesheetUrls` for why they\r\n * carry `?direct`.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, not imported, to avoid a dev-server.ts <-> this-file cycle. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n};\r\n\r\n/**\r\n * Registers every discoverable page into `options.router`. Throws\r\n * IMMEDIATELY, naming both files, the moment two pages declare the same\r\n * `route.path` — a registration-time failure, not a runtime 404 one of them\r\n * silently loses.\r\n *\r\n * Pages with no `route` export are skipped: discovery cannot invent a public\r\n * URL or route name for an undeclared page.\r\n */\r\nexport async function installPageRoutes(\r\n options: InstallPageRoutesOptions,\r\n): Promise<InstalledPageRoute[]> {\r\n const {\r\n router,\r\n vite,\r\n appSrcRoot,\r\n appFile,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n } = options;\r\n const pageFiles = [...discoverPageFiles(appSrcRoot)].sort((left, right) =>\r\n left.pageFile < right.pageFile ? -1 : left.pageFile > right.pageFile ? 1 : 0,\r\n );\r\n\r\n const installed: InstalledPageRoute[] = [];\r\n const fileByPath = new Map<string, string>();\r\n\r\n for (const { pageFile, webRoot } of pageFiles) {\r\n const pageModule = (await vite.ssrLoadModule(pageFile)) as PageModuleShape;\r\n\r\n if (pageModule.route === undefined) {\r\n continue;\r\n }\r\n\r\n const sourceFile = canonicalSourceFileFor(pageFile, appSrcRoot);\r\n const { path: routePath, name } = resolveRoute(pageModule.route, sourceFile);\r\n\r\n const loadLayout: LoadLayout = layoutFile =>\r\n vite.ssrLoadModule(layoutFile) as Promise<LayoutModuleShape>;\r\n const layoutLevel = await resolveLayoutLevel(pageFile, webRoot, loadLayout);\r\n const { layoutFile, prefix: layoutPrefix } = layoutLevel;\r\n\r\n const effectivePath = composeRoutePath(layoutPrefix, routePath);\r\n\r\n const existingFile = fileByPath.get(effectivePath);\r\n\r\n if (existingFile) {\r\n throw new Error(\r\n `installPageRoutes: composed route path \"${effectivePath}\" (layout ` +\r\n `prefix \"${layoutPrefix}\" + route.path \"${routePath}\") is declared by two ` +\r\n `pages (web/src/server/install-page-routes.ts) — \"${existingFile}\" and ` +\r\n `\"${pageFile}\". Every page's composed route path must be unique.`,\r\n );\r\n }\r\n\r\n fileByPath.set(effectivePath, pageFile);\r\n\r\n router.get(\r\n effectivePath,\r\n // The handler itself is `createPageRouteHandler`\r\n // (`web/src/server/create-page-route-handler.ts`) — a named seam a\r\n // future `type: \"page\"` route can bind to, and testable without a Vite\r\n // server. Vite appears here only as the dev answer to \"how do I load a\r\n // module\"; the handler takes that as an input and knows nothing else\r\n // about it.\r\n createPageRouteHandler({\r\n path: effectivePath,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n // The layout slot's id resolves to the COMPOSED level — every layout's\r\n // middleware, in chain order — and every other id goes straight to\r\n // Vite. A one-layout chain has nothing to compose, so it is left to\r\n // resolve as the exact module Vite hands back, untouched.\r\n loadModule:\r\n layoutLevel.chain.length > 1 && layoutFile !== undefined\r\n ? moduleId =>\r\n moduleId === layoutFile\r\n ? composeLayoutLevel({ ...layoutLevel, layoutFile }, loadLayout)\r\n : vite.ssrLoadModule(moduleId)\r\n : moduleId => vite.ssrLoadModule(moduleId),\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n }),\r\n // `isPage` marks this route as SSR-served. Pages and API routes share one\r\n // router and one route-name namespace, so the router's duplicate-name\r\n // error reads this flag to say which claimant is the page.\r\n { name, isPage: true },\r\n );\r\n\r\n installed.push({ path: effectivePath, name, file: pageFile, layoutFile });\r\n }\r\n\r\n /*\r\n Published from the SAME loop that registered the routes, so `href()` and the\r\n router cannot disagree about where a name points. It happens here rather\r\n than in the caller because a caller that forgets leaves every `<Link>` on\r\n the server throwing at render — and dev republishes on every restart, which\r\n is why the table replaces wholesale instead of merging: a deleted page's\r\n name has to stop resolving.\r\n */\r\n publishRouteTable(installed, \"installPageRoutes (dev)\");\r\n\r\n return installed;\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"install-page-routes.mjs","names":[],"sources":["../../../../../../../web/src/server/install-page-routes.ts"],"sourcesContent":["/**\r\n * Registers every page {@link discoverPageFiles} finds under `<appSrcRoot>`\r\n * into Warlock's router (`router.get`, `core/src/router/router.ts:359-361`)\r\n * so `router.scanDevServer(fastify)` —\r\n * the sanctioned dev-server dispatch path (server matching is Warlock's\r\n * router; there is no second server matcher) — picks\r\n * it up. Replaces the two hand-rolled `fastify.get()` calls this file's\r\n * sibling, `dev-server.ts`, used to make directly.\r\n *\r\n * DELIBERATE EXCEPTION to \"web has no core dependency\", same\r\n * reasoning `dev-server.ts`'s own header comment records: this module is not\r\n * exported from either package barrel and is not part of `web/package.json`'s\r\n * dependency graph — dev/CLI bootstrap only.\r\n *\r\n * Scope note: a page's\r\n * `route.path` is now composed with the `prefix` export of EVERY `layout.tsx`\r\n * on its path — outermost first (`composeRoutePath` below) — before\r\n * registration and before the collision check, so `home.page.tsx`\r\n * (`path: \"/\"`, main layout `prefix: \"/\"`) resolves to `/` and\r\n * `products.page.tsx` (`path: \"/\"`, products layout `prefix: \"/products\"`)\r\n * resolves to `/products` — no collision. A page with no `layout.tsx` on its\r\n * path composes against the implicit root prefix `\"/\"` (e.g. `/contact-us`,\r\n * `/hydration-demo`, both unaffected by composition).\r\n *\r\n * WHICH PAGES EXIST is answered by {@link discoverPageFiles}\r\n * (`web/src/build/discover-pages.ts`) — the same walk production's build\r\n * shares — so this file owns no directory-walking of its own and serves the\r\n * global root (`<appSrcRoot>/web/**`) exactly as it serves a module's\r\n * (`<appSrcRoot>/app/<module>/web/**`). WHAT ROUTE A PAGE ANSWERS ON stays\r\n * this file's own job: each page and its nearest layout are still evaluated\r\n * through Vite (`vite.ssrLoadModule`), never read statically, because a dev\r\n * page module must be the one Vite serves, warm cache and all.\r\n */\r\nimport path from \"node:path\";\r\nimport type { ViteDevServer } from \"vite\";\r\nimport {\r\n discoverPageFiles,\r\n layoutChainFor,\r\n MissingRouteExportError,\r\n toPosix,\r\n} from \"../build/discover-pages\";\r\nimport { composeRoutePath } from \"../routing/compose-route-path\";\r\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\r\nimport { canonicalizeRouteExport, deriveFallbackRouteName } from \"../routing/route-identity\";\r\nimport { publishRouteTable } from \"../routing/route-table\";\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport { createPageRouteHandler } from \"./create-page-route-handler\";\r\nimport type { PipelineMiddleware } from \"./execute-page-request\";\r\nimport {\r\n createNotFoundRouteHandler,\r\n DuplicateNotFoundPageError,\r\n isNotFoundPageFile,\r\n NotFoundPageDeclaresRouteError,\r\n NOT_FOUND_ROUTE_NAME,\r\n NOT_FOUND_ROUTE_PATH,\r\n type RegisteredRouteShape,\r\n} from \"./not-found-page\";\r\n\r\n/** Re-exported so `web/src/server/index.ts`'s existing barrel export keeps resolving. */\r\nexport { composeRoutePath };\r\n\r\nexport type PageRouteExport = string | { path: string; name?: string };\r\n\r\nexport type PageModuleShape = {\r\n route?: PageRouteExport;\r\n};\r\n\r\nexport type InstalledPageRoute = {\r\n path: string;\r\n name: string;\r\n file: string;\r\n layoutFile: string | undefined;\r\n};\r\n\r\n/**\r\n * The page's app-root-relative POSIX source path, e.g.\r\n * \".../v5/app/src/app/main/web/home.page.tsx\" with appSrcRoot\r\n * \".../v5/app/src\" -> \"src/app/main/web/home.page.tsx\" — the canonical form\r\n * `deriveFallbackRouteName` (`../routing/route-identity`) requires. The first\r\n * segment's actual name is arbitrary to that function (it only inspects the\r\n * segment AFTER it), so `appSrcRoot`'s own basename is used rather than\r\n * discovering the true app root.\r\n */\r\nfunction canonicalSourceFileFor(pageFile: string, appSrcRoot: string): string {\r\n return `${path.basename(appSrcRoot)}/${toPosix(path.relative(appSrcRoot, pageFile))}`;\r\n}\r\n\r\nfunction resolveRoute(\r\n routeExport: PageRouteExport,\r\n sourceFile: string,\r\n): { path: string; name: string } {\r\n const canonical = canonicalizeRouteExport(routeExport);\r\n\r\n return {\r\n path: canonical.path,\r\n name: canonical.name ?? deriveFallbackRouteName({ routePath: canonical.path, sourceFile }),\r\n };\r\n}\r\n\r\nexport type LayoutModuleShape = {\r\n prefix?: string;\r\n /**\r\n * The default export — the thing that puts an element in the document, and\r\n * therefore the ONLY export that decides whether a layout counts against the\r\n * single-rendering-layout rule (`../routing/layout-policy.ts`). In dev the\r\n * module is loaded, so this is a fact rather than a guess.\r\n */\r\n default?: unknown;\r\n /** The layout's guards, in the order it declared them. */\r\n middleware?: readonly PipelineMiddleware[];\r\n};\r\n\r\n/** How this module gets a layout module namespace — `vite.ssrLoadModule`, in practice. */\r\ntype LoadLayout = (layoutFile: string) => Promise<LayoutModuleShape>;\r\n\r\n/**\r\n * The page's layout LEVEL, resolved from its whole chain rather than from the\r\n * one layout nearest to it.\r\n *\r\n * The render pipeline has exactly one layout slot per page\r\n * (`execute-page-request.ts`'s `PageRouteEntry[\"triple\"]`), so the chain has to\r\n * be collapsed into one module before it reaches a handler. Two things collapse\r\n * differently and both matter:\r\n *\r\n * - RENDERING is a selection: at most one layout on the chain may render, and\r\n * the policy picks it. `renders` is read off the loaded module\r\n * (`typeof module.default !== \"undefined\"`), never off the filename — a\r\n * `middleware`-only layout has no default export and is not a wrapper, and\r\n * passing a bare path to `selectPageLayout` would have it read as a rendering\r\n * one, which is the conservative default and the wrong answer here.\r\n * - MIDDLEWARE and PREFIX are compositions: every layout on the path\r\n * contributes, outermost first. A guard on an outer layout that the page's\r\n * own directory knows nothing about is exactly the guard that must still run,\r\n * and a prefix nobody composed is a URL nobody wrote down.\r\n */\r\ntype LayoutLevel = {\r\n /** Every `layout.tsx` from the web root down to the page's directory, outermost first. */\r\n chain: string[];\r\n /**\r\n * The module id the handler's layout slot is registered under, or `undefined`\r\n * when the page has no layout at all: the layout that RENDERS, or — when none\r\n * does — the nearest one, which is the slot dev has always used and so the\r\n * choice that changes nothing but the middleware for a chain with no wrapper\r\n * in it.\r\n */\r\n layoutFile: string | undefined;\r\n /** Every layout's `prefix`, composed outermost first — `discoverPages`' own reduction. */\r\n prefix: string;\r\n};\r\n\r\nasync function resolveLayoutLevel(\r\n pageFile: string,\r\n webRoot: string,\r\n loadLayout: LoadLayout,\r\n): Promise<LayoutLevel> {\r\n const chain = layoutChainFor(pageFile, webRoot);\r\n const modules = await Promise.all(chain.map(loadLayout));\r\n const selection = selectPageLayout(\r\n chain.map((layout, index) => ({\r\n layout,\r\n renders: typeof modules[index].default !== \"undefined\",\r\n })),\r\n );\r\n\r\n if (selection.type === \"rejected\") {\r\n throw new NestedLayoutsNotSupportedError(pageFile, selection.layouts);\r\n }\r\n\r\n return {\r\n chain,\r\n layoutFile: selection.type === \"selected\" ? selection.layout : chain.at(-1),\r\n prefix: modules.reduce(\r\n (composed, layoutModule) => composeRoutePath(composed, layoutModule.prefix ?? \"/\"),\r\n \"/\",\r\n ),\r\n };\r\n}\r\n\r\n/**\r\n * The layout slot's module for ONE request: the slot host's own namespace, with\r\n * the whole chain's middleware in place of its own — outermost first, which is\r\n * the order stage 3 runs the array in (`execute-page-request.ts:519-524`) and\r\n * the order an outer `optionalAuth` needs in order to have resolved an identity\r\n * before an inner `gate()` checks it.\r\n *\r\n * Loaded per call, not once at install time: a dev layout module must be the\r\n * one Vite is currently serving, edits and all.\r\n */\r\nasync function composeLayoutLevel(\r\n level: LayoutLevel & { layoutFile: string },\r\n loadLayout: LoadLayout,\r\n): Promise<LayoutModuleShape> {\r\n const modules = await Promise.all(level.chain.map(loadLayout));\r\n const host = modules[level.chain.indexOf(level.layoutFile)];\r\n\r\n return {\r\n ...host,\r\n middleware: modules.flatMap(layoutModule => [...(layoutModule.middleware ?? [])]),\r\n };\r\n}\r\n\r\nexport type InstallPageRoutesOptions = {\r\n router: Router;\r\n vite: ViteDevServer;\r\n /** v5/app/src — pages live under \"<appSrcRoot>/app/*\\/web/**\" and \"<appSrcRoot>/web/**\". */\r\n appSrcRoot: string;\r\n /** v5/app/src/web/root.tsx — the single global app-root file. */\r\n appFile: string;\r\n /** Browser module loaded after the server-rendered application and payload. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs emitted into every page's `<head>`.\r\n *\r\n * In dev these are Vite source URLs; see `devStylesheetUrls` for why they\r\n * carry `?direct`.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, not imported, to avoid a dev-server.ts <-> this-file cycle. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n};\r\n\r\n/**\r\n * Registers every discoverable page into `options.router`. Throws\r\n * IMMEDIATELY, naming both files, the moment two pages declare the same\r\n * `route.path` — a registration-time failure, not a runtime 404 one of them\r\n * silently loses.\r\n *\r\n * Pages with no `route` export are REFUSED, not skipped, with the same\r\n * `MissingRouteExportError` the build throws: discovery cannot invent a public\r\n * URL for an undeclared page, and a dev server that silently drops the file you\r\n * just wrote is indistinguishable from a typo in the URL.\r\n */\r\nexport async function installPageRoutes(\r\n options: InstallPageRoutesOptions,\r\n): Promise<InstalledPageRoute[]> {\r\n const {\r\n router,\r\n vite,\r\n appSrcRoot,\r\n appFile,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n } = options;\r\n const discovered = [...discoverPageFiles(appSrcRoot)].sort((left, right) =>\r\n left.pageFile < right.pageFile ? -1 : left.pageFile > right.pageFile ? 1 : 0,\r\n );\r\n\r\n // THE NOT-FOUND PAGE IS TAKEN OUT OF THE ORDINARY LOOP, not filtered inside\r\n // it. It has no `route` export to read, no path to compose and no collision\r\n // to check — every step below is about a page with a URL, and `404.page.tsx`\r\n // does not have one. Registering it here would put it at `/404`, which is not\r\n // a page anybody asked to be able to visit.\r\n const notFoundPageFiles = discovered.filter((page) => isNotFoundPageFile(page.pageFile));\r\n const pageFiles = discovered.filter((page) => !isNotFoundPageFile(page.pageFile));\r\n\r\n if (notFoundPageFiles.length > 1) {\r\n throw new DuplicateNotFoundPageError(notFoundPageFiles.map((page) => page.pageFile));\r\n }\r\n\r\n const installed: InstalledPageRoute[] = [];\r\n const fileByPath = new Map<string, string>();\r\n\r\n for (const { pageFile, webRoot } of pageFiles) {\r\n const pageModule = (await vite.ssrLoadModule(pageFile)) as PageModuleShape;\r\n\r\n const sourceFile = canonicalSourceFileFor(pageFile, appSrcRoot);\r\n\r\n // Same condition, same error, same wording the build already throws\r\n // (`MissingRouteExportError`, `web/src/build/discover-pages.ts`) — dev used\r\n // to `continue` here, so a page you had just written vanished into a\r\n // not-found with nothing said. One condition, one verdict.\r\n if (pageModule.route === undefined) {\r\n throw new MissingRouteExportError(sourceFile);\r\n }\r\n\r\n const { path: routePath, name } = resolveRoute(pageModule.route, sourceFile);\r\n\r\n const loadLayout: LoadLayout = layoutFile =>\r\n vite.ssrLoadModule(layoutFile) as Promise<LayoutModuleShape>;\r\n const layoutLevel = await resolveLayoutLevel(pageFile, webRoot, loadLayout);\r\n const { layoutFile, prefix: layoutPrefix } = layoutLevel;\r\n\r\n const effectivePath = composeRoutePath(layoutPrefix, routePath);\r\n\r\n const existingFile = fileByPath.get(effectivePath);\r\n\r\n if (existingFile) {\r\n throw new Error(\r\n `installPageRoutes: composed route path \"${effectivePath}\" (layout ` +\r\n `prefix \"${layoutPrefix}\" + route.path \"${routePath}\") is declared by two ` +\r\n `pages (web/src/server/install-page-routes.ts) — \"${existingFile}\" and ` +\r\n `\"${pageFile}\". Every page's composed route path must be unique.`,\r\n );\r\n }\r\n\r\n fileByPath.set(effectivePath, pageFile);\r\n\r\n router.get(\r\n effectivePath,\r\n // The handler itself is `createPageRouteHandler`\r\n // (`web/src/server/create-page-route-handler.ts`) — a named seam a\r\n // future `type: \"page\"` route can bind to, and testable without a Vite\r\n // server. Vite appears here only as the dev answer to \"how do I load a\r\n // module\"; the handler takes that as an input and knows nothing else\r\n // about it.\r\n createPageRouteHandler({\r\n path: effectivePath,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n // The layout slot's id resolves to the COMPOSED level — every layout's\r\n // middleware, in chain order — and every other id goes straight to\r\n // Vite. A one-layout chain has nothing to compose, so it is left to\r\n // resolve as the exact module Vite hands back, untouched.\r\n loadModule:\r\n layoutLevel.chain.length > 1 && layoutFile !== undefined\r\n ? moduleId =>\r\n moduleId === layoutFile\r\n ? composeLayoutLevel({ ...layoutLevel, layoutFile }, loadLayout)\r\n : vite.ssrLoadModule(moduleId)\r\n : moduleId => vite.ssrLoadModule(moduleId),\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n }),\r\n // `isPage` marks this route as SSR-served. Pages and API routes share one\r\n // router and one route-name namespace, so the router's duplicate-name\r\n // error reads this flag to say which claimant is the page.\r\n { name, isPage: true },\r\n );\r\n\r\n installed.push({ path: effectivePath, name, file: pageFile, layoutFile });\r\n }\r\n\r\n /*\r\n THE CATCH-ALL, registered LAST and only when this application has a page\r\n surface at all. \"Configured with web, no pages yet\" is a legal state, and an\r\n application serving no pages has no page 404 to answer with — its unmatched\r\n URLs stay core's to answer, exactly as they are today.\r\n\r\n Registered even when the application ships no `404.page.tsx`: the framework\r\n default still answers 404, so an application that has not written one yet\r\n gets the right STATUS from the first request, and adding the file later\r\n changes the body and nothing else.\r\n */\r\n if (discovered.length > 0) {\r\n const notFoundPageFile = notFoundPageFiles[0]?.pageFile;\r\n\r\n // Read at INSTALL time, so a `route` export on the not-found page is\r\n // refused at boot with everything else — not on the first request that\r\n // misses, which is the one request nobody is watching.\r\n if (notFoundPageFile !== undefined) {\r\n const notFoundModule = (await vite.ssrLoadModule(notFoundPageFile)) as PageModuleShape;\r\n\r\n if (notFoundModule.route !== undefined) {\r\n throw new NotFoundPageDeclaresRouteError(notFoundPageFile);\r\n }\r\n }\r\n\r\n router.get(\r\n NOT_FOUND_ROUTE_PATH,\r\n createNotFoundRouteHandler({\r\n renderPage:\r\n notFoundPageFile === undefined\r\n ? undefined\r\n : createPageRouteHandler({\r\n path: NOT_FOUND_ROUTE_PATH,\r\n name: NOT_FOUND_ROUTE_NAME,\r\n appFile,\r\n pageFile: notFoundPageFile,\r\n // NO LAYOUT, deliberately, and it is the same trade as \"no\r\n // loader on the 404 page\": a layout brings its whole chain's\r\n // middleware with it, and a guard that redirects or throws on\r\n // the not-found path turns a missing page into an incident. The\r\n // page renders inside the application root and nothing else.\r\n layoutFile: undefined,\r\n loadModule: (moduleId) => vite.ssrLoadModule(moduleId),\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n // The URL that missed IS this route's pattern for this request.\r\n matchPath: (requestPath) => requestPath,\r\n statusForRenderedOk: 404,\r\n }),\r\n }),\r\n // `isPage` for the same reason every other page route carries it: the\r\n // router's duplicate-name error reads the flag to say which claimant is\r\n // the page.\r\n { name: NOT_FOUND_ROUTE_NAME, isPage: true },\r\n );\r\n }\r\n\r\n /*\r\n Published from the SAME loop that registered the routes, so `href()` and the\r\n router cannot disagree about where a name points. It happens here rather\r\n than in the caller because a caller that forgets leaves every `<Link>` on\r\n the server throwing at render — and dev republishes on every restart, which\r\n is why the table replaces wholesale instead of merging: a deleted page's\r\n name has to stop resolving.\r\n */\r\n publishRouteTable(installed, \"installPageRoutes (dev)\");\r\n\r\n return installed;\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isNotFoundPageFile } from "./not-found-page.mjs";
|
|
2
|
+
|
|
1
3
|
//#region ../web/src/server/install-production-page-routes.ts
|
|
2
4
|
/**
|
|
3
5
|
* A manifest page whose module namespace carries no `route` export.
|
|
@@ -23,7 +25,10 @@ var PageManifestEntryMissingRouteError = class extends Error {
|
|
|
23
25
|
* serves.
|
|
24
26
|
*/
|
|
25
27
|
function assertEveryPageDeclaresRoute(manifest) {
|
|
26
|
-
for (const page of manifest.pages)
|
|
28
|
+
for (const page of manifest.pages) {
|
|
29
|
+
if (isNotFoundPageFile(page.sourceFile)) continue;
|
|
30
|
+
if (page.module.route === void 0) throw new PageManifestEntryMissingRouteError(page.sourceFile);
|
|
31
|
+
}
|
|
27
32
|
}
|
|
28
33
|
/**
|
|
29
34
|
* Register every page the manifest carries, and connect the pipeline the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"install-production-page-routes.mjs","names":[],"sources":["../../../../../../../web/src/server/install-production-page-routes.ts"],"sourcesContent":["/**\r\n * Standing up page serving for a BUILT application — the production half of\r\n * `WebConnector.boot()`, kept out of the connector so the mode branch there\r\n * stays one `if` and this path stays readable on its own.\r\n *\r\n * The development path answers \"which pages exist?\" by walking `app/` and\r\n * \"what is this module?\" by asking Vite to evaluate it. A production process\r\n * can ask neither question: there is no source tree beside the bundle and no\r\n * Vite. Both answers arrived at import time instead, in the generated pages\r\n * barrel's {@link PageManifest}, and everything below is what remains once\r\n * those two questions are already answered — a guard, a wiring step and a\r\n * registration step.\r\n *\r\n * NOTHING HERE REACHES FOR VITE, transitively included. `vite` is an optional\r\n * peer, so a production install does not carry it; an import that only ever\r\n * runs in development still fails at module load in production, which is why\r\n * the one import that has to happen at boot is a dynamic one and why it names\r\n * the pipeline barrel and nothing else.\r\n */\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { SharedStoreResolver } from \"../shared\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport type { PageContextRunner } from \"./execute-page-request\";\r\nimport type { InstalledManifestPageRoute } from \"./install-page-routes-from-manifest\";\r\nimport type { PageManifest } from \"./page-manifest\";\r\n\r\n/** The only export this module reads off a page module namespace. */\r\ntype PageModuleShape = {\r\n route?: unknown;\r\n};\r\n\r\nexport type InstallProductionPageRoutesOptions = {\r\n router: Router;\r\n /** The table the generated production barrel provided at import time. */\r\n manifest: PageManifest;\r\n /**\r\n * Core's per-request context, handed over rather than imported: core is a\r\n * type-only peer of web, and two copies of core would mean two\r\n * AsyncLocalStorage stores and a page pipeline reading an empty one.\r\n */\r\n pageContext: PageContextRunner;\r\n /** Reads the current request's store out of {@link pageContext}. */\r\n sharedStore: SharedStoreResolver;\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n /**\r\n * Where the browser fetches the hydration entry from.\r\n *\r\n * A THUNK, not a value: the URL is read out of the client build's manifest,\r\n * and a build that discovered no pages has no hydration entry for that read\r\n * to find. Deferring it means a page-free production bundle boots without\r\n * demanding a file it had no reason to emit.\r\n */\r\n resolveHydrationClientModuleUrl: () => string;\r\n /**\r\n * Where the client build wrote its output. The stylesheets every page must\r\n * link are read from the manifest inside it — resolved HERE rather than by\r\n * the caller, because the barrel that owns that reader is the one this\r\n * function already imports at boot.\r\n *\r\n * OPTIONAL for the same reason `PageManifest.clientDir` is: a build that\r\n * discovered zero pages emits no client bundle, so there is no directory to\r\n * name — and no page that could need a stylesheet either.\r\n */\r\n clientDir?: string;\r\n};\r\n\r\n/**\r\n * A manifest page whose module namespace carries no `route` export.\r\n *\r\n * Page discovery only emits a module it read a `route` off, so a valid build\r\n * cannot produce this — a stale artifact or a hand-edited barrel can. The\r\n * alternative to refusing it is a page table that silently shrinks: the\r\n * operator gets a clean boot log and a 404 on a page that is demonstrably in\r\n * the bundle, with nothing anywhere naming the file. The file is named here\r\n * because it is the entire diagnosis.\r\n */\r\nexport class PageManifestEntryMissingRouteError extends Error {\r\n public constructor(sourceFile: string) {\r\n super(\r\n `Cannot serve pages: the page manifest entry for \"${sourceFile}\" has no \\`route\\` export, ` +\r\n \"so it has no URL to be registered under. This build's generated `pages.ts` barrel does \" +\r\n \"not match the sources it was generated from — re-run `warlock build`, and if the page is \" +\r\n \"meant to be served, give it a `route` export.\",\r\n );\r\n this.name = \"PageManifestEntryMissingRouteError\";\r\n }\r\n}\r\n\r\n/**\r\n * Refuse the whole table before any of it is registered.\r\n *\r\n * Checked in one pass UP FRONT rather than per page inside the registration\r\n * loop: a half-installed page table is worse than a refused boot, because it\r\n * serves.\r\n */\r\nfunction assertEveryPageDeclaresRoute(manifest: PageManifest): void {\r\n for (const page of manifest.pages) {\r\n if ((page.module as PageModuleShape).route === undefined) {\r\n throw new PageManifestEntryMissingRouteError(page.sourceFile);\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Register every page the manifest carries, and connect the pipeline the\r\n * handlers will run in.\r\n *\r\n * An empty table registers nothing and is not an error: \"built with web, no\r\n * pages\" is a legal state of a built application, and it is also the one state\r\n * where there is no pipeline to connect and no hydration entry to point at, so\r\n * it returns before doing either.\r\n */\r\nexport async function installProductionPageRoutes(\r\n options: InstallProductionPageRoutesOptions,\r\n): Promise<InstalledManifestPageRoute[]> {\r\n const {\r\n router,\r\n manifest,\r\n pageContext,\r\n sharedStore,\r\n applyBufferedCookie,\r\n resolveHydrationClientModuleUrl,\r\n clientDir,\r\n } = options;\r\n\r\n assertEveryPageDeclaresRoute(manifest);\r\n\r\n if (manifest.pages.length === 0) return [];\r\n\r\n // Loaded at boot rather than statically, and through the BARREL: production\r\n // has one module graph, so the barrel's instance of the pipeline is the same\r\n // instance the manifest's page modules were linked against, and connecting\r\n // the request context on it connects it for every handler registered below.\r\n // Development cannot do this — its modules live in Vite's separate graph, and\r\n // it wires the same two seams on the copy Vite evaluated.\r\n const webServer = await import(\"./index\");\r\n\r\n webServer.connectSharedStore(sharedStore);\r\n webServer.connectPageContext(pageContext);\r\n\r\n return webServer.installPageRoutesFromManifest({\r\n router,\r\n manifest,\r\n hydrationClientModuleUrl: resolveHydrationClientModuleUrl(),\r\n stylesheetUrls:\r\n clientDir === undefined ? [] : webServer.productionStylesheetUrls(clientDir),\r\n applyBufferedCookie,\r\n });\r\n}\r\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"install-production-page-routes.mjs","names":[],"sources":["../../../../../../../web/src/server/install-production-page-routes.ts"],"sourcesContent":["/**\r\n * Standing up page serving for a BUILT application — the production half of\r\n * `WebConnector.boot()`, kept out of the connector so the mode branch there\r\n * stays one `if` and this path stays readable on its own.\r\n *\r\n * The development path answers \"which pages exist?\" by walking `app/` and\r\n * \"what is this module?\" by asking Vite to evaluate it. A production process\r\n * can ask neither question: there is no source tree beside the bundle and no\r\n * Vite. Both answers arrived at import time instead, in the generated pages\r\n * barrel's {@link PageManifest}, and everything below is what remains once\r\n * those two questions are already answered — a guard, a wiring step and a\r\n * registration step.\r\n *\r\n * NOTHING HERE REACHES FOR VITE, transitively included. `vite` is an optional\r\n * peer, so a production install does not carry it; an import that only ever\r\n * runs in development still fails at module load in production, which is why\r\n * the one import that has to happen at boot is a dynamic one and why it names\r\n * the pipeline barrel and nothing else.\r\n */\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { SharedStoreResolver } from \"../shared\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport type { PageContextRunner } from \"./execute-page-request\";\r\nimport type { InstalledManifestPageRoute } from \"./install-page-routes-from-manifest\";\r\nimport { isNotFoundPageFile } from \"./not-found-page\";\r\nimport type { PageManifest } from \"./page-manifest\";\r\n\r\n/** The only export this module reads off a page module namespace. */\r\ntype PageModuleShape = {\r\n route?: unknown;\r\n};\r\n\r\nexport type InstallProductionPageRoutesOptions = {\r\n router: Router;\r\n /** The table the generated production barrel provided at import time. */\r\n manifest: PageManifest;\r\n /**\r\n * Core's per-request context, handed over rather than imported: core is a\r\n * type-only peer of web, and two copies of core would mean two\r\n * AsyncLocalStorage stores and a page pipeline reading an empty one.\r\n */\r\n pageContext: PageContextRunner;\r\n /** Reads the current request's store out of {@link pageContext}. */\r\n sharedStore: SharedStoreResolver;\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n /**\r\n * Where the browser fetches the hydration entry from.\r\n *\r\n * A THUNK, not a value: the URL is read out of the client build's manifest,\r\n * and a build that discovered no pages has no hydration entry for that read\r\n * to find. Deferring it means a page-free production bundle boots without\r\n * demanding a file it had no reason to emit.\r\n */\r\n resolveHydrationClientModuleUrl: () => string;\r\n /**\r\n * Where the client build wrote its output. The stylesheets every page must\r\n * link are read from the manifest inside it — resolved HERE rather than by\r\n * the caller, because the barrel that owns that reader is the one this\r\n * function already imports at boot.\r\n *\r\n * OPTIONAL for the same reason `PageManifest.clientDir` is: a build that\r\n * discovered zero pages emits no client bundle, so there is no directory to\r\n * name — and no page that could need a stylesheet either.\r\n */\r\n clientDir?: string;\r\n};\r\n\r\n/**\r\n * A manifest page whose module namespace carries no `route` export.\r\n *\r\n * Page discovery only emits a module it read a `route` off, so a valid build\r\n * cannot produce this — a stale artifact or a hand-edited barrel can. The\r\n * alternative to refusing it is a page table that silently shrinks: the\r\n * operator gets a clean boot log and a 404 on a page that is demonstrably in\r\n * the bundle, with nothing anywhere naming the file. The file is named here\r\n * because it is the entire diagnosis.\r\n */\r\nexport class PageManifestEntryMissingRouteError extends Error {\r\n public constructor(sourceFile: string) {\r\n super(\r\n `Cannot serve pages: the page manifest entry for \"${sourceFile}\" has no \\`route\\` export, ` +\r\n \"so it has no URL to be registered under. This build's generated `pages.ts` barrel does \" +\r\n \"not match the sources it was generated from — re-run `warlock build`, and if the page is \" +\r\n \"meant to be served, give it a `route` export.\",\r\n );\r\n this.name = \"PageManifestEntryMissingRouteError\";\r\n }\r\n}\r\n\r\n/**\r\n * Refuse the whole table before any of it is registered.\r\n *\r\n * Checked in one pass UP FRONT rather than per page inside the registration\r\n * loop: a half-installed page table is worse than a refused boot, because it\r\n * serves.\r\n */\r\nfunction assertEveryPageDeclaresRoute(manifest: PageManifest): void {\r\n for (const page of manifest.pages) {\r\n // THE ONE EXEMPTION, and it is not a relaxation of the rule — it is the\r\n // rule's premise not applying. Every other page needs a `route` because a\r\n // page with no URL is a page nothing can reach; `404.page.tsx` is reached by\r\n // NOT matching, so a `route` on it would be the error instead\r\n // (`install-page-routes-from-manifest.ts` refuses that one).\r\n if (isNotFoundPageFile(page.sourceFile)) continue;\r\n\r\n if ((page.module as PageModuleShape).route === undefined) {\r\n throw new PageManifestEntryMissingRouteError(page.sourceFile);\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Register every page the manifest carries, and connect the pipeline the\r\n * handlers will run in.\r\n *\r\n * An empty table registers nothing and is not an error: \"built with web, no\r\n * pages\" is a legal state of a built application, and it is also the one state\r\n * where there is no pipeline to connect and no hydration entry to point at, so\r\n * it returns before doing either.\r\n */\r\nexport async function installProductionPageRoutes(\r\n options: InstallProductionPageRoutesOptions,\r\n): Promise<InstalledManifestPageRoute[]> {\r\n const {\r\n router,\r\n manifest,\r\n pageContext,\r\n sharedStore,\r\n applyBufferedCookie,\r\n resolveHydrationClientModuleUrl,\r\n clientDir,\r\n } = options;\r\n\r\n assertEveryPageDeclaresRoute(manifest);\r\n\r\n if (manifest.pages.length === 0) return [];\r\n\r\n // Loaded at boot rather than statically, and through the BARREL: production\r\n // has one module graph, so the barrel's instance of the pipeline is the same\r\n // instance the manifest's page modules were linked against, and connecting\r\n // the request context on it connects it for every handler registered below.\r\n // Development cannot do this — its modules live in Vite's separate graph, and\r\n // it wires the same two seams on the copy Vite evaluated.\r\n const webServer = await import(\"./index\");\r\n\r\n webServer.connectSharedStore(sharedStore);\r\n webServer.connectPageContext(pageContext);\r\n\r\n return webServer.installPageRoutesFromManifest({\r\n router,\r\n manifest,\r\n hydrationClientModuleUrl: resolveHydrationClientModuleUrl(),\r\n stylesheetUrls:\r\n clientDir === undefined ? [] : webServer.productionStylesheetUrls(clientDir),\r\n applyBufferedCookie,\r\n });\r\n}\r\n"],"mappings":";;;;;;;;;;;;;AA8EA,IAAa,qCAAb,cAAwD,MAAM;CAC5D,AAAO,YAAY,YAAoB;EACrC,MACE,oDAAoD,WAAW,+PAIjE;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,SAAS,6BAA6B,UAA8B;CAClE,KAAK,MAAM,QAAQ,SAAS,OAAO;EAMjC,IAAI,mBAAmB,KAAK,UAAU,GAAG;EAEzC,IAAK,KAAK,OAA2B,UAAU,QAC7C,MAAM,IAAI,mCAAmC,KAAK,UAAU;CAEhE;AACF;;;;;;;;;;AAWA,eAAsB,4BACpB,SACuC;CACvC,MAAM,EACJ,QACA,UACA,aACA,aACA,qBACA,iCACA,cACE;CAEJ,6BAA6B,QAAQ;CAErC,IAAI,SAAS,MAAM,WAAW,GAAG,OAAO,CAAC;CAQzC,MAAM,YAAY,MAAM,OAAO;CAE/B,UAAU,mBAAmB,WAAW;CACxC,UAAU,mBAAmB,WAAW;CAExC,OAAO,UAAU,8BAA8B;EAC7C;EACA;EACA,0BAA0B,gCAAgC;EAC1D,gBACE,cAAc,SAAY,CAAC,IAAI,UAAU,yBAAyB,SAAS;EAC7E;CACF,CAAC;AACH"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { PageRouteHandler } from "./create-page-route-handler.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../web/src/server/not-found-page.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The one filename that makes a page THE not-found page.
|
|
6
|
+
*
|
|
7
|
+
* `404.page.tsx`, not `not-found.page.tsx`: it keeps the `*.page.tsx`
|
|
8
|
+
* convention every other page follows, and `404` is the token a developer
|
|
9
|
+
* greps for when a URL answers with one.
|
|
10
|
+
*/
|
|
11
|
+
declare const NOT_FOUND_PAGE_FILENAME = "404.page.tsx";
|
|
12
|
+
/**
|
|
13
|
+
* The path the not-found route is registered on — find-my-way's and Fastify's
|
|
14
|
+
* catch-all, and the same literal core's own dev dispatcher registers
|
|
15
|
+
* (`core/src/router/router.ts`, `server.route({ url: "*" })`).
|
|
16
|
+
*
|
|
17
|
+
* A catch-all has the LOWEST matching priority in both routers, so every
|
|
18
|
+
* declared page and every declared API route still wins on its own path; this
|
|
19
|
+
* route is only ever reached because nothing else claimed the URL.
|
|
20
|
+
*/
|
|
21
|
+
declare const NOT_FOUND_ROUTE_PATH = "*";
|
|
22
|
+
/**
|
|
23
|
+
* The reserved route name the not-found page is registered under.
|
|
24
|
+
*
|
|
25
|
+
* Namespaced under `warlock.` because it is the framework's route rather than
|
|
26
|
+
* the application's, and because the router's name namespace is shared with API
|
|
27
|
+
* routes — an application that takes this name gets core's duplicate-name error,
|
|
28
|
+
* which is the loud answer, not a silent overwrite.
|
|
29
|
+
*
|
|
30
|
+
* It is deliberately NOT published into the route table (`href()` / `<Link>`):
|
|
31
|
+
* the not-found page has no URL of its own to link to.
|
|
32
|
+
*/
|
|
33
|
+
declare const NOT_FOUND_ROUTE_NAME = "warlock.not-found";
|
|
34
|
+
/** True when `sourceFile`'s basename is exactly {@link NOT_FOUND_PAGE_FILENAME}. */
|
|
35
|
+
declare function isNotFoundPageFile(sourceFile: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Raised when more than one `404.page.tsx` exists.
|
|
38
|
+
*
|
|
39
|
+
* There is exactly one not-found route in a process, so a second file is not a
|
|
40
|
+
* per-module override — it is two files claiming one route, with the winner
|
|
41
|
+
* decided by directory-walk order. Both are named because the fix is to delete
|
|
42
|
+
* one and the operator has to know which two are in play.
|
|
43
|
+
*/
|
|
44
|
+
declare class DuplicateNotFoundPageError extends Error {
|
|
45
|
+
readonly pageFiles: readonly string[];
|
|
46
|
+
constructor(pageFiles: readonly string[]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Raised when `404.page.tsx` declares a `route` export.
|
|
50
|
+
*
|
|
51
|
+
* The not-found page has no URL of its own — it is reached by NOT matching. A
|
|
52
|
+
* `route` export on it reads like a promise that `/404` is browsable, and it is
|
|
53
|
+
* not: the installers register this file on the catch-all and nowhere else. So
|
|
54
|
+
* the export is refused rather than ignored, because a declaration the framework
|
|
55
|
+
* silently drops is worse than one it rejects.
|
|
56
|
+
*/
|
|
57
|
+
declare class NotFoundPageDeclaresRouteError extends Error {
|
|
58
|
+
readonly pageFile: string;
|
|
59
|
+
constructor(pageFile: string);
|
|
60
|
+
}
|
|
61
|
+
/** The shape this module reads off a registered route — core's `Route`, narrowed. */
|
|
62
|
+
type RegisteredRouteShape = {
|
|
63
|
+
path: string;
|
|
64
|
+
isPage?: boolean;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* True when `text/html` is named EXPLICITLY in an `Accept` header — the whole
|
|
68
|
+
* discriminator, in one predicate.
|
|
69
|
+
*
|
|
70
|
+
* Wildcards are refused on purpose. `* /*` is what `fetch()` and `curl` send
|
|
71
|
+
* when the caller expressed no preference at all, and `text/*` names a family;
|
|
72
|
+
* neither is a request for a document, and treating either as one is what makes
|
|
73
|
+
* a mistyped `/api/...` answer HTML to a JSON parser.
|
|
74
|
+
*
|
|
75
|
+
* `q=0` is honoured because it is the header's own way of saying "not this
|
|
76
|
+
* one" — `Accept: text/html;q=0, application/json` is a client refusing the
|
|
77
|
+
* document, and reading it as a request for one would be reading the header
|
|
78
|
+
* backwards. Any other `q`, present or absent, counts.
|
|
79
|
+
*/
|
|
80
|
+
declare function acceptsHtmlExplicitly(accept: string | undefined): boolean;
|
|
81
|
+
type UnmatchedRequestKind = "page" | "api";
|
|
82
|
+
/**
|
|
83
|
+
* The rule, in one function — see this file's header for why it is these two
|
|
84
|
+
* conditions and why the second one refuses wildcards.
|
|
85
|
+
*/
|
|
86
|
+
declare function classifyUnmatchedRequest(input: {
|
|
87
|
+
method: string;
|
|
88
|
+
accept: string | undefined;
|
|
89
|
+
}): UnmatchedRequestKind;
|
|
90
|
+
/**
|
|
91
|
+
* The document served when the application ships no `404.page.tsx`.
|
|
92
|
+
*
|
|
93
|
+
* Deliberately a STRING, not a React render: it must survive the case where the
|
|
94
|
+
* application root, a layout or the page module is exactly what is broken, and
|
|
95
|
+
* a default that can itself fail is not a default. It carries no stylesheet and
|
|
96
|
+
* no hydration script for the same reason — nothing here can 500.
|
|
97
|
+
*
|
|
98
|
+
* It answers 404 like the real page does, because the status is the part that
|
|
99
|
+
* search engines, caches and monitoring read; a framework default that soft-404s
|
|
100
|
+
* would teach every un-customised application to lie.
|
|
101
|
+
*/
|
|
102
|
+
declare function frameworkDefaultNotFoundDocument(): string;
|
|
103
|
+
type NotFoundRouteHandlerOptions = {
|
|
104
|
+
/**
|
|
105
|
+
* The application's `404.page.tsx`, already built into a page handler by
|
|
106
|
+
* whichever installer owns module loading — `undefined` when the application
|
|
107
|
+
* ships no such file, which is what selects
|
|
108
|
+
* {@link frameworkDefaultNotFoundDocument}.
|
|
109
|
+
*
|
|
110
|
+
* Taking a built handler rather than a module keeps this file out of the
|
|
111
|
+
* render pipeline entirely: dev hands over a Vite-backed handler, production a
|
|
112
|
+
* manifest-backed one, and neither difference is visible here.
|
|
113
|
+
*/
|
|
114
|
+
renderPage?: PageRouteHandler;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The handler registered on the catch-all.
|
|
118
|
+
*
|
|
119
|
+
* Three answers, in this order, and the order is the safety property: the API
|
|
120
|
+
* check runs BEFORE anything can render, so no request that the rule calls an
|
|
121
|
+
* API request can reach a React render even if the page module is broken.
|
|
122
|
+
*/
|
|
123
|
+
declare function createNotFoundRouteHandler(options: NotFoundRouteHandlerOptions): PageRouteHandler;
|
|
124
|
+
//#endregion
|
|
125
|
+
export { DuplicateNotFoundPageError, NOT_FOUND_PAGE_FILENAME, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, NotFoundRouteHandlerOptions, RegisteredRouteShape, UnmatchedRequestKind, acceptsHtmlExplicitly, classifyUnmatchedRequest, createNotFoundRouteHandler, frameworkDefaultNotFoundDocument, isNotFoundPageFile };
|
|
126
|
+
//# sourceMappingURL=not-found-page.d.mts.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//#region ../web/src/server/not-found-page.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one filename that makes a page THE not-found page.
|
|
4
|
+
*
|
|
5
|
+
* `404.page.tsx`, not `not-found.page.tsx`: it keeps the `*.page.tsx`
|
|
6
|
+
* convention every other page follows, and `404` is the token a developer
|
|
7
|
+
* greps for when a URL answers with one.
|
|
8
|
+
*/
|
|
9
|
+
const NOT_FOUND_PAGE_FILENAME = "404.page.tsx";
|
|
10
|
+
/**
|
|
11
|
+
* The path the not-found route is registered on — find-my-way's and Fastify's
|
|
12
|
+
* catch-all, and the same literal core's own dev dispatcher registers
|
|
13
|
+
* (`core/src/router/router.ts`, `server.route({ url: "*" })`).
|
|
14
|
+
*
|
|
15
|
+
* A catch-all has the LOWEST matching priority in both routers, so every
|
|
16
|
+
* declared page and every declared API route still wins on its own path; this
|
|
17
|
+
* route is only ever reached because nothing else claimed the URL.
|
|
18
|
+
*/
|
|
19
|
+
const NOT_FOUND_ROUTE_PATH = "*";
|
|
20
|
+
/**
|
|
21
|
+
* The reserved route name the not-found page is registered under.
|
|
22
|
+
*
|
|
23
|
+
* Namespaced under `warlock.` because it is the framework's route rather than
|
|
24
|
+
* the application's, and because the router's name namespace is shared with API
|
|
25
|
+
* routes — an application that takes this name gets core's duplicate-name error,
|
|
26
|
+
* which is the loud answer, not a silent overwrite.
|
|
27
|
+
*
|
|
28
|
+
* It is deliberately NOT published into the route table (`href()` / `<Link>`):
|
|
29
|
+
* the not-found page has no URL of its own to link to.
|
|
30
|
+
*/
|
|
31
|
+
const NOT_FOUND_ROUTE_NAME = "warlock.not-found";
|
|
32
|
+
/** True when `sourceFile`'s basename is exactly {@link NOT_FOUND_PAGE_FILENAME}. */
|
|
33
|
+
function isNotFoundPageFile(sourceFile) {
|
|
34
|
+
const separator = Math.max(sourceFile.lastIndexOf("/"), sourceFile.lastIndexOf("\\"));
|
|
35
|
+
return sourceFile.slice(separator + 1) === NOT_FOUND_PAGE_FILENAME;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Raised when more than one `404.page.tsx` exists.
|
|
39
|
+
*
|
|
40
|
+
* There is exactly one not-found route in a process, so a second file is not a
|
|
41
|
+
* per-module override — it is two files claiming one route, with the winner
|
|
42
|
+
* decided by directory-walk order. Both are named because the fix is to delete
|
|
43
|
+
* one and the operator has to know which two are in play.
|
|
44
|
+
*/
|
|
45
|
+
var DuplicateNotFoundPageError = class extends Error {
|
|
46
|
+
pageFiles;
|
|
47
|
+
constructor(pageFiles) {
|
|
48
|
+
super(`Two or more not-found pages were found: ${pageFiles.map((file) => `"${file}"`).join(", ")}. An application has exactly one \`${NOT_FOUND_PAGE_FILENAME}\` — it answers every unmatched page URL in the process, so a second one would silently never render. Keep one and delete the rest.`);
|
|
49
|
+
this.pageFiles = pageFiles;
|
|
50
|
+
this.name = "DuplicateNotFoundPageError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Raised when `404.page.tsx` declares a `route` export.
|
|
55
|
+
*
|
|
56
|
+
* The not-found page has no URL of its own — it is reached by NOT matching. A
|
|
57
|
+
* `route` export on it reads like a promise that `/404` is browsable, and it is
|
|
58
|
+
* not: the installers register this file on the catch-all and nowhere else. So
|
|
59
|
+
* the export is refused rather than ignored, because a declaration the framework
|
|
60
|
+
* silently drops is worse than one it rejects.
|
|
61
|
+
*/
|
|
62
|
+
var NotFoundPageDeclaresRouteError = class extends Error {
|
|
63
|
+
pageFile;
|
|
64
|
+
constructor(pageFile) {
|
|
65
|
+
super(`"${pageFile}" is the not-found page but declares a \`route\` export. \`${NOT_FOUND_PAGE_FILENAME}\` has no URL of its own — it answers every page URL that matched nothing, and is never registered at a path of its own. Remove the \`route\` export; to serve a browsable page at a fixed path, use a normal \`*.page.tsx\`.`);
|
|
66
|
+
this.pageFile = pageFile;
|
|
67
|
+
this.name = "NotFoundPageDeclaresRouteError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The media range the not-found PAGE is gated on. Compared literally: a request
|
|
72
|
+
* either named this exact type or it did not.
|
|
73
|
+
*/
|
|
74
|
+
const HTML_MEDIA_TYPE = "text/html";
|
|
75
|
+
/**
|
|
76
|
+
* True when `text/html` is named EXPLICITLY in an `Accept` header — the whole
|
|
77
|
+
* discriminator, in one predicate.
|
|
78
|
+
*
|
|
79
|
+
* Wildcards are refused on purpose. `* /*` is what `fetch()` and `curl` send
|
|
80
|
+
* when the caller expressed no preference at all, and `text/*` names a family;
|
|
81
|
+
* neither is a request for a document, and treating either as one is what makes
|
|
82
|
+
* a mistyped `/api/...` answer HTML to a JSON parser.
|
|
83
|
+
*
|
|
84
|
+
* `q=0` is honoured because it is the header's own way of saying "not this
|
|
85
|
+
* one" — `Accept: text/html;q=0, application/json` is a client refusing the
|
|
86
|
+
* document, and reading it as a request for one would be reading the header
|
|
87
|
+
* backwards. Any other `q`, present or absent, counts.
|
|
88
|
+
*/
|
|
89
|
+
function acceptsHtmlExplicitly(accept) {
|
|
90
|
+
if (!accept) return false;
|
|
91
|
+
for (const entry of accept.split(",")) {
|
|
92
|
+
const [rawType, ...parameters] = entry.split(";");
|
|
93
|
+
if (rawType.trim().toLowerCase() !== HTML_MEDIA_TYPE) continue;
|
|
94
|
+
const quality = parameters.map((parameter) => parameter.trim().toLowerCase()).find((parameter) => parameter.startsWith("q="));
|
|
95
|
+
if (quality !== void 0 && Number.parseFloat(quality.slice(2)) === 0) continue;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The rule, in one function — see this file's header for why it is these two
|
|
102
|
+
* conditions and why the second one refuses wildcards.
|
|
103
|
+
*/
|
|
104
|
+
function classifyUnmatchedRequest(input) {
|
|
105
|
+
const method = input.method.toUpperCase();
|
|
106
|
+
if (method !== "GET" && method !== "HEAD") return "api";
|
|
107
|
+
return acceptsHtmlExplicitly(input.accept) ? "page" : "api";
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The document served when the application ships no `404.page.tsx`.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately a STRING, not a React render: it must survive the case where the
|
|
113
|
+
* application root, a layout or the page module is exactly what is broken, and
|
|
114
|
+
* a default that can itself fail is not a default. It carries no stylesheet and
|
|
115
|
+
* no hydration script for the same reason — nothing here can 500.
|
|
116
|
+
*
|
|
117
|
+
* It answers 404 like the real page does, because the status is the part that
|
|
118
|
+
* search engines, caches and monitoring read; a framework default that soft-404s
|
|
119
|
+
* would teach every un-customised application to lie.
|
|
120
|
+
*/
|
|
121
|
+
function frameworkDefaultNotFoundDocument() {
|
|
122
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>404 — Page not found</title></head><body><h1>404 — Page not found</h1><p>This URL does not match any page.</p><p>To replace this page, add <code>${NOT_FOUND_PAGE_FILENAME}</code> to a web folder (for example <code>src/web/404.page.tsx</code>).</p></body></html>`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The handler registered on the catch-all.
|
|
126
|
+
*
|
|
127
|
+
* Three answers, in this order, and the order is the safety property: the API
|
|
128
|
+
* check runs BEFORE anything can render, so no request that the rule calls an
|
|
129
|
+
* API request can reach a React render even if the page module is broken.
|
|
130
|
+
*/
|
|
131
|
+
function createNotFoundRouteHandler(options) {
|
|
132
|
+
const { renderPage } = options;
|
|
133
|
+
return async (context) => {
|
|
134
|
+
const { request, response } = context;
|
|
135
|
+
const accept = request.header("accept");
|
|
136
|
+
if (classifyUnmatchedRequest({
|
|
137
|
+
method: request.method,
|
|
138
|
+
accept: Array.isArray(accept) ? accept.join(",") : accept
|
|
139
|
+
}) === "api") {
|
|
140
|
+
await response.send({
|
|
141
|
+
error: "Route not found",
|
|
142
|
+
path: request.path,
|
|
143
|
+
method: request.method
|
|
144
|
+
}, 404);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (renderPage === void 0) {
|
|
148
|
+
await response.html(frameworkDefaultNotFoundDocument(), 404);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await renderPage(context);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
export { DuplicateNotFoundPageError, NOT_FOUND_PAGE_FILENAME, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, acceptsHtmlExplicitly, classifyUnmatchedRequest, createNotFoundRouteHandler, frameworkDefaultNotFoundDocument, isNotFoundPageFile };
|
|
157
|
+
//# sourceMappingURL=not-found-page.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"not-found-page.mjs","names":[],"sources":["../../../../../../../web/src/server/not-found-page.ts"],"sourcesContent":["/**\n * THE NOT-FOUND PATH — the one route in the application that answers for URLs\n * nobody declared.\n *\n * An application gets it by writing `404.page.tsx` anywhere under a web root.\n * The file is named for the status it answers with, not for a concept\n * (\"not-found\"), because `404` is the string people actually search for, and\n * the `*.page.tsx` suffix is what makes it a page in the first place.\n *\n * ── THE RULE THIS FILE EXISTS FOR ────────────────────────────────────────────\n *\n * Pages and API routes share ONE route namespace and ONE router. So a catch-all\n * page route sees every unmatched request in the process, including\n * `GET /api/uzers` — and if it renders a document for that, a `fetch()` gets\n * `<!doctype html>` back and dies inside `response.json()` with a SyntaxError\n * pointing at the parser instead of at the typo. That failure is expensive\n * precisely because the error names nothing near its cause.\n *\n * Because pages and API share one namespace there is no path-prefix rule\n * available: `/anything` may legitimately be either. The discriminator is\n * therefore the `Accept` header, and it is stated as a narrow permission rather\n * than a guess — the document is the exception, JSON is the default:\n *\n * A request renders the not-found PAGE only if\n * 1. its method is GET or HEAD, and\n * 2. `text/html` appears EXPLICITLY in its `Accept` header.\n *\n * (1) is not a heuristic about browsers. Pages are registered with\n * `router.get` and nothing else — every page route in this codebase is\n * installed by `installPageRoutes` / `installPageRoutesFromManifest`, both\n * of which call `router.get`. A `POST` therefore cannot have been meant\n * for a page, by construction. HEAD rides along because Fastify answers it\n * from the GET route.\n *\n * (2) is EXPLICIT and the word is load-bearing. A wildcard does NOT count:\n * `* /*` — what a bare `fetch()` sends — is not a request for a document,\n * it is the absence of a preference, and `text/*` claims a family rather\n * than the type. Only the literal `text/html` media range, with a non-zero\n * `q`, opens the page path. A browser address-bar navigation always sends\n * an explicit `text/html`; a `fetch()` that has not asked for one never\n * does. So the mistyped `/api/...` in a `fetch()` keeps its JSON body and\n * dies at the typo rather than inside `response.json()`.\n *\n * WHAT THE RULE CANNOT DECIDE, and does not pretend to: a browser navigating to\n * a typo'd API URL asks for `text/html`, and so is answered with the document.\n * Nothing in that request distinguishes it from a typo'd page URL — same verb,\n * same header, same absence of a match — so the rule does not guess. The status\n * is 404 either way, which is the part machines read.\n *\n * ── DEPENDENCY NOTE ──────────────────────────────────────────────────────────\n * This module has NO runtime imports. `../build/discover-pages` imports the\n * filename constant and the identity helpers from here so build discovery and\n * both installers cannot disagree about what a not-found page is, and that edge\n * must not drag the render pipeline into the build.\n */\nimport type { HttpContext } from \"@warlock.js/core\";\nimport type { PageRouteHandler } from \"./create-page-route-handler\";\n\n/**\n * The one filename that makes a page THE not-found page.\n *\n * `404.page.tsx`, not `not-found.page.tsx`: it keeps the `*.page.tsx`\n * convention every other page follows, and `404` is the token a developer\n * greps for when a URL answers with one.\n */\nexport const NOT_FOUND_PAGE_FILENAME = \"404.page.tsx\";\n\n/**\n * The path the not-found route is registered on — find-my-way's and Fastify's\n * catch-all, and the same literal core's own dev dispatcher registers\n * (`core/src/router/router.ts`, `server.route({ url: \"*\" })`).\n *\n * A catch-all has the LOWEST matching priority in both routers, so every\n * declared page and every declared API route still wins on its own path; this\n * route is only ever reached because nothing else claimed the URL.\n */\nexport const NOT_FOUND_ROUTE_PATH = \"*\";\n\n/**\n * The reserved route name the not-found page is registered under.\n *\n * Namespaced under `warlock.` because it is the framework's route rather than\n * the application's, and because the router's name namespace is shared with API\n * routes — an application that takes this name gets core's duplicate-name error,\n * which is the loud answer, not a silent overwrite.\n *\n * It is deliberately NOT published into the route table (`href()` / `<Link>`):\n * the not-found page has no URL of its own to link to.\n */\nexport const NOT_FOUND_ROUTE_NAME = \"warlock.not-found\";\n\n/** True when `sourceFile`'s basename is exactly {@link NOT_FOUND_PAGE_FILENAME}. */\nexport function isNotFoundPageFile(sourceFile: string): boolean {\n const separator = Math.max(sourceFile.lastIndexOf(\"/\"), sourceFile.lastIndexOf(\"\\\\\"));\n\n return sourceFile.slice(separator + 1) === NOT_FOUND_PAGE_FILENAME;\n}\n\n/**\n * Raised when more than one `404.page.tsx` exists.\n *\n * There is exactly one not-found route in a process, so a second file is not a\n * per-module override — it is two files claiming one route, with the winner\n * decided by directory-walk order. Both are named because the fix is to delete\n * one and the operator has to know which two are in play.\n */\nexport class DuplicateNotFoundPageError extends Error {\n public constructor(public readonly pageFiles: readonly string[]) {\n super(\n `Two or more not-found pages were found: ${pageFiles.map((file) => `\"${file}\"`).join(\", \")}. ` +\n `An application has exactly one \\`${NOT_FOUND_PAGE_FILENAME}\\` — it answers every ` +\n \"unmatched page URL in the process, so a second one would silently never render. \" +\n \"Keep one and delete the rest.\",\n );\n this.name = \"DuplicateNotFoundPageError\";\n }\n}\n\n/**\n * Raised when `404.page.tsx` declares a `route` export.\n *\n * The not-found page has no URL of its own — it is reached by NOT matching. A\n * `route` export on it reads like a promise that `/404` is browsable, and it is\n * not: the installers register this file on the catch-all and nowhere else. So\n * the export is refused rather than ignored, because a declaration the framework\n * silently drops is worse than one it rejects.\n */\nexport class NotFoundPageDeclaresRouteError extends Error {\n public constructor(public readonly pageFile: string) {\n super(\n `\"${pageFile}\" is the not-found page but declares a \\`route\\` export. ` +\n `\\`${NOT_FOUND_PAGE_FILENAME}\\` has no URL of its own — it answers every page URL that ` +\n \"matched nothing, and is never registered at a path of its own. Remove the `route` \" +\n \"export; to serve a browsable page at a fixed path, use a normal `*.page.tsx`.\",\n );\n this.name = \"NotFoundPageDeclaresRouteError\";\n }\n}\n\n/** The shape this module reads off a registered route — core's `Route`, narrowed. */\nexport type RegisteredRouteShape = {\n path: string;\n isPage?: boolean;\n};\n\n/**\n * The media range the not-found PAGE is gated on. Compared literally: a request\n * either named this exact type or it did not.\n */\nconst HTML_MEDIA_TYPE = \"text/html\";\n\n/**\n * True when `text/html` is named EXPLICITLY in an `Accept` header — the whole\n * discriminator, in one predicate.\n *\n * Wildcards are refused on purpose. `* /*` is what `fetch()` and `curl` send\n * when the caller expressed no preference at all, and `text/*` names a family;\n * neither is a request for a document, and treating either as one is what makes\n * a mistyped `/api/...` answer HTML to a JSON parser.\n *\n * `q=0` is honoured because it is the header's own way of saying \"not this\n * one\" — `Accept: text/html;q=0, application/json` is a client refusing the\n * document, and reading it as a request for one would be reading the header\n * backwards. Any other `q`, present or absent, counts.\n */\nexport function acceptsHtmlExplicitly(accept: string | undefined): boolean {\n if (!accept) return false;\n\n for (const entry of accept.split(\",\")) {\n const [rawType, ...parameters] = entry.split(\";\");\n\n if (rawType.trim().toLowerCase() !== HTML_MEDIA_TYPE) continue;\n\n const quality = parameters\n .map((parameter) => parameter.trim().toLowerCase())\n .find((parameter) => parameter.startsWith(\"q=\"));\n\n // A malformed `q` is not a refusal — only an explicit zero is.\n if (quality !== undefined && Number.parseFloat(quality.slice(2)) === 0) continue;\n\n return true;\n }\n\n return false;\n}\n\nexport type UnmatchedRequestKind = \"page\" | \"api\";\n\n/**\n * The rule, in one function — see this file's header for why it is these two\n * conditions and why the second one refuses wildcards.\n */\nexport function classifyUnmatchedRequest(input: {\n method: string;\n accept: string | undefined;\n}): UnmatchedRequestKind {\n const method = input.method.toUpperCase();\n\n // Pages are installed with `router.get`, so only these two verbs can ever\n // have been asking for one.\n if (method !== \"GET\" && method !== \"HEAD\") return \"api\";\n\n return acceptsHtmlExplicitly(input.accept) ? \"page\" : \"api\";\n}\n\n/**\n * The document served when the application ships no `404.page.tsx`.\n *\n * Deliberately a STRING, not a React render: it must survive the case where the\n * application root, a layout or the page module is exactly what is broken, and\n * a default that can itself fail is not a default. It carries no stylesheet and\n * no hydration script for the same reason — nothing here can 500.\n *\n * It answers 404 like the real page does, because the status is the part that\n * search engines, caches and monitoring read; a framework default that soft-404s\n * would teach every un-customised application to lie.\n */\nexport function frameworkDefaultNotFoundDocument(): string {\n return (\n \"<!doctype html>\" +\n '<html lang=\"en\">' +\n \"<head>\" +\n '<meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<meta name=\"robots\" content=\"noindex\">' +\n \"<title>404 — Page not found</title>\" +\n \"</head>\" +\n \"<body>\" +\n \"<h1>404 — Page not found</h1>\" +\n \"<p>This URL does not match any page.</p>\" +\n `<p>To replace this page, add <code>${NOT_FOUND_PAGE_FILENAME}</code> to a web folder ` +\n \"(for example <code>src/web/404.page.tsx</code>).</p>\" +\n \"</body>\" +\n \"</html>\"\n );\n}\n\nexport type NotFoundRouteHandlerOptions = {\n /**\n * The application's `404.page.tsx`, already built into a page handler by\n * whichever installer owns module loading — `undefined` when the application\n * ships no such file, which is what selects\n * {@link frameworkDefaultNotFoundDocument}.\n *\n * Taking a built handler rather than a module keeps this file out of the\n * render pipeline entirely: dev hands over a Vite-backed handler, production a\n * manifest-backed one, and neither difference is visible here.\n */\n renderPage?: PageRouteHandler;\n};\n\n/**\n * The handler registered on the catch-all.\n *\n * Three answers, in this order, and the order is the safety property: the API\n * check runs BEFORE anything can render, so no request that the rule calls an\n * API request can reach a React render even if the page module is broken.\n */\nexport function createNotFoundRouteHandler(\n options: NotFoundRouteHandlerOptions,\n): PageRouteHandler {\n const { renderPage } = options;\n\n return async (context: HttpContext) => {\n const { request, response } = context;\n // Node lowercases header names and collapses a repeated `Accept` into an\n // array; both forms are read, so a duplicated header cannot silently mean\n // \"no preference\".\n const accept = request.header(\"accept\");\n\n if (\n classifyUnmatchedRequest({\n method: request.method,\n accept: Array.isArray(accept) ? accept.join(\",\") : accept,\n }) === \"api\"\n ) {\n // The same body core's own dev dispatcher writes for an unmatched route\n // (`core/src/router/router.ts`), so an API 404 reads identically whether\n // it fell through to core or was declined here — and identically in\n // development and in production, which it previously was not.\n await response.send(\n { error: \"Route not found\", path: request.path, method: request.method },\n 404,\n );\n\n return;\n }\n\n if (renderPage === undefined) {\n await response.html(frameworkDefaultNotFoundDocument(), 404);\n\n return;\n }\n\n await renderPage(context);\n };\n}\n"],"mappings":";;;;;;;;AAiEA,MAAa,0BAA0B;;;;;;;;;;AAWvC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,uBAAuB;;AAGpC,SAAgB,mBAAmB,YAA6B;CAC9D,MAAM,YAAY,KAAK,IAAI,WAAW,YAAY,GAAG,GAAG,WAAW,YAAY,IAAI,CAAC;CAEpF,OAAO,WAAW,MAAM,YAAY,CAAC,MAAM;AAC7C;;;;;;;;;AAUA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,WAA8B;EAC/D,MACE,2CAA2C,UAAU,KAAK,SAAS,IAAI,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,qCACrD,wBAAwB,oIAGhE;EANiC;EAOjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;AAWA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,IAAI,SAAS,6DACN,wBAAwB,8NAGjC;EANiC;EAOjC,KAAK,OAAO;CACd;AACF;;;;;AAYA,MAAM,kBAAkB;;;;;;;;;;;;;;;AAgBxB,SAAgB,sBAAsB,QAAqC;CACzE,IAAI,CAAC,QAAQ,OAAO;CAEpB,KAAK,MAAM,SAAS,OAAO,MAAM,GAAG,GAAG;EACrC,MAAM,CAAC,SAAS,GAAG,cAAc,MAAM,MAAM,GAAG;EAEhD,IAAI,QAAQ,KAAK,CAAC,CAAC,YAAY,MAAM,iBAAiB;EAEtD,MAAM,UAAU,WACb,KAAK,cAAc,UAAU,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAClD,MAAM,cAAc,UAAU,WAAW,IAAI,CAAC;EAGjD,IAAI,YAAY,UAAa,OAAO,WAAW,QAAQ,MAAM,CAAC,CAAC,MAAM,GAAG;EAExE,OAAO;CACT;CAEA,OAAO;AACT;;;;;AAQA,SAAgB,yBAAyB,OAGhB;CACvB,MAAM,SAAS,MAAM,OAAO,YAAY;CAIxC,IAAI,WAAW,SAAS,WAAW,QAAQ,OAAO;CAElD,OAAO,sBAAsB,MAAM,MAAM,IAAI,SAAS;AACxD;;;;;;;;;;;;;AAcA,SAAgB,mCAA2C;CACzD,OACE,gUAWsC,wBAAwB;AAKlE;;;;;;;;AAuBA,SAAgB,2BACd,SACkB;CAClB,MAAM,EAAE,eAAe;CAEvB,OAAO,OAAO,YAAyB;EACrC,MAAM,EAAE,SAAS,aAAa;EAI9B,MAAM,SAAS,QAAQ,OAAO,QAAQ;EAEtC,IACE,yBAAyB;GACvB,QAAQ,QAAQ;GAChB,QAAQ,MAAM,QAAQ,MAAM,IAAI,OAAO,KAAK,GAAG,IAAI;EACrD,CAAC,MAAM,OACP;GAKA,MAAM,SAAS,KACb;IAAE,OAAO;IAAmB,MAAM,QAAQ;IAAM,QAAQ,QAAQ;GAAO,GACvE,GACF;GAEA;EACF;EAEA,IAAI,eAAe,QAAW;GAC5B,MAAM,SAAS,KAAK,iCAAiC,GAAG,GAAG;GAE3D;EACF;EAEA,MAAM,WAAW,OAAO;CAC1B;AACF"}
|
|
@@ -76,7 +76,8 @@ function webConnector(options = {}) {
|
|
|
76
76
|
const webRoot = connectorOptions.webRoot ?? deriveWebRoot();
|
|
77
77
|
const build = createWebBuildContribution({
|
|
78
78
|
...buildOptions,
|
|
79
|
-
webRoot: buildOptions?.webRoot ?? webRoot
|
|
79
|
+
webRoot: buildOptions?.webRoot ?? webRoot,
|
|
80
|
+
connectorPluginCount: connectorOptions.plugins?.length ?? 0
|
|
80
81
|
});
|
|
81
82
|
let instance;
|
|
82
83
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-connector-factory.mjs","names":[],"sources":["../../../../../../../web/src/server/web-connector-factory.ts"],"sourcesContent":["/**\n * `webConnector()` — the ONE thing `warlock.config.ts` imports from\n * `@warlock.js/web/connector`, and the only value the `./connector` barrel\n * adds beyond the build/runtime seams.\n *\n * WHY THIS MODULE EXISTS AT ALL, rather than the barrel exporting\n * `WebConnector` directly: `./web-connector.ts` imports `../vite`\n * (`web-connector.ts:52` → `@babel/parser` + `magic-string`),\n * `../../../core/src/router/router` (`:51`) and `./dev-server` (`:53`, which\n * itself pulls core's http stack and `../vite`) at VALUE level. Re-exporting\n * that class from `web/src/connector/index.ts` would drag every one of those\n * into the static graph of every consuming app's config file — the exact\n * config-load weight the `./connector` subpath was created to prevent.\n *\n * So the factory returns a LAZY DELEGATE: a `Connector` whose identity fields\n * (`name`, `priority`, `lifecyclePhase`, `build`) are plain data available\n * synchronously, and whose lifecycle methods `await import(\"./web-connector\")`\n * on first use. `warlock build` reads `build` off this object and never boots\n * anything, so a build never loads Vite or React through\n * here either. This is the third instance of a pattern the codebase already\n * uses twice — `core/src/connectors/access-connector.ts:39` and\n * `web/src/server/dev-cli.ts:61` — not a new one.\n *\n * KEEP THIS MODULE LIGHT. Its whole value-level static graph is `node:path`,\n * `node:url`, `../../../core/src/connectors/types` (whose own two imports are\n * both `import type` and therefore erased — `core/src/connectors/types.ts:1-2`)\n * and `../build/contribution` (`node:fs` + `node:path` + type-only core).\n * Everything else here is `import type`, which `verbatimModuleSyntax` erases.\n */\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Connector,\n type ConnectorBuildContribution,\n ConnectorLifecyclePhase,\n type ConnectorName,\n} from \"@warlock.js/core\";\nimport { createWebBuildContribution, type WebBuildOptions } from \"../build/contribution\";\nimport type { WebConnector, WebConnectorOptions } from \"./web-connector\";\n\n/**\n * Boot/shutdown position relative to core's own connectors.\n *\n * `ConnectorPriority.HTTP` is `5` and `ConnectorPriority.STORAGE` is `6`\n * (`core/src/connectors/types.ts:187-188`), and the manager sorts on a plain\n * numeric compare (`core/src/connectors/connectors-manager.ts:46`) — so `5.5`\n * is \"immediately after http, before everything else\".\n *\n * Defined HERE and re-exported by `./web-connector` rather than the other way\n * round: the delegate must publish `priority` synchronously, and reading it\n * from the heavy module would defeat the whole point of the delegate.\n */\nexport const WEB_CONNECTOR_PRIORITY = 5.5;\n\nexport type WebConnectorFactoryOptions = WebConnectorOptions & {\n /**\n * What web contributes to `warlock build`. Passed straight to\n * {@link createWebBuildContribution}; JSON-serializable values only — no\n * plugin or pipeline instances, so a config load never pulls Vite in.\n */\n build?: WebBuildOptions;\n};\n\n/**\n * The `@warlock.js/web` package root, derived once from THIS module's location\n * and then passed EXPLICITLY to both halves.\n *\n * `web/src/server/web-connector-factory.ts` → `web/src/server` → `web/src` →\n * `web`; published as `web/esm/server/web-connector-factory.js`, two levels up\n * is the package root under both layouts — the same arithmetic\n * `contribution.ts:136-140` documents.\n *\n * Deriving it here and handing it down means the build contribution never falls\n * back to its own `import.meta.url` guess (`contribution.ts:129-141`): one\n * derivation, one place to be wrong, and `assertWebPackageRoot` verifies it\n * against `<root>/package.json`'s `name` either way.\n */\nfunction deriveWebRoot(): string {\n return path.resolve(path.dirname(fileURLToPath(import.meta.url)), \"..\", \"..\");\n}\n\n/**\n * Construct web's connector for `warlock.config.ts > connectors`.\n *\n * @example\n * ```ts\n * export default defineConfig({ connectors: [webConnector()] });\n * ```\n */\nexport function webConnector(options: WebConnectorFactoryOptions = {}): Connector {\n const { build: buildOptions, ...connectorOptions } = options;\n const webRoot = connectorOptions.webRoot ?? deriveWebRoot();\n\n const build: ConnectorBuildContribution = createWebBuildContribution({\n ...buildOptions,\n webRoot: buildOptions?.webRoot ?? webRoot,\n });\n\n let instance: WebConnector | undefined;\n\n /**\n * Load the heavy half on first lifecycle call. `boot()` is always the first\n * of these to run (`core/src/connectors/connectors-manager.ts:87-93`), so the\n * import lands in a process that has already committed to serving pages.\n */\n const load = async (): Promise<WebConnector> => {\n if (!instance) {\n const { WebConnector: WebConnectorClass } = await import(\"./web-connector\");\n\n instance = new WebConnectorClass({ ...connectorOptions, webRoot });\n }\n\n return instance;\n };\n\n return {\n name: \"web\" satisfies ConnectorName,\n priority: WEB_CONNECTOR_PRIORITY,\n lifecyclePhase: ConnectorLifecyclePhase.Late,\n build,\n\n // Never loads the heavy half: a connector that was never booted is not\n // active, and answering that must not cost a Vite import.\n isActive: () => instance?.isActive() ?? false,\n\n boot: async () => {\n await (await load()).boot();\n },\n start: async () => {\n await (await load()).start();\n },\n restart: async () => {\n await (await load()).restart();\n },\n\n // Both of these are asked of every registered connector, including ones\n // that never booted — so neither may force the import.\n shutdown: async () => {\n await instance?.shutdown();\n },\n shouldRestart: () => false,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDA,MAAa,yBAAyB;;;;;;;;;;;;;;;AAyBtC,SAAS,gBAAwB;CAC/B,OAAO,KAAK,QAAQ,KAAK,QAAQ,cAAc,OAAO,KAAK,GAAG,CAAC,GAAG,MAAM,IAAI;AAC9E;;;;;;;;;AAUA,SAAgB,aAAa,UAAsC,CAAC,GAAc;CAChF,MAAM,EAAE,OAAO,cAAc,GAAG,qBAAqB;CACrD,MAAM,UAAU,iBAAiB,WAAW,cAAc;CAE1D,MAAM,QAAoC,2BAA2B;EACnE,GAAG;EACH,SAAS,cAAc,WAAW;
|
|
1
|
+
{"version":3,"file":"web-connector-factory.mjs","names":[],"sources":["../../../../../../../web/src/server/web-connector-factory.ts"],"sourcesContent":["/**\n * `webConnector()` — the ONE thing `warlock.config.ts` imports from\n * `@warlock.js/web/connector`, and the only value the `./connector` barrel\n * adds beyond the build/runtime seams.\n *\n * WHY THIS MODULE EXISTS AT ALL, rather than the barrel exporting\n * `WebConnector` directly: `./web-connector.ts` imports `../vite`\n * (`web-connector.ts:52` → `@babel/parser` + `magic-string`),\n * `../../../core/src/router/router` (`:51`) and `./dev-server` (`:53`, which\n * itself pulls core's http stack and `../vite`) at VALUE level. Re-exporting\n * that class from `web/src/connector/index.ts` would drag every one of those\n * into the static graph of every consuming app's config file — the exact\n * config-load weight the `./connector` subpath was created to prevent.\n *\n * So the factory returns a LAZY DELEGATE: a `Connector` whose identity fields\n * (`name`, `priority`, `lifecyclePhase`, `build`) are plain data available\n * synchronously, and whose lifecycle methods `await import(\"./web-connector\")`\n * on first use. `warlock build` reads `build` off this object and never boots\n * anything, so a build never loads Vite or React through\n * here either. This is the third instance of a pattern the codebase already\n * uses twice — `core/src/connectors/access-connector.ts:39` and\n * `web/src/server/dev-cli.ts:61` — not a new one.\n *\n * KEEP THIS MODULE LIGHT. Its whole value-level static graph is `node:path`,\n * `node:url`, `../../../core/src/connectors/types` (whose own two imports are\n * both `import type` and therefore erased — `core/src/connectors/types.ts:1-2`)\n * and `../build/contribution` (`node:fs` + `node:path` + type-only core).\n * Everything else here is `import type`, which `verbatimModuleSyntax` erases.\n */\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Connector,\n type ConnectorBuildContribution,\n ConnectorLifecyclePhase,\n type ConnectorName,\n} from \"@warlock.js/core\";\nimport { createWebBuildContribution, type WebBuildOptions } from \"../build/contribution\";\nimport type { WebConnector, WebConnectorOptions } from \"./web-connector\";\n\n/**\n * Boot/shutdown position relative to core's own connectors.\n *\n * `ConnectorPriority.HTTP` is `5` and `ConnectorPriority.STORAGE` is `6`\n * (`core/src/connectors/types.ts:187-188`), and the manager sorts on a plain\n * numeric compare (`core/src/connectors/connectors-manager.ts:46`) — so `5.5`\n * is \"immediately after http, before everything else\".\n *\n * Defined HERE and re-exported by `./web-connector` rather than the other way\n * round: the delegate must publish `priority` synchronously, and reading it\n * from the heavy module would defeat the whole point of the delegate.\n */\nexport const WEB_CONNECTOR_PRIORITY = 5.5;\n\nexport type WebConnectorFactoryOptions = WebConnectorOptions & {\n /**\n * What web contributes to `warlock build`. Passed straight to\n * {@link createWebBuildContribution}; JSON-serializable values only — no\n * plugin or pipeline instances, so a config load never pulls Vite in.\n */\n build?: WebBuildOptions;\n};\n\n/**\n * The `@warlock.js/web` package root, derived once from THIS module's location\n * and then passed EXPLICITLY to both halves.\n *\n * `web/src/server/web-connector-factory.ts` → `web/src/server` → `web/src` →\n * `web`; published as `web/esm/server/web-connector-factory.js`, two levels up\n * is the package root under both layouts — the same arithmetic\n * `contribution.ts:136-140` documents.\n *\n * Deriving it here and handing it down means the build contribution never falls\n * back to its own `import.meta.url` guess (`contribution.ts:129-141`): one\n * derivation, one place to be wrong, and `assertWebPackageRoot` verifies it\n * against `<root>/package.json`'s `name` either way.\n */\nfunction deriveWebRoot(): string {\n return path.resolve(path.dirname(fileURLToPath(import.meta.url)), \"..\", \"..\");\n}\n\n/**\n * Construct web's connector for `warlock.config.ts > connectors`.\n *\n * @example\n * ```ts\n * export default defineConfig({ connectors: [webConnector()] });\n * ```\n */\nexport function webConnector(options: WebConnectorFactoryOptions = {}): Connector {\n const { build: buildOptions, ...connectorOptions } = options;\n const webRoot = connectorOptions.webRoot ?? deriveWebRoot();\n\n const build: ConnectorBuildContribution = createWebBuildContribution({\n ...buildOptions,\n webRoot: buildOptions?.webRoot ?? webRoot,\n // The COUNT, never the array. `connectorOptions.plugins` are dev-server\n // plugins and the production build cannot apply them; the build contribution\n // refuses the build rather than let them vanish silently\n // (`ConnectorPluginsNotSupportedError`). Passing a number keeps this options\n // object JSON-serializable — handing the plugin instances over would pull\n // Vite into every config load, which is the one thing this module exists to\n // prevent — and the refusal needs nothing more than \"how many\".\n connectorPluginCount: connectorOptions.plugins?.length ?? 0,\n });\n\n let instance: WebConnector | undefined;\n\n /**\n * Load the heavy half on first lifecycle call. `boot()` is always the first\n * of these to run (`core/src/connectors/connectors-manager.ts:87-93`), so the\n * import lands in a process that has already committed to serving pages.\n */\n const load = async (): Promise<WebConnector> => {\n if (!instance) {\n const { WebConnector: WebConnectorClass } = await import(\"./web-connector\");\n\n instance = new WebConnectorClass({ ...connectorOptions, webRoot });\n }\n\n return instance;\n };\n\n return {\n name: \"web\" satisfies ConnectorName,\n priority: WEB_CONNECTOR_PRIORITY,\n lifecyclePhase: ConnectorLifecyclePhase.Late,\n build,\n\n // Never loads the heavy half: a connector that was never booted is not\n // active, and answering that must not cost a Vite import.\n isActive: () => instance?.isActive() ?? false,\n\n boot: async () => {\n await (await load()).boot();\n },\n start: async () => {\n await (await load()).start();\n },\n restart: async () => {\n await (await load()).restart();\n },\n\n // Both of these are asked of every registered connector, including ones\n // that never booted — so neither may force the import.\n shutdown: async () => {\n await instance?.shutdown();\n },\n shouldRestart: () => false,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDA,MAAa,yBAAyB;;;;;;;;;;;;;;;AAyBtC,SAAS,gBAAwB;CAC/B,OAAO,KAAK,QAAQ,KAAK,QAAQ,cAAc,OAAO,KAAK,GAAG,CAAC,GAAG,MAAM,IAAI;AAC9E;;;;;;;;;AAUA,SAAgB,aAAa,UAAsC,CAAC,GAAc;CAChF,MAAM,EAAE,OAAO,cAAc,GAAG,qBAAqB;CACrD,MAAM,UAAU,iBAAiB,WAAW,cAAc;CAE1D,MAAM,QAAoC,2BAA2B;EACnE,GAAG;EACH,SAAS,cAAc,WAAW;EAQlC,sBAAsB,iBAAiB,SAAS,UAAU;CAC5D,CAAC;CAED,IAAI;;;;;;CAOJ,MAAM,OAAO,YAAmC;EAC9C,IAAI,CAAC,UAAU;GACb,MAAM,EAAE,cAAc,sBAAsB,MAAM,OAAO;GAEzD,WAAW,IAAI,kBAAkB;IAAE,GAAG;IAAkB;GAAQ,CAAC;EACnE;EAEA,OAAO;CACT;CAEA,OAAO;EACL,MAAM;EACN,UAAU;EACV,gBAAgB,wBAAwB;EACxC;EAIA,gBAAgB,UAAU,SAAS,KAAK;EAExC,MAAM,YAAY;GAChB,OAAO,MAAM,KAAK,EAAC,CAAE,KAAK;EAC5B;EACA,OAAO,YAAY;GACjB,OAAO,MAAM,KAAK,EAAC,CAAE,MAAM;EAC7B;EACA,SAAS,YAAY;GACnB,OAAO,MAAM,KAAK,EAAC,CAAE,QAAQ;EAC/B;EAIA,UAAU,YAAY;GACpB,MAAM,UAAU,SAAS;EAC3B;EACA,qBAAqB;CACvB;AACF"}
|