@warlock.js/web 5.5.0 → 5.6.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 +27 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +127 -205
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +5 -3
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/public-files.mjs +29 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +23 -103
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +10 -3
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +35 -2
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +6 -6
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +87 -23
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +10 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +2 -2
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -2
- package/esm/server/index.mjs +2 -2
- package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs +43 -40
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +22 -2
- package/esm/server/install-page-routes.mjs +108 -42
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +25 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +11 -48
- package/esm/server/render-page.mjs +13 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/route-validation-error.mjs +32 -0
- package/esm/server/route-validation-error.mjs.map +1 -0
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +24 -321
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +31 -70
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +5 -7
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +558 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +348 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-connector.mjs","names":[],"sources":["../../../../../../../web/src/server/web-connector.ts"],"sourcesContent":["/**\r\n * `WebConnector` — the SSR page surface as a first-class Warlock connector.\r\n *\r\n * It runs BESIDE `HttpConnector`, never instead of it: `warlock dev` alone now boots the API *and* serves React\r\n * pages on one port, and `web` no longer owns a private copy of the HTTP\r\n * lifecycle. Everything this file does used to live in `startDevServer()`\r\n * (`web/src/server/dev-server.ts`), which created its own Fastify instance,\r\n * scanned the router and called `listen()` itself — three responsibilities core\r\n * already owns at `core/src/connectors/http-connector.ts:72`, `:133` and `:147`.\r\n *\r\n * WHY A `Late` CONNECTOR IS THE RIGHT SEAM, in ordering terms:\r\n * `ConnectorsManager.startPhase` runs **every** `boot()` in a phase before\r\n * **any** `start()` (`core/src/connectors/connectors-manager.ts:87-93`).\r\n * `HttpConnector` is itself `Late` (`core/src/connectors/http-connector.ts:41`)\r\n * and publishes its Fastify instance during its own `boot()`\r\n * (`container.set(\"http.server\", …)`, `core/src/connectors/http-connector.ts:74`).\r\n * So by the time this connector's `boot()` runs, Fastify and its plugins exist,\r\n * the raw node server exists, and NOTHING has been scanned or bound yet — page\r\n * routes registered here are picked up by `HttpConnector.start()`'s\r\n * `router.scanDevServer(…)` (`core/src/connectors/http-connector.ts:133`) before\r\n * `listen()` (`:147`). `SocketConnector.boot()` reads the same container key the\r\n * same way (`core/src/connectors/socket-connector.ts:78-80`) — this file is\r\n * deliberately shaped after it.\r\n *\r\n * What it can NOT do, and why that is fine: route COLLECTION happens earlier\r\n * (`core/src/dev-server/development-server.ts:57` precedes `:66`), so pages are\r\n * not discovered by the framework's file scanner. They are discovered here, by\r\n * `installPageRoutes` (`./install-page-routes.ts:189`), and registered through\r\n * the ordinary `router.get(…)` API — there is no second server matcher.\r\n *\r\n * DELIBERATE EXCEPTION to A.3 §2 (\"web has no core dependency\"), the same one\r\n * `./dev-server.ts` and `./install-page-routes.ts` record in their own headers:\r\n * this module is not re-exported from any package barrel (`web/src/index.ts`,\r\n * `web/src/server/index.ts`, `web/src/connector/index.ts`) and is not part of\r\n * `web/package.json`'s dependency graph. It is dev/CLI bootstrap code, only\r\n * ever imported by tooling that already depends on core.\r\n *\r\n * `@warlock.js/web/connector` reaches this class ONLY through\r\n * `./web-connector-factory.ts`'s `await import(\"./web-connector\")` — a\r\n * deliberate seam, because a static edge from that barrel to this file would\r\n * put `../vite`, core's router and `./dev-server` into the import graph of\r\n * every consuming app's `warlock.config.ts`.\r\n */\r\nimport fs from \"node:fs\";\r\nimport path from \"node:path\";\r\nimport { fileURLToPath } from \"node:url\";\r\nimport type { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from \"fastify\";\r\nimport type { Alias, Plugin, PluginOption, ViteDevServer } from \"vite\";\r\nimport { Application, BaseConnector, ConnectorLifecyclePhase, type ConnectorName, container, type FastifyInstance, requestContext, router } from \"@warlock.js/core\";\r\nimport { resolveWebPackageRoot } from \"../build/contribution\";\r\nimport { appConventionAliases } from \"../vite/app-convention-aliases\";\r\nimport {\r\n createHydrationClientEntry,\r\n invalidateClientPageRegistry,\r\n warlockClientBoundary,\r\n} from \"../vite\";\r\nimport { CLIENT_ASSET_URL_PREFIX } from \"./client-asset-url-prefix\";\r\nimport { devErrorTransportPlugin, sendCapturedDevError } from \"./dev-server\";\r\nimport { resolveHydrationClientUrl } from \"./hydration-client-url\";\r\nimport type { InstalledPageRoute } from \"./install-page-routes\";\r\nimport { installProductionPageRoutes } from \"./install-production-page-routes\";\r\nimport {\r\n classifyPageFileChanges,\r\n hasPageFileChanges,\r\n type PageFileChanges,\r\n} from \"./page-file-change\";\r\nimport {\r\n pageRouteSourceFiles,\r\n pageRoutesNeedReplacement,\r\n registeredPageFiles,\r\n} from \"./page-route-reload\";\r\nimport { consumePageManifest, type PageManifest } from \"./page-manifest\";\r\nimport { registerProductionPublicFiles } from \"./register-production-public-files\";\r\nimport { createUnregisteredPageReporter } from \"./unregistered-pages\";\r\nimport { WEB_CONNECTOR_PRIORITY } from \"./web-connector-factory\";\r\n\r\n/**\r\n * Boot/shutdown position relative to core's own connectors.\r\n *\r\n * `ConnectorPriority.HTTP` is `5` and `ConnectorPriority.STORAGE` is `6`\r\n * (`core/src/connectors/types.ts:187-188`), and the manager sorts on a plain\r\n * numeric compare (`core/src/connectors/connectors-manager.ts:46`) — so `5.5`\r\n * is \"immediately after http, before everything else\". Two consequences, both\r\n * wanted:\r\n *\r\n * - `boot()` sees a Fastify instance that already has core's plugins and\r\n * health routes on it (`core/src/connectors/http-connector.ts:76`, `:85`).\r\n * - teardown is reverse-priority (`core/src/connectors/connectors-manager.ts:118`),\r\n * so Vite closes BEFORE the HTTP server does, not after.\r\n *\r\n * Note this is a magic number, not a declared dependency: core has no\r\n * `after`/`dependsOn` on the connector interface (`core/src/connectors/types.ts:8-71`).\r\n * Ordering only *needs* to be right for shutdown — `boot()` correctness is\r\n * guaranteed by the phase's boot-all-then-start-all pass regardless of priority.\r\n *\r\n * Declared in `./web-connector-factory` and re-exported here: the lazy delegate\r\n * that `webConnector()` returns must publish `priority` without loading this\r\n * (heavy) module. Importers keep the specifier they already use.\r\n */\r\nexport { WEB_CONNECTOR_PRIORITY };\r\n\r\n/**\r\n * Production boot ran with no page manifest in the registry.\r\n *\r\n * `undefined` from `consumePageManifest()` is a FACT, not an error — the registry never throws\r\n * on absence. The connector supplies the meaning, and it does so from MODE, not\r\n * from the value: in dev the absence is normal because Vite supplies the\r\n * modules and no build has run; in production it means the app was not built\r\n * with web, and a prod server that boots anyway serves 404s while looking\r\n * healthy — the exact silent failure this error exists to prevent.\r\n */\r\n/**\r\n * Which half of the handoff is live: Vite serving from source, or a bundle\r\n * produced by `warlock build`.\r\n *\r\n * RUNTIME STRATEGY, NOT `Application.environment`. The two are explicitly a\r\n * \"separate axis\" (`core/src/utils/environment.ts:4-7`), and the question this\r\n * connector asks — \"is there a page manifest, or does Vite supply the modules?\"\r\n * — is a HOSTING question. `warlock dev` with `NODE_ENV=production` (a staging\r\n * checkout, or just an inherited shell variable) is still Vite-hosted and still\r\n * has no manifest; keying off the environment would make that app refuse to\r\n * boot. Core sets the strategy on both sides deliberately:\r\n * `core/src/cli/commands/dev-server.command.ts:12` declares `\"development\"` for\r\n * `warlock dev`, and the generated production entry declares `\"production\"`\r\n * (`core/src/production/production-builder.ts:245`).\r\n *\r\n * This is core's own connector-level idiom, not a new one:\r\n * `core/src/connectors/http-connector.ts:132` picks `scanDevServer` over `scan`\r\n * the same way.\r\n */\r\nfunction isProductionRuntime(): boolean {\r\n return Application.runtimeStrategy === \"production\";\r\n}\r\n\r\nfunction pageFileVersion(file: string): string {\r\n try {\r\n return `present:${fs.readFileSync(file, \"utf8\")}`;\r\n } catch {\r\n return \"absent\";\r\n }\r\n}\r\n\r\nfunction pageChangeVersions(changes: PageFileChanges): Map<string, string> {\r\n return new Map(\r\n [...changes.added, ...changes.removed, ...changes.inspectionNeeded].map((file) => {\r\n const absoluteFile = path.resolve(file);\r\n return [absoluteFile, pageFileVersion(absoluteFile)] as const;\r\n }),\r\n );\r\n}\r\n\r\nexport class WebPageManifestMissingError extends Error {\r\n public constructor() {\r\n super(\r\n \"WebConnector booted in production without a page manifest. The generated \" +\r\n \"`pages.ts` barrel never ran, so no page modules were handed to the connector \" +\r\n \"and there is nothing to serve. Run `warlock build` with the web connector \" +\r\n \"registered in `warlock.config.ts > connectors`, and start the artifact that \" +\r\n \"build produced.\",\r\n );\r\n this.name = \"WebPageManifestMissingError\";\r\n }\r\n}\r\n\r\n/**\r\n * The manifest carried browser artifacts but no `clientDir`.\r\n *\r\n * The build bakes that field in beside the page table, so the only way to\r\n * observe this is a VERSION SPLIT: an artifact produced by a `@warlock.js/web`\r\n * older than the field, started against a newer runtime. Named rather than\r\n * left to `path.resolve(cwd, undefined)`, which throws a `TypeError` naming\r\n * neither the manifest nor the rebuild that fixes it.\r\n */\r\nexport class WebClientDirMissingError extends Error {\r\n public constructor() {\r\n super(\r\n \"The page manifest carries pages or public files but no `clientDir`, so the \" +\r\n \"connector cannot \" +\r\n \"locate the browser artifacts. This artifact was built by an older @warlock.js/web \" +\r\n \"than the one now running it. Re-run `warlock build` to regenerate the \" +\r\n \"`pages.ts` barrel against the current version.\",\r\n );\r\n this.name = \"WebClientDirMissingError\";\r\n }\r\n}\r\n\r\n/**\r\n * Third-party packages core reaches through `await import(...)` and that must\r\n * therefore never enter Vite's SSR transform graph.\r\n *\r\n * Left un-externalized, Vite's SSR module runner tries to resolve them anyway\r\n * and jams: the failure is NOT a missing-module error but a `transport invoke\r\n * timed out` on whatever unrelated module happened to be in flight. Derived in\r\n * one pass from `peerDependenciesMeta.optional` across every workspace package\r\n * reachable from `core/src/index.ts`, and carried over verbatim from\r\n * `dev-server.ts`'s own list. Only THIRD-PARTY peers belong here — every\r\n * `@warlock.js/*` sibling must stay in Vite's graph.\r\n *\r\n * This list is core's peer list, not web's; publishing it from core instead\r\n * of duplicating it here is still outstanding.\r\n */\r\nconst CORE_OPTIONAL_PEERS = [\r\n // mail\r\n \"nodemailer\",\r\n \"@aws-sdk/client-sesv2\",\r\n \"@react-email/render\",\r\n // cache\r\n \"redis\",\r\n \"pg\",\r\n // cascade\r\n \"mongodb\",\r\n // logger\r\n \"@sentry/node\",\r\n // core\r\n \"sharp\",\r\n \"socket.io\",\r\n \"@aws-sdk/client-s3\",\r\n \"@aws-sdk/lib-storage\",\r\n \"@aws-sdk/s3-request-presigner\",\r\n // herald\r\n \"amqplib\",\r\n // ai\r\n \"langfuse\",\r\n \"openai\",\r\n \"pdf-parse\",\r\n] as const;\r\n\r\n/**\r\n * `web`'s OWN optional peers, kept separate from {@link CORE_OPTIONAL_PEERS}\r\n * because that list is core's and is documented as such.\r\n *\r\n * Same rule, same reason: anything this package reaches through\r\n * `await import(...)` must be external to every bundler and SSR pipeline, or\r\n * some pipeline will try to resolve it in an install that legitimately does not\r\n * have it. `vite` itself is the precedent — `createViteServer` has always\r\n * loaded it this way — and `@vitejs/plugin-react` is loaded from the same\r\n * function, for the same dev-only reason, so it belongs in the same set.\r\n */\r\nconst WEB_OPTIONAL_PEERS = [\"vite\", \"@vitejs/plugin-react\"] as const;\r\n\r\n/**\r\n * Public specifier for the React Fast Refresh preamble module this connector\r\n * injects. See {@link WebConnector.resolveReactFastRefreshPlugins}.\r\n */\r\nconst REACT_REFRESH_PREAMBLE_ID = \"virtual:warlock/react-refresh-preamble\";\r\n\r\n/** The `\\0`-prefixed form Rollup uses to mark a module as not-a-file. */\r\nconst RESOLVED_REACT_REFRESH_PREAMBLE_ID = `\\0${REACT_REFRESH_PREAMBLE_ID}`;\r\n\r\n/**\r\n * `@fastify/static`'s `maxAge` is milliseconds (the `send` package's option,\r\n * not seconds like `Cache-Control`'s own `max-age`) — one year, matching the\r\n * header this produces: `public, max-age=31536000, immutable`. Safe forever\r\n * because every filename under {@link CLIENT_ASSET_URL_PREFIX} is content-hashed\r\n * by the client build: a changed file is a changed URL, never a changed\r\n * response at the same URL, which is the one condition `immutable` requires.\r\n */\r\nconst HASHED_ASSET_CACHE_MAX_AGE_MS = 31536000 * 1000;\r\n\r\n/**\r\n * Exported so a test can assert the real options this connector hands\r\n * `router.directory` — the same object `boot()` uses below, not a copy a spec\r\n * could drift from unnoticed.\r\n */\r\nexport function productionAssetsDirectoryOptions(clientDir: string) {\r\n return {\r\n root: path.join(clientDir, \"assets\"),\r\n prefix: `${CLIENT_ASSET_URL_PREFIX}/`,\r\n maxAge: HASHED_ASSET_CACHE_MAX_AGE_MS,\r\n immutable: true,\r\n };\r\n}\r\n\r\n/** `@vitejs/plugin-react`'s default export, plus the one static it publishes. */\r\ntype ReactPluginFactory = ((options?: Record<string, unknown>) => PluginOption[]) & {\r\n preambleCode?: string;\r\n};\r\n\r\n/**\r\n * A directory, plus the path the filesystem really stores it at when the two\r\n * differ. Both forms belong in `server.fs.allow` — see the `fs` block in\r\n * {@link WebConnector.createViteServer} for why one of them is never enough.\r\n *\r\n * A missing directory is not this function's problem to report: the roots it is\r\n * handed are already proven (`resolveWebPackageRoot`) or are the app's own cwd,\r\n * and an allow-list entry that points nowhere simply matches nothing.\r\n */\r\nfunction withRealPath(directory: string): string[] {\r\n try {\r\n const realPath = fs.realpathSync(directory);\r\n\r\n return realPath === directory ? [directory] : [directory, realPath];\r\n } catch {\r\n return [directory];\r\n }\r\n}\r\n\r\n/** POSIX-normalised, case-folded — Vite ids are `/`-separated on Windows too. */\r\nfunction normalizeModuleId(id: string): string {\r\n const [filepath] = id.split(\"?\");\r\n\r\n return filepath.replace(/\\\\/g, \"/\").toLowerCase();\r\n}\r\n\r\nexport type WebConnectorOptions = {\r\n /**\r\n * Vite's `root` — the application directory that owns `src/`, `package.json`\r\n * and `tsconfig.json`. Rooting Vite at the APP (not at the `web` package) is\r\n * what makes the app's own bare specifiers and dependency-optimizer scan\r\n * resolve correctly. Defaults to `process.cwd()`, which is where `warlock dev`\r\n * already runs.\r\n */\r\n appRoot?: string;\r\n /** `<appRoot>/src` by default. Pages are `<appSrcRoot>/app/**\\/*.page.tsx`. */\r\n appSrcRoot?: string;\r\n /** The single global app-root file. `<appSrcRoot>/web/root.tsx` by default. */\r\n appFile?: string;\r\n /**\r\n * Root of the `@warlock.js/web` package, used to locate the hydration client\r\n * entry. Derived from this module's own location by default — a caller only\r\n * sets it when the package is not laid out normally.\r\n */\r\n webRoot?: string;\r\n /**\r\n * Extra `resolve.alias` entries, prepended to the app-convention aliases\r\n * (`web/*` → `src/web`, `app/*` → `src/app`) so a caller can win a conflict.\r\n * A normal application needs none of these; a monorepo checkout with unbuilt\r\n * workspace packages does.\r\n */\r\n resolveAlias?: Alias[];\r\n /** Extra `ssr.external` entries, appended to {@link CORE_OPTIONAL_PEERS}. */\r\n ssrExternal?: string[];\r\n /** Extra Vite plugins, appended after the client-boundary gates. */\r\n plugins?: PluginOption[];\r\n};\r\n\r\n/**\r\n * Web Connector\r\n * Manages the Vite dev server and the SSR page routes, mounted on the HTTP\r\n * connector's Fastify instance.\r\n */\r\nexport class WebConnector extends BaseConnector {\r\n public readonly name: ConnectorName = \"web\";\r\n public readonly priority = WEB_CONNECTOR_PRIORITY;\r\n public readonly lifecyclePhase = ConnectorLifecyclePhase.Late;\r\n\r\n /**\r\n * Nothing. Core already supplies each watcher batch to `shouldRestart`; page\r\n * membership and route identity are classified there, while component and\r\n * layout body edits remain Vite's HMR domain.\r\n */\r\n protected readonly watchedFiles: string[] = [];\r\n\r\n protected readonly options: WebConnectorOptions;\r\n\r\n protected vite?: ViteDevServer;\r\n\r\n protected installedPages: InstalledPageRoute[] = [];\r\n\r\n /**\r\n * The build→runtime handoff table, read once at boot.\r\n *\r\n * `undefined` in dev is the normal case and carries no meaning beyond \"no\r\n * build has run\" — see {@link WebPageManifestMissingError} for why the\r\n * production reading is a hard error and why the branch is on MODE.\r\n */\r\n protected pageManifest?: PageManifest;\r\n\r\n /**\r\n * The paths the DEVELOPMENT boot resolved, kept so `shouldRestart` can decide\r\n * whether a changed file is a page without re-deriving (and re-proving) the\r\n * web package root on every watcher batch. `undefined` in production and\r\n * before boot, which is exactly when `shouldRestart` must answer `false`.\r\n */\r\n protected resolvedPaths?: Awaited<ReturnType<WebConnector[\"resolvePaths\"]>>;\r\n\r\n /** Synchronous watcher classification handed to the async reload phase. */\r\n protected pendingPageChanges?: PageFileChanges;\r\n\r\n /** Reuses Vite's pipeline-barrel module instance for every dev reinstall. */\r\n protected installDevPageRoutes?: () => Promise<InstalledPageRoute[]>;\r\n\r\n /** Serializes Vite and core watcher callbacks that can observe the same edit. */\r\n protected pageRouteReloadQueue: Promise<unknown> = Promise.resolve();\r\n\r\n /** Committed file versions awaiting the overlapping watcher callback. */\r\n protected pendingHotUpdateSuppressions = new Map<string, string>();\r\n\r\n public constructor(options: WebConnectorOptions = {}) {\r\n super();\r\n this.options = options;\r\n }\r\n\r\n /**\r\n * Boot the connector — wire the page pipeline's request context and register\r\n * every page on the router.\r\n *\r\n * Two ways of doing that, one per hosting mode, and they share the shape\r\n * rather than the mechanism. Development creates Vite in middleware mode,\r\n * mounts it on the HTTP connector's Fastify instance and discovers pages by\r\n * walking `app/`; production takes both answers from the manifest the build\r\n * handed over (`./install-production-page-routes`) and touches no Vite at all.\r\n *\r\n * Everything here happens BEFORE `HttpConnector.start()` scans and listens,\r\n * which is the entire reason this is a `Late` connector's `boot()` and not its\r\n * `start()`.\r\n */\r\n public async boot() {\r\n // THE MODE BRANCH. One `if`, and it reads the mode — never the value.\r\n // `consumePageManifest()` returning `undefined` must not mean two different\r\n // things at one call site, so the only\r\n // question asked of the value here is \"is it there\", and the only thing\r\n // that decides whether that matters is {@link isProductionRuntime}.\r\n this.pageManifest = consumePageManifest();\r\n\r\n if (isProductionRuntime() && !this.pageManifest) {\r\n throw new WebPageManifestMissingError();\r\n }\r\n\r\n // The manifest is guaranteed present by the guard above; naming it again is\r\n // what narrows the type, not a second check of the same condition.\r\n if (isProductionRuntime() && this.pageManifest) {\r\n if ((this.pageManifest.publicFiles?.length ?? 0) > 0) {\r\n registerProductionPublicFiles(\r\n router,\r\n this.resolveClientDir(),\r\n this.pageManifest.publicFiles ?? [],\r\n );\r\n }\r\n\r\n this.installedPages = await installProductionPageRoutes({\r\n router,\r\n manifest: this.pageManifest,\r\n pageContext: requestContext,\r\n sharedStore: () => requestContext.getStore(),\r\n // The URL is resolved lazily, by the production path, only if there are\r\n // pages to hydrate — see the option's own note.\r\n resolveHydrationClientModuleUrl: () => this.resolveHydrationClientModuleUrl(),\r\n // The stylesheets are read from the manifest in this directory, by the\r\n // installer itself — it already imports the barrel that owns that\r\n // reader, and production has one module graph, so resolving there\r\n // rather than here avoids loading the barrel twice.\r\n clientDir: this.pageManifest.clientDir,\r\n });\r\n\r\n // SERVE THE CLIENT BUNDLE. Without this the whole production page path\r\n // completes and still ships a dead page: the SSR HTML carries\r\n // `<script type=\"module\" src=\"/assets/hydration-<hash>.js\">`, that request\r\n // 404s, and React never takes over. Nothing else in the process serves\r\n // that directory — `CLIENT_ASSET_URL_PREFIX` was, until now, only ever\r\n // read to VALIDATE the URL written into the HTML, never to mount the\r\n // files it points at.\r\n //\r\n // In `boot()` rather than `start()` because the router registers static\r\n // directories during its SCAN, and the scan is `HttpConnector.start()` —\r\n // which every `boot()` precedes. Registering in `start()` would be a\r\n // no-op that looked correct.\r\n //\r\n // Dev needs no equivalent: Vite's middleware serves the module graph\r\n // itself, which is why this sits inside the production branch and not\r\n // above it.\r\n //\r\n // Gated on the page count for the same reason the build's hydration\r\n // client step is: a zero-page build produces no `assets/` bundle to\r\n // mount. Such a build may still carry `clientDir` for copied public\r\n // files, which were registered individually above rather than exposing\r\n // this directory wholesale.\r\n if (this.pageManifest.pages.length > 0) {\r\n router.directory(productionAssetsDirectoryOptions(this.resolveClientDir()));\r\n }\r\n\r\n return;\r\n }\r\n\r\n // Everything below this line is the Vite-hosted development path. Nothing\r\n // above it touches Vite: it is an optional peer, so a production install\r\n // does not carry it, and this method is the only place the two halves meet.\r\n //\r\n // Fastify is required by the DEVELOPMENT path alone, and the guard sits\r\n // here rather than above the branch for that reason: production mounts no\r\n // middleware and needs no HMR socket, it registers page routes on the\r\n // router and `HttpConnector.start()` scans them like any other route.\r\n const fastify = this.resolveFastify();\r\n const paths = await this.resolvePaths();\r\n\r\n // Kept for `shouldRestart`, which is asked of this connector on every\r\n // watcher batch and must answer without re-resolving anything.\r\n this.resolvedPaths = paths;\r\n\r\n this.vite = await this.createViteServer(fastify, paths);\r\n\r\n // The pipeline barrel is loaded THROUGH VITE, not imported directly, and\r\n // that is load-bearing: page modules are evaluated inside Vite's SSR module\r\n // graph, so `connectSharedStore`/`connectPageContext` must be called on\r\n // VITE's instance of those modules. A plain Node `import` here would wire a\r\n // second, unrelated module instance and every page would render with an\r\n // empty shared store.\r\n const webServerSsr = await this.vite.ssrLoadModule(paths.webServerBarrel);\r\n\r\n webServerSsr.connectSharedStore(() => requestContext.getStore());\r\n webServerSsr.connectPageContext(requestContext);\r\n\r\n // Vite's `middlewares` is a plain Connect `(req, res, next)` stack and\r\n // Fastify's `request.raw`/`reply.raw` ARE node's `req`/`res`, so an\r\n // `onRequest` hook mounts it with no plugin at all — `@fastify/middie` is\r\n // not needed and is not a core dependency (`core/package.json`). Vite never\r\n // fronts the server: it either answers its own asset request or calls\r\n // `done()` and Warlock's router owns the response.\r\n //\r\n // ONE EXCEPTION, and it is why `done` is wrapped rather than passed\r\n // straight through: vite reaches this callback for TWO different reasons in\r\n // middleware mode — \"not mine\" and \"mine, and it failed\". The second one\r\n // arrives indistinguishable from the first, because vite's own error\r\n // handler logs the failure and then calls `next()` with the error cleared\r\n // (`node_modules/vite/dist/node/chunks/config.js:9525-9527`). Handing that\r\n // to the framework produced an empty 404 on a module that exists — via the\r\n // app's catch-all page route (`./render-page.ts:604`,\r\n // `./create-page-route-handler.ts:147`) — and threw the only useful\r\n // explanation away. `devErrorTransportPlugin` captures it upstream; this\r\n // reads it back.\r\n // Dev-only on both sides — nothing below this line runs in production.\r\n fastify.addHook(\r\n \"onRequest\",\r\n (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => {\r\n this.vite?.middlewares(request.raw, reply.raw, (error?: Error) => {\r\n if (sendCapturedDevError(request.raw, reply.raw)) return;\r\n\r\n done(error);\r\n });\r\n },\r\n );\r\n\r\n const reportUnregisteredPages = createUnregisteredPageReporter({\r\n appRoot: paths.appRoot,\r\n appSrcRoot: paths.appSrcRoot,\r\n registeredPageFiles: () => registeredPageFiles(router.list(), paths.appSrcRoot),\r\n });\r\n\r\n fastify.addHook(\r\n \"onResponse\",\r\n (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => {\r\n if (reply.statusCode === 404) {\r\n reportUnregisteredPages({\r\n method: request.method,\r\n url: request.url,\r\n pathname: new URL(request.url, \"http://warlock.local\").pathname,\r\n });\r\n }\r\n\r\n done();\r\n },\r\n );\r\n\r\n this.installDevPageRoutes = () => webServerSsr.installPageRoutes({\r\n router,\r\n vite: this.vite,\r\n appSrcRoot: paths.appSrcRoot,\r\n appFile: paths.appFile,\r\n hydrationClientModuleUrl: this.resolveHydrationClientModuleUrl(paths.webRoot),\r\n // Without these the first paint of every full page load is unstyled: the\r\n // client bundle imports the CSS, so JavaScript applies it only after the\r\n // module graph loads. A render-blocking <link> in <head> is what makes\r\n // the page arrive styled instead of arriving and then correcting itself.\r\n stylesheetUrls: webServerSsr.devStylesheetUrls(paths.appRoot, paths.appFile),\r\n });\r\n\r\n this.installedPages = await this.installDevPageRoutes();\r\n }\r\n\r\n /**\r\n * Where the browser fetches the hydration entry from — the one line that\r\n * differs between the two modes, so it is the only thing that branches.\r\n *\r\n * Dev keeps Vite's `/@fs/` URL, which Vite's own middleware transforms on\r\n * demand. Production reads the hashed filename out of the client build's\r\n * `.vite/manifest.json` exactly once, at boot, and NEVER falls back: each way\r\n * that read can fail is its own named error (`./hydration-client-url.ts:24`,\r\n * `:36`, `:53`), because \"serve without hydration\" would be a page that\r\n * renders and then does nothing.\r\n *\r\n * `webRoot` is OPTIONAL because only the dev branch has any use for it, and\r\n * asking production for it would be worse than useless: it is proved by\r\n * reading `<root>/package.json` off disk, which is exactly the kind of\r\n * source-tree lookup a built artifact must never depend on. A dev boot that\r\n * somehow reaches here without one is refused by name by the entry factory.\r\n */\r\n protected resolveHydrationClientModuleUrl(webRoot?: string): string {\r\n if (isProductionRuntime()) {\r\n return resolveHydrationClientUrl({ clientDir: this.resolveClientDir() });\r\n }\r\n\r\n return createHydrationClientEntry(webRoot ?? \"\").devUrl;\r\n }\r\n\r\n /**\r\n * `<outdir>/client` — the layout the build half writes and this half reads\r\n * at boot, taken from the value the build BAKED into the page manifest.\r\n *\r\n * It used to call `resolveBuildConfig()`, which reads `warlock.config.ts`\r\n * through `warlockConfigManager`. That works in `warlock build` and in the\r\n * `warlock start` SUPERVISOR, and it cannot work here: the supervisor spawns\r\n * a plain `node dist/app.js` CHILD, and that process never loads — and could\r\n * not load — a TypeScript build-time config. The call threw\r\n * `WarlockConfig not loaded` inside connector boot, so the production server\r\n * died before it ever listened.\r\n *\r\n * Baking it also settles the drift the old comment was worried about, and\r\n * settles it harder: `build` and `start` cannot disagree about where the\r\n * bundle lives, because `start` is no longer re-deriving the path at all —\r\n * it reads back the one string `build` wrote.\r\n */\r\n protected resolveClientDir(): string {\r\n // The manifest CACHED at boot (line ~269), not a second `consumePageManifest()`:\r\n // this runs from a callback the production branch invokes lazily, long after\r\n // that assignment, and reading the same field the mode branch already\r\n // decided on keeps one source of truth for the boot's view of the manifest.\r\n const clientDir = this.pageManifest?.clientDir;\r\n\r\n if (clientDir === undefined) {\r\n // Reached only via a manifest with browser artifacts but no `clientDir`\r\n // — i.e. a bundle built by a web version older than this field. Named here\r\n // rather than left to surface as an ENOENT on a `path.join(undefined)`\r\n // deep inside the manifest read.\r\n throw new WebClientDirMissingError();\r\n }\r\n\r\n return path.resolve(process.cwd(), clientDir);\r\n }\r\n\r\n /**\r\n * The page manifest this connector consumed at boot, or `undefined` in dev\r\n * where Vite supplies the modules instead.\r\n */\r\n public getPageManifest(): PageManifest | undefined {\r\n return this.pageManifest;\r\n }\r\n\r\n /**\r\n * Activate. There is nothing to listen on — `HttpConnector.start()` owns the\r\n * single `listen()` for the whole process — so this only marks the connector\r\n * live once `boot()` has wired everything.\r\n */\r\n public async start(): Promise<void> {\r\n if (!this.vite) return;\r\n\r\n this.active = true;\r\n }\r\n\r\n /**\r\n * Shutdown — close Vite, and drop the sockets Vite's middleware left behind.\r\n *\r\n * Reverse-priority teardown (`core/src/connectors/connectors-manager.ts:118`)\r\n * puts this BEFORE `HttpConnector.shutdown()`, which is exactly what the\r\n * second call needs: requests answered by Vite's connect stack are written\r\n * straight to `reply.raw`, so Fastify never observes them completing and their\r\n * keep-alive sockets are never counted idle. Core's default\r\n * `forceCloseConnections: \"idle\"` (`core/src/http/server.ts:34`) then waits on\r\n * them forever. A dev server has no draining obligation, and the connector\r\n * that caused the raw writes is the right one to clean up after them.\r\n */\r\n public async shutdown(): Promise<void> {\r\n if (!this.active) return;\r\n\r\n if (container.has(\"http.server\")) {\r\n container.get(\"http.server\").server.closeAllConnections();\r\n }\r\n\r\n await this.vite?.close();\r\n this.vite = undefined;\r\n this.installedPages = [];\r\n this.installDevPageRoutes = undefined;\r\n this.pendingPageChanges = undefined;\r\n this.pendingHotUpdateSuppressions.clear();\r\n this.pageManifest = undefined;\r\n\r\n this.active = false;\r\n }\r\n\r\n /**\r\n * Queue page add/remove/edit candidates for asynchronous live routing work.\r\n * Classification stays synchronous because core's connector interface is;\r\n * edited route exports are evaluated later through Vite's fresh SSR graph.\r\n */\r\n public shouldRestart(changedFiles: string[] = []): boolean {\r\n if (this.vite === undefined || this.resolvedPaths === undefined) {\r\n return false;\r\n }\r\n\r\n this.pendingPageChanges = this.classifyPageChanges(changedFiles);\r\n return this.pendingPageChanges !== undefined;\r\n }\r\n\r\n protected classifyPageChanges(changedFiles: readonly string[]): PageFileChanges | undefined {\r\n if (this.resolvedPaths === undefined) return undefined;\r\n\r\n const changes = classifyPageFileChanges(changedFiles, {\r\n appRoot: this.resolvedPaths.appRoot,\r\n appSrcRoot: this.resolvedPaths.appSrcRoot,\r\n installedPageFiles: registeredPageFiles(router.list(), this.resolvedPaths.appSrcRoot),\r\n });\r\n\r\n return hasPageFileChanges(changes) ? changes : undefined;\r\n }\r\n\r\n protected enqueuePageRouteReload(changes: PageFileChanges): Promise<boolean> {\r\n const eventVersions = pageChangeVersions(changes);\r\n const run = this.pageRouteReloadQueue.catch(() => undefined).then(async () => {\r\n const vite = this.vite;\r\n const install = this.installDevPageRoutes;\r\n const paths = this.resolvedPaths;\r\n\r\n if (vite === undefined || install === undefined || paths === undefined) return false;\r\n\r\n const matchingCommittedFiles = new Set<string>();\r\n\r\n // This check belongs inside the queue: a matching core transaction may\r\n // commit while a Vite callback is waiting behind it. File versions are\r\n // captured when the job is queued so a later edit cannot consume an\r\n // earlier event's marker.\r\n for (const [file, eventVersion] of eventVersions) {\r\n const committedVersion = this.pendingHotUpdateSuppressions.get(file);\r\n if (committedVersion === undefined) continue;\r\n\r\n // Matching markers are consumed exactly once. A mismatched marker is\r\n // obsolete and must not survive to suppress a future reverted edit.\r\n this.pendingHotUpdateSuppressions.delete(file);\r\n if (committedVersion === eventVersion) matchingCommittedFiles.add(file);\r\n }\r\n\r\n if (eventVersions.size > 0 && matchingCommittedFiles.size === eventVersions.size) {\r\n return true;\r\n }\r\n\r\n const replace = await pageRoutesNeedReplacement(changes, {\r\n vite,\r\n appSrcRoot: paths.appSrcRoot,\r\n installedPages: this.installedPages,\r\n });\r\n\r\n if (!replace) return false;\r\n\r\n const nextInstalledPages = await router.replaceRoutesBySourceFiles(\r\n pageRouteSourceFiles(router.list()),\r\n install,\r\n );\r\n\r\n // Advance observable state only after the router transaction commits.\r\n // A rejected install keeps both the old route table and browser live.\r\n this.installedPages = nextInstalledPages;\r\n invalidateClientPageRegistry(vite);\r\n\r\n for (const [file, eventVersion] of eventVersions) {\r\n if (!matchingCommittedFiles.has(file)) {\r\n this.pendingHotUpdateSuppressions.set(file, eventVersion);\r\n }\r\n }\r\n\r\n return true;\r\n });\r\n\r\n this.pageRouteReloadQueue = run;\r\n return run;\r\n }\r\n\r\n /**\r\n * Vite-side ordering barrier. It publishes a changed route graph before the\r\n * page-registry plugin can reload the document; a matching change already\r\n * handled by core is consumed once, preventing a duplicate full reload.\r\n */\r\n protected async handlePageHotUpdate(file: string): Promise<boolean> {\r\n const absoluteFile = path.resolve(file);\r\n const changes = this.classifyPageChanges([absoluteFile]);\r\n if (changes === undefined) return false;\r\n\r\n return this.enqueuePageRouteReload(changes);\r\n }\r\n\r\n /**\r\n * Live page routing update. Despite the connector API name, this never closes\r\n * Vite: it atomically replaces page-owned routes only when membership or the\r\n * canonical route identity changed, then refreshes the client registry.\r\n */\r\n public async restart(): Promise<void> {\r\n const changes = this.pendingPageChanges;\r\n this.pendingPageChanges = undefined;\r\n\r\n if (\r\n changes === undefined ||\r\n this.resolvedPaths === undefined ||\r\n this.vite === undefined ||\r\n this.installDevPageRoutes === undefined\r\n ) {\r\n return;\r\n }\r\n\r\n await this.enqueuePageRouteReload(changes);\r\n }\r\n\r\n /** The pages this connector registered on the router, in registration order. */\r\n public getInstalledPages(): readonly InstalledPageRoute[] {\r\n return this.installedPages;\r\n }\r\n\r\n /**\r\n * The Fastify instance the HTTP connector published during its own `boot()`\r\n * (`core/src/connectors/http-connector.ts:74`).\r\n *\r\n * Absence is fatal rather than a silent no-op: unlike sockets, there is no\r\n * standalone fallback a page surface could serve from, and the failure this\r\n * guards against — an app with no `src/config/http.ts` — otherwise shows up\r\n * as every page 404ing with no explanation.\r\n */\r\n protected resolveFastify(): FastifyInstance {\r\n if (!container.has(\"http.server\")) {\r\n throw new Error(\r\n \"WebConnector requires the HTTP connector's Fastify instance, but \" +\r\n '`http.server` is not in the container. The `http` config is missing — add `src/config/http.ts` ' +\r\n \"so `HttpConnector.boot()` runs (core/src/connectors/http-connector.ts:61-74).\",\r\n );\r\n }\r\n\r\n return container.get(\"http.server\");\r\n }\r\n\r\n /** Resolve every path this connector needs from the (optional) options. */\r\n protected async resolvePaths() {\r\n const appRoot = this.options.appRoot ?? process.cwd();\r\n const appSrcRoot = this.options.appSrcRoot ?? path.join(appRoot, \"src\");\r\n const selfPath = fileURLToPath(import.meta.url);\r\n // The web package root goes through `resolveWebPackageRoot`, which PROVES\r\n // the directory by reading `<root>/package.json` and matching its `name`,\r\n // rather than trusting a fixed number of `..` hops. A configured root is\r\n // asserted the same way. Either failure throws\r\n // `WebPackageRootResolutionError` naming the directory at boot — the\r\n // alternative was a wrong root surfacing much later as a 404 on the\r\n // hydration entry with nothing to point at.\r\n const webRoot = await resolveWebPackageRoot(this.options.webRoot);\r\n\r\n return {\r\n appRoot,\r\n appSrcRoot,\r\n appFile: this.options.appFile ?? path.join(appSrcRoot, \"web/root.tsx\"),\r\n webRoot,\r\n // Extension-agnostic on purpose: the sibling barrel is `index.ts` when\r\n // this package runs from source and `index.js` once it is built, the same\r\n // trick `registerLoader` uses when it resolves its own siblings.\r\n webServerBarrel: path.join(path.dirname(selfPath), `index${path.extname(selfPath)}`),\r\n };\r\n }\r\n\r\n /**\r\n * Vite in middleware mode, `appType: \"custom\"` — Warlock owns the response\r\n * shape and Vite never fronts the server.\r\n *\r\n * `server.hmr.server` is handed the RAW node server (`fastify.server`), so the\r\n * HMR websocket shares the one port the app already listens on. No `hmr.port`\r\n * and no `clientPort`: Vite's HMR path stays the default `\"/\"`, socket.io\r\n * stays on `\"/socket.io\"` (`core/src/connectors/socket-connector.ts:90`), and\r\n * the two `upgrade` listeners coexist because each is a selective filter that\r\n * leaves a non-matching socket alone — verified empirically in both attachment\r\n * orders.\r\n */\r\n /**\r\n * React Fast Refresh, or nothing at all — never half of it.\r\n *\r\n * TWO plugins, and the second one is not optional. `@vitejs/plugin-react`\r\n * emits, into every client component module, a wrapper that reads\r\n * `window.$RefreshReg$` and THROWS \"can't detect preamble\" if it is missing.\r\n * The preamble that defines it normally arrives through Vite's\r\n * `transformIndexHtml`, and this pipeline has no HTML for Vite to transform:\r\n * the document is React's SSR output and the only script appended to it is\r\n * the hydration entry (`./create-page-route-handler.ts:95`). So the preamble\r\n * is delivered as a virtual module that the hydration entry imports FIRST.\r\n *\r\n * \"First\" is load-bearing, twice over. ESM evaluates a module's imports in\r\n * source order before the importer's own body, so an import placed at\r\n * position 0 runs before `virtual:warlock/pages` pulls in a single component\r\n * — which is what `$RefreshReg$` has to exist for — and before `react-dom`\r\n * initialises, which is what `injectIntoGlobalHook` has to precede.\r\n *\r\n * The preamble TEXT is read from the plugin's own `preambleCode` export\r\n * rather than copied here, so a version bump cannot leave this file holding\r\n * a stale runtime contract. If that export ever stops being a string, Fast\r\n * Refresh is DECLINED entirely (empty array) instead of registered without\r\n * its preamble — a loud \"no HMR\" beats a component graph that throws on\r\n * first paint.\r\n *\r\n * Dev-only by construction, like everything else this method is called from:\r\n * `apply: \"serve\"` on our own plugin, `apply: \"serve\"` on the plugin's\r\n * refresh half, and `skipFastRefresh` on `config.isProduction` inside it. The\r\n * PRODUCTION client bundle cannot reach any of this — it is built by\r\n * `buildWarlockHydrationClient` (`../vite/index.ts:185`), which composes\r\n * `warlockClientBoundary()` and nothing else, and never calls this method.\r\n *\r\n * @param paths resolved by {@link WebConnector.resolvePaths}\r\n */\r\n protected async resolveReactFastRefreshPlugins(\r\n paths: Awaited<ReturnType<WebConnector[\"resolvePaths\"]>>,\r\n ): Promise<PluginOption[]> {\r\n let viteReact: ReactPluginFactory;\r\n\r\n try {\r\n // OPTIONAL peer (`web/package.json`'s `peerDependenciesMeta`), so this is\r\n // a lazy `await import` exactly like the `vite` one above it, and\r\n // `@vitejs/plugin-react` is listed in `WEB_OPTIONAL_PEERS` for the same\r\n // reason `vite` is.\r\n ({ default: viteReact } = (await import(\"@vitejs/plugin-react\")) as unknown as {\r\n default: ReactPluginFactory;\r\n });\r\n } catch {\r\n console.warn(\r\n \"[warlock:web] React Fast Refresh is OFF: `@vitejs/plugin-react` is not installed. \" +\r\n \"Edits to a component will reload the page instead of hot-swapping it. \" +\r\n \"Install it with `npm i -D @vitejs/plugin-react` to enable it.\",\r\n );\r\n\r\n return [];\r\n }\r\n\r\n const preambleCode = viteReact.preambleCode;\r\n\r\n if (typeof preambleCode !== \"string\") {\r\n console.warn(\r\n \"[warlock:web] React Fast Refresh is OFF: this `@vitejs/plugin-react` no longer exports \" +\r\n \"`preambleCode`, so the refresh preamble cannot be injected into the hydration entry. \" +\r\n \"Registering the plugin without it would make every component module throw \" +\r\n \"\\\"can't detect preamble\\\" in the browser.\",\r\n );\r\n\r\n return [];\r\n }\r\n\r\n // `base` is never set on the config below, so it is Vite's default `\"/\"`.\r\n const preambleSource = preambleCode.replace(\"__BASE__\", \"/\");\r\n const hydrationEntryId = normalizeModuleId(createHydrationClientEntry(paths.webRoot).sourcePath);\r\n\r\n const preamblePlugin: Plugin = {\r\n name: \"warlock:react-refresh-preamble\",\r\n // BEFORE `warlock:projection` and the gates would be wrong and BEFORE\r\n // esbuild's TS transform is required: this prepends one import statement\r\n // to TypeScript source, so it has to see the file before anything lowers\r\n // it. It touches exactly one module, so it cannot reorder anything else.\r\n enforce: \"pre\",\r\n apply: \"serve\",\r\n // The preamble is browser state (`window.$RefreshReg$`). The SSR\r\n // environment must never evaluate it — there is no `window` there, and\r\n // the server render must stay byte-identical to what it produced before\r\n // this plugin existed.\r\n applyToEnvironment: environment => environment.config.consumer === \"client\",\r\n resolveId(source) {\r\n if (source === REACT_REFRESH_PREAMBLE_ID) return RESOLVED_REACT_REFRESH_PREAMBLE_ID;\r\n\r\n return null;\r\n },\r\n load(id) {\r\n if (id === RESOLVED_REACT_REFRESH_PREAMBLE_ID) return preambleSource;\r\n\r\n return null;\r\n },\r\n transform(code, id) {\r\n if (normalizeModuleId(id) !== hydrationEntryId) return null;\r\n\r\n return { code: `import ${JSON.stringify(REACT_REFRESH_PREAMBLE_ID)};\\n${code}`, map: null };\r\n },\r\n };\r\n\r\n return [preamblePlugin, ...viteReact()];\r\n }\r\n\r\n protected async createViteServer(\r\n fastify: FastifyInstance,\r\n paths: Awaited<ReturnType<WebConnector[\"resolvePaths\"]>>,\r\n ): Promise<ViteDevServer> {\r\n const { createServer, buildErrorMessage, searchForWorkspaceRoot } = await import(\"vite\");\r\n\r\n // Vite's own default for `server.fs.allow`, reproduced rather than dropped:\r\n // naming the key at all REPLACES the default, and an application that\r\n // legitimately serves files from above its own root has to keep working.\r\n const workspaceRoot = searchForWorkspaceRoot(paths.appRoot);\r\n\r\n return createServer({\r\n root: paths.appRoot,\r\n appType: \"custom\",\r\n plugins: [\r\n // FIRST, and dev-only by construction: this method is reachable only\r\n // from `boot()`'s Vite branch, past the `isProductionRuntime()` guard.\r\n // The same predicate is handed in rather than re-derived, and the\r\n // factory throws if it is ever true — see the plugin's own header for\r\n // why the layer has to be registered from a plugin and not from\r\n // `vite.middlewares.use(...)` after this call returns.\r\n devErrorTransportPlugin({ isProductionRuntime, buildErrorMessage }),\r\n ...warlockClientBoundary({\r\n appRoot: paths.appRoot,\r\n beforePageHotUpdate: ({ file }) => this.handlePageHotUpdate(file),\r\n }),\r\n // AFTER the boundary, and the order matters among `enforce: \"pre\"`\r\n // plugins (Vite keeps array order within an enforce bucket).\r\n // `warlock:projection` strips a page's server exports — `loader` and\r\n // friends — before React's babel pass sees the module, so Fast Refresh\r\n // never registers a refresh boundary for an export that is not supposed\r\n // to reach the browser at all.\r\n //\r\n // BEFORE `options.plugins` so an application can still override.\r\n ...(await this.resolveReactFastRefreshPlugins(paths)),\r\n ...(this.options.plugins ?? []),\r\n ],\r\n server: {\r\n middlewareMode: true,\r\n hmr: { server: fastify.server },\r\n fs: {\r\n // `<Scripts />` points the browser at the hydration client entry under\r\n // `<webRoot>`: the published `esm/hydration/index.mjs` when installed,\r\n // or `src/hydration/index.ts` in this checkout. A dependency normally\r\n // lives under the app root's `node_modules`; when `@warlock.js/web` is\r\n // LINKED — a monorepo checkout, `npm link`, or a `file:` dependency —\r\n // its real path can sit outside every directory Vite allows by default\r\n // and the request comes back `403 Restricted`.\r\n //\r\n // That failure is silent in the worst way: SSR has already produced\r\n // the markup by the time the browser asks for the script, so the page\r\n // renders perfectly, nothing is logged, and the only symptom is that\r\n // no button ever works. Naming web's own root makes a linked install\r\n // behave like an installed one.\r\n //\r\n // Every root is listed twice, as given and as `realpathSync` reports\r\n // it, because Vite resolves a requested file to its REAL path before\r\n // testing it against this list. Allowing the symlink alone therefore\r\n // matches nothing — the 403 page prints the link that was allowed\r\n // directly above the real path it rejected.\r\n allow: [\r\n ...new Set([\r\n ...withRealPath(workspaceRoot),\r\n ...withRealPath(paths.appRoot),\r\n ...withRealPath(paths.webRoot),\r\n ]),\r\n ],\r\n },\r\n },\r\n // Without an explicit target, esbuild assumes native (TC39) decorator\r\n // support and leaves `@RegisterModel()`-style syntax untouched — but Vite's\r\n // SSR module runner evaluates transformed code via `new AsyncFunction(...)`,\r\n // which node has no native decorator support for. `es2022` downlevels them\r\n // into helper calls. `jsx` is named explicitly rather than left to tsconfig\r\n // discovery, because Vite matches a file against a tsconfig's `include` and\r\n // `web/tsconfig.json`'s is narrow enough that most of `web/src` matched no\r\n // config at all and fell back to the CLASSIC transform — emitting\r\n // `React.createElement` into modules that import no `React` binding.\r\n esbuild: { target: \"es2022\", jsx: \"automatic\" },\r\n /**\r\n * REACT MUST BE PRE-BUNDLED, and naming it here is the only thing that\r\n * makes that happen for the framework's own client graph.\r\n *\r\n * `react-dom/client` is CommonJS. A browser cannot import a named export\r\n * from it, so Vite's dep optimizer normally rewrites it into an ESM shim\r\n * under `/node_modules/.vite/deps/`. Whether that rewrite happens is\r\n * decided in `tryNodeResolve`, and one of the conditions that SKIPS it is\r\n * `importer && isInNodeModules(importer)`\r\n * (`vite/dist/node/chunks/config.js:32822`). Vite's reasoning is sound in\r\n * general — a dependency's own internal imports are the optimizer's job,\r\n * not the resolver's — but it is exactly wrong here:\r\n *\r\n * `<Scripts />` points the browser at the hydration entry, and in an\r\n * INSTALLED app that entry is\r\n * `<app>/node_modules/@warlock.js/web/esm/hydration/index.mjs`.\r\n * Every module it reaches is therefore inside `node_modules`, so every\r\n * bare import it makes takes the skip branch and is served as the raw\r\n * file with a `?v=<browserHash>` cache key bolted on.\r\n *\r\n * For `esm/**.mjs` that is harmless — they are already ESM. For\r\n * `react-dom/client` it is fatal, and it is the whole defect: the browser\r\n * receives `\"use strict\"; function checkDCE()…` and refuses the module\r\n * with\r\n *\r\n * SyntaxError: The requested module '/node_modules/react-dom/client.js?v=…'\r\n * does not provide an export named 'hydrateRoot'\r\n *\r\n * Nothing in the client runtime then runs at all: no hydration, so no\r\n * `useState`, no Fast Refresh, and `<Link>` degrades to a full document\r\n * load because the navigation listener was never attached. Measured on a\r\n * published 5.0.2 install (fresh app, no symlinks): all four symptoms\r\n * before this block, none after.\r\n *\r\n * `include` is the fix rather than `entries` because it does not depend\r\n * on the scanner reaching the entry: an included id is pre-bundled at\r\n * server start and `tryOptimizedResolve` matches it by NAME\r\n * (`config.js:32633`) before `tryNodeResolve` — and that lookup has no\r\n * importer condition, so a node_modules importer resolves to the shim\r\n * like anyone else. Pointing `optimizeDeps.entries` at the hydration\r\n * entry would not work: the scanner classifies a resolution inside\r\n * `node_modules` as a dependency to externalize rather than a source to\r\n * walk.\r\n *\r\n * All four names are listed even though `react` and `react/jsx-runtime`\r\n * usually get discovered anyway — they are discovered only because the\r\n * APP's own pages import them, which is a fact about the app and not\r\n * something the framework may rely on. `react-dom/client` is imported by\r\n * `client/hydrate-page.tsx` and by nothing a normal app writes, which is\r\n * why it was the one that broke.\r\n *\r\n * This is INVISIBLE from the monorepo checkout: there the hydration entry\r\n * resolves to `web/src/hydration/index.ts`, a path with no `node_modules`\r\n * segment, so the skip branch never fires and React optimizes normally.\r\n * Canon: nothing measured inside the checkout is evidence about a\r\n * published install.\r\n */\r\n optimizeDeps: {\r\n include: [\"react\", \"react-dom\", \"react-dom/client\", \"react/jsx-runtime\"],\r\n /**\r\n * The SECOND instance problem, and the reason this is `exclude` and not\r\n * another `include`.\r\n *\r\n * An app page's `import { Link } from \"@warlock.js/web\"` has an importer\r\n * OUTSIDE `node_modules`, so it takes the opposite branch above and the\r\n * whole package is pre-bundled into\r\n * `/node_modules/.vite/deps/@warlock__js_web.js`. The hydration entry\r\n * cannot reach that bundle — it is loaded by absolute path through\r\n * `/@fs/` and its imports are relative, so they resolve to the raw\r\n * `esm/*.mjs` files. The browser then holds TWO copies of every client\r\n * module in this package: the app renders `<Link>` from the bundled one\r\n * while the hydration entry publishes routes and mounts the document\r\n * context on the raw one. Module-level state does not cross that line.\r\n *\r\n * `routing/route-table` survives it by accident — it keeps its table in\r\n * a `Symbol.for` slot on `globalThis`, which is realm-wide — but\r\n * `components/document-context` is a React context OBJECT, and two\r\n * `createContext()` calls are two different contexts no provider can\r\n * bridge.\r\n *\r\n * Excluding the package makes the app's import resolve to\r\n * `esm/index.mjs` with the same `?v=` key the hydration entry's imports\r\n * carry, so there is one instance again. It costs the pre-bundle (a\r\n * handful of extra dev requests for files that are already valid ESM)\r\n * and nothing else — the grep across every client-reachable module in\r\n * this package finds exactly three bare imports, all of them React, all\r\n * of them covered by `include` above.\r\n */\r\n exclude: [\"@warlock.js/web\"],\r\n },\r\n ssr: {\r\n external: [\r\n ...CORE_OPTIONAL_PEERS,\r\n ...WEB_OPTIONAL_PEERS,\r\n ...(this.options.ssrExternal ?? []),\r\n ],\r\n /**\r\n * ONE `@warlock.js/web`, for the same reason `resolve.dedupe` below\r\n * insists on one React — and it is invisible from inside this repo.\r\n *\r\n * Vite externalises `node_modules` in SSR by default, so an installed\r\n * app gets TWO instances: the app's own `root.tsx` imports\r\n * `@warlock.js/web` and Vite hands that off to Node, while the pipeline\r\n * is loaded deliberately through `vite.ssrLoadModule(...)` and stays\r\n * inside Vite's graph. `renderPage` then sets the document context on\r\n * Vite's copy of `components/document-context`, and the app's `<Head/>`\r\n * reads Node's copy, which has nothing in it:\r\n *\r\n * <Head/> was rendered outside the page pipeline's document context\r\n *\r\n * Measured on a published 5.0.1 install: `GET /` 500 without this line,\r\n * 200 with it (404 control still 404). In THIS checkout the package\r\n * resolves to source under Vite's root, never through `node_modules`,\r\n * so both paths land on one instance and the bug cannot reproduce.\r\n * Canon `6b7ab838`.\r\n */\r\n noExternal: [\"@warlock.js/web\"],\r\n },\r\n resolve: {\r\n // ONE React, resolved from the application. A linked `@warlock.js/web`\r\n // resolves `react` out of its own tree while the app's pages resolve it\r\n // out of theirs; two React instances share no hook dispatcher, and SSR\r\n // dies on the first `useState` with \"Cannot read properties of null\".\r\n // `dedupe` forces these package names to resolve from Vite's `root` —\r\n // the app — whoever imported them, in the SSR environment as much as in\r\n // the client one.\r\n //\r\n // Two names cover every entry point. Vite matches a deep import against\r\n // the package it belongs to before consulting this list, so\r\n // `react-dom/client`, `react/jsx-runtime` and `react/jsx-dev-runtime`\r\n // are already deduped by `react-dom` and `react`; listing them\r\n // separately would only add entries that can never match.\r\n //\r\n // Do NOT express this as a `resolve.alias` entry instead. Pointing a\r\n // bare React specifier at a directory drags React's CommonJS entry into\r\n // Vite's SSR module graph, and the dev server then dies at startup with\r\n // \"module is not defined\" before it renders anything at all.\r\n dedupe: [\"react\", \"react-dom\"],\r\n alias: [\r\n ...(this.options.resolveAlias ?? []),\r\n // The app-tree convention `v5/app/tsconfig.json`'s own `paths` declare.\r\n // Vite does not read tsconfig paths on its own and no\r\n // `vite-tsconfig-paths` plugin is installed in this workspace.\r\n //\r\n // ONE definition, shared with the production build contribution\r\n // (`web/src/build/contribution.ts`). These were two separate literals\r\n // until 2026-08-24, and the production half simply did not have them —\r\n // dev resolved `web/*` while the production client build died on the\r\n // first page. Do not inline them back here.\r\n ...appConventionAliases(paths.appSrcRoot),\r\n ],\r\n },\r\n });\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"web-connector.mjs","names":[],"sources":["../../../../../../../web/src/server/web-connector.ts"],"sourcesContent":["/**\r\n * `WebConnector` — the SSR page surface as a first-class Warlock connector.\r\n *\r\n * It runs BESIDE `HttpConnector`, never instead of it: `warlock dev` alone now boots the API *and* serves React\r\n * pages on one port, and `web` no longer owns a private copy of the HTTP\r\n * lifecycle. Everything this file does used to live in `startDevServer()`\r\n * (`web/src/server/dev-error-transport.ts`), which created its own Fastify instance,\r\n * scanned the router and called `listen()` itself — three responsibilities core\r\n * already owns at `core/src/connectors/http-connector.ts:72`, `:133` and `:147`.\r\n *\r\n * WHY A `Late` CONNECTOR IS THE RIGHT SEAM, in ordering terms:\r\n * `ConnectorsManager.startPhase` runs **every** `boot()` in a phase before\r\n * **any** `start()` (`core/src/connectors/connectors-manager.ts:87-93`).\r\n * `HttpConnector` is itself `Late` (`core/src/connectors/http-connector.ts:41`)\r\n * and publishes its Fastify instance during its own `boot()`\r\n * (`container.set(\"http.server\", …)`, `core/src/connectors/http-connector.ts:74`).\r\n * So by the time this connector's `boot()` runs, Fastify and its plugins exist,\r\n * the raw node server exists, and NOTHING has been scanned or bound yet — page\r\n * routes registered here are picked up by `HttpConnector.start()`'s\r\n * `router.scanDevServer(…)` (`core/src/connectors/http-connector.ts:133`) before\r\n * `listen()` (`:147`). `SocketConnector.boot()` reads the same container key the\r\n * same way (`core/src/connectors/socket-connector.ts:78-80`) — this file is\r\n * deliberately shaped after it.\r\n *\r\n * What it can NOT do, and why that is fine: route COLLECTION happens earlier\r\n * (`core/src/dev-server/development-server.ts:57` precedes `:66`), so pages are\r\n * not discovered by the framework's file scanner. They are discovered here, by\r\n * `installPageRoutes` (`./install-page-routes.ts:189`), and registered through\r\n * the ordinary `router.get(…)` API — there is no second server matcher.\r\n *\r\n * DELIBERATE EXCEPTION to A.3 §2 (\"web has no core dependency\"), the same one\r\n * `./dev-error-transport.ts` and `./install-page-routes.ts` record in their own headers:\r\n * this module is not re-exported from any package barrel (`web/src/index.ts`,\r\n * `web/src/server/index.ts`, `web/src/connector/index.ts`) and is not part of\r\n * `web/package.json`'s dependency graph. It is dev/CLI bootstrap code, only\r\n * ever imported by tooling that already depends on core.\r\n *\r\n * `@warlock.js/web/connector` reaches this class ONLY through\r\n * `./web-connector-factory.ts`'s `await import(\"./web-connector\")` — a\r\n * deliberate seam, because a static edge from that barrel to this file would\r\n * put `../vite`, core's router and `./dev-error-transport` into the import graph of\r\n * every consuming app's `warlock.config.ts`.\r\n */\r\nimport fs from \"node:fs\";\r\nimport path from \"node:path\";\r\nimport { fileURLToPath } from \"node:url\";\r\nimport type { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from \"fastify\";\r\nimport type { Alias, PluginOption, ViteDevServer } from \"vite\";\r\nimport {\r\n Application,\r\n BaseConnector,\r\n ConnectorLifecyclePhase,\r\n type ConnectorName,\r\n container,\r\n type FastifyInstance,\r\n requestContext,\r\n router,\r\n} from \"@warlock.js/core\";\r\nimport { resolveWebPackageRoot } from \"../build/contribution\";\r\nimport { createHydrationClientEntry, invalidateClientPageRegistry } from \"../vite\";\r\nimport { createWebConnectorViteConfig } from \"../vite/dev-server-config\";\r\nimport { CLIENT_ASSET_URL_PREFIX } from \"./client-asset-url-prefix\";\r\nimport { devErrorTransportPlugin, sendCapturedDevError } from \"./dev-error-transport\";\r\nimport { resolveHydrationClientUrl } from \"./hydration-client-url\";\r\nimport type { InstalledPageRoute } from \"./install-page-routes\";\r\nimport { installProductionPageRoutes } from \"./install-production-page-routes\";\r\nimport {\r\n classifyPageFileChanges,\r\n hasPageFileChanges,\r\n type PageFileChanges,\r\n} from \"./page-file-change\";\r\nimport {\r\n pageRouteSourceFiles,\r\n pageRoutesNeedReplacement,\r\n registeredPageFiles,\r\n} from \"./page-route-reload\";\r\nimport { consumePageManifest, type PageManifest } from \"./page-manifest\";\r\nimport { registerProductionPublicFiles } from \"./register-production-public-files\";\r\nimport { createUnregisteredPageReporter } from \"./unregistered-pages\";\r\nimport { WEB_CONNECTOR_PRIORITY } from \"./web-connector-factory\";\r\n\r\n/**\r\n * Boot/shutdown position relative to core's own connectors.\r\n *\r\n * `ConnectorPriority.HTTP` is `5` and `ConnectorPriority.STORAGE` is `6`\r\n * (`core/src/connectors/types.ts:187-188`), and the manager sorts on a plain\r\n * numeric compare (`core/src/connectors/connectors-manager.ts:46`) — so `5.5`\r\n * is \"immediately after http, before everything else\". Two consequences, both\r\n * wanted:\r\n *\r\n * - `boot()` sees a Fastify instance that already has core's plugins and\r\n * health routes on it (`core/src/connectors/http-connector.ts:76`, `:85`).\r\n * - teardown is reverse-priority (`core/src/connectors/connectors-manager.ts:118`),\r\n * so Vite closes BEFORE the HTTP server does, not after.\r\n *\r\n * Note this is a magic number, not a declared dependency: core has no\r\n * `after`/`dependsOn` on the connector interface (`core/src/connectors/types.ts:8-71`).\r\n * Ordering only *needs* to be right for shutdown — `boot()` correctness is\r\n * guaranteed by the phase's boot-all-then-start-all pass regardless of priority.\r\n *\r\n * Declared in `./web-connector-factory` and re-exported here: the lazy delegate\r\n * that `webConnector()` returns must publish `priority` without loading this\r\n * (heavy) module. Importers keep the specifier they already use.\r\n */\r\nexport { WEB_CONNECTOR_PRIORITY };\r\n\r\n/**\r\n * Production boot ran with no page manifest in the registry.\r\n *\r\n * `undefined` from `consumePageManifest()` is a FACT, not an error — the registry never throws\r\n * on absence. The connector supplies the meaning, and it does so from MODE, not\r\n * from the value: in dev the absence is normal because Vite supplies the\r\n * modules and no build has run; in production it means the app was not built\r\n * with web, and a prod server that boots anyway serves 404s while looking\r\n * healthy — the exact silent failure this error exists to prevent.\r\n */\r\n/**\r\n * Which half of the handoff is live: Vite serving from source, or a bundle\r\n * produced by `warlock build`.\r\n *\r\n * RUNTIME STRATEGY, NOT `Application.environment`. The two are explicitly a\r\n * \"separate axis\" (`core/src/utils/environment.ts:4-7`), and the question this\r\n * connector asks — \"is there a page manifest, or does Vite supply the modules?\"\r\n * — is a HOSTING question. `warlock dev` with `NODE_ENV=production` (a staging\r\n * checkout, or just an inherited shell variable) is still Vite-hosted and still\r\n * has no manifest; keying off the environment would make that app refuse to\r\n * boot. Core sets the strategy on both sides deliberately:\r\n * `core/src/cli/commands/dev-server.command.ts:12` declares `\"development\"` for\r\n * `warlock dev`, and the generated production entry declares `\"production\"`\r\n * (`core/src/production/production-builder.ts:245`).\r\n *\r\n * This is core's own connector-level idiom, not a new one:\r\n * `core/src/connectors/http-connector.ts:132` picks `scanDevServer` over `scan`\r\n * the same way.\r\n */\r\nfunction isProductionRuntime(): boolean {\r\n return Application.runtimeStrategy === \"production\";\r\n}\r\n\r\nfunction pageFileVersion(file: string): string {\r\n try {\r\n return `present:${fs.readFileSync(file, \"utf8\")}`;\r\n } catch {\r\n return \"absent\";\r\n }\r\n}\r\n\r\nfunction pageChangeVersions(changes: PageFileChanges): Map<string, string> {\r\n return new Map(\r\n [...changes.added, ...changes.removed, ...changes.inspectionNeeded].map((file) => {\r\n const absoluteFile = path.resolve(file);\r\n return [absoluteFile, pageFileVersion(absoluteFile)] as const;\r\n }),\r\n );\r\n}\r\n\r\nexport class WebPageManifestMissingError extends Error {\r\n public constructor() {\r\n super(\r\n \"WebConnector booted in production without a page manifest. The generated \" +\r\n \"`pages.ts` barrel never ran, so no page modules were handed to the connector \" +\r\n \"and there is nothing to serve. Run `warlock build` with the web connector \" +\r\n \"registered in `warlock.config.ts > connectors`, and start the artifact that \" +\r\n \"build produced.\",\r\n );\r\n this.name = \"WebPageManifestMissingError\";\r\n }\r\n}\r\n\r\n/**\r\n * The manifest carried browser artifacts but no `clientDir`.\r\n *\r\n * The build bakes that field in beside the page table, so the only way to\r\n * observe this is a VERSION SPLIT: an artifact produced by a `@warlock.js/web`\r\n * older than the field, started against a newer runtime. Named rather than\r\n * left to `path.resolve(cwd, undefined)`, which throws a `TypeError` naming\r\n * neither the manifest nor the rebuild that fixes it.\r\n */\r\nexport class WebClientDirMissingError extends Error {\r\n public constructor() {\r\n super(\r\n \"The page manifest carries pages or public files but no `clientDir`, so the \" +\r\n \"connector cannot \" +\r\n \"locate the browser artifacts. This artifact was built by an older @warlock.js/web \" +\r\n \"than the one now running it. Re-run `warlock build` to regenerate the \" +\r\n \"`pages.ts` barrel against the current version.\",\r\n );\r\n this.name = \"WebClientDirMissingError\";\r\n }\r\n}\r\n\r\n/**\r\n * `@fastify/static`'s `maxAge` is milliseconds (the `send` package's option,\r\n * not seconds like `Cache-Control`'s own `max-age`) — one year, matching the\r\n * header this produces: `public, max-age=31536000, immutable`. Safe forever\r\n * because every filename under {@link CLIENT_ASSET_URL_PREFIX} is content-hashed\r\n * by the client build: a changed file is a changed URL, never a changed\r\n * response at the same URL, which is the one condition `immutable` requires.\r\n */\r\nconst HASHED_ASSET_CACHE_MAX_AGE_MS = 31536000 * 1000;\r\n\r\n/**\r\n * Exported so a test can assert the real options this connector hands\r\n * `router.directory` — the same object `boot()` uses below, not a copy a spec\r\n * could drift from unnoticed.\r\n */\r\nexport function productionAssetsDirectoryOptions(clientDir: string) {\r\n return {\r\n root: path.join(clientDir, \"assets\"),\r\n prefix: `${CLIENT_ASSET_URL_PREFIX}/`,\r\n maxAge: HASHED_ASSET_CACHE_MAX_AGE_MS,\r\n immutable: true,\r\n };\r\n}\r\n\r\nexport type WebConnectorOptions = {\r\n /**\r\n * Vite's `root` — the application directory that owns `src/`, `package.json`\r\n * and `tsconfig.json`. Rooting Vite at the APP (not at the `web` package) is\r\n * what makes the app's own bare specifiers and dependency-optimizer scan\r\n * resolve correctly. Defaults to `process.cwd()`, which is where `warlock dev`\r\n * already runs.\r\n */\r\n appRoot?: string;\r\n /** `<appRoot>/src` by default. Pages are `<appSrcRoot>/app/**\\/*.page.tsx`. */\r\n appSrcRoot?: string;\r\n /** The single global app-root file. `<appSrcRoot>/web/root.tsx` by default. */\r\n appFile?: string;\r\n /**\r\n * Root of the `@warlock.js/web` package, used to locate the hydration client\r\n * entry. Derived from this module's own location by default — a caller only\r\n * sets it when the package is not laid out normally.\r\n */\r\n webRoot?: string;\r\n /**\r\n * Extra `resolve.alias` entries, prepended to the app-convention aliases\r\n * (`web/*` → `src/web`, `app/*` → `src/app`) so a caller can win a conflict.\r\n * A normal application needs none of these; a monorepo checkout with unbuilt\r\n * workspace packages does.\r\n */\r\n resolveAlias?: Alias[];\r\n /** Extra `ssr.external` entries, appended to {@link CORE_OPTIONAL_PEERS}. */\r\n ssrExternal?: string[];\r\n /** Extra Vite plugins, appended after the client-boundary gates. */\r\n plugins?: PluginOption[];\r\n};\r\n\r\n/**\r\n * Web Connector\r\n * Manages the Vite dev server and the SSR page routes, mounted on the HTTP\r\n * connector's Fastify instance.\r\n */\r\nexport class WebConnector extends BaseConnector {\r\n public readonly name: ConnectorName = \"web\";\r\n public readonly priority = WEB_CONNECTOR_PRIORITY;\r\n public readonly lifecyclePhase = ConnectorLifecyclePhase.Late;\r\n\r\n /**\r\n * Nothing. Core already supplies each watcher batch to `shouldRestart`; page\r\n * membership and route identity are classified there, while component and\r\n * layout body edits remain Vite's HMR domain.\r\n */\r\n protected readonly watchedFiles: string[] = [];\r\n\r\n protected readonly options: WebConnectorOptions;\r\n\r\n protected vite?: ViteDevServer;\r\n\r\n protected installedPages: InstalledPageRoute[] = [];\r\n\r\n /**\r\n * The build→runtime handoff table, read once at boot.\r\n *\r\n * `undefined` in dev is the normal case and carries no meaning beyond \"no\r\n * build has run\" — see {@link WebPageManifestMissingError} for why the\r\n * production reading is a hard error and why the branch is on MODE.\r\n */\r\n protected pageManifest?: PageManifest;\r\n\r\n /**\r\n * The paths the DEVELOPMENT boot resolved, kept so `shouldRestart` can decide\r\n * whether a changed file is a page without re-deriving (and re-proving) the\r\n * web package root on every watcher batch. `undefined` in production and\r\n * before boot, which is exactly when `shouldRestart` must answer `false`.\r\n */\r\n protected resolvedPaths?: Awaited<ReturnType<WebConnector[\"resolvePaths\"]>>;\r\n\r\n /** Synchronous watcher classification handed to the async reload phase. */\r\n protected pendingPageChanges?: PageFileChanges;\r\n\r\n /** Reuses Vite's pipeline-barrel module instance for every dev reinstall. */\r\n protected installDevPageRoutes?: () => Promise<InstalledPageRoute[]>;\r\n\r\n /** Serializes Vite and core watcher callbacks that can observe the same edit. */\r\n protected pageRouteReloadQueue: Promise<unknown> = Promise.resolve();\r\n\r\n /** Committed file versions awaiting the overlapping watcher callback. */\r\n protected pendingHotUpdateSuppressions = new Map<string, string>();\r\n\r\n public constructor(options: WebConnectorOptions = {}) {\r\n super();\r\n this.options = options;\r\n }\r\n\r\n /**\r\n * Boot the connector — wire the page pipeline's request context and register\r\n * every page on the router.\r\n *\r\n * Two ways of doing that, one per hosting mode, and they share the shape\r\n * rather than the mechanism. Development creates Vite in middleware mode,\r\n * mounts it on the HTTP connector's Fastify instance and discovers pages by\r\n * walking `app/`; production takes both answers from the manifest the build\r\n * handed over (`./install-production-page-routes`) and touches no Vite at all.\r\n *\r\n * Everything here happens BEFORE `HttpConnector.start()` scans and listens,\r\n * which is the entire reason this is a `Late` connector's `boot()` and not its\r\n * `start()`.\r\n */\r\n public async boot() {\r\n // THE MODE BRANCH. One `if`, and it reads the mode — never the value.\r\n // `consumePageManifest()` returning `undefined` must not mean two different\r\n // things at one call site, so the only\r\n // question asked of the value here is \"is it there\", and the only thing\r\n // that decides whether that matters is {@link isProductionRuntime}.\r\n this.pageManifest = consumePageManifest();\r\n\r\n if (isProductionRuntime() && !this.pageManifest) {\r\n throw new WebPageManifestMissingError();\r\n }\r\n\r\n // The manifest is guaranteed present by the guard above; naming it again is\r\n // what narrows the type, not a second check of the same condition.\r\n if (isProductionRuntime() && this.pageManifest) {\r\n // Gated on `clientDir`, never on `publicFiles.length`: a zero-page,\r\n // zero-public-file build legitimately carries no `clientDir` at all (see\r\n // `resolveClientDir`'s note), and calling this without one would demand\r\n // a client build that had no reason to exist. But once a `clientDir` IS\r\n // present — a client build actually ran — registering an EMPTY\r\n // `publicFiles` is free (the loop inside is a no-op) and the staleness\r\n // check this runs (`warnIfPublicBuildIsStale`) is most needed exactly\r\n // here: an app that shipped a build with NO public files still needs a\r\n // file added to `public/` afterwards to be caught, not 404 in silence\r\n // because a length check skipped the call that would have named it.\r\n if (this.pageManifest.clientDir !== undefined) {\r\n registerProductionPublicFiles(\r\n router,\r\n this.resolveClientDir(),\r\n this.pageManifest.publicFiles ?? [],\r\n );\r\n }\r\n\r\n this.installedPages = await installProductionPageRoutes({\r\n router,\r\n manifest: this.pageManifest,\r\n pageContext: requestContext,\r\n sharedStore: () => requestContext.getStore(),\r\n // The URL is resolved lazily, by the production path, only if there are\r\n // pages to hydrate — see the option's own note.\r\n resolveHydrationClientModuleUrl: () => this.resolveHydrationClientModuleUrl(),\r\n // The stylesheets are read from the manifest in this directory, by the\r\n // installer itself — it already imports the barrel that owns that\r\n // reader, and production has one module graph, so resolving there\r\n // rather than here avoids loading the barrel twice.\r\n clientDir: this.pageManifest.clientDir,\r\n });\r\n\r\n // SERVE THE CLIENT BUNDLE. Without this the whole production page path\r\n // completes and still ships a dead page: the SSR HTML carries\r\n // `<script type=\"module\" src=\"/assets/hydration-<hash>.js\">`, that request\r\n // 404s, and React never takes over. Nothing else in the process serves\r\n // that directory — `CLIENT_ASSET_URL_PREFIX` was, until now, only ever\r\n // read to VALIDATE the URL written into the HTML, never to mount the\r\n // files it points at.\r\n //\r\n // In `boot()` rather than `start()` because the router registers static\r\n // directories during its SCAN, and the scan is `HttpConnector.start()` —\r\n // which every `boot()` precedes. Registering in `start()` would be a\r\n // no-op that looked correct.\r\n //\r\n // Dev needs no equivalent: Vite's middleware serves the module graph\r\n // itself, which is why this sits inside the production branch and not\r\n // above it.\r\n //\r\n // Gated on the page count for the same reason the build's hydration\r\n // client step is: a zero-page build produces no `assets/` bundle to\r\n // mount. Such a build may still carry `clientDir` for copied public\r\n // files, which were registered individually above rather than exposing\r\n // this directory wholesale.\r\n if (this.pageManifest.pages.length > 0) {\r\n router.directory(productionAssetsDirectoryOptions(this.resolveClientDir()));\r\n }\r\n\r\n return;\r\n }\r\n\r\n // Everything below this line is the Vite-hosted development path. Nothing\r\n // above it touches Vite: it is an optional peer, so a production install\r\n // does not carry it, and this method is the only place the two halves meet.\r\n //\r\n // Fastify is required by the DEVELOPMENT path alone, and the guard sits\r\n // here rather than above the branch for that reason: production mounts no\r\n // middleware and needs no HMR socket, it registers page routes on the\r\n // router and `HttpConnector.start()` scans them like any other route.\r\n const fastify = this.resolveFastify();\r\n const paths = await this.resolvePaths();\r\n\r\n // Kept for `shouldRestart`, which is asked of this connector on every\r\n // watcher batch and must answer without re-resolving anything.\r\n this.resolvedPaths = paths;\r\n\r\n this.vite = await this.createViteServer(fastify, paths);\r\n\r\n // The pipeline barrel is loaded THROUGH VITE, not imported directly, and\r\n // that is load-bearing: page modules are evaluated inside Vite's SSR module\r\n // graph, so `connectSharedStore`/`connectPageContext` must be called on\r\n // VITE's instance of those modules. A plain Node `import` here would wire a\r\n // second, unrelated module instance and every page would render with an\r\n // empty shared store.\r\n const webServerSsr = await this.vite.ssrLoadModule(paths.webServerBarrel);\r\n\r\n webServerSsr.connectSharedStore(() => requestContext.getStore());\r\n webServerSsr.connectPageContext(requestContext);\r\n\r\n // Vite's `middlewares` is a plain Connect `(req, res, next)` stack and\r\n // Fastify's `request.raw`/`reply.raw` ARE node's `req`/`res`, so an\r\n // `onRequest` hook mounts it with no plugin at all — `@fastify/middie` is\r\n // not needed and is not a core dependency (`core/package.json`). Vite never\r\n // fronts the server: it either answers its own asset request or calls\r\n // `done()` and Warlock's router owns the response.\r\n //\r\n // ONE EXCEPTION, and it is why `done` is wrapped rather than passed\r\n // straight through: vite reaches this callback for TWO different reasons in\r\n // middleware mode — \"not mine\" and \"mine, and it failed\". The second one\r\n // arrives indistinguishable from the first, because vite's own error\r\n // handler logs the failure and then calls `next()` with the error cleared\r\n // (`node_modules/vite/dist/node/chunks/config.js:9525-9527`). Handing that\r\n // to the framework produced an empty 404 on a module that exists — via the\r\n // app's catch-all page route (`./render-page.ts:604`,\r\n // `./create-page-route-handler.ts:147`) — and threw the only useful\r\n // explanation away. `devErrorTransportPlugin` captures it upstream; this\r\n // reads it back.\r\n // Dev-only on both sides — nothing below this line runs in production.\r\n fastify.addHook(\r\n \"onRequest\",\r\n (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => {\r\n this.vite?.middlewares(request.raw, reply.raw, (error?: Error) => {\r\n if (sendCapturedDevError(request.raw, reply.raw)) return;\r\n\r\n done(error);\r\n });\r\n },\r\n );\r\n\r\n const reportUnregisteredPages = createUnregisteredPageReporter({\r\n appRoot: paths.appRoot,\r\n appSrcRoot: paths.appSrcRoot,\r\n registeredPageFiles: () => registeredPageFiles(router.list(), paths.appSrcRoot),\r\n });\r\n\r\n fastify.addHook(\r\n \"onResponse\",\r\n (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => {\r\n if (reply.statusCode === 404) {\r\n reportUnregisteredPages({\r\n method: request.method,\r\n url: request.url,\r\n pathname: new URL(request.url, \"http://warlock.local\").pathname,\r\n });\r\n }\r\n\r\n done();\r\n },\r\n );\r\n\r\n this.installDevPageRoutes = () =>\r\n webServerSsr.installPageRoutes({\r\n router,\r\n vite: this.vite,\r\n appSrcRoot: paths.appSrcRoot,\r\n appFile: paths.appFile,\r\n hydrationClientModuleUrl: this.resolveHydrationClientModuleUrl(paths.webRoot),\r\n // Without these the first paint of every full page load is unstyled: the\r\n // client bundle imports the CSS, so JavaScript applies it only after the\r\n // module graph loads. A render-blocking <link> in <head> is what makes\r\n // the page arrive styled instead of arriving and then correcting itself.\r\n stylesheetUrls: webServerSsr.devStylesheetUrls(paths.appRoot, paths.appFile),\r\n // Resolved here, on the NODE side, and forwarded — see\r\n // `InstallPageRoutesOptions.httpServer` (`install-page-routes.ts`) for\r\n // why `createPageRouteHandler` cannot read this out of the container\r\n // itself from inside Vite's SSR module graph. `fastify` is this same\r\n // request's `resolveFastify()` result, already in scope above.\r\n httpServer: fastify,\r\n });\r\n\r\n this.installedPages = await this.installDevPageRoutes();\r\n }\r\n\r\n /**\r\n * Where the browser fetches the hydration entry from — the one line that\r\n * differs between the two modes, so it is the only thing that branches.\r\n *\r\n * Dev keeps Vite's `/@fs/` URL, which Vite's own middleware transforms on\r\n * demand. Production reads the hashed filename out of the client build's\r\n * `.vite/manifest.json` exactly once, at boot, and NEVER falls back: each way\r\n * that read can fail is its own named error (`./hydration-client-url.ts:24`,\r\n * `:36`, `:53`), because \"serve without hydration\" would be a page that\r\n * renders and then does nothing.\r\n *\r\n * `webRoot` is OPTIONAL because only the dev branch has any use for it, and\r\n * asking production for it would be worse than useless: it is proved by\r\n * reading `<root>/package.json` off disk, which is exactly the kind of\r\n * source-tree lookup a built artifact must never depend on. A dev boot that\r\n * somehow reaches here without one is refused by name by the entry factory.\r\n */\r\n protected resolveHydrationClientModuleUrl(webRoot?: string): string {\r\n if (isProductionRuntime()) {\r\n return resolveHydrationClientUrl({ clientDir: this.resolveClientDir() });\r\n }\r\n\r\n return createHydrationClientEntry(webRoot ?? \"\").devUrl;\r\n }\r\n\r\n /**\r\n * `<outdir>/client` — the layout the build half writes and this half reads\r\n * at boot, taken from the value the build BAKED into the page manifest.\r\n *\r\n * It used to call `resolveBuildConfig()`, which reads `warlock.config.ts`\r\n * through `warlockConfigManager`. That works in `warlock build` and in the\r\n * `warlock start` SUPERVISOR, and it cannot work here: the supervisor spawns\r\n * a plain `node dist/app.js` CHILD, and that process never loads — and could\r\n * not load — a TypeScript build-time config. The call threw\r\n * `WarlockConfig not loaded` inside connector boot, so the production server\r\n * died before it ever listened.\r\n *\r\n * Baking it also settles the drift the old comment was worried about, and\r\n * settles it harder: `build` and `start` cannot disagree about where the\r\n * bundle lives, because `start` is no longer re-deriving the path at all —\r\n * it reads back the one string `build` wrote.\r\n */\r\n protected resolveClientDir(): string {\r\n // The manifest CACHED at boot (line ~269), not a second `consumePageManifest()`:\r\n // this runs from a callback the production branch invokes lazily, long after\r\n // that assignment, and reading the same field the mode branch already\r\n // decided on keeps one source of truth for the boot's view of the manifest.\r\n const clientDir = this.pageManifest?.clientDir;\r\n\r\n if (clientDir === undefined) {\r\n // Reached only via a manifest with browser artifacts but no `clientDir`\r\n // — i.e. a bundle built by a web version older than this field. Named here\r\n // rather than left to surface as an ENOENT on a `path.join(undefined)`\r\n // deep inside the manifest read.\r\n throw new WebClientDirMissingError();\r\n }\r\n\r\n return path.resolve(process.cwd(), clientDir);\r\n }\r\n\r\n /**\r\n * The page manifest this connector consumed at boot, or `undefined` in dev\r\n * where Vite supplies the modules instead.\r\n */\r\n public getPageManifest(): PageManifest | undefined {\r\n return this.pageManifest;\r\n }\r\n\r\n /**\r\n * Activate. There is nothing to listen on — `HttpConnector.start()` owns the\r\n * single `listen()` for the whole process — so this only marks the connector\r\n * live once `boot()` has wired everything.\r\n */\r\n public async start(): Promise<void> {\r\n if (!this.vite) return;\r\n\r\n this.active = true;\r\n }\r\n\r\n /**\r\n * Shutdown — close Vite, and drop the sockets Vite's middleware left behind.\r\n *\r\n * Reverse-priority teardown (`core/src/connectors/connectors-manager.ts:118`)\r\n * puts this BEFORE `HttpConnector.shutdown()`, which is exactly what the\r\n * second call needs: requests answered by Vite's connect stack are written\r\n * straight to `reply.raw`, so Fastify never observes them completing and their\r\n * keep-alive sockets are never counted idle. Core's default\r\n * `forceCloseConnections: \"idle\"` (`core/src/http/server.ts:34`) then waits on\r\n * them forever. A dev server has no draining obligation, and the connector\r\n * that caused the raw writes is the right one to clean up after them.\r\n */\r\n public async shutdown(): Promise<void> {\r\n if (!this.active) return;\r\n\r\n if (container.has(\"http.server\")) {\r\n container.get(\"http.server\").server.closeAllConnections();\r\n }\r\n\r\n await this.vite?.close();\r\n this.vite = undefined;\r\n this.installedPages = [];\r\n this.installDevPageRoutes = undefined;\r\n this.pendingPageChanges = undefined;\r\n this.pendingHotUpdateSuppressions.clear();\r\n this.pageManifest = undefined;\r\n\r\n this.active = false;\r\n }\r\n\r\n /**\r\n * Queue page add/remove/edit candidates for asynchronous live routing work.\r\n * Classification stays synchronous because core's connector interface is;\r\n * edited route exports are evaluated later through Vite's fresh SSR graph.\r\n */\r\n public shouldRestart(changedFiles: string[] = []): boolean {\r\n if (this.vite === undefined || this.resolvedPaths === undefined) {\r\n return false;\r\n }\r\n\r\n this.pendingPageChanges = this.classifyPageChanges(changedFiles);\r\n return this.pendingPageChanges !== undefined;\r\n }\r\n\r\n protected classifyPageChanges(changedFiles: readonly string[]): PageFileChanges | undefined {\r\n if (this.resolvedPaths === undefined) return undefined;\r\n\r\n const changes = classifyPageFileChanges(changedFiles, {\r\n appRoot: this.resolvedPaths.appRoot,\r\n appSrcRoot: this.resolvedPaths.appSrcRoot,\r\n installedPageFiles: registeredPageFiles(router.list(), this.resolvedPaths.appSrcRoot),\r\n });\r\n\r\n return hasPageFileChanges(changes) ? changes : undefined;\r\n }\r\n\r\n protected enqueuePageRouteReload(changes: PageFileChanges): Promise<boolean> {\r\n const eventVersions = pageChangeVersions(changes);\r\n const run = this.pageRouteReloadQueue\r\n .catch(() => undefined)\r\n .then(async () => {\r\n const vite = this.vite;\r\n const install = this.installDevPageRoutes;\r\n const paths = this.resolvedPaths;\r\n\r\n if (vite === undefined || install === undefined || paths === undefined) return false;\r\n\r\n const matchingCommittedFiles = new Set<string>();\r\n\r\n // This check belongs inside the queue: a matching core transaction may\r\n // commit while a Vite callback is waiting behind it. File versions are\r\n // captured when the job is queued so a later edit cannot consume an\r\n // earlier event's marker.\r\n for (const [file, eventVersion] of eventVersions) {\r\n const committedVersion = this.pendingHotUpdateSuppressions.get(file);\r\n if (committedVersion === undefined) continue;\r\n\r\n // Matching markers are consumed exactly once. A mismatched marker is\r\n // obsolete and must not survive to suppress a future reverted edit.\r\n this.pendingHotUpdateSuppressions.delete(file);\r\n if (committedVersion === eventVersion) matchingCommittedFiles.add(file);\r\n }\r\n\r\n if (eventVersions.size > 0 && matchingCommittedFiles.size === eventVersions.size) {\r\n return true;\r\n }\r\n\r\n const replace = await pageRoutesNeedReplacement(changes, {\r\n vite,\r\n appSrcRoot: paths.appSrcRoot,\r\n installedPages: this.installedPages,\r\n });\r\n\r\n if (!replace) return false;\r\n\r\n const nextInstalledPages = await router.replaceRoutesBySourceFiles(\r\n pageRouteSourceFiles(router.list()),\r\n install,\r\n );\r\n\r\n // Advance observable state only after the router transaction commits.\r\n // A rejected install keeps both the old route table and browser live.\r\n this.installedPages = nextInstalledPages;\r\n invalidateClientPageRegistry(vite);\r\n\r\n for (const [file, eventVersion] of eventVersions) {\r\n if (!matchingCommittedFiles.has(file)) {\r\n this.pendingHotUpdateSuppressions.set(file, eventVersion);\r\n }\r\n }\r\n\r\n return true;\r\n });\r\n\r\n this.pageRouteReloadQueue = run;\r\n return run;\r\n }\r\n\r\n /**\r\n * Vite-side ordering barrier. It publishes a changed route graph before the\r\n * page-registry plugin can reload the document; a matching change already\r\n * handled by core is consumed once, preventing a duplicate full reload.\r\n */\r\n protected async handlePageHotUpdate(file: string): Promise<boolean> {\r\n const absoluteFile = path.resolve(file);\r\n const changes = this.classifyPageChanges([absoluteFile]);\r\n if (changes === undefined) return false;\r\n\r\n return this.enqueuePageRouteReload(changes);\r\n }\r\n\r\n /**\r\n * Live page routing update. Despite the connector API name, this never closes\r\n * Vite: it atomically replaces page-owned routes only when membership or the\r\n * canonical route identity changed, then refreshes the client registry.\r\n */\r\n public async restart(): Promise<void> {\r\n const changes = this.pendingPageChanges;\r\n this.pendingPageChanges = undefined;\r\n\r\n if (\r\n changes === undefined ||\r\n this.resolvedPaths === undefined ||\r\n this.vite === undefined ||\r\n this.installDevPageRoutes === undefined\r\n ) {\r\n return;\r\n }\r\n\r\n await this.enqueuePageRouteReload(changes);\r\n }\r\n\r\n /** The pages this connector registered on the router, in registration order. */\r\n public getInstalledPages(): readonly InstalledPageRoute[] {\r\n return this.installedPages;\r\n }\r\n\r\n /**\r\n * The Fastify instance the HTTP connector published during its own `boot()`\r\n * (`core/src/connectors/http-connector.ts:74`).\r\n *\r\n * Absence is fatal rather than a silent no-op: unlike sockets, there is no\r\n * standalone fallback a page surface could serve from, and the failure this\r\n * guards against — an app with no `src/config/http.ts` — otherwise shows up\r\n * as every page 404ing with no explanation.\r\n */\r\n protected resolveFastify(): FastifyInstance {\r\n if (!container.has(\"http.server\")) {\r\n throw new Error(\r\n \"WebConnector requires the HTTP connector's Fastify instance, but \" +\r\n \"`http.server` is not in the container. The `http` config is missing — add `src/config/http.ts` \" +\r\n \"so `HttpConnector.boot()` runs (core/src/connectors/http-connector.ts:61-74).\",\r\n );\r\n }\r\n\r\n return container.get(\"http.server\");\r\n }\r\n\r\n /** Resolve every path this connector needs from the (optional) options. */\r\n protected async resolvePaths() {\r\n const appRoot = this.options.appRoot ?? process.cwd();\r\n const appSrcRoot = this.options.appSrcRoot ?? path.join(appRoot, \"src\");\r\n const selfPath = fileURLToPath(import.meta.url);\r\n // The web package root goes through `resolveWebPackageRoot`, which PROVES\r\n // the directory by reading `<root>/package.json` and matching its `name`,\r\n // rather than trusting a fixed number of `..` hops. A configured root is\r\n // asserted the same way. Either failure throws\r\n // `WebPackageRootResolutionError` naming the directory at boot — the\r\n // alternative was a wrong root surfacing much later as a 404 on the\r\n // hydration entry with nothing to point at.\r\n const webRoot = await resolveWebPackageRoot(this.options.webRoot);\r\n\r\n return {\r\n appRoot,\r\n appSrcRoot,\r\n appFile: this.options.appFile ?? path.join(appSrcRoot, \"web/root.tsx\"),\r\n webRoot,\r\n // Extension-agnostic on purpose: the sibling barrel is `index.ts` when\r\n // this package runs from source and `index.js` once it is built, the same\r\n // trick `registerLoader` uses when it resolves its own siblings.\r\n webServerBarrel: path.join(path.dirname(selfPath), `index${path.extname(selfPath)}`),\r\n };\r\n }\r\n\r\n protected async createViteServer(\r\n fastify: FastifyInstance,\r\n paths: Awaited<ReturnType<WebConnector[\"resolvePaths\"]>>,\r\n ): Promise<ViteDevServer> {\r\n const { createServer, buildErrorMessage, searchForWorkspaceRoot } = await import(\"vite\");\r\n\r\n // Vite's own default for `server.fs.allow`, reproduced rather than dropped:\r\n // naming the key at all REPLACES the default, and an application that\r\n // legitimately serves files from above its own root has to keep working.\r\n const workspaceRoot = searchForWorkspaceRoot(paths.appRoot);\r\n\r\n return createServer(\r\n await createWebConnectorViteConfig({\r\n appRoot: paths.appRoot,\r\n appSrcRoot: paths.appSrcRoot,\r\n webRoot: paths.webRoot,\r\n workspaceRoot,\r\n hmrServer: fastify.server,\r\n handlePageHotUpdate: (file) => this.handlePageHotUpdate(file),\r\n leadingPlugins: [devErrorTransportPlugin({ isProductionRuntime, buildErrorMessage })],\r\n resolveAlias: this.options.resolveAlias,\r\n ssrExternal: this.options.ssrExternal,\r\n plugins: this.options.plugins,\r\n }),\r\n );\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { toPosix } from "./to-posix.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../web/src/shared/module-key.ts
|
|
4
|
+
/**
|
|
5
|
+
* Rollup/Vite module ids carry query suffixes (`?v=`, `?used`, ...) that differ
|
|
6
|
+
* between a `transform` id and a later `importer`, and carry `\`-separated
|
|
7
|
+
* paths on Windows. Both sides key through here so they agree.
|
|
8
|
+
*/
|
|
9
|
+
function moduleKey(id) {
|
|
10
|
+
return toPosix(id.split("?")[0]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { moduleKey };
|
|
15
|
+
//# sourceMappingURL=module-key.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module-key.mjs","names":[],"sources":["../../../../../../../web/src/shared/module-key.ts"],"sourcesContent":["import { toPosix } from \"./to-posix\";\n\n/**\n * Rollup/Vite module ids carry query suffixes (`?v=`, `?used`, ...) that differ\n * between a `transform` id and a later `importer`, and carry `\\`-separated\n * paths on Windows. Both sides key through here so they agree.\n */\nexport function moduleKey(id: string): string {\n return toPosix(id.split(\"?\")[0]);\n}\n"],"mappings":";;;;;;;;AAOA,SAAgB,UAAU,IAAoB;CAC5C,OAAO,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,EAAE;AACjC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region ../web/src/shared/to-posix.ts
|
|
2
|
+
/**
|
|
3
|
+
* The single `\` → `/` rule every path-normalising call site in this package
|
|
4
|
+
* routes through, so a Windows path and its POSIX equivalent always compare
|
|
5
|
+
* equal.
|
|
6
|
+
*/
|
|
7
|
+
function toPosix(value) {
|
|
8
|
+
return value.replace(/\\/g, "/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
export { toPosix };
|
|
13
|
+
//# sourceMappingURL=to-posix.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-posix.mjs","names":[],"sources":["../../../../../../../web/src/shared/to-posix.ts"],"sourcesContent":["/**\n * The single `\\` → `/` rule every path-normalising call site in this package\n * routes through, so a Windows path and its POSIX equivalent always compare\n * equal.\n */\nexport function toPosix(value: string): string {\n return value.replace(/\\\\/g, \"/\");\n}\n"],"mappings":";;;;;;AAKA,SAAgB,QAAQ,OAAuB;CAC7C,OAAO,MAAM,QAAQ,OAAO,GAAG;AACjC"}
|
package/esm/shared.mjs
CHANGED
|
@@ -32,6 +32,27 @@ function scopeOf(store, access) {
|
|
|
32
32
|
function requireScope(access) {
|
|
33
33
|
return scopeOf(currentStore(access), access);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by every write path on `shared` (the Proxy export, not `useShared()`)
|
|
37
|
+
* when it runs in the browser.
|
|
38
|
+
*
|
|
39
|
+
* On the client the value behind `shared` is a DEAD SNAPSHOT: `hydrateShared`
|
|
40
|
+
* (below) installed it from THIS page's hydration payload, there is no ALS
|
|
41
|
+
* store to write into (`connectSharedStore` is a server-boot call, never
|
|
42
|
+
* made in the browser bundle), a write cannot reach the server, and it does
|
|
43
|
+
* not survive a `Link` navigation — `navigation-root.tsx` and `refresh.ts`
|
|
44
|
+
* both call `hydrateShared()` again on the NEXT payload, replacing this
|
|
45
|
+
* snapshot outright. Silently accepting the write (or failing silently the
|
|
46
|
+
* way `Object.freeze` does in non-strict code) would let `shared.x = 1`
|
|
47
|
+
* *appear* to work while meaning nothing — the exact defect class this
|
|
48
|
+
* class exists to surface instead of hide.
|
|
49
|
+
*/
|
|
50
|
+
var SharedClientWriteError = class extends Error {
|
|
51
|
+
constructor(action, key) {
|
|
52
|
+
super(`Cannot ${action} \`shared.${String(key)}\` in the browser (web/src/shared.ts). \`shared\` is FROZEN on the client: the value behind it is a dead snapshot serialized into this page's hydration payload at SSR time, not a live connection to the server. A write here cannot reach the server, does not survive a \`Link\` navigation (the next page's payload replaces this snapshot outright, via hydrateShared()), and would silently disagree with the next SSR of this same page. Fix: request data reaches client components through the page's declared props (delivered via the hydration payload) or \`useShared()\` for read access at depth — never through a client-side write to \`shared\`. To send data back to the server, make a request (a loader re-run via refresh(), or a mutation) instead of assigning to \`shared\`.`);
|
|
53
|
+
this.name = "SharedClientWriteError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
35
56
|
function sealedWriteError(action, key) {
|
|
36
57
|
return /* @__PURE__ */ new Error(`Cannot ${action} \`shared.${String(key)}\`: shared is SEALED for this request (web/src/shared.ts). Writes are middleware work and happen before the seal; after the seal the payload is committed to the page and any further write would silently diverge server from client. Reads keep working. Fix: move this write into middleware (before the pipeline's seal stage), or if the value is render-time state, it does not belong in \`shared\`.`);
|
|
37
58
|
}
|
|
@@ -170,6 +191,7 @@ const shared = new Proxy({}, {
|
|
|
170
191
|
return requireScope(`read \`shared.${String(key)}\``).target[key];
|
|
171
192
|
},
|
|
172
193
|
set(_stub, key, value) {
|
|
194
|
+
if (typeof window !== "undefined") throw new SharedClientWriteError("write", key);
|
|
173
195
|
const scope = requireScope(`write \`shared.${String(key)}\``);
|
|
174
196
|
if (scope.sealed) throw sealedWriteError("write", key);
|
|
175
197
|
scope.target[key] = value;
|
|
@@ -179,6 +201,7 @@ const shared = new Proxy({}, {
|
|
|
179
201
|
return key in requireScope(`check \`shared.${String(key)}\``).target;
|
|
180
202
|
},
|
|
181
203
|
deleteProperty(_stub, key) {
|
|
204
|
+
if (typeof window !== "undefined") throw new SharedClientWriteError("delete", key);
|
|
182
205
|
const scope = requireScope(`delete \`shared.${String(key)}\``);
|
|
183
206
|
if (scope.sealed) throw sealedWriteError("delete", key);
|
|
184
207
|
delete scope.target[key];
|
|
@@ -196,6 +219,7 @@ const shared = new Proxy({}, {
|
|
|
196
219
|
};
|
|
197
220
|
},
|
|
198
221
|
defineProperty(_stub, key, descriptor) {
|
|
222
|
+
if (typeof window !== "undefined") throw new SharedClientWriteError("define", key);
|
|
199
223
|
const scope = requireScope(`define \`shared.${String(key)}\``);
|
|
200
224
|
if (scope.sealed) throw sealedWriteError("define", key);
|
|
201
225
|
Reflect.defineProperty(scope.target, key, descriptor);
|
package/esm/shared.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.mjs","names":[],"sources":["../../../../../../web/src/shared.ts"],"sourcesContent":["import type { SharedContext } from \"./index\";\r\n\r\n/**\r\n * Vite's own `ImportMetaEnv` augmentation ships with the `vite/client` triple-slash\r\n * types, which this package does not pull in (`shared.ts` compiles standalone, not\r\n * inside a Vite app). This narrows only the one key `sealShared` reads — the literal\r\n * `import.meta.env?.DEV` expression below is load-bearing (see the comment at its\r\n * call site) and must stay untouched, so the fix lives in the type, not the syntax.\r\n */\r\ndeclare global {\r\n interface ImportMetaEnv {\r\n readonly DEV?: boolean;\r\n }\r\n\r\n interface ImportMeta {\r\n readonly env?: ImportMetaEnv;\r\n }\r\n}\r\n\r\n/**\r\n * The runtime behind `shared` — the per-request, ALS-backed audit surface.\r\n *\r\n * `shared` is syntactically global for DX but semantically request-scoped: the\r\n * exported value is a Proxy, and EVERY trap re-resolves the current request's\r\n * target through core's AsyncLocalStorage store. This module holds a resolver,\r\n * never data — nothing here may cache a target, because a cached target\r\n * reintroduces the cross-request leak in the one form that still passes a\r\n * single-request test. And it throws rather than falling\r\n * back: outside a live request there is no process-wide object to land on —\r\n * that is `useRequestStore()`'s `|| {}` shape (request-context.ts:74-76),\r\n * deliberately not copied here; the pattern followed instead is\r\n * `requestMemo()`'s raw-store-or-throw (core/src/http/context/request-memo.ts:29-40).\r\n *\r\n * WHY A RESOLVER AND NOT `import { requestContext } from \"@warlock.js/core\"`:\r\n * web does not (and must not yet) depend on core — no core dep in\r\n * web/package.json, no tsconfig path, and no built esm/ in this checkout\r\n * (context.ts:96-98 records the same fact for types). So the PIPELINE, which\r\n * lives where `requestContext` is in scope, owns the wiring end to end:\r\n *\r\n * connectSharedStore(() => requestContext.getStore()); // boot, once\r\n * enterSharedScope(store); // stage 2, per request\r\n * await sealShared(); // settle stage: gate → parse → freeze\r\n *\r\n * shared.ts never opens an ALS scope itself.\r\n */\r\n\r\n/**\r\n * The per-request target's runtime shape. `SharedContext` (index.ts:17) is the\r\n * app-augmentable public surface — apps declaration-merge their own keys onto\r\n * it — so `SharedTarget` intersects it in rather than replacing it: every\r\n * `SharedContext` member is structurally a `SharedTarget` member too, which is\r\n * what lets `enterSharedScope` and `shared` below hand back a `SharedTarget`\r\n * value where a `SharedContext` is expected with no cast at the boundary.\r\n */\r\ntype SharedTarget = SharedContext & Record<string | symbol, unknown>;\r\n\r\n/**\r\n * The pipeline's per-request store, structurally. At runtime this IS core's\r\n * `RequestContextStore` (`{ request, response }`, request-context.ts:10-13);\r\n * only `response.parse` is named here because it is the one member seal needs —\r\n * core's `Response.parse` is public (core/src/http/response.ts:297) and the\r\n * store already carries the instance, which is how seal reaches it without web\r\n * importing core.\r\n */\r\nexport type SharedStore = {\r\n response?: { parse(value: unknown): Promise<unknown> };\r\n};\r\n\r\n/**\r\n * Returns the CURRENT request's store, or undefined outside a request. The\r\n * pipeline connects `() => requestContext.getStore()`.\r\n */\r\nexport type SharedStoreResolver = () => SharedStore | undefined;\r\n\r\ntype SharedScope = {\r\n target: SharedTarget;\r\n sealed: boolean;\r\n};\r\n\r\n/**\r\n * Per-request scope records, keyed by the request's own store object — the\r\n * exact idiom of request-memo.ts:10: the store object is fresh per request\r\n * (RequestContext.buildStore), so two concurrent requests cannot share or even\r\n * see each other's entries, and everything is GC-eligible when the request's\r\n * ALS frame ends.\r\n */\r\nconst sharedScopes = new WeakMap<SharedStore, SharedScope>();\r\n\r\nlet resolveSharedStore: SharedStoreResolver | undefined;\r\n\r\n/**\r\n * Boot-time wiring, called once by the pipeline before any request runs.\r\n * Returns the previously connected resolver so a caller (tests, mainly) can\r\n * restore it.\r\n */\r\nexport function connectSharedStore(\r\n resolve: SharedStoreResolver | undefined,\r\n): SharedStoreResolver | undefined {\r\n const previous = resolveSharedStore;\r\n resolveSharedStore = resolve;\r\n return previous;\r\n}\r\n\r\nfunction currentStore(access: string): SharedStore {\r\n if (!resolveSharedStore) {\r\n throw new Error(\r\n `Cannot ${access}: \\`shared\\` is not connected to a request store ` +\r\n \"(web/src/shared.ts). `shared` is request-scoped — its data lives in \" +\r\n \"core's per-request AsyncLocalStorage store, and this module holds only \" +\r\n \"a resolver, never data. Fix: the server bootstrap must call \" +\r\n \"connectSharedStore(() => requestContext.getStore()) before any request \" +\r\n \"runs; in a unit test, connect a resolver for the context the test runs in.\",\r\n );\r\n }\r\n\r\n const store = resolveSharedStore();\r\n\r\n if (!store) {\r\n throw new Error(\r\n `Cannot ${access}: \\`shared\\` was accessed outside a request context ` +\r\n \"(web/src/shared.ts). `shared` is per-request — this happens at module \" +\r\n \"load, in a background job, or from a late timer/dangling promise that \" +\r\n \"outlived its request. There is deliberately NO fallback to a \" +\r\n \"process-wide object: that would leak one user's data into another's \" +\r\n \"response. Fix: move this access into code the request pipeline runs \" +\r\n \"(middleware, a loader, a component render), or pass the value you need \" +\r\n \"explicitly.\",\r\n );\r\n }\r\n\r\n return store;\r\n}\r\n\r\nfunction scopeOf(store: SharedStore, access: string): SharedScope {\r\n const scope = sharedScopes.get(store);\r\n\r\n if (!scope) {\r\n throw new Error(\r\n `Cannot ${access}: this request has no \\`shared\\` scope yet ` +\r\n \"(web/src/shared.ts). The per-request target is created by the pipeline \" +\r\n \"at stage 2 via enterSharedScope(store); this access ran inside the \" +\r\n \"request context but before that point. Fix: move the access after \" +\r\n \"pipeline stage 2 (any middleware/loader/render code qualifies), or — if \" +\r\n \"you are the pipeline — call enterSharedScope(store) first.\",\r\n );\r\n }\r\n\r\n return scope;\r\n}\r\n\r\nfunction requireScope(access: string): SharedScope {\r\n return scopeOf(currentStore(access), access);\r\n}\r\n\r\nfunction sealedWriteError(action: string, key: string | symbol): Error {\r\n return new Error(\r\n `Cannot ${action} \\`shared.${String(key)}\\`: shared is SEALED for this ` +\r\n \"request (web/src/shared.ts). Writes are middleware work and happen \" +\r\n \"before the seal; after the seal the payload is committed to the page and \" +\r\n \"any further write would silently diverge server from client. Reads keep \" +\r\n \"working. Fix: move this write into middleware (before the pipeline's \" +\r\n \"seal stage), or if the value is render-time state, it does not belong in \" +\r\n \"`shared`.\",\r\n );\r\n}\r\n\r\n/**\r\n * Pipeline stage 2: create THE per-request target. Once per request — the\r\n * store object is the request's identity (fresh per request), so a second call\r\n * for the same store is a pipeline bug, not a merge.\r\n *\r\n * Returns the raw target; the pipeline may hold it, but app code goes through\r\n * `shared`.\r\n */\r\nexport function enterSharedScope(store: SharedStore): SharedContext {\r\n if (sharedScopes.has(store)) {\r\n throw new Error(\r\n \"enterSharedScope() was called twice for the same request store \" +\r\n \"(web/src/shared.ts). Each request gets exactly one `shared` target, \" +\r\n \"created once at pipeline stage 2. Fix: enter the scope once per \" +\r\n \"request; to reach the existing target, use `shared` inside the \" +\r\n \"request context instead.\",\r\n );\r\n }\r\n\r\n const target: SharedTarget = {};\r\n\r\n sharedScopes.set(store, { target, sealed: false });\r\n\r\n return target;\r\n}\r\n\r\nfunction rejectAtGate(path: string, offender: string): never {\r\n throw new Error(\r\n `sealShared(): \\`${path}\\` is ${offender} (web/src/shared.ts prototype ` +\r\n \"gate — runs in production AND dev). `shared` is the audit surface \" +\r\n \"serialized into the page: every value must be plain data (scalars, \" +\r\n \"arrays, plain objects) or carry a `toJSON()` serialization contract \" +\r\n \"(a Resource). A Date/Map/Set/class instance smuggles prototype state \" +\r\n \"past that audit and does not survive serialization intact. Fix: store \" +\r\n \"plain data — an ISO string instead of a Date, an object or array \" +\r\n \"instead of a Map/Set, a Resource (or its `.toJSON()` output) instead \" +\r\n \"of a model or class instance.\",\r\n );\r\n}\r\n\r\n/**\r\n * The prototype gate. Precedence mirrors `Response.parse` exactly so the gate\r\n * and the normalization that follows it agree on every value: toJSON wins over\r\n * the plain-object branch (response.ts:301-305 vs :319) and is NOT descended —\r\n * a Resource's field list is its own contract. Date is\r\n * checked BEFORE toJSON because `Date.prototype.toJSON` exists and Dates are\r\n * rejected regardless.\r\n */\r\nfunction assertClientSafe(value: unknown, path: string): void {\r\n if (value === null || value === undefined) return;\r\n\r\n const valueType = typeof value;\r\n\r\n if (valueType === \"function\") rejectAtGate(path, \"a function\");\r\n if (valueType !== \"object\") return;\r\n\r\n if (value instanceof Date) rejectAtGate(path, \"a Date\");\r\n if (value instanceof Map) rejectAtGate(path, \"a Map\");\r\n if (value instanceof Set) rejectAtGate(path, \"a Set\");\r\n\r\n if (typeof (value as { toJSON?: unknown }).toJSON === \"function\") return;\r\n\r\n if (Array.isArray(value)) {\r\n value.forEach((item, index) => assertClientSafe(item, `${path}[${index}]`));\r\n return;\r\n }\r\n\r\n const proto = Object.getPrototypeOf(value);\r\n\r\n if (proto === Object.prototype || proto === null) {\r\n for (const key of Object.keys(value)) {\r\n assertClientSafe((value as SharedTarget)[key], `${path}.${key}`);\r\n }\r\n return;\r\n }\r\n\r\n const name = (proto?.constructor?.name as string | undefined) ?? \"unknown class\";\r\n rejectAtGate(path, `a class instance (${name})`);\r\n}\r\n\r\nfunction deepFreeze(value: unknown): void {\r\n if (!value || typeof value !== \"object\" || Object.isFrozen(value)) return;\r\n\r\n Object.freeze(value);\r\n\r\n for (const key of Object.keys(value)) {\r\n deepFreeze((value as SharedTarget)[key]);\r\n }\r\n}\r\n\r\nlet browserSharedSnapshot: Readonly<SharedContext> | undefined;\r\nlet browserSharedInstalled = false;\r\n\r\nfunction freezeBrowserSnapshot(value: object, seen: Set<object>): void {\r\n if (seen.has(value)) return;\r\n\r\n seen.add(value);\r\n\r\n for (const key of Object.keys(value)) {\r\n const child = (value as SharedTarget)[key];\r\n\r\n if (child !== null && typeof child === \"object\") {\r\n freezeBrowserSnapshot(child, seen);\r\n }\r\n }\r\n\r\n Object.freeze(value);\r\n}\r\n\r\n/**\r\n * Install the browser's one readonly `shared` snapshot. An object value is\r\n * recursively frozen in place and retained wholesale so every consumer\r\n * observes the same identity; this never reads or writes the server ALS scope.\r\n */\r\nexport function hydrateShared(value: unknown): void {\r\n if (value !== null && typeof value === \"object\") {\r\n freezeBrowserSnapshot(value, new Set<object>());\r\n }\r\n\r\n browserSharedSnapshot = value as Readonly<SharedContext>;\r\n browserSharedInstalled = true;\r\n}\r\n\r\nfunction readBrowserSharedSnapshot(): Readonly<SharedContext> {\r\n if (!browserSharedInstalled) {\r\n throw new Error(\r\n \"Cannot read `shared` via useShared(): the browser snapshot has not been \" +\r\n \"installed (web/src/shared.ts). Hydration must validate the complete \" +\r\n \"#__WARLOCK_DATA__ payload and call hydrateShared() \" +\r\n \"before constructing the React tree.\",\r\n );\r\n }\r\n\r\n return browserSharedSnapshot as Readonly<SharedContext>;\r\n}\r\n\r\n/**\r\n * The settle-stage seal, exported for the pipeline. Order is load-bearing:\r\n *\r\n * 1. PROTOTYPE GATE (pre-parse) — production and dev, offending key named\r\n * with its full path. Fail-fast courtesy: catches most violations before\r\n * spending a parse pass on them.\r\n * 2. `Response.parse` NORMALIZATION — via the store's own response instance\r\n * (core/src/http/response.ts:297, the public surface). parse mutates the\r\n * target IN PLACE (response.ts:327), which is exactly why it MUST precede\r\n * the freeze: freeze-then-parse throws on the first key parse writes\r\n * in place.\r\n * 3. PROTOTYPE GATE AGAIN (post-parse) — closes the re-entry window the first\r\n * gate cannot see: `Response.parse` never re-parses a `toJSON()` result\r\n * (it only normalizes the value handed to it), so a Resource whose `toJSON()` returns a\r\n * Date/Map/Set/class instance re-enters the payload AFTER the first gate\r\n * already ran — the first gate inspects the pre-parse value (a plain\r\n * object with a callable `toJSON`, correctly not descended) and parse then\r\n * puts back exactly what that gate would have rejected. Only a gate that\r\n * runs on the POST-parse target — the object that actually ships — can\r\n * catch it. This is the only gate that matters for correctness; the first\r\n * is a fail-fast courtesy that never gets to be wrong on its own.\r\n * 4. DEEP FREEZE — dev only (env read at seal time). Prod skips the freeze but\r\n * NOT the sealed-write throw below; freezing is defense-in-depth for direct\r\n * target references, the throw is the contract.\r\n * 5. Sealed flag — writes/deletes/defines through `shared` now throw naming\r\n * the key; reads keep working.\r\n *\r\n * `store` defaults to the current request's store; the pipeline holds the\r\n * store either way, since it entered the scope with it. Returns the sealed\r\n * target — serialize THAT, not a re-read of the proxy.\r\n */\r\nexport async function sealShared(store?: SharedStore): Promise<Readonly<SharedContext>> {\r\n const scopeStore = store ?? currentStore(\"seal `shared`\");\r\n const scope = scopeOf(scopeStore, \"seal `shared`\");\r\n\r\n if (scope.sealed) {\r\n throw new Error(\r\n \"sealShared() was called twice for the same request (web/src/shared.ts). \" +\r\n \"The seal is the pipeline's settle stage and runs once, after \" +\r\n \"middleware writes and before serialization. Fix: seal once per \" +\r\n \"request.\",\r\n );\r\n }\r\n\r\n assertClientSafe(scope.target, \"shared\");\r\n\r\n const response = scopeStore.response;\r\n\r\n if (!response || typeof response.parse !== \"function\") {\r\n throw new Error(\r\n \"sealShared() found no `response.parse` on the request store \" +\r\n \"(web/src/shared.ts). Sealing normalizes `shared` through core's \" +\r\n \"public `Response.parse` (core/src/http/response.ts:297), reached via \" +\r\n \"the store's own `response` — the store the pipeline entered the scope \" +\r\n \"with must be core's request store (`{ request, response }`, \" +\r\n \"request-context.ts:10-13). Fix: pass that store (or run sealShared() \" +\r\n \"inside the request context that carries it).\",\r\n );\r\n }\r\n\r\n await response.parse(scope.target);\r\n\r\n assertClientSafe(scope.target, \"shared\");\r\n\r\n /*\r\n OPTIONAL CHAINING IS LOAD-BEARING, and the fallback that used to sit here\r\n is deliberately gone.\r\n\r\n `import.meta.env` is injected by a BUNDLER. When this package is compiled\r\n into an app's server bundle — what happens in this checkout — esbuild's\r\n `define` (`WEB_ESBUILD_PATCH.define` in\r\n `src/build/generate-pages-barrel.ts`) does a literal text-level\r\n match on `import.meta.env.DEV` and replaces it verbatim wherever it\r\n appears, `?.` or not — proven against esbuild's real `transform()`, not\r\n assumed (vite/gate-b-secrets.spec.ts, \"WEB_ESBUILD_PATCH.define\" describe\r\n block): `import.meta.env?.DEV` compiles to `false`, same as the\r\n non-optional form, and no `import.meta` survives in either case. When\r\n `@warlock.js/web` is INSTALLED, it is external to that bundle, nothing\r\n replaces anything, and plain `import.meta.env.DEV` threw `Cannot read\r\n properties of undefined` on EVERY page render. `?.` is what makes the\r\n absent case falsy instead of fatal.\r\n\r\n A `globalThis.process?.env?.NODE_ENV` fallback was tried and REJECTED:\r\n Gate B matches `process.env` only when the object is the bare identifier\r\n `process` (vite/gate-b-secrets.ts:100), so routing through `globalThis`\r\n walks straight past the secret-leak gate. A dev-only freeze is not worth\r\n teaching the codebase the shape that evades that check.\r\n\r\n The cost is honest and small: an installed package running under\r\n `NODE_ENV=development` outside a bundler does not freeze. `DEV` is in Gate\r\n B's own allowlist (gate-b-secrets.ts:44), so this form stays permitted in\r\n client-bound code.\r\n */\r\n if (import.meta.env?.DEV) {\r\n deepFreeze(scope.target);\r\n }\r\n\r\n scope.sealed = true;\r\n\r\n return scope.target as Readonly<SharedContext>;\r\n}\r\n\r\n/**\r\n * The WRITABLE per-request payload — middleware's half of the contract.\r\n *\r\n * Every `shared.x = …` at a call site reads like a global write; it is not —\r\n * each trap below resolves the CURRENT request's target through the connected\r\n * store resolver, on every single access — two concurrent requests writing\r\n * `shared.locale` write to two different objects.\r\n */\r\nexport const shared: SharedContext = new Proxy({} as SharedTarget, {\r\n get(_stub, key) {\r\n return requireScope(`read \\`shared.${String(key)}\\``).target[key];\r\n },\r\n\r\n set(_stub, key, value) {\r\n const scope = requireScope(`write \\`shared.${String(key)}\\``);\r\n\r\n if (scope.sealed) throw sealedWriteError(\"write\", key);\r\n\r\n scope.target[key] = value;\r\n\r\n return true;\r\n },\r\n\r\n has(_stub, key) {\r\n return key in requireScope(`check \\`shared.${String(key)}\\``).target;\r\n },\r\n\r\n deleteProperty(_stub, key) {\r\n const scope = requireScope(`delete \\`shared.${String(key)}\\``);\r\n\r\n if (scope.sealed) throw sealedWriteError(\"delete\", key);\r\n\r\n delete scope.target[key];\r\n\r\n return true;\r\n },\r\n\r\n ownKeys() {\r\n return Reflect.ownKeys(requireScope(\"enumerate `shared`\").target);\r\n },\r\n\r\n getOwnPropertyDescriptor(_stub, key) {\r\n const descriptor = Object.getOwnPropertyDescriptor(\r\n requireScope(`describe \\`shared.${String(key)}\\``).target,\r\n key,\r\n );\r\n\r\n if (!descriptor) return undefined;\r\n\r\n // The proxy's book-keeping target is the empty stub, so a frozen real\r\n // target's non-configurable descriptors would violate proxy invariants if\r\n // reported as-is; configurable:true is the honest report for the proxy\r\n // surface (the sealed-write throw is what enforces immutability).\r\n return { ...descriptor, configurable: true };\r\n },\r\n\r\n defineProperty(_stub, key, descriptor) {\r\n const scope = requireScope(`define \\`shared.${String(key)}\\``);\r\n\r\n if (scope.sealed) throw sealedWriteError(\"define\", key);\r\n\r\n Reflect.defineProperty(scope.target, key, descriptor);\r\n\r\n return true;\r\n },\r\n});\r\n\r\n/**\r\n * The READ half for components at depth. On the server it preserves the live\r\n * ALS proxy behavior. In the browser it returns the exact recursively frozen\r\n * object installed from the validated hydration payload, never an empty or\r\n * process-wide fallback.\r\n */\r\nexport function useShared(): Readonly<SharedContext> {\r\n if (typeof window !== \"undefined\") return readBrowserSharedSnapshot();\r\n\r\n requireScope(\"read `shared` via useShared()\");\r\n\r\n return shared as Readonly<SharedContext>;\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"shared.mjs","names":[],"sources":["../../../../../../web/src/shared.ts"],"sourcesContent":["import type { SharedContext } from \"./index\";\r\n\r\n/**\r\n * Vite's own `ImportMetaEnv` augmentation ships with the `vite/client` triple-slash\r\n * types, which this package does not pull in (`shared.ts` compiles standalone, not\r\n * inside a Vite app). This narrows only the one key `sealShared` reads — the literal\r\n * `import.meta.env?.DEV` expression below is load-bearing (see the comment at its\r\n * call site) and must stay untouched, so the fix lives in the type, not the syntax.\r\n */\r\ndeclare global {\r\n interface ImportMetaEnv {\r\n readonly DEV?: boolean;\r\n }\r\n\r\n interface ImportMeta {\r\n readonly env?: ImportMetaEnv;\r\n }\r\n}\r\n\r\n/**\r\n * The runtime behind `shared` — the per-request, ALS-backed audit surface.\r\n *\r\n * `shared` is syntactically global for DX but semantically request-scoped: the\r\n * exported value is a Proxy, and EVERY trap re-resolves the current request's\r\n * target through core's AsyncLocalStorage store. This module holds a resolver,\r\n * never data — nothing here may cache a target, because a cached target\r\n * reintroduces the cross-request leak in the one form that still passes a\r\n * single-request test. And it throws rather than falling\r\n * back: outside a live request there is no process-wide object to land on —\r\n * that is `useRequestStore()`'s `|| {}` shape (request-context.ts:74-76),\r\n * deliberately not copied here; the pattern followed instead is\r\n * `requestMemo()`'s raw-store-or-throw (core/src/http/context/request-memo.ts:29-40).\r\n *\r\n * WHY A RESOLVER AND NOT `import { requestContext } from \"@warlock.js/core\"`:\r\n * web does not (and must not yet) depend on core — no core dep in\r\n * web/package.json, no tsconfig path, and no built esm/ in this checkout\r\n * (context.ts:96-98 records the same fact for types). So the PIPELINE, which\r\n * lives where `requestContext` is in scope, owns the wiring end to end:\r\n *\r\n * connectSharedStore(() => requestContext.getStore()); // boot, once\r\n * enterSharedScope(store); // stage 2, per request\r\n * await sealShared(); // settle stage: gate → parse → freeze\r\n *\r\n * shared.ts never opens an ALS scope itself.\r\n */\r\n\r\n/**\r\n * The per-request target's runtime shape. `SharedContext` (index.ts:17) is the\r\n * app-augmentable public surface — apps declaration-merge their own keys onto\r\n * it — so `SharedTarget` intersects it in rather than replacing it: every\r\n * `SharedContext` member is structurally a `SharedTarget` member too, which is\r\n * what lets `enterSharedScope` and `shared` below hand back a `SharedTarget`\r\n * value where a `SharedContext` is expected with no cast at the boundary.\r\n */\r\ntype SharedTarget = SharedContext & Record<string | symbol, unknown>;\r\n\r\n/**\r\n * The pipeline's per-request store, structurally. At runtime this IS core's\r\n * `RequestContextStore` (`{ request, response }`, request-context.ts:10-13);\r\n * only `response.parse` is named here because it is the one member seal needs —\r\n * core's `Response.parse` is public (core/src/http/response.ts:297) and the\r\n * store already carries the instance, which is how seal reaches it without web\r\n * importing core.\r\n */\r\nexport type SharedStore = {\r\n response?: { parse(value: unknown): Promise<unknown> };\r\n};\r\n\r\n/**\r\n * Returns the CURRENT request's store, or undefined outside a request. The\r\n * pipeline connects `() => requestContext.getStore()`.\r\n */\r\nexport type SharedStoreResolver = () => SharedStore | undefined;\r\n\r\ntype SharedScope = {\r\n target: SharedTarget;\r\n sealed: boolean;\r\n};\r\n\r\n/**\r\n * Per-request scope records, keyed by the request's own store object — the\r\n * exact idiom of request-memo.ts:10: the store object is fresh per request\r\n * (RequestContext.buildStore), so two concurrent requests cannot share or even\r\n * see each other's entries, and everything is GC-eligible when the request's\r\n * ALS frame ends.\r\n */\r\nconst sharedScopes = new WeakMap<SharedStore, SharedScope>();\r\n\r\nlet resolveSharedStore: SharedStoreResolver | undefined;\r\n\r\n/**\r\n * Boot-time wiring, called once by the pipeline before any request runs.\r\n * Returns the previously connected resolver so a caller (tests, mainly) can\r\n * restore it.\r\n */\r\nexport function connectSharedStore(\r\n resolve: SharedStoreResolver | undefined,\r\n): SharedStoreResolver | undefined {\r\n const previous = resolveSharedStore;\r\n resolveSharedStore = resolve;\r\n return previous;\r\n}\r\n\r\nfunction currentStore(access: string): SharedStore {\r\n if (!resolveSharedStore) {\r\n throw new Error(\r\n `Cannot ${access}: \\`shared\\` is not connected to a request store ` +\r\n \"(web/src/shared.ts). `shared` is request-scoped — its data lives in \" +\r\n \"core's per-request AsyncLocalStorage store, and this module holds only \" +\r\n \"a resolver, never data. Fix: the server bootstrap must call \" +\r\n \"connectSharedStore(() => requestContext.getStore()) before any request \" +\r\n \"runs; in a unit test, connect a resolver for the context the test runs in.\",\r\n );\r\n }\r\n\r\n const store = resolveSharedStore();\r\n\r\n if (!store) {\r\n throw new Error(\r\n `Cannot ${access}: \\`shared\\` was accessed outside a request context ` +\r\n \"(web/src/shared.ts). `shared` is per-request — this happens at module \" +\r\n \"load, in a background job, or from a late timer/dangling promise that \" +\r\n \"outlived its request. There is deliberately NO fallback to a \" +\r\n \"process-wide object: that would leak one user's data into another's \" +\r\n \"response. Fix: move this access into code the request pipeline runs \" +\r\n \"(middleware, a loader, a component render), or pass the value you need \" +\r\n \"explicitly.\",\r\n );\r\n }\r\n\r\n return store;\r\n}\r\n\r\nfunction scopeOf(store: SharedStore, access: string): SharedScope {\r\n const scope = sharedScopes.get(store);\r\n\r\n if (!scope) {\r\n throw new Error(\r\n `Cannot ${access}: this request has no \\`shared\\` scope yet ` +\r\n \"(web/src/shared.ts). The per-request target is created by the pipeline \" +\r\n \"at stage 2 via enterSharedScope(store); this access ran inside the \" +\r\n \"request context but before that point. Fix: move the access after \" +\r\n \"pipeline stage 2 (any middleware/loader/render code qualifies), or — if \" +\r\n \"you are the pipeline — call enterSharedScope(store) first.\",\r\n );\r\n }\r\n\r\n return scope;\r\n}\r\n\r\nfunction requireScope(access: string): SharedScope {\r\n return scopeOf(currentStore(access), access);\r\n}\r\n\r\n/**\r\n * Thrown by every write path on `shared` (the Proxy export, not `useShared()`)\r\n * when it runs in the browser.\r\n *\r\n * On the client the value behind `shared` is a DEAD SNAPSHOT: `hydrateShared`\r\n * (below) installed it from THIS page's hydration payload, there is no ALS\r\n * store to write into (`connectSharedStore` is a server-boot call, never\r\n * made in the browser bundle), a write cannot reach the server, and it does\r\n * not survive a `Link` navigation — `navigation-root.tsx` and `refresh.ts`\r\n * both call `hydrateShared()` again on the NEXT payload, replacing this\r\n * snapshot outright. Silently accepting the write (or failing silently the\r\n * way `Object.freeze` does in non-strict code) would let `shared.x = 1`\r\n * *appear* to work while meaning nothing — the exact defect class this\r\n * class exists to surface instead of hide.\r\n */\r\nexport class SharedClientWriteError extends Error {\r\n public constructor(action: string, key: string | symbol) {\r\n super(\r\n `Cannot ${action} \\`shared.${String(key)}\\` in the browser (web/src/shared.ts). ` +\r\n \"`shared` is FROZEN on the client: the value behind it is a dead snapshot \" +\r\n \"serialized into this page's hydration payload at SSR time, not a live \" +\r\n \"connection to the server. A write here cannot reach the server, does not \" +\r\n \"survive a `Link` navigation (the next page's payload replaces this snapshot \" +\r\n \"outright, via hydrateShared()), and would silently disagree with the next SSR \" +\r\n \"of this same page. Fix: request data reaches client components through the \" +\r\n \"page's declared props (delivered via the hydration payload) or `useShared()` \" +\r\n \"for read access at depth — never through a client-side write to `shared`. To \" +\r\n \"send data back to the server, make a request (a loader re-run via refresh(), \" +\r\n \"or a mutation) instead of assigning to `shared`.\",\r\n );\r\n this.name = \"SharedClientWriteError\";\r\n }\r\n}\r\n\r\nfunction sealedWriteError(action: string, key: string | symbol): Error {\r\n return new Error(\r\n `Cannot ${action} \\`shared.${String(key)}\\`: shared is SEALED for this ` +\r\n \"request (web/src/shared.ts). Writes are middleware work and happen \" +\r\n \"before the seal; after the seal the payload is committed to the page and \" +\r\n \"any further write would silently diverge server from client. Reads keep \" +\r\n \"working. Fix: move this write into middleware (before the pipeline's \" +\r\n \"seal stage), or if the value is render-time state, it does not belong in \" +\r\n \"`shared`.\",\r\n );\r\n}\r\n\r\n/**\r\n * Pipeline stage 2: create THE per-request target. Once per request — the\r\n * store object is the request's identity (fresh per request), so a second call\r\n * for the same store is a pipeline bug, not a merge.\r\n *\r\n * Returns the raw target; the pipeline may hold it, but app code goes through\r\n * `shared`.\r\n */\r\nexport function enterSharedScope(store: SharedStore): SharedContext {\r\n if (sharedScopes.has(store)) {\r\n throw new Error(\r\n \"enterSharedScope() was called twice for the same request store \" +\r\n \"(web/src/shared.ts). Each request gets exactly one `shared` target, \" +\r\n \"created once at pipeline stage 2. Fix: enter the scope once per \" +\r\n \"request; to reach the existing target, use `shared` inside the \" +\r\n \"request context instead.\",\r\n );\r\n }\r\n\r\n const target: SharedTarget = {};\r\n\r\n sharedScopes.set(store, { target, sealed: false });\r\n\r\n return target;\r\n}\r\n\r\nfunction rejectAtGate(path: string, offender: string): never {\r\n throw new Error(\r\n `sealShared(): \\`${path}\\` is ${offender} (web/src/shared.ts prototype ` +\r\n \"gate — runs in production AND dev). `shared` is the audit surface \" +\r\n \"serialized into the page: every value must be plain data (scalars, \" +\r\n \"arrays, plain objects) or carry a `toJSON()` serialization contract \" +\r\n \"(a Resource). A Date/Map/Set/class instance smuggles prototype state \" +\r\n \"past that audit and does not survive serialization intact. Fix: store \" +\r\n \"plain data — an ISO string instead of a Date, an object or array \" +\r\n \"instead of a Map/Set, a Resource (or its `.toJSON()` output) instead \" +\r\n \"of a model or class instance.\",\r\n );\r\n}\r\n\r\n/**\r\n * The prototype gate. Precedence mirrors `Response.parse` exactly so the gate\r\n * and the normalization that follows it agree on every value: toJSON wins over\r\n * the plain-object branch (response.ts:301-305 vs :319) and is NOT descended —\r\n * a Resource's field list is its own contract. Date is\r\n * checked BEFORE toJSON because `Date.prototype.toJSON` exists and Dates are\r\n * rejected regardless.\r\n */\r\nfunction assertClientSafe(value: unknown, path: string): void {\r\n if (value === null || value === undefined) return;\r\n\r\n const valueType = typeof value;\r\n\r\n if (valueType === \"function\") rejectAtGate(path, \"a function\");\r\n if (valueType !== \"object\") return;\r\n\r\n if (value instanceof Date) rejectAtGate(path, \"a Date\");\r\n if (value instanceof Map) rejectAtGate(path, \"a Map\");\r\n if (value instanceof Set) rejectAtGate(path, \"a Set\");\r\n\r\n if (typeof (value as { toJSON?: unknown }).toJSON === \"function\") return;\r\n\r\n if (Array.isArray(value)) {\r\n value.forEach((item, index) => assertClientSafe(item, `${path}[${index}]`));\r\n return;\r\n }\r\n\r\n const proto = Object.getPrototypeOf(value);\r\n\r\n if (proto === Object.prototype || proto === null) {\r\n for (const key of Object.keys(value)) {\r\n assertClientSafe((value as SharedTarget)[key], `${path}.${key}`);\r\n }\r\n return;\r\n }\r\n\r\n const name = (proto?.constructor?.name as string | undefined) ?? \"unknown class\";\r\n rejectAtGate(path, `a class instance (${name})`);\r\n}\r\n\r\nfunction deepFreeze(value: unknown): void {\r\n if (!value || typeof value !== \"object\" || Object.isFrozen(value)) return;\r\n\r\n Object.freeze(value);\r\n\r\n for (const key of Object.keys(value)) {\r\n deepFreeze((value as SharedTarget)[key]);\r\n }\r\n}\r\n\r\nlet browserSharedSnapshot: Readonly<SharedContext> | undefined;\r\nlet browserSharedInstalled = false;\r\n\r\nfunction freezeBrowserSnapshot(value: object, seen: Set<object>): void {\r\n if (seen.has(value)) return;\r\n\r\n seen.add(value);\r\n\r\n for (const key of Object.keys(value)) {\r\n const child = (value as SharedTarget)[key];\r\n\r\n if (child !== null && typeof child === \"object\") {\r\n freezeBrowserSnapshot(child, seen);\r\n }\r\n }\r\n\r\n Object.freeze(value);\r\n}\r\n\r\n/**\r\n * Install the browser's one readonly `shared` snapshot. An object value is\r\n * recursively frozen in place and retained wholesale so every consumer\r\n * observes the same identity; this never reads or writes the server ALS scope.\r\n */\r\nexport function hydrateShared(value: unknown): void {\r\n if (value !== null && typeof value === \"object\") {\r\n freezeBrowserSnapshot(value, new Set<object>());\r\n }\r\n\r\n browserSharedSnapshot = value as Readonly<SharedContext>;\r\n browserSharedInstalled = true;\r\n}\r\n\r\nfunction readBrowserSharedSnapshot(): Readonly<SharedContext> {\r\n if (!browserSharedInstalled) {\r\n throw new Error(\r\n \"Cannot read `shared` via useShared(): the browser snapshot has not been \" +\r\n \"installed (web/src/shared.ts). Hydration must validate the complete \" +\r\n \"#__WARLOCK_DATA__ payload and call hydrateShared() \" +\r\n \"before constructing the React tree.\",\r\n );\r\n }\r\n\r\n return browserSharedSnapshot as Readonly<SharedContext>;\r\n}\r\n\r\n/**\r\n * The settle-stage seal, exported for the pipeline. Order is load-bearing:\r\n *\r\n * 1. PROTOTYPE GATE (pre-parse) — production and dev, offending key named\r\n * with its full path. Fail-fast courtesy: catches most violations before\r\n * spending a parse pass on them.\r\n * 2. `Response.parse` NORMALIZATION — via the store's own response instance\r\n * (core/src/http/response.ts:297, the public surface). parse mutates the\r\n * target IN PLACE (response.ts:327), which is exactly why it MUST precede\r\n * the freeze: freeze-then-parse throws on the first key parse writes\r\n * in place.\r\n * 3. PROTOTYPE GATE AGAIN (post-parse) — closes the re-entry window the first\r\n * gate cannot see: `Response.parse` never re-parses a `toJSON()` result\r\n * (it only normalizes the value handed to it), so a Resource whose `toJSON()` returns a\r\n * Date/Map/Set/class instance re-enters the payload AFTER the first gate\r\n * already ran — the first gate inspects the pre-parse value (a plain\r\n * object with a callable `toJSON`, correctly not descended) and parse then\r\n * puts back exactly what that gate would have rejected. Only a gate that\r\n * runs on the POST-parse target — the object that actually ships — can\r\n * catch it. This is the only gate that matters for correctness; the first\r\n * is a fail-fast courtesy that never gets to be wrong on its own.\r\n * 4. DEEP FREEZE — dev only (env read at seal time). Prod skips the freeze but\r\n * NOT the sealed-write throw below; freezing is defense-in-depth for direct\r\n * target references, the throw is the contract.\r\n * 5. Sealed flag — writes/deletes/defines through `shared` now throw naming\r\n * the key; reads keep working.\r\n *\r\n * `store` defaults to the current request's store; the pipeline holds the\r\n * store either way, since it entered the scope with it. Returns the sealed\r\n * target — serialize THAT, not a re-read of the proxy.\r\n */\r\nexport async function sealShared(store?: SharedStore): Promise<Readonly<SharedContext>> {\r\n const scopeStore = store ?? currentStore(\"seal `shared`\");\r\n const scope = scopeOf(scopeStore, \"seal `shared`\");\r\n\r\n if (scope.sealed) {\r\n throw new Error(\r\n \"sealShared() was called twice for the same request (web/src/shared.ts). \" +\r\n \"The seal is the pipeline's settle stage and runs once, after \" +\r\n \"middleware writes and before serialization. Fix: seal once per \" +\r\n \"request.\",\r\n );\r\n }\r\n\r\n assertClientSafe(scope.target, \"shared\");\r\n\r\n const response = scopeStore.response;\r\n\r\n if (!response || typeof response.parse !== \"function\") {\r\n throw new Error(\r\n \"sealShared() found no `response.parse` on the request store \" +\r\n \"(web/src/shared.ts). Sealing normalizes `shared` through core's \" +\r\n \"public `Response.parse` (core/src/http/response.ts:297), reached via \" +\r\n \"the store's own `response` — the store the pipeline entered the scope \" +\r\n \"with must be core's request store (`{ request, response }`, \" +\r\n \"request-context.ts:10-13). Fix: pass that store (or run sealShared() \" +\r\n \"inside the request context that carries it).\",\r\n );\r\n }\r\n\r\n await response.parse(scope.target);\r\n\r\n assertClientSafe(scope.target, \"shared\");\r\n\r\n /*\r\n OPTIONAL CHAINING IS LOAD-BEARING, and the fallback that used to sit here\r\n is deliberately gone.\r\n\r\n `import.meta.env` is injected by a BUNDLER. When this package is compiled\r\n into an app's server bundle — what happens in this checkout — esbuild's\r\n `define` (`WEB_ESBUILD_PATCH.define` in\r\n `src/build/generate-pages-barrel.ts`) does a literal text-level\r\n match on `import.meta.env.DEV` and replaces it verbatim wherever it\r\n appears, `?.` or not — proven against esbuild's real `transform()`, not\r\n assumed (vite/gate-b-secrets.spec.ts, \"WEB_ESBUILD_PATCH.define\" describe\r\n block): `import.meta.env?.DEV` compiles to `false`, same as the\r\n non-optional form, and no `import.meta` survives in either case. When\r\n `@warlock.js/web` is INSTALLED, it is external to that bundle, nothing\r\n replaces anything, and plain `import.meta.env.DEV` threw `Cannot read\r\n properties of undefined` on EVERY page render. `?.` is what makes the\r\n absent case falsy instead of fatal.\r\n\r\n A `globalThis.process?.env?.NODE_ENV` fallback was tried and REJECTED:\r\n Gate B matches `process.env` only when the object is the bare identifier\r\n `process` (vite/gate-b-secrets.ts:100), so routing through `globalThis`\r\n walks straight past the secret-leak gate. A dev-only freeze is not worth\r\n teaching the codebase the shape that evades that check.\r\n\r\n The cost is honest and small: an installed package running under\r\n `NODE_ENV=development` outside a bundler does not freeze. `DEV` is in Gate\r\n B's own allowlist (gate-b-secrets.ts:44), so this form stays permitted in\r\n client-bound code.\r\n */\r\n if (import.meta.env?.DEV) {\r\n deepFreeze(scope.target);\r\n }\r\n\r\n scope.sealed = true;\r\n\r\n return scope.target as Readonly<SharedContext>;\r\n}\r\n\r\n/**\r\n * The WRITABLE per-request payload — middleware's half of the contract.\r\n *\r\n * Every `shared.x = …` at a call site reads like a global write; it is not —\r\n * each trap below resolves the CURRENT request's target through the connected\r\n * store resolver, on every single access — two concurrent requests writing\r\n * `shared.locale` write to two different objects.\r\n */\r\nexport const shared: SharedContext = new Proxy({} as SharedTarget, {\r\n get(_stub, key) {\r\n return requireScope(`read \\`shared.${String(key)}\\``).target[key];\r\n },\r\n\r\n set(_stub, key, value) {\r\n if (typeof window !== \"undefined\") throw new SharedClientWriteError(\"write\", key);\r\n\r\n const scope = requireScope(`write \\`shared.${String(key)}\\``);\r\n\r\n if (scope.sealed) throw sealedWriteError(\"write\", key);\r\n\r\n scope.target[key] = value;\r\n\r\n return true;\r\n },\r\n\r\n has(_stub, key) {\r\n return key in requireScope(`check \\`shared.${String(key)}\\``).target;\r\n },\r\n\r\n deleteProperty(_stub, key) {\r\n if (typeof window !== \"undefined\") throw new SharedClientWriteError(\"delete\", key);\r\n\r\n const scope = requireScope(`delete \\`shared.${String(key)}\\``);\r\n\r\n if (scope.sealed) throw sealedWriteError(\"delete\", key);\r\n\r\n delete scope.target[key];\r\n\r\n return true;\r\n },\r\n\r\n ownKeys() {\r\n return Reflect.ownKeys(requireScope(\"enumerate `shared`\").target);\r\n },\r\n\r\n getOwnPropertyDescriptor(_stub, key) {\r\n const descriptor = Object.getOwnPropertyDescriptor(\r\n requireScope(`describe \\`shared.${String(key)}\\``).target,\r\n key,\r\n );\r\n\r\n if (!descriptor) return undefined;\r\n\r\n // The proxy's book-keeping target is the empty stub, so a frozen real\r\n // target's non-configurable descriptors would violate proxy invariants if\r\n // reported as-is; configurable:true is the honest report for the proxy\r\n // surface (the sealed-write throw is what enforces immutability).\r\n return { ...descriptor, configurable: true };\r\n },\r\n\r\n defineProperty(_stub, key, descriptor) {\r\n if (typeof window !== \"undefined\") throw new SharedClientWriteError(\"define\", key);\r\n\r\n const scope = requireScope(`define \\`shared.${String(key)}\\``);\r\n\r\n if (scope.sealed) throw sealedWriteError(\"define\", key);\r\n\r\n Reflect.defineProperty(scope.target, key, descriptor);\r\n\r\n return true;\r\n },\r\n});\r\n\r\n/**\r\n * The READ half for components at depth. On the server it preserves the live\r\n * ALS proxy behavior. In the browser it returns the exact recursively frozen\r\n * object installed from the validated hydration payload, never an empty or\r\n * process-wide fallback.\r\n */\r\nexport function useShared(): Readonly<SharedContext> {\r\n if (typeof window !== \"undefined\") return readBrowserSharedSnapshot();\r\n\r\n requireScope(\"read `shared` via useShared()\");\r\n\r\n return shared as Readonly<SharedContext>;\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-client.mjs","names":[],"sources":["../../../../../../../web/src/vite/build-client.ts"],"sourcesContent":["import { statSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { AliasOptions, PluginOption, Rollup } from \"vite\";\nimport { createHydrationClientEntry, type HydrationClientEntry } from \"./hydration-entries\";\n\nexport interface BuildHydrationClientOptions {\n /** `@warlock.js/web` root containing the packaged or checkout hydration entry. */\n webRoot: string;\n /**\n * Absolute output directory for the client bundle — REQUIRED.\n *\n * Was hardcoded to `<webRoot>/dist/client`, which wrote the artifacts into\n * the framework package rather than the app's build output. The caller owns\n * the build layout (`<outdir>/client`, contract §1), so it passes the\n * directory; `manifestPath` is derived from it.\n */\n outDir: string;\n /** The caller-composed projection and boundary-gate plugin pipeline. */\n plugins: readonly PluginOption[];\n /** The caller-owned application and workspace source aliases. */\n resolveAliases: AliasOptions;\n /** Optional peers that must remain external to this bundler pipeline. */\n external?: Rollup.ExternalOption;\n}\n\nexport type HydrationClientBuildOutput = Rollup.RollupOutput | Rollup.RollupOutput[];\n\nexport interface BuildHydrationClientResult {\n entry: HydrationClientEntry;\n outDir: string;\n manifestPath: string;\n output: HydrationClientBuildOutput;\n}\n\nfunction assertEntryFile(entry: HydrationClientEntry): void {\n let entryStat: ReturnType<typeof statSync>;\n\n try {\n entryStat = statSync(entry.sourcePath);\n } catch (error) {\n throw new Error(\n `Cannot build the hydration client: entry \"${entry.sourcePath}\" is missing or unreadable.`,\n { cause: error },\n );\n }\n\n if (!entryStat.isFile()) {\n throw new Error(`Cannot build the hydration client: entry \"${entry.sourcePath}\" is not a file
|
|
1
|
+
{"version":3,"file":"build-client.mjs","names":[],"sources":["../../../../../../../web/src/vite/build-client.ts"],"sourcesContent":["import { statSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { AliasOptions, PluginOption, Rollup } from \"vite\";\nimport { createHydrationClientEntry, type HydrationClientEntry } from \"./hydration-entries\";\n\nexport interface BuildHydrationClientOptions {\n /** `@warlock.js/web` root containing the packaged or checkout hydration entry. */\n webRoot: string;\n /**\n * Absolute output directory for the client bundle — REQUIRED.\n *\n * Was hardcoded to `<webRoot>/dist/client`, which wrote the artifacts into\n * the framework package rather than the app's build output. The caller owns\n * the build layout (`<outdir>/client`, contract §1), so it passes the\n * directory; `manifestPath` is derived from it.\n */\n outDir: string;\n /** The caller-composed projection and boundary-gate plugin pipeline. */\n plugins: readonly PluginOption[];\n /** The caller-owned application and workspace source aliases. */\n resolveAliases: AliasOptions;\n /** Optional peers that must remain external to this bundler pipeline. */\n external?: Rollup.ExternalOption;\n}\n\nexport type HydrationClientBuildOutput = Rollup.RollupOutput | Rollup.RollupOutput[];\n\nexport interface BuildHydrationClientResult {\n entry: HydrationClientEntry;\n outDir: string;\n manifestPath: string;\n output: HydrationClientBuildOutput;\n}\n\nfunction assertEntryFile(entry: HydrationClientEntry): void {\n let entryStat: ReturnType<typeof statSync>;\n\n try {\n entryStat = statSync(entry.sourcePath);\n } catch (error) {\n throw new Error(\n `Cannot build the hydration client: entry \"${entry.sourcePath}\" is missing or unreadable.`,\n { cause: error },\n );\n }\n\n if (!entryStat.isFile()) {\n throw new Error(\n `Cannot build the hydration client: entry \"${entry.sourcePath}\" is not a file.`,\n );\n }\n}\n\nfunction assertBuildOptions(options: BuildHydrationClientOptions): void {\n if (!options || typeof options !== \"object\") {\n throw new TypeError(\"Cannot build the hydration client: options are required.\");\n }\n\n if (!Array.isArray(options.plugins) || options.plugins.length === 0) {\n throw new TypeError(\n \"Cannot build the hydration client: the caller must provide its composed boundary plugins.\",\n );\n }\n\n if (options.resolveAliases === undefined || options.resolveAliases === null) {\n throw new TypeError(\n \"Cannot build the hydration client: the caller must provide its resolve aliases.\",\n );\n }\n\n // An EMPTY table is rejected too, and that is the whole point of this branch.\n //\n // The not-null check above looks like it already covers a missing alias table.\n // It did not: the call site read `options.aliases ?? {}`, and `??` manufactures\n // an empty object that is neither undefined nor null — so the guard passed and\n // the build proceeded misconfigured, failing much later with a bare Rollup\n // \"failed to resolve import\" that named none of this.\n //\n // An app whose source uses alias imports cannot be built with zero aliases, so\n // absent and present-but-empty are the same error and must be reported the same\n // way, here, by name.\n if (\n (Array.isArray(options.resolveAliases) && options.resolveAliases.length === 0) ||\n (!Array.isArray(options.resolveAliases) && Object.keys(options.resolveAliases).length === 0)\n ) {\n throw new TypeError(\n \"Cannot build the hydration client: the resolve alias table is empty. \" +\n \"App source that imports via `web/*` or `app/*` cannot resolve without it.\",\n );\n }\n\n if (typeof options.outDir !== \"string\" || options.outDir.trim().length === 0) {\n throw new TypeError(\n \"Cannot build the hydration client: the caller must provide an absolute outDir.\",\n );\n }\n}\n\n/**\n * Builds the one browser hydration entry. Vite is an optional peer and is\n * imported only after the caller explicitly invokes this build operation.\n */\nexport async function buildHydrationClient(\n options: BuildHydrationClientOptions,\n): Promise<BuildHydrationClientResult> {\n assertBuildOptions(options);\n\n const entry = createHydrationClientEntry(options.webRoot);\n assertEntryFile(entry);\n\n const outDir = path.resolve(options.outDir);\n const manifestPath = path.join(outDir, \".vite/manifest.json\");\n const { build } = await import(\"vite\");\n const viteResult = await build({\n root: options.webRoot,\n appType: \"custom\",\n configFile: false,\n plugins: [...options.plugins],\n resolve: { alias: options.resolveAliases },\n build: {\n copyPublicDir: false,\n emptyOutDir: true,\n manifest: true,\n outDir,\n target: \"es2022\",\n rollupOptions: {\n external: options.external,\n input: { [entry.name]: entry.sourcePath },\n output: {\n assetFileNames: \"assets/[name]-[hash][extname]\",\n chunkFileNames: \"assets/[name]-[hash].js\",\n entryFileNames: \"assets/[name]-[hash].js\",\n format: \"es\",\n },\n },\n watch: null,\n },\n });\n\n if (!Array.isArray(viteResult) && !(\"output\" in viteResult)) {\n await viteResult.close();\n throw new Error(\"Cannot build the hydration client: Vite unexpectedly returned a watcher.\");\n }\n\n return { entry, outDir, manifestPath, output: viteResult };\n}\n"],"mappings":";;;;;AAkCA,SAAS,gBAAgB,OAAmC;CAC1D,IAAI;CAEJ,IAAI;EACF,YAAY,SAAS,MAAM,UAAU;CACvC,SAAS,OAAO;EACd,MAAM,IAAI,MACR,6CAA6C,MAAM,WAAW,8BAC9D,EAAE,OAAO,MAAM,CACjB;CACF;CAEA,IAAI,CAAC,UAAU,OAAO,GACpB,MAAM,IAAI,MACR,6CAA6C,MAAM,WAAW,iBAChE;AAEJ;AAEA,SAAS,mBAAmB,SAA4C;CACtE,IAAI,CAAC,WAAW,OAAO,YAAY,UACjC,MAAM,IAAI,UAAU,0DAA0D;CAGhF,IAAI,CAAC,MAAM,QAAQ,QAAQ,OAAO,KAAK,QAAQ,QAAQ,WAAW,GAChE,MAAM,IAAI,UACR,2FACF;CAGF,IAAI,QAAQ,mBAAmB,UAAa,QAAQ,mBAAmB,MACrE,MAAM,IAAI,UACR,iFACF;CAcF,IACG,MAAM,QAAQ,QAAQ,cAAc,KAAK,QAAQ,eAAe,WAAW,KAC3E,CAAC,MAAM,QAAQ,QAAQ,cAAc,KAAK,OAAO,KAAK,QAAQ,cAAc,CAAC,CAAC,WAAW,GAE1F,MAAM,IAAI,UACR,gJAEF;CAGF,IAAI,OAAO,QAAQ,WAAW,YAAY,QAAQ,OAAO,KAAK,CAAC,CAAC,WAAW,GACzE,MAAM,IAAI,UACR,gFACF;AAEJ;;;;;AAMA,eAAsB,qBACpB,SACqC;CACrC,mBAAmB,OAAO;CAE1B,MAAM,QAAQ,2BAA2B,QAAQ,OAAO;CACxD,gBAAgB,KAAK;CAErB,MAAM,SAAS,KAAK,QAAQ,QAAQ,MAAM;CAC1C,MAAM,eAAe,KAAK,KAAK,QAAQ,qBAAqB;CAC5D,MAAM,EAAE,UAAU,MAAM,OAAO;CAC/B,MAAM,aAAa,MAAM,MAAM;EAC7B,MAAM,QAAQ;EACd,SAAS;EACT,YAAY;EACZ,SAAS,CAAC,GAAG,QAAQ,OAAO;EAC5B,SAAS,EAAE,OAAO,QAAQ,eAAe;EACzC,OAAO;GACL,eAAe;GACf,aAAa;GACb,UAAU;GACV;GACA,QAAQ;GACR,eAAe;IACb,UAAU,QAAQ;IAClB,OAAO,GAAG,MAAM,OAAO,MAAM,WAAW;IACxC,QAAQ;KACN,gBAAgB;KAChB,gBAAgB;KAChB,gBAAgB;KAChB,QAAQ;IACV;GACF;GACA,OAAO;EACT;CACF,CAAC;CAED,IAAI,CAAC,MAAM,QAAQ,UAAU,KAAK,EAAE,YAAY,aAAa;EAC3D,MAAM,WAAW,MAAM;EACvB,MAAM,IAAI,MAAM,0EAA0E;CAC5F;CAEA,OAAO;EAAE;EAAO;EAAQ;EAAc,QAAQ;CAAW;AAC3D"}
|