@warlock.js/web 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs +1 -1
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +130 -208
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +7 -5
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs +1 -1
- package/esm/build/public-files.mjs +30 -2
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/fetch-page-data.mjs +3 -10
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +24 -104
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs +19 -10
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +13 -1
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +9 -0
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +18 -12
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +91 -35
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +21 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +3 -3
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +4 -3
- package/esm/server/index.mjs +6 -6
- package/esm/server/install-page-routes-from-manifest.d.mts +10 -1
- package/esm/server/install-page-routes-from-manifest.mjs +61 -58
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +24 -26
- package/esm/server/install-page-routes.mjs +149 -60
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +6 -10
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -0
- package/esm/server/not-found-page.mjs +8 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-module-shapes.d.mts +24 -0
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +26 -2
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +13 -49
- package/esm/server/render-page.mjs +14 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/resolve-validation-data.mjs +1 -1
- package/esm/server/resolve-validation-data.mjs.map +1 -1
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs +78 -19
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +25 -322
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/validation.d.mts +12 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +32 -71
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +6 -8
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +564 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +354 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AA8FA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,mMAGzE;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,IAAa,iCAAb,cAAoD,MAAM;CAEtC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,aAChB;EACA,MACE,IAAI,SAAS,uDACR,YAAY,KAAK,SAAS,IAAI,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,ibAMzD;EAXgB;EACA;EAWhB,KAAK,OAAO;CACd;AACF;;AAGA,SAAS,QAAQ,OAAoD;CACnE,OAAO,OAAO,UAAU,WAAW;EAAE,QAAQ;EAAO,SAAS;CAAK,IAAI;AACxE;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAA6C;CAC5E,MAAM,UAAU,MAAM,IAAI,OAAO;CAEjC,IAAI,QAAQ,MAAM,UAAU,MAAM,WAAW,EAAE,GAC7C,MAAM,IAAI,2BAA2B,QAAQ,KAAK,UAAU,MAAM,MAAM,CAAC;CAG3E,MAAM,YAAY,QAAQ,QAAQ,UAAU,MAAM,OAAO,CAAC,CAAC,KAAK,UAAU,MAAM,MAAM;CAEtF,IAAI,UAAU,WAAW,GACvB,OAAO,EAAE,MAAM,OAAO;CAGxB,IAAI,UAAU,WAAW,GACvB,OAAO;EAAE,MAAM;EAAY,QAAQ,UAAU;CAAG;CAGlD,OAAO;EAAE,MAAM;EAAY,SAAS;CAAU;AAChD"}
|
|
1
|
+
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * A page's OWN middleware, declared on `route.middleware` (`../route.ts`), is\n * the other half of this story and is documented together with this module's\n * layout middleware in one place: `../server/execute-page-request.ts`'s\n * middleware loop (canon `b79c4f55`, point 5) — read that comment for the one\n * ordering rule that covers both surfaces.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AAoGA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,mMAGzE;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,IAAa,iCAAb,cAAoD,MAAM;CAEtC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,aAChB;EACA,MACE,IAAI,SAAS,uDACR,YAAY,KAAK,SAAS,IAAI,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,ibAMzD;EAXgB;EACA;EAWhB,KAAK,OAAO;CACd;AACF;;AAGA,SAAS,QAAQ,OAAoD;CACnE,OAAO,OAAO,UAAU,WAAW;EAAE,QAAQ;EAAO,SAAS;CAAK,IAAI;AACxE;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAA6C;CAC5E,MAAM,UAAU,MAAM,IAAI,OAAO;CAEjC,IAAI,QAAQ,MAAM,UAAU,MAAM,WAAW,EAAE,GAC7C,MAAM,IAAI,2BAA2B,QAAQ,KAAK,UAAU,MAAM,MAAM,CAAC;CAG3E,MAAM,YAAY,QAAQ,QAAQ,UAAU,MAAM,OAAO,CAAC,CAAC,KAAK,UAAU,MAAM,MAAM;CAEtF,IAAI,UAAU,WAAW,GACvB,OAAO,EAAE,MAAM,OAAO;CAGxB,IAAI,UAAU,WAAW,GACvB,OAAO;EAAE,MAAM;EAAY,QAAQ,UAAU;CAAG;CAGlD,OAAO;EAAE,MAAM;EAAY,SAAS;CAAU;AAChD"}
|
|
@@ -195,6 +195,28 @@ declare function setQueryStringOptions(options: QueryStringOptions): void;
|
|
|
195
195
|
* order and failing under `--shuffle`. Same reasoning as `resetRouteTable`.
|
|
196
196
|
*/
|
|
197
197
|
declare function resetQueryStringOptions(): void;
|
|
198
|
+
/**
|
|
199
|
+
* Resolves the search string of the REQUEST currently being server-rendered.
|
|
200
|
+
*
|
|
201
|
+
* Called by the pipeline, not read by it: the same `connect*`-resolver seam
|
|
202
|
+
* `shared.ts` uses for `connectSharedStore` and `page-context.ts` uses for
|
|
203
|
+
* `connectPageContext` — a FUNCTION is connected once at boot, and it is that
|
|
204
|
+
* function, not a cached value, that is asked again on every render. Two
|
|
205
|
+
* concurrent requests calling the resolver therefore each get their own
|
|
206
|
+
* answer; a value connected once would be the cross-request leak canon
|
|
207
|
+
* `9c8f878b`'s sibling defects are made of.
|
|
208
|
+
*
|
|
209
|
+
* `undefined` means the resolver has not been connected yet (a pipeline stage
|
|
210
|
+
* that has not wired this up) — not "no query string". `useQueryString`
|
|
211
|
+
* (`client/navigation/use-query-string.ts`) is the one caller and treats the
|
|
212
|
+
* two the same way its doc explains; nothing else in this module reads it.
|
|
213
|
+
*/
|
|
214
|
+
type RequestSearchResolver = () => string | undefined;
|
|
215
|
+
/**
|
|
216
|
+
* Boot-time wiring for {@link resolveRequestSearch}. Returns the previously
|
|
217
|
+
* connected resolver, so a caller — a test, mainly — can restore it.
|
|
218
|
+
*/
|
|
219
|
+
declare function connectRequestSearch(resolve: RequestSearchResolver | undefined): RequestSearchResolver | undefined;
|
|
198
220
|
/**
|
|
199
221
|
* Reading the current query string, and any query string.
|
|
200
222
|
*
|
|
@@ -246,5 +268,5 @@ declare const queryString: {
|
|
|
246
268
|
toString(): string;
|
|
247
269
|
};
|
|
248
270
|
//#endregion
|
|
249
|
-
export { QueryStringInput, QueryStringLeaf, QueryStringNested, QueryStringObject, QueryStringOptions, QueryStringValue, RepeatedKeyStrategy, UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions };
|
|
271
|
+
export { QueryStringInput, QueryStringLeaf, QueryStringNested, QueryStringObject, QueryStringOptions, QueryStringValue, RepeatedKeyStrategy, RequestSearchResolver, UnserializableQueryValueError, connectRequestSearch, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions };
|
|
250
272
|
//# sourceMappingURL=query-string.d.mts.map
|
|
@@ -144,6 +144,28 @@ function browserSearch() {
|
|
|
144
144
|
if (typeof window === "undefined") return "";
|
|
145
145
|
return window.location?.search ?? "";
|
|
146
146
|
}
|
|
147
|
+
let resolveRequestSearch;
|
|
148
|
+
/**
|
|
149
|
+
* Boot-time wiring for {@link resolveRequestSearch}. Returns the previously
|
|
150
|
+
* connected resolver, so a caller — a test, mainly — can restore it.
|
|
151
|
+
*/
|
|
152
|
+
function connectRequestSearch(resolve) {
|
|
153
|
+
const previous = resolveRequestSearch;
|
|
154
|
+
resolveRequestSearch = resolve;
|
|
155
|
+
return previous;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The search string `useQueryString` reads: {@link browserSearch} in the
|
|
159
|
+
* browser, the connected request's search on the server.
|
|
160
|
+
*
|
|
161
|
+
* `""` on the server before {@link connectRequestSearch} has been wired —
|
|
162
|
+
* the same "nothing to report yet" answer `browserSearch` gives outside a
|
|
163
|
+
* document, not a claim that the request has no query string.
|
|
164
|
+
*/
|
|
165
|
+
function currentSearch() {
|
|
166
|
+
if (typeof window !== "undefined") return browserSearch();
|
|
167
|
+
return resolveRequestSearch?.() ?? "";
|
|
168
|
+
}
|
|
147
169
|
/**
|
|
148
170
|
* Reduce anything search-shaped to the pairs themselves.
|
|
149
171
|
*
|
|
@@ -336,5 +358,5 @@ const queryString = {
|
|
|
336
358
|
};
|
|
337
359
|
|
|
338
360
|
//#endregion
|
|
339
|
-
export { UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions };
|
|
361
|
+
export { UnserializableQueryValueError, connectRequestSearch, currentSearch, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions };
|
|
340
362
|
//# sourceMappingURL=query-string.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-string.mjs","names":[],"sources":["../../../../../../../web/src/routing/query-string.ts"],"sourcesContent":["/**\n * The query string: BOTH directions, one grammar, one file.\n *\n * ── The rule this module exists to obey ──────────────────────────────────────\n *\n * `queryStringOf` is what `href(name, params, query)` appends, and therefore\n * what every `<Link>` puts in the document; `queryString.parse` is what reads it\n * back. They live together because a second, independently-written\n * query-string implementation would mean one thing writes URLs and a different\n * thing reads them. Query-string libraries genuinely disagree about arrays,\n * nested objects and space escaping, so the moment the two drift the round trip\n * loses data SILENTLY — the wrong value renders and no error is raised\n * anywhere. That is the two-route-matchers failure class (canon 9c8f878b) one\n * layer down.\n *\n * Escaping is not two rules that agree today. Both directions stand on\n * `URLSearchParams`: the encoder builds one and calls `.toString()`, the decoder\n * hands the string back to `new URLSearchParams(...)`. `+`-for-space and every\n * percent-encoding rule are therefore ONE rule and cannot drift.\n *\n * ── The grammar, and why it is not ours to choose ────────────────────────────\n *\n * Structure — arrays and nested objects — is the part `URLSearchParams` has no\n * opinion about, so it was read off the SERVER rather than invented. Warlock is\n * a backend framework and core already parses query strings for list endpoints\n * and filters; a client that emitted anything else would not have fixed this\n * module's bug, it would have moved it across the wire where it is harder to\n * see. Measured, with file:line, in\n * `reports/query-grammar-findings-2026-08-24.md`:\n *\n * - `core/src/http/server.ts:14-36` configures Fastify with NO\n * `querystringParser`, so find-my-way's `fast-querystring` default applies:\n * flat keys, `+` is a space, a repeated key collapses to an array.\n * - `core/src/http/request.ts:491` then runs `request.query` through\n * `parseBody` — the SAME bracket-aware parser as the request body.\n * - `core/src/http/request.ts:516-520` reads `key[]` as an array marker, and\n * `:568-575` wraps a single `key[]=a` into the one-element array `[\"a\"]`.\n * - `core/src/http/request.ts:557-561` turns `key[sub]=v` into\n * `{key: {sub: v}}`, one level deep.\n *\n * So: `tags[]=a&tags[]=b` for arrays, `filter[status]=active` for objects,\n * `filter[tags][]=a` for an array inside an object. Bare repeated keys\n * (`tags=a&tags=b`) would also reach core as an array, but only at cardinality\n * two or more — `tags=a` comes back as the scalar `\"a\"`, and a shape that\n * changes with the number of matches is a `TypeError` waiting for the day a\n * filter matches one item. `[]` is the form core wrote a branch for.\n *\n * ── What is REFUSED, and why refusing is the safe answer ─────────────────────\n *\n * Two levels of nesting is not a gap in core, it is measured data loss:\n * `a[b][c]=x` takes the `][` branch at `core/src/http/request.ts:528-551`,\n * computes `Number(\"b\")` as `NaN`, and the value lands on a `NaN` index and\n * vanishes — core answers `{a: []}`. So the encoder throws\n * {@link UnserializableQueryValueError} rather than write it. `href()` already\n * throws on a missing route parameter rather than emit a link that renders\n * correctly and 404s; this is the same call.\n *\n * ── What the encoder still normalises, and what that costs ───────────────────\n *\n * Leaf values are serialised with `String(value)`, so every value arrives back\n * as a `string`: `href(\"p\", {}, { page: 2 })` writes `?page=2`, and `2` and\n * `\"2\"` are the same URL. The round-trip law that can actually hold is\n * `parse(encode(query)) === query with String() applied to its leaves and\n * undefined dropped`, and that is what the spec asserts. Asserting anything\n * stronger would be asserting a decoder that guesses types, which is how\n * `?zip=01234` becomes the number `1234`.\n *\n * The decoder does NOT split values on `,`: that would turn the legitimate\n * string `\"Doe, John\"` into `[\"Doe\", \" John\"]`, trading a visible limitation for\n * a silent corruption of ordinary data. Arrays are carried by `[]`, which is\n * unambiguous, so there is nothing left to guess at.\n *\n * ── Universal by requirement, not by accident ────────────────────────────────\n *\n * `web` renders on the server first, so every export here is safe to import and\n * to CALL with no DOM. `@mongez/react-router`'s equivalent reads\n * `window.location.search` unguarded (query-string.ts:22) and throws under SSR.\n * Here the browser-only readers return their empty answer instead — see\n * `queryString.all` for the hydration caveat that comes with that.\n *\n * ── Still NOT here: `toQueryString` and `update` ─────────────────────────────\n *\n * MRR exposes `toQueryString(params)` and `update(params)`, which SERIALISE.\n * The serialiser they need is no longer module-private — `queryStringOf` is\n * exported from here — so the old blocker is gone. What remains is that\n * `web/src/index.ts` does not re-export it yet, and the barrel is owned\n * elsewhere. That is a one-line follow-up, not a redesign.\n */\n\n/** A decoded leaf. Always a `string` — see the header on `String(value)`. */\nexport type QueryStringLeaf = string;\n\n/**\n * The inside of a `key[sub]` bag: one level deep, exactly as far as core parses.\n *\n * There is no deeper case to model. `key[sub][deeper]` is refused by the encoder\n * and carried verbatim as a literal key by the decoder, so a nested bag only\n * ever holds a leaf or an array of leaves.\n */\nexport type QueryStringNested = Record<string, QueryStringLeaf | QueryStringLeaf[]>;\n\n/** A decoded value: a leaf, a `key[]` array, or a `key[sub]` bag. */\nexport type QueryStringValue = QueryStringLeaf | QueryStringLeaf[] | QueryStringNested;\n\n/** A decoded query string. */\nexport type QueryStringObject = Record<string, QueryStringValue>;\n\n/** What {@link queryStringOf} accepts. Values are validated, not trusted. */\nexport type QueryStringInput = Readonly<Record<string, unknown>>;\n\n/**\n * Thrown when a query value has a shape the wire format cannot carry.\n *\n * Deliberately LOUD. The alternative is emitting something core silently\n * mangles — `a[b][c]=x` arrives as `{a: []}` — which is this defect all over\n * again, one layer further from where anyone would look for it.\n */\nexport class UnserializableQueryValueError extends Error {\n public constructor(\n public readonly queryKey: string,\n public readonly shape: string,\n ) {\n super(\n `Warlock cannot put ${shape} in a query string at \"${queryKey}\". The wire format is the ` +\n \"one @warlock.js/core parses (core/src/http/request.ts:503-591): a value may be a \" +\n \"scalar, an array of scalars (`key[]=a&key[]=b`), or an object one level deep whose \" +\n \"values are scalars or arrays of scalars (`key[sub]=a`, `key[sub][]=a`). Anything \" +\n \"deeper is refused rather than written, because core reads `a[b][c]=x` back as \" +\n \"`{a: []}` — the value is destroyed on arrival with no error. Flatten the value, or \" +\n \"JSON.stringify it into a single scalar and parse it on the server.\",\n );\n this.name = \"UnserializableQueryValueError\";\n }\n}\n\n/**\n * Is this a BAG of query keys, as opposed to a value with its own string form?\n *\n * A `Date`, a `URL` or anything else carrying its own `toString` is a scalar\n * here — `String(value)` is meaningful for it, and turning it into\n * `since[getTime]=...` would be absurd. A plain object, or a class instance that\n * would otherwise stringify to the useless `\"[object Object]\"`, is a bag and\n * gets enumerated.\n *\n * The `typeof` check matters for null-prototype objects: they have no\n * `toString` at all, and `String()` on one THROWS.\n */\nfunction isKeyBag(value: object): boolean {\n const stringForm = (value as { toString?: unknown }).toString;\n\n return typeof stringForm !== \"function\" || stringForm === Object.prototype.toString;\n}\n\n/** Refuse anything that is not a leaf. Used everywhere a leaf is the only legal shape. */\nfunction requireLeaf(key: string, value: unknown): void {\n if (Array.isArray(value)) {\n throw new UnserializableQueryValueError(key, \"a nested array\");\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n throw new UnserializableQueryValueError(key, \"a nested object\");\n }\n}\n\n/**\n * Append `key[]=element` per element.\n *\n * An `undefined` element is skipped, matching the top-level rule. Because the\n * `[]` form carries no indices, skipping shortens the array rather than leaving\n * a hole — there is no hole to leave.\n *\n * An EMPTY array appends nothing, so the key is absent from the URL. The format\n * has no way to say \"an array with no elements\": `key[]=` means the one-element\n * array `[\"\"]`, which is a different value. A visible limitation beats a guess.\n */\nfunction appendArray(search: URLSearchParams, key: string, value: readonly unknown[]): void {\n const arrayKey = `${key}[]`;\n\n for (const element of value) {\n if (element === undefined) continue;\n\n requireLeaf(arrayKey, element);\n\n search.append(arrayKey, String(element));\n }\n}\n\n/** Append `key[sub]=value`, or `key[sub][]=value` when the member is an array. */\nfunction appendBag(search: URLSearchParams, key: string, value: object): void {\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue === undefined) continue;\n\n const nestedKey = `${key}[${subKey}]`;\n\n if (Array.isArray(subValue)) {\n appendArray(search, nestedKey, subValue);\n continue;\n }\n\n requireLeaf(nestedKey, subValue);\n\n search.append(nestedKey, String(subValue));\n }\n}\n\n/**\n * Serialise a query object to a search string, INCLUDING the leading `?`.\n *\n * Returns `\"\"` — not `\"?\"` — when there is nothing to write, so the result can\n * always be concatenated onto a path. `href()` is the primary caller; the\n * grammar it emits is the one core parses, and the whole justification is in the\n * module header.\n *\n * @throws {UnserializableQueryValueError} when a value nests deeper than the\n * wire format can carry.\n */\nexport function queryStringOf(query: QueryStringInput | undefined): string {\n if (query === undefined) return \"\";\n\n const search = new URLSearchParams();\n\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined) continue;\n\n if (Array.isArray(value)) {\n appendArray(search, key, value);\n continue;\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n appendBag(search, key, value);\n continue;\n }\n\n search.append(key, String(value));\n }\n\n const serialized = search.toString();\n\n return serialized === \"\" ? \"\" : `?${serialized}`;\n}\n\n/**\n * What to do when the same PLAIN key appears more than once, e.g. `?tag=a&tag=b`.\n *\n * - `\"last\"` — keep the final occurrence. Matches `URLSearchParams.get()`, the\n * read side of the primitive the encoder writes with, and keeps every value a\n * `string`.\n * - `\"first\"` — keep the earliest occurrence.\n * - `\"array\"` — collect every occurrence. Under this strategy a key that occurs\n * ONCE is still an array of one, because a shape that changes with the number\n * of values is a `TypeError` waiting for the day a filter matches a single\n * item.\n *\n * It does NOT govern `key[]` or `key[sub]`; those are shapes the encoder writes\n * deliberately, and their meaning is fixed. See {@link QueryStringOptions}.\n */\nexport type RepeatedKeyStrategy = \"last\" | \"first\" | \"array\";\n\n/**\n * The whole option surface.\n *\n * ── Why this is one flag and not MRR's pluggable parsers ─────────────────────\n *\n * MRR's `setQueryStringOptions` swaps `objectParser` and `stringParser`\n * (query-string.ts:10-15). Neither survives contact with a fixed, shared\n * encoder:\n *\n * - A pluggable `objectParser` is a licence to install a READER that disagrees\n * with the writer. It is precisely the silent round-trip break this module\n * was written to prevent, offered as a supported API.\n * - A pluggable `stringParser` would be worse: it cannot reach `href()`, which\n * calls `queryStringOf` directly with no hook. Setting it would change what\n * this module emits while every `<Link>` on the page kept emitting the old\n * format — two writers, disagreeing, by configuration.\n *\n * `repeatedKeys` is safe for one specific reason, and the reason survived the\n * move to bracket notation: `queryStringOf` iterates `Object.entries`, so it can\n * never emit a duplicate PLAIN key. It now does emit duplicate `key[]` pairs —\n * that is how an array is written — but those go down the array branch of the\n * decoder, which this flag does not touch. Were the flag to govern the bracket\n * forms as well, `repeatedKeys: \"array\"` would decode `filter[x]=1` as\n * `{filter: {x: [\"1\"]}}` and break the round trip BY CONFIGURATION.\n *\n * So the flag still decides only something the encoder has no opinion about: a\n * repeated plain key, which can only arrive from a URL this package did not\n * write — hand-typed, external links, `GET` forms.\n *\n * Set it at BOOT, not per request: this is process-global config, and a\n * request-scoped write would be read by whatever request happens to be\n * rendering (the defect catalogued at route-table.ts:10-25).\n */\nexport type QueryStringOptions = {\n readonly repeatedKeys?: RepeatedKeyStrategy;\n};\n\ntype ResolvedQueryStringOptions = Required<QueryStringOptions>;\n\nconst DEFAULT_OPTIONS: ResolvedQueryStringOptions = {\n repeatedKeys: \"last\",\n};\n\n/**\n * Held on `globalThis`, not in a module binding, for the reason measured and\n * written up at route-table.ts:43-65: in dev the process runs two module graphs\n * over these files (tsx/Node and Vite's SSR runner), so a `let` written by boot\n * code is not the `let` a component reads. A split here would be quiet — the\n * second graph would silently fall back to the defaults and decode a repeated\n * key the other way.\n */\nconst OPTIONS_SLOT = Symbol.for(\"warlock.web.queryStringOptions\");\n\ntype OptionsHost = typeof globalThis & {\n [OPTIONS_SLOT]?: ResolvedQueryStringOptions;\n};\n\nfunction currentOptions(): ResolvedQueryStringOptions {\n return (globalThis as OptionsHost)[OPTIONS_SLOT] ?? DEFAULT_OPTIONS;\n}\n\n/**\n * Merge query-string options over the current ones.\n *\n * Named for `@mongez/react-router`'s function so migrating call sites keep\n * compiling; the OPTIONS are deliberately narrower, and `QueryStringOptions`\n * explains exactly which ones were refused and why.\n */\nexport function setQueryStringOptions(options: QueryStringOptions): void {\n (globalThis as OptionsHost)[OPTIONS_SLOT] = { ...currentOptions(), ...options };\n}\n\n/**\n * Drop back to the defaults.\n *\n * Exists because the options are process-global: a test that set one would\n * otherwise leak it into every later test in the same worker, passing in file\n * order and failing under `--shuffle`. Same reasoning as `resetRouteTable`.\n */\nexport function resetQueryStringOptions(): void {\n delete (globalThis as OptionsHost)[OPTIONS_SLOT];\n}\n\n/**\n * The search string of the current document, or `\"\"` when there is no document.\n *\n * The `typeof` guard is the SSR contract: no export in this module may touch a\n * DOM global without one.\n */\nfunction browserSearch(): string {\n if (typeof window === \"undefined\") return \"\";\n\n return window.location?.search ?? \"\";\n}\n\n/**\n * Reduce anything search-shaped to the pairs themselves.\n *\n * Accepts `\"?a=1\"`, `\"a=1\"`, `\"/path?a=1\"` and a full URL, because\n * `location.href`, `location.search` and a hand-written literal all turn up at\n * this door and `new URLSearchParams(\"/path?a=1\")` would answer with the key\n * `\"/path?a\"` — a wrong answer with no error. Cutting at the first `?` and the\n * first `#` is safe rather than heuristic: `queryStringOf` percent-encodes both\n * characters inside values (`%3F`, `%23`), so a literal one is always a\n * delimiter.\n */\nfunction searchPairsOf(source: string): string {\n const withoutHash = source.split(\"#\", 1)[0];\n const questionMark = withoutHash.indexOf(\"?\");\n\n return questionMark === -1 ? withoutHash : withoutHash.slice(questionMark + 1);\n}\n\n/**\n * The three key shapes the encoder writes. Everything else is a literal key.\n *\n * The character classes exclude brackets, so each pattern matches exactly one\n * shape: `tags[]` cannot satisfy `NESTED`, and `a[b][c]` satisfies none of them.\n */\nconst ARRAY_KEY = /^([^[\\]]+)\\[\\]$/;\nconst NESTED_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]$/;\nconst NESTED_ARRAY_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]\\[\\]$/;\n\ntype ParsedKey =\n | { kind: \"plain\"; name: string }\n | { kind: \"array\"; name: string }\n | { kind: \"nested\"; name: string; subKey: string }\n | { kind: \"nestedArray\"; name: string; subKey: string };\n\n/**\n * Classify a decoded key.\n *\n * A shape neither side produces — `a[b][c]`, `items[0][name]`, an unbalanced\n * `a[` — falls through to `\"plain\"` and is carried VERBATIM. Those can only come\n * from a URL warlock did not write, and guessing at them is exactly how a\n * decoder drifts from its writer. Carrying the key as text loses nothing.\n */\nfunction parseKey(key: string): ParsedKey {\n const nestedArray = NESTED_ARRAY_KEY.exec(key);\n\n if (nestedArray) {\n return { kind: \"nestedArray\", name: nestedArray[1], subKey: nestedArray[2] };\n }\n\n const array = ARRAY_KEY.exec(key);\n\n if (array) return { kind: \"array\", name: array[1] };\n\n const nested = NESTED_KEY.exec(key);\n\n if (nested) return { kind: \"nested\", name: nested[1], subKey: nested[2] };\n\n return { kind: \"plain\", name: key };\n}\n\n/** A fresh bag with NO prototype. See {@link decode} for why that is not optional. */\nfunction emptyBag<T extends object>(): T {\n return Object.create(null) as T;\n}\n\nfunction isBag(value: unknown): value is QueryStringNested {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * The array living at `container[name]`, created if it is not there yet.\n *\n * A conflicting earlier shape is REPLACED rather than written into. `?a=1&a[]=2`\n * is not something the encoder can emit, so it is a foreign URL contradicting\n * itself; appending to a string would be a silent no-op, and last-shape-wins at\n * least matches how `repeatedKeys: \"last\"` resolves the scalar version.\n */\nfunction arrayAt(container: Record<string, unknown>, name: string): string[] {\n const existing = container[name];\n\n if (Array.isArray(existing)) return existing;\n\n const created: string[] = [];\n\n container[name] = created;\n\n return created;\n}\n\n/** The bag living at `result[name]`, created if absent. Same last-shape-wins rule. */\nfunction bagAt(result: QueryStringObject, name: string): QueryStringNested {\n const existing = result[name];\n\n if (isBag(existing)) return existing;\n\n const created = emptyBag<QueryStringNested>();\n\n result[name] = created;\n\n return created;\n}\n\n/**\n * The single decode path. Every reader below goes through here.\n *\n * Every container has a NULL PROTOTYPE — the result and each `key[sub]` bag.\n * Query keys are attacker-controlled, and assigning `?__proto__=x` onto a `{}`\n * literal hits the inherited setter, which DISCARDS the key: data loss with no\n * error. Bracket parsing makes this sharper than it was when this decoder was\n * flat, because `?filter[__proto__]=x` now reaches a nested container too —\n * which is why `bagAt` builds with `Object.create(null)` rather than `{}`.\n * MRR's parser walks `key.split(\"[\")` onto plain objects and needs the blocklist\n * at query-string-parsers.ts:7 as a result; with no prototype there is no setter\n * to hit and the key is stored as the ordinary data it is.\n */\nfunction decode(source: string): QueryStringObject {\n const pairs = searchPairsOf(source);\n const result = emptyBag<QueryStringObject>();\n\n if (pairs === \"\") return result;\n\n const { repeatedKeys } = currentOptions();\n\n for (const [key, value] of new URLSearchParams(pairs)) {\n const parsed = parseKey(key);\n\n if (parsed.kind === \"array\") {\n arrayAt(result, parsed.name).push(value);\n continue;\n }\n\n if (parsed.kind === \"nestedArray\") {\n arrayAt(bagAt(result, parsed.name), parsed.subKey).push(value);\n continue;\n }\n\n if (parsed.kind === \"nested\") {\n // Last wins. The encoder cannot repeat a `key[sub]` pair — `Object.entries`\n // yields each sub-key once — so this only arises from a foreign URL, and\n // `repeatedKeys` deliberately does not reach here (QueryStringOptions).\n bagAt(result, parsed.name)[parsed.subKey] = value;\n continue;\n }\n\n if (!(parsed.name in result)) {\n result[parsed.name] = repeatedKeys === \"array\" ? [value] : value;\n continue;\n }\n\n if (repeatedKeys === \"first\") continue;\n\n if (repeatedKeys === \"last\") {\n result[parsed.name] = value;\n continue;\n }\n\n arrayAt(result, parsed.name).push(value);\n }\n\n return result;\n}\n\n/**\n * Reading the current query string, and any query string.\n *\n * Named for `@mongez/react-router`'s object so the familiar calls keep working;\n * the implementation is not ported — see the module header for what changed and\n * why.\n */\nexport const queryString = {\n /**\n * The current document's query string, decoded.\n *\n * On the server this is `{}`, because the browser location is the only source\n * this module has and a per-request one would be module state two concurrent\n * requests could race over (route-table.ts:10-25).\n *\n * That makes it a HYDRATION HAZARD in a component: the server renders `{}`\n * and the browser renders the real values, so the two trees differ. In a\n * component, take the query from the page's own props and hand it to\n * {@link queryString.parse}, which is universal. `all()` is for browser-only\n * code — an event handler, an effect, a client-side helper.\n */\n all(): QueryStringObject {\n return decode(browserSearch());\n },\n\n /**\n * Decode a query string that was handed to you.\n *\n * Universal: it reads no globals, so this is the entry point that is safe on\n * the server. Accepts a bare pair list, a leading `?`, a path, or a full URL.\n */\n parse(search: string): QueryStringObject {\n return decode(search);\n },\n\n /**\n * One key from the current query string.\n *\n * PRESENCE, not truthiness. MRR returns `all[key] || defaultValue`\n * (query-string.ts:43), which hands back the default for `?a=` — so a\n * deliberately-cleared filter reads as though it was never set, and the page\n * shows the default instead of the empty state. Here `?a=` returns `\"\"`, and\n * only an absent key returns the default.\n *\n * @param defaultValue returned only when the key is absent. Defaults to\n * `null`, as MRR's does.\n */\n get<T = null>(key: string, defaultValue: T = null as T): QueryStringValue | T {\n const all = decode(browserSearch());\n\n return key in all ? all[key] : defaultValue;\n },\n\n /**\n * The current query string verbatim, without the leading `?`.\n *\n * `\"\"` on the server, and `\"\"` when there is no query — the same two cases\n * `queryStringOf` collapses when it writes.\n */\n toString(): string {\n return searchPairsOf(browserSearch());\n },\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"query-string.mjs","names":[],"sources":["../../../../../../../web/src/routing/query-string.ts"],"sourcesContent":["/**\n * The query string: BOTH directions, one grammar, one file.\n *\n * ── The rule this module exists to obey ──────────────────────────────────────\n *\n * `queryStringOf` is what `href(name, params, query)` appends, and therefore\n * what every `<Link>` puts in the document; `queryString.parse` is what reads it\n * back. They live together because a second, independently-written\n * query-string implementation would mean one thing writes URLs and a different\n * thing reads them. Query-string libraries genuinely disagree about arrays,\n * nested objects and space escaping, so the moment the two drift the round trip\n * loses data SILENTLY — the wrong value renders and no error is raised\n * anywhere. That is the two-route-matchers failure class (canon 9c8f878b) one\n * layer down.\n *\n * Escaping is not two rules that agree today. Both directions stand on\n * `URLSearchParams`: the encoder builds one and calls `.toString()`, the decoder\n * hands the string back to `new URLSearchParams(...)`. `+`-for-space and every\n * percent-encoding rule are therefore ONE rule and cannot drift.\n *\n * ── The grammar, and why it is not ours to choose ────────────────────────────\n *\n * Structure — arrays and nested objects — is the part `URLSearchParams` has no\n * opinion about, so it was read off the SERVER rather than invented. Warlock is\n * a backend framework and core already parses query strings for list endpoints\n * and filters; a client that emitted anything else would not have fixed this\n * module's bug, it would have moved it across the wire where it is harder to\n * see. Measured, with file:line, in\n * `reports/query-grammar-findings-2026-08-24.md`:\n *\n * - `core/src/http/server.ts:14-36` configures Fastify with NO\n * `querystringParser`, so find-my-way's `fast-querystring` default applies:\n * flat keys, `+` is a space, a repeated key collapses to an array.\n * - `core/src/http/request.ts:491` then runs `request.query` through\n * `parseBody` — the SAME bracket-aware parser as the request body.\n * - `core/src/http/request.ts:516-520` reads `key[]` as an array marker, and\n * `:568-575` wraps a single `key[]=a` into the one-element array `[\"a\"]`.\n * - `core/src/http/request.ts:557-561` turns `key[sub]=v` into\n * `{key: {sub: v}}`, one level deep.\n *\n * So: `tags[]=a&tags[]=b` for arrays, `filter[status]=active` for objects,\n * `filter[tags][]=a` for an array inside an object. Bare repeated keys\n * (`tags=a&tags=b`) would also reach core as an array, but only at cardinality\n * two or more — `tags=a` comes back as the scalar `\"a\"`, and a shape that\n * changes with the number of matches is a `TypeError` waiting for the day a\n * filter matches one item. `[]` is the form core wrote a branch for.\n *\n * ── What is REFUSED, and why refusing is the safe answer ─────────────────────\n *\n * Two levels of nesting is not a gap in core, it is measured data loss:\n * `a[b][c]=x` takes the `][` branch at `core/src/http/request.ts:528-551`,\n * computes `Number(\"b\")` as `NaN`, and the value lands on a `NaN` index and\n * vanishes — core answers `{a: []}`. So the encoder throws\n * {@link UnserializableQueryValueError} rather than write it. `href()` already\n * throws on a missing route parameter rather than emit a link that renders\n * correctly and 404s; this is the same call.\n *\n * ── What the encoder still normalises, and what that costs ───────────────────\n *\n * Leaf values are serialised with `String(value)`, so every value arrives back\n * as a `string`: `href(\"p\", {}, { page: 2 })` writes `?page=2`, and `2` and\n * `\"2\"` are the same URL. The round-trip law that can actually hold is\n * `parse(encode(query)) === query with String() applied to its leaves and\n * undefined dropped`, and that is what the spec asserts. Asserting anything\n * stronger would be asserting a decoder that guesses types, which is how\n * `?zip=01234` becomes the number `1234`.\n *\n * The decoder does NOT split values on `,`: that would turn the legitimate\n * string `\"Doe, John\"` into `[\"Doe\", \" John\"]`, trading a visible limitation for\n * a silent corruption of ordinary data. Arrays are carried by `[]`, which is\n * unambiguous, so there is nothing left to guess at.\n *\n * ── Universal by requirement, not by accident ────────────────────────────────\n *\n * `web` renders on the server first, so every export here is safe to import and\n * to CALL with no DOM. `@mongez/react-router`'s equivalent reads\n * `window.location.search` unguarded (query-string.ts:22) and throws under SSR.\n * Here the browser-only readers return their empty answer instead — see\n * `queryString.all` for the hydration caveat that comes with that.\n *\n * ── Still NOT here: `toQueryString` and `update` ─────────────────────────────\n *\n * MRR exposes `toQueryString(params)` and `update(params)`, which SERIALISE.\n * The serialiser they need is no longer module-private — `queryStringOf` is\n * exported from here — so the old blocker is gone. What remains is that\n * `web/src/index.ts` does not re-export it yet, and the barrel is owned\n * elsewhere. That is a one-line follow-up, not a redesign.\n */\n\n/** A decoded leaf. Always a `string` — see the header on `String(value)`. */\nexport type QueryStringLeaf = string;\n\n/**\n * The inside of a `key[sub]` bag: one level deep, exactly as far as core parses.\n *\n * There is no deeper case to model. `key[sub][deeper]` is refused by the encoder\n * and carried verbatim as a literal key by the decoder, so a nested bag only\n * ever holds a leaf or an array of leaves.\n */\nexport type QueryStringNested = Record<string, QueryStringLeaf | QueryStringLeaf[]>;\n\n/** A decoded value: a leaf, a `key[]` array, or a `key[sub]` bag. */\nexport type QueryStringValue = QueryStringLeaf | QueryStringLeaf[] | QueryStringNested;\n\n/** A decoded query string. */\nexport type QueryStringObject = Record<string, QueryStringValue>;\n\n/** What {@link queryStringOf} accepts. Values are validated, not trusted. */\nexport type QueryStringInput = Readonly<Record<string, unknown>>;\n\n/**\n * Thrown when a query value has a shape the wire format cannot carry.\n *\n * Deliberately LOUD. The alternative is emitting something core silently\n * mangles — `a[b][c]=x` arrives as `{a: []}` — which is this defect all over\n * again, one layer further from where anyone would look for it.\n */\nexport class UnserializableQueryValueError extends Error {\n public constructor(\n public readonly queryKey: string,\n public readonly shape: string,\n ) {\n super(\n `Warlock cannot put ${shape} in a query string at \"${queryKey}\". The wire format is the ` +\n \"one @warlock.js/core parses (core/src/http/request.ts:503-591): a value may be a \" +\n \"scalar, an array of scalars (`key[]=a&key[]=b`), or an object one level deep whose \" +\n \"values are scalars or arrays of scalars (`key[sub]=a`, `key[sub][]=a`). Anything \" +\n \"deeper is refused rather than written, because core reads `a[b][c]=x` back as \" +\n \"`{a: []}` — the value is destroyed on arrival with no error. Flatten the value, or \" +\n \"JSON.stringify it into a single scalar and parse it on the server.\",\n );\n this.name = \"UnserializableQueryValueError\";\n }\n}\n\n/**\n * Is this a BAG of query keys, as opposed to a value with its own string form?\n *\n * A `Date`, a `URL` or anything else carrying its own `toString` is a scalar\n * here — `String(value)` is meaningful for it, and turning it into\n * `since[getTime]=...` would be absurd. A plain object, or a class instance that\n * would otherwise stringify to the useless `\"[object Object]\"`, is a bag and\n * gets enumerated.\n *\n * The `typeof` check matters for null-prototype objects: they have no\n * `toString` at all, and `String()` on one THROWS.\n */\nfunction isKeyBag(value: object): boolean {\n const stringForm = (value as { toString?: unknown }).toString;\n\n return typeof stringForm !== \"function\" || stringForm === Object.prototype.toString;\n}\n\n/** Refuse anything that is not a leaf. Used everywhere a leaf is the only legal shape. */\nfunction requireLeaf(key: string, value: unknown): void {\n if (Array.isArray(value)) {\n throw new UnserializableQueryValueError(key, \"a nested array\");\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n throw new UnserializableQueryValueError(key, \"a nested object\");\n }\n}\n\n/**\n * Append `key[]=element` per element.\n *\n * An `undefined` element is skipped, matching the top-level rule. Because the\n * `[]` form carries no indices, skipping shortens the array rather than leaving\n * a hole — there is no hole to leave.\n *\n * An EMPTY array appends nothing, so the key is absent from the URL. The format\n * has no way to say \"an array with no elements\": `key[]=` means the one-element\n * array `[\"\"]`, which is a different value. A visible limitation beats a guess.\n */\nfunction appendArray(search: URLSearchParams, key: string, value: readonly unknown[]): void {\n const arrayKey = `${key}[]`;\n\n for (const element of value) {\n if (element === undefined) continue;\n\n requireLeaf(arrayKey, element);\n\n search.append(arrayKey, String(element));\n }\n}\n\n/** Append `key[sub]=value`, or `key[sub][]=value` when the member is an array. */\nfunction appendBag(search: URLSearchParams, key: string, value: object): void {\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue === undefined) continue;\n\n const nestedKey = `${key}[${subKey}]`;\n\n if (Array.isArray(subValue)) {\n appendArray(search, nestedKey, subValue);\n continue;\n }\n\n requireLeaf(nestedKey, subValue);\n\n search.append(nestedKey, String(subValue));\n }\n}\n\n/**\n * Serialise a query object to a search string, INCLUDING the leading `?`.\n *\n * Returns `\"\"` — not `\"?\"` — when there is nothing to write, so the result can\n * always be concatenated onto a path. `href()` is the primary caller; the\n * grammar it emits is the one core parses, and the whole justification is in the\n * module header.\n *\n * @throws {UnserializableQueryValueError} when a value nests deeper than the\n * wire format can carry.\n */\nexport function queryStringOf(query: QueryStringInput | undefined): string {\n if (query === undefined) return \"\";\n\n const search = new URLSearchParams();\n\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined) continue;\n\n if (Array.isArray(value)) {\n appendArray(search, key, value);\n continue;\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n appendBag(search, key, value);\n continue;\n }\n\n search.append(key, String(value));\n }\n\n const serialized = search.toString();\n\n return serialized === \"\" ? \"\" : `?${serialized}`;\n}\n\n/**\n * What to do when the same PLAIN key appears more than once, e.g. `?tag=a&tag=b`.\n *\n * - `\"last\"` — keep the final occurrence. Matches `URLSearchParams.get()`, the\n * read side of the primitive the encoder writes with, and keeps every value a\n * `string`.\n * - `\"first\"` — keep the earliest occurrence.\n * - `\"array\"` — collect every occurrence. Under this strategy a key that occurs\n * ONCE is still an array of one, because a shape that changes with the number\n * of values is a `TypeError` waiting for the day a filter matches a single\n * item.\n *\n * It does NOT govern `key[]` or `key[sub]`; those are shapes the encoder writes\n * deliberately, and their meaning is fixed. See {@link QueryStringOptions}.\n */\nexport type RepeatedKeyStrategy = \"last\" | \"first\" | \"array\";\n\n/**\n * The whole option surface.\n *\n * ── Why this is one flag and not MRR's pluggable parsers ─────────────────────\n *\n * MRR's `setQueryStringOptions` swaps `objectParser` and `stringParser`\n * (query-string.ts:10-15). Neither survives contact with a fixed, shared\n * encoder:\n *\n * - A pluggable `objectParser` is a licence to install a READER that disagrees\n * with the writer. It is precisely the silent round-trip break this module\n * was written to prevent, offered as a supported API.\n * - A pluggable `stringParser` would be worse: it cannot reach `href()`, which\n * calls `queryStringOf` directly with no hook. Setting it would change what\n * this module emits while every `<Link>` on the page kept emitting the old\n * format — two writers, disagreeing, by configuration.\n *\n * `repeatedKeys` is safe for one specific reason, and the reason survived the\n * move to bracket notation: `queryStringOf` iterates `Object.entries`, so it can\n * never emit a duplicate PLAIN key. It now does emit duplicate `key[]` pairs —\n * that is how an array is written — but those go down the array branch of the\n * decoder, which this flag does not touch. Were the flag to govern the bracket\n * forms as well, `repeatedKeys: \"array\"` would decode `filter[x]=1` as\n * `{filter: {x: [\"1\"]}}` and break the round trip BY CONFIGURATION.\n *\n * So the flag still decides only something the encoder has no opinion about: a\n * repeated plain key, which can only arrive from a URL this package did not\n * write — hand-typed, external links, `GET` forms.\n *\n * Set it at BOOT, not per request: this is process-global config, and a\n * request-scoped write would be read by whatever request happens to be\n * rendering (the defect catalogued at route-table.ts:10-25).\n */\nexport type QueryStringOptions = {\n readonly repeatedKeys?: RepeatedKeyStrategy;\n};\n\ntype ResolvedQueryStringOptions = Required<QueryStringOptions>;\n\nconst DEFAULT_OPTIONS: ResolvedQueryStringOptions = {\n repeatedKeys: \"last\",\n};\n\n/**\n * Held on `globalThis`, not in a module binding, for the reason measured and\n * written up at route-table.ts:43-65: in dev the process runs two module graphs\n * over these files (tsx/Node and Vite's SSR runner), so a `let` written by boot\n * code is not the `let` a component reads. A split here would be quiet — the\n * second graph would silently fall back to the defaults and decode a repeated\n * key the other way.\n */\nconst OPTIONS_SLOT = Symbol.for(\"warlock.web.queryStringOptions\");\n\ntype OptionsHost = typeof globalThis & {\n [OPTIONS_SLOT]?: ResolvedQueryStringOptions;\n};\n\nfunction currentOptions(): ResolvedQueryStringOptions {\n return (globalThis as OptionsHost)[OPTIONS_SLOT] ?? DEFAULT_OPTIONS;\n}\n\n/**\n * Merge query-string options over the current ones.\n *\n * Named for `@mongez/react-router`'s function so migrating call sites keep\n * compiling; the OPTIONS are deliberately narrower, and `QueryStringOptions`\n * explains exactly which ones were refused and why.\n */\nexport function setQueryStringOptions(options: QueryStringOptions): void {\n (globalThis as OptionsHost)[OPTIONS_SLOT] = { ...currentOptions(), ...options };\n}\n\n/**\n * Drop back to the defaults.\n *\n * Exists because the options are process-global: a test that set one would\n * otherwise leak it into every later test in the same worker, passing in file\n * order and failing under `--shuffle`. Same reasoning as `resetRouteTable`.\n */\nexport function resetQueryStringOptions(): void {\n delete (globalThis as OptionsHost)[OPTIONS_SLOT];\n}\n\n/**\n * The search string of the current document, or `\"\"` when there is no document.\n *\n * The `typeof` guard is the SSR contract: no export in this module may touch a\n * DOM global without one.\n */\nfunction browserSearch(): string {\n if (typeof window === \"undefined\") return \"\";\n\n return window.location?.search ?? \"\";\n}\n\n/**\n * Resolves the search string of the REQUEST currently being server-rendered.\n *\n * Called by the pipeline, not read by it: the same `connect*`-resolver seam\n * `shared.ts` uses for `connectSharedStore` and `page-context.ts` uses for\n * `connectPageContext` — a FUNCTION is connected once at boot, and it is that\n * function, not a cached value, that is asked again on every render. Two\n * concurrent requests calling the resolver therefore each get their own\n * answer; a value connected once would be the cross-request leak canon\n * `9c8f878b`'s sibling defects are made of.\n *\n * `undefined` means the resolver has not been connected yet (a pipeline stage\n * that has not wired this up) — not \"no query string\". `useQueryString`\n * (`client/navigation/use-query-string.ts`) is the one caller and treats the\n * two the same way its doc explains; nothing else in this module reads it.\n */\nexport type RequestSearchResolver = () => string | undefined;\n\nlet resolveRequestSearch: RequestSearchResolver | undefined;\n\n/**\n * Boot-time wiring for {@link resolveRequestSearch}. Returns the previously\n * connected resolver, so a caller — a test, mainly — can restore it.\n */\nexport function connectRequestSearch(\n resolve: RequestSearchResolver | undefined,\n): RequestSearchResolver | undefined {\n const previous = resolveRequestSearch;\n\n resolveRequestSearch = resolve;\n\n return previous;\n}\n\n/**\n * The search string `useQueryString` reads: {@link browserSearch} in the\n * browser, the connected request's search on the server.\n *\n * `\"\"` on the server before {@link connectRequestSearch} has been wired —\n * the same \"nothing to report yet\" answer `browserSearch` gives outside a\n * document, not a claim that the request has no query string.\n */\nexport function currentSearch(): string {\n if (typeof window !== \"undefined\") return browserSearch();\n\n return resolveRequestSearch?.() ?? \"\";\n}\n\n/**\n * Reduce anything search-shaped to the pairs themselves.\n *\n * Accepts `\"?a=1\"`, `\"a=1\"`, `\"/path?a=1\"` and a full URL, because\n * `location.href`, `location.search` and a hand-written literal all turn up at\n * this door and `new URLSearchParams(\"/path?a=1\")` would answer with the key\n * `\"/path?a\"` — a wrong answer with no error. Cutting at the first `?` and the\n * first `#` is safe rather than heuristic: `queryStringOf` percent-encodes both\n * characters inside values (`%3F`, `%23`), so a literal one is always a\n * delimiter.\n */\nfunction searchPairsOf(source: string): string {\n const withoutHash = source.split(\"#\", 1)[0];\n const questionMark = withoutHash.indexOf(\"?\");\n\n return questionMark === -1 ? withoutHash : withoutHash.slice(questionMark + 1);\n}\n\n/**\n * The three key shapes the encoder writes. Everything else is a literal key.\n *\n * The character classes exclude brackets, so each pattern matches exactly one\n * shape: `tags[]` cannot satisfy `NESTED`, and `a[b][c]` satisfies none of them.\n */\nconst ARRAY_KEY = /^([^[\\]]+)\\[\\]$/;\nconst NESTED_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]$/;\nconst NESTED_ARRAY_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]\\[\\]$/;\n\ntype ParsedKey =\n | { kind: \"plain\"; name: string }\n | { kind: \"array\"; name: string }\n | { kind: \"nested\"; name: string; subKey: string }\n | { kind: \"nestedArray\"; name: string; subKey: string };\n\n/**\n * Classify a decoded key.\n *\n * A shape neither side produces — `a[b][c]`, `items[0][name]`, an unbalanced\n * `a[` — falls through to `\"plain\"` and is carried VERBATIM. Those can only come\n * from a URL warlock did not write, and guessing at them is exactly how a\n * decoder drifts from its writer. Carrying the key as text loses nothing.\n */\nfunction parseKey(key: string): ParsedKey {\n const nestedArray = NESTED_ARRAY_KEY.exec(key);\n\n if (nestedArray) {\n return { kind: \"nestedArray\", name: nestedArray[1], subKey: nestedArray[2] };\n }\n\n const array = ARRAY_KEY.exec(key);\n\n if (array) return { kind: \"array\", name: array[1] };\n\n const nested = NESTED_KEY.exec(key);\n\n if (nested) return { kind: \"nested\", name: nested[1], subKey: nested[2] };\n\n return { kind: \"plain\", name: key };\n}\n\n/** A fresh bag with NO prototype. See {@link decode} for why that is not optional. */\nfunction emptyBag<T extends object>(): T {\n return Object.create(null) as T;\n}\n\nfunction isBag(value: unknown): value is QueryStringNested {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * The array living at `container[name]`, created if it is not there yet.\n *\n * A conflicting earlier shape is REPLACED rather than written into. `?a=1&a[]=2`\n * is not something the encoder can emit, so it is a foreign URL contradicting\n * itself; appending to a string would be a silent no-op, and last-shape-wins at\n * least matches how `repeatedKeys: \"last\"` resolves the scalar version.\n */\nfunction arrayAt(container: Record<string, unknown>, name: string): string[] {\n const existing = container[name];\n\n if (Array.isArray(existing)) return existing;\n\n const created: string[] = [];\n\n container[name] = created;\n\n return created;\n}\n\n/** The bag living at `result[name]`, created if absent. Same last-shape-wins rule. */\nfunction bagAt(result: QueryStringObject, name: string): QueryStringNested {\n const existing = result[name];\n\n if (isBag(existing)) return existing;\n\n const created = emptyBag<QueryStringNested>();\n\n result[name] = created;\n\n return created;\n}\n\n/**\n * The single decode path. Every reader below goes through here.\n *\n * Every container has a NULL PROTOTYPE — the result and each `key[sub]` bag.\n * Query keys are attacker-controlled, and assigning `?__proto__=x` onto a `{}`\n * literal hits the inherited setter, which DISCARDS the key: data loss with no\n * error. Bracket parsing makes this sharper than it was when this decoder was\n * flat, because `?filter[__proto__]=x` now reaches a nested container too —\n * which is why `bagAt` builds with `Object.create(null)` rather than `{}`.\n * MRR's parser walks `key.split(\"[\")` onto plain objects and needs the blocklist\n * at query-string-parsers.ts:7 as a result; with no prototype there is no setter\n * to hit and the key is stored as the ordinary data it is.\n */\nfunction decode(source: string): QueryStringObject {\n const pairs = searchPairsOf(source);\n const result = emptyBag<QueryStringObject>();\n\n if (pairs === \"\") return result;\n\n const { repeatedKeys } = currentOptions();\n\n for (const [key, value] of new URLSearchParams(pairs)) {\n const parsed = parseKey(key);\n\n if (parsed.kind === \"array\") {\n arrayAt(result, parsed.name).push(value);\n continue;\n }\n\n if (parsed.kind === \"nestedArray\") {\n arrayAt(bagAt(result, parsed.name), parsed.subKey).push(value);\n continue;\n }\n\n if (parsed.kind === \"nested\") {\n // Last wins. The encoder cannot repeat a `key[sub]` pair — `Object.entries`\n // yields each sub-key once — so this only arises from a foreign URL, and\n // `repeatedKeys` deliberately does not reach here (QueryStringOptions).\n bagAt(result, parsed.name)[parsed.subKey] = value;\n continue;\n }\n\n if (!(parsed.name in result)) {\n result[parsed.name] = repeatedKeys === \"array\" ? [value] : value;\n continue;\n }\n\n if (repeatedKeys === \"first\") continue;\n\n if (repeatedKeys === \"last\") {\n result[parsed.name] = value;\n continue;\n }\n\n arrayAt(result, parsed.name).push(value);\n }\n\n return result;\n}\n\n/**\n * Reading the current query string, and any query string.\n *\n * Named for `@mongez/react-router`'s object so the familiar calls keep working;\n * the implementation is not ported — see the module header for what changed and\n * why.\n */\nexport const queryString = {\n /**\n * The current document's query string, decoded.\n *\n * On the server this is `{}`, because the browser location is the only source\n * this module has and a per-request one would be module state two concurrent\n * requests could race over (route-table.ts:10-25).\n *\n * That makes it a HYDRATION HAZARD in a component: the server renders `{}`\n * and the browser renders the real values, so the two trees differ. In a\n * component, take the query from the page's own props and hand it to\n * {@link queryString.parse}, which is universal. `all()` is for browser-only\n * code — an event handler, an effect, a client-side helper.\n */\n all(): QueryStringObject {\n return decode(browserSearch());\n },\n\n /**\n * Decode a query string that was handed to you.\n *\n * Universal: it reads no globals, so this is the entry point that is safe on\n * the server. Accepts a bare pair list, a leading `?`, a path, or a full URL.\n */\n parse(search: string): QueryStringObject {\n return decode(search);\n },\n\n /**\n * One key from the current query string.\n *\n * PRESENCE, not truthiness. MRR returns `all[key] || defaultValue`\n * (query-string.ts:43), which hands back the default for `?a=` — so a\n * deliberately-cleared filter reads as though it was never set, and the page\n * shows the default instead of the empty state. Here `?a=` returns `\"\"`, and\n * only an absent key returns the default.\n *\n * @param defaultValue returned only when the key is absent. Defaults to\n * `null`, as MRR's does.\n */\n get<T = null>(key: string, defaultValue: T = null as T): QueryStringValue | T {\n const all = decode(browserSearch());\n\n return key in all ? all[key] : defaultValue;\n },\n\n /**\n * The current query string verbatim, without the leading `?`.\n *\n * `\"\"` on the server, and `\"\"` when there is no query — the same two cases\n * `queryStringOf` collapses when it writes.\n */\n toString(): string {\n return searchPairsOf(browserSearch());\n },\n};\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deriveFilesystemRouteName } from "./filesystem-route.mjs";
|
|
1
|
+
import { deriveFilesystemRouteName, deriveFilesystemRoutePath } from "./filesystem-route.mjs";
|
|
2
2
|
import { PageRoutePathNotSupportedError, classifyPageRoutePath } from "./page-route-grammar.mjs";
|
|
3
3
|
|
|
4
4
|
//#region ../web/src/routing/route-identity.ts
|
|
@@ -118,7 +118,32 @@ function resolvePageRouteName(route, pageFile) {
|
|
|
118
118
|
if (route === void 0) return deriveFilesystemRouteName(pageFile);
|
|
119
119
|
return canonicalizeRouteExport(route, pageFile).name ?? deriveFilesystemRouteName(pageFile);
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves a page's route IDENTITY — its declared path (before layout-prefix
|
|
123
|
+
* composition) and its name — the one derivation both page installers
|
|
124
|
+
* (`../server/install-page-routes.ts`'s `resolvePageRouteIdentity`,
|
|
125
|
+
* `../server/install-page-routes-from-manifest.ts`'s `resolveRoute`) call
|
|
126
|
+
* before composing a layout prefix into it. Proven to agree between the two
|
|
127
|
+
* callers by `route-name-parity.spec.ts`, which calls each installer's own
|
|
128
|
+
* wrapper rather than reimplementing either.
|
|
129
|
+
*
|
|
130
|
+
* `pageFile` must already be canonical (see {@link resolvePageRouteName}).
|
|
131
|
+
* `errorContextFile` is the identifier {@link canonicalizeRouteExport} names
|
|
132
|
+
* in a rejected-path error — dev's absolute page file, production's manifest
|
|
133
|
+
* `sourceFile` — which is why it is taken separately from `pageFile` rather
|
|
134
|
+
* than reused from it.
|
|
135
|
+
*/
|
|
136
|
+
function resolvePageRouteIdentity(route, pageFile, errorContextFile) {
|
|
137
|
+
if (route === void 0) return {
|
|
138
|
+
path: deriveFilesystemRoutePath({ pageFile }),
|
|
139
|
+
name: resolvePageRouteName(route, pageFile)
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
path: canonicalizeRouteExport(route, errorContextFile).path,
|
|
143
|
+
name: resolvePageRouteName(route, pageFile)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
121
146
|
|
|
122
147
|
//#endregion
|
|
123
|
-
export { canonicalizeRouteExport, resolvePageRouteCache, resolvePageRouteName };
|
|
148
|
+
export { canonicalizeRouteExport, resolvePageRouteCache, resolvePageRouteIdentity, resolvePageRouteName };
|
|
124
149
|
//# sourceMappingURL=route-identity.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-identity.mjs","names":[],"sources":["../../../../../../../web/src/routing/route-identity.ts"],"sourcesContent":["/**\r\n * Route identity — the single, pure implementation of \"what is this page's\r\n * route path and name\". The dev installer\r\n * (`web/src/server/install-page-routes.ts`), the production manifest\r\n * installer (`web/src/server/install-page-routes-from-manifest.ts`) and\r\n * discovery (`web/src/build/discover-pages.ts`) each hand-derived this on\r\n * their own until all three were made to delegate here.\r\n *\r\n * {@link resolvePageRouteName} is the ONE answer to \"what is this page's\r\n * route name\": an explicit `name` on the declared `route` export wins,\r\n * otherwise the name comes from the page's own FILE PATH\r\n * (`deriveFilesystemRouteName`) — never from `route.path`. The route name is\r\n * an identity key (`routing/route-table.ts`'s lookup key, `components/link.ts`,\r\n * `server/render-page.ts`, the generated client registry and the hydration\r\n * payload all address a page by it), and an identity key must be stable under\r\n * the change most likely to happen to a page — its URL, renamed for SEO,\r\n * localization or restructuring. A file path is also unique by construction,\r\n * while a declared `path: \"/\"` yields no usable name at all.\r\n *\r\n * Pure string logic only: no `fs`, no `path`, no Node built-ins. Every input\r\n * this module accepts is already CANONICAL — a POSIX, app-root-relative\r\n * source path (e.g. `\"src/web/index.page.tsx\"`).\r\n *\r\n * {@link canonicalizeRouteExport} is also the ONE seam every declared\r\n * `route.path` passes through on its way into either installer\r\n * (`install-page-routes.ts`, `install-page-routes-from-manifest.ts`), so it is\r\n * where `../routing/page-route-grammar.ts`'s `classifyPageRoutePath` is\r\n * applied: a rejected path raises {@link PageRoutePathNotSupportedError}\r\n * naming the offending page file, rather than being published literally.\r\n *\r\n * Well-formedness of the declared `route` export itself (is it a string or an\r\n * object, does the object have a `path`) is the extractor's problem — already\r\n * rejected at build before either derivation function here is called.\r\n *\r\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\r\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. Modules in\r\n * this directory receive canonical values and trust nothing — they assert\r\n * rather than trust, but they never repair. A module that needs the\r\n * filesystem does not belong here. The purity is deliberate: it keeps these\r\n * modules consumable from the dev server, the build, the production runtime,\r\n * and — if ever needed — the browser client, without dragging any of those\r\n * environments along.\r\n */\r\n\r\nimport { deriveFilesystemRouteName } from \"./filesystem-route\";\r\nimport { classifyPageRoutePath, PageRoutePathNotSupportedError } from \"./page-route-grammar\";\r\n\r\n/**\r\n * A page's opt-in into shared-cache storage for its document AND its data\r\n * representation (`x-warlock-data`) — the two must never diverge, because a\r\n * cacheable data payload leaks exactly what an uncacheable document was\r\n * protecting (`../server/create-page-route-handler.ts`).\r\n *\r\n * `public: true` is not a flag with a `false` counterpart: the framework is\r\n * closed by default (`../server/response-cache-floor.ts`), so the only\r\n * meaningful state this object can express is \"yes, cache me\" — a page that\r\n * wants the default simply omits `cache` entirely. `maxAge` has no framework\r\n * default and never will: a route's freshness window is a decision only the\r\n * route's author can make safely, and guessing one would be exactly the kind\r\n * of silent, environment-dependent behaviour this feature exists to remove.\r\n * Both keys are required — see {@link InvalidPageCacheOptInError}.\r\n *\r\n * Shaped as an object, not a boolean or a bare number, so a later addition\r\n * (e.g. CDN surrogate keys) extends it without a breaking change.\r\n */\r\nexport type PageCacheOptIn = {\r\n public: true;\r\n /** Freshness window in seconds, emitted as `Cache-Control: public, max-age=<maxAge>`. */\r\n maxAge: number;\r\n};\r\n\r\n/** The shape a page's `route` export may declare — mirrors `PageRouteExport` in `install-page-routes.ts`. */\r\nexport type DeclaredRouteExport = string | { path: string; name?: string; cache?: PageCacheOptIn };\r\n\r\n/** The canonical form every declared `route` export resolves to. */\r\nexport type CanonicalRoute = {\r\n path: string;\r\n name?: string;\r\n};\r\n\r\n/**\r\n * Raised when a page's `route.cache` is present but malformed — most notably\r\n * `public: true` with no `maxAge`. The framework refuses to invent a\r\n * freshness window (see {@link PageCacheOptIn}), so this is a BOOT-TIME\r\n * failure rather than a silent fallback to `no-store`: a developer who wrote\r\n * `cache: { public: true }` meant for the route to be cacheable, and serving\r\n * it `no-store` without a word would be the exact silent-failure class this\r\n * release exists to kill.\r\n */\r\nexport class InvalidPageCacheOptInError extends Error {\r\n public constructor(public readonly pageFile: string) {\r\n super(\r\n `\"${pageFile}\" declares \\`route.cache\\` without a valid opt-in. Both keys are required: ` +\r\n \"write `cache: { public: true, maxAge: <seconds> }` — for example `cache: { public: \" +\r\n \"true, maxAge: 60 }`. Remove `cache` entirely to keep the route `no-store` (the default) \" +\r\n \"instead.\",\r\n );\r\n this.name = \"InvalidPageCacheOptInError\";\r\n }\r\n}\r\n\r\n/**\r\n * Resolves and validates a declared `route` export's `cache` opt-in.\r\n *\r\n * `undefined` — the common case — means \"no opt-in\", which the caller (the\r\n * response-cache-floor seam) treats as `no-store`, not as \"cacheable with no\r\n * limit\". A malformed opt-in throws {@link InvalidPageCacheOptInError} rather\r\n * than being coerced or ignored, so a typo in a route's `cache` field fails\r\n * the build/boot instead of quietly shipping an unintended cache policy.\r\n */\r\nexport function resolvePageRouteCache(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n): PageCacheOptIn | undefined {\r\n if (route === undefined || typeof route === \"string\") return undefined;\r\n\r\n const { cache } = route;\r\n\r\n if (cache === undefined) return undefined;\r\n\r\n if (cache.public !== true || typeof cache.maxAge !== \"number\") {\r\n throw new InvalidPageCacheOptInError(pageFile);\r\n }\r\n\r\n return cache;\r\n}\r\n\r\n/**\r\n * Canonicalizes a declared `route` export — string or `{ path, name? }` —\r\n * into `{ path, name? }`. Requires well-formed input; an export that is\r\n * neither a string nor an object with a `path` is the extractor's problem,\r\n * already rejected before this function is ever called.\r\n *\r\n * This is also where the declared `path` is validated: `pageFile` names the\r\n * page whose `route` export is being canonicalized, and a `path` that\r\n * {@link classifyPageRoutePath} rejects raises\r\n * {@link PageRoutePathNotSupportedError} naming it — rather than being\r\n * published literally. Both installers reach every declared path through\r\n * here, so this is the one place that check has to live.\r\n */\r\nexport function canonicalizeRouteExport(\r\n route: DeclaredRouteExport,\r\n pageFile: string,\r\n): CanonicalRoute {\r\n const path = typeof route === \"string\" ? route : route.path;\r\n const verdict = classifyPageRoutePath(path);\r\n\r\n if (verdict.type === \"rejected\") {\r\n throw new PageRoutePathNotSupportedError(pageFile, path, verdict.reason);\r\n }\r\n\r\n if (typeof route === \"string\") {\r\n return { path: route };\r\n }\r\n\r\n return route.name === undefined ? { path: route.path } : { path: route.path, name: route.name };\r\n}\r\n\r\n/**\r\n * Resolves a page's route NAME — the single derivation `install-page-routes.ts`,\r\n * `install-page-routes-from-manifest.ts` and `discover-pages.ts` all delegate\r\n * to, so dev, the production manifest installer and build discovery cannot\r\n * derive three different names for the same page (see the module doc comment\r\n * for why the file path, not `route.path`, is the source of truth).\r\n *\r\n * An explicit `name` on `route` always wins. Otherwise the name is derived\r\n * from `pageFile` via {@link deriveFilesystemRouteName} — `pageFile` must\r\n * already be canonical: a POSIX path relative to the page's web root (e.g.\r\n * `\"blog/archive.page.tsx\"`), the same value each caller already computes as\r\n * `filesystemPageFileFor`/`webRelativeSourceFile`/`relativePageFile`.\r\n */\r\nexport function resolvePageRouteName(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n): string {\r\n if (route === undefined) {\r\n return deriveFilesystemRouteName(pageFile);\r\n }\r\n\r\n return canonicalizeRouteExport(route, pageFile).name ?? deriveFilesystemRouteName(pageFile);\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyFA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,IAAI,SAAS,uQAIf;EANiC;EAOjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;AAWA,SAAgB,sBACd,OACA,UAC4B;CAC5B,IAAI,UAAU,UAAa,OAAO,UAAU,UAAU,OAAO;CAE7D,MAAM,EAAE,UAAU;CAElB,IAAI,UAAU,QAAW,OAAO;CAEhC,IAAI,MAAM,WAAW,QAAQ,OAAO,MAAM,WAAW,UACnD,MAAM,IAAI,2BAA2B,QAAQ;CAG/C,OAAO;AACT;;;;;;;;;;;;;;AAeA,SAAgB,wBACd,OACA,UACgB;CAChB,MAAM,OAAO,OAAO,UAAU,WAAW,QAAQ,MAAM;CACvD,MAAM,UAAU,sBAAsB,IAAI;CAE1C,IAAI,QAAQ,SAAS,YACnB,MAAM,IAAI,+BAA+B,UAAU,MAAM,QAAQ,MAAM;CAGzE,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,MAAM,MAAM;CAGvB,OAAO,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,KAAK,IAAI;EAAE,MAAM,MAAM;EAAM,MAAM,MAAM;CAAK;AAChG;;;;;;;;;;;;;;AAeA,SAAgB,qBACd,OACA,UACQ;CACR,IAAI,UAAU,QACZ,OAAO,0BAA0B,QAAQ;CAG3C,OAAO,wBAAwB,OAAO,QAAQ,CAAC,CAAC,QAAQ,0BAA0B,QAAQ;AAC5F"}
|
|
1
|
+
{"version":3,"file":"route-identity.mjs","names":[],"sources":["../../../../../../../web/src/routing/route-identity.ts"],"sourcesContent":["/**\r\n * Route identity — the single, pure implementation of \"what is this page's\r\n * route path and name\". The dev installer\r\n * (`web/src/server/install-page-routes.ts`), the production manifest\r\n * installer (`web/src/server/install-page-routes-from-manifest.ts`) and\r\n * discovery (`web/src/build/discover-pages.ts`) each hand-derived this on\r\n * their own until all three were made to delegate here.\r\n *\r\n * {@link resolvePageRouteName} is the ONE answer to \"what is this page's\r\n * route name\": an explicit `name` on the declared `route` export wins,\r\n * otherwise the name comes from the page's own FILE PATH\r\n * (`deriveFilesystemRouteName`) — never from `route.path`. The route name is\r\n * an identity key (`routing/route-table.ts`'s lookup key, `components/link.ts`,\r\n * `server/render-page.ts`, the generated client registry and the hydration\r\n * payload all address a page by it), and an identity key must be stable under\r\n * the change most likely to happen to a page — its URL, renamed for SEO,\r\n * localization or restructuring. A file path is also unique by construction,\r\n * while a declared `path: \"/\"` yields no usable name at all.\r\n *\r\n * Pure string logic only: no `fs`, no `path`, no Node built-ins. Every input\r\n * this module accepts is already CANONICAL — a POSIX, app-root-relative\r\n * source path (e.g. `\"src/web/index.page.tsx\"`).\r\n *\r\n * {@link canonicalizeRouteExport} is also the ONE seam every declared\r\n * `route.path` passes through on its way into either installer\r\n * (`install-page-routes.ts`, `install-page-routes-from-manifest.ts`), so it is\r\n * where `../routing/page-route-grammar.ts`'s `classifyPageRoutePath` is\r\n * applied: a rejected path raises {@link PageRoutePathNotSupportedError}\r\n * naming the offending page file, rather than being published literally.\r\n *\r\n * Well-formedness of the declared `route` export itself (is it a string or an\r\n * object, does the object have a `path`) is the extractor's problem — already\r\n * rejected at build before either derivation function here is called.\r\n *\r\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\r\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. Modules in\r\n * this directory receive canonical values and trust nothing — they assert\r\n * rather than trust, but they never repair. A module that needs the\r\n * filesystem does not belong here. The purity is deliberate: it keeps these\r\n * modules consumable from the dev server, the build, the production runtime,\r\n * and — if ever needed — the browser client, without dragging any of those\r\n * environments along.\r\n */\r\n\r\nimport { deriveFilesystemRoutePath, deriveFilesystemRouteName } from \"./filesystem-route\";\r\nimport { classifyPageRoutePath, PageRoutePathNotSupportedError } from \"./page-route-grammar\";\r\n\r\n/**\r\n * A page's opt-in into shared-cache storage for its document AND its data\r\n * representation (`x-warlock-data`) — the two must never diverge, because a\r\n * cacheable data payload leaks exactly what an uncacheable document was\r\n * protecting (`../server/create-page-route-handler.ts`).\r\n *\r\n * `public: true` is not a flag with a `false` counterpart: the framework is\r\n * closed by default (`../server/response-cache-floor.ts`), so the only\r\n * meaningful state this object can express is \"yes, cache me\" — a page that\r\n * wants the default simply omits `cache` entirely. `maxAge` has no framework\r\n * default and never will: a route's freshness window is a decision only the\r\n * route's author can make safely, and guessing one would be exactly the kind\r\n * of silent, environment-dependent behaviour this feature exists to remove.\r\n * Both keys are required — see {@link InvalidPageCacheOptInError}.\r\n *\r\n * Shaped as an object, not a boolean or a bare number, so a later addition\r\n * (e.g. CDN surrogate keys) extends it without a breaking change.\r\n */\r\nexport type PageCacheOptIn = {\r\n public: true;\r\n /** Freshness window in seconds, emitted as `Cache-Control: public, max-age=<maxAge>`. */\r\n maxAge: number;\r\n};\r\n\r\n/** The shape a page's `route` export may declare — mirrors `PageRouteExport` in `install-page-routes.ts`. */\r\nexport type DeclaredRouteExport = string | { path: string; name?: string; cache?: PageCacheOptIn };\r\n\r\n/** The canonical form every declared `route` export resolves to. */\r\nexport type CanonicalRoute = {\r\n path: string;\r\n name?: string;\r\n};\r\n\r\n/**\r\n * Raised when a page's `route.cache` is present but malformed — most notably\r\n * `public: true` with no `maxAge`. The framework refuses to invent a\r\n * freshness window (see {@link PageCacheOptIn}), so this is a BOOT-TIME\r\n * failure rather than a silent fallback to `no-store`: a developer who wrote\r\n * `cache: { public: true }` meant for the route to be cacheable, and serving\r\n * it `no-store` without a word would be the exact silent-failure class this\r\n * release exists to kill.\r\n */\r\nexport class InvalidPageCacheOptInError extends Error {\r\n public constructor(public readonly pageFile: string) {\r\n super(\r\n `\"${pageFile}\" declares \\`route.cache\\` without a valid opt-in. Both keys are required: ` +\r\n \"write `cache: { public: true, maxAge: <seconds> }` — for example `cache: { public: \" +\r\n \"true, maxAge: 60 }`. Remove `cache` entirely to keep the route `no-store` (the default) \" +\r\n \"instead.\",\r\n );\r\n this.name = \"InvalidPageCacheOptInError\";\r\n }\r\n}\r\n\r\n/**\r\n * Resolves and validates a declared `route` export's `cache` opt-in.\r\n *\r\n * `undefined` — the common case — means \"no opt-in\", which the caller (the\r\n * response-cache-floor seam) treats as `no-store`, not as \"cacheable with no\r\n * limit\". A malformed opt-in throws {@link InvalidPageCacheOptInError} rather\r\n * than being coerced or ignored, so a typo in a route's `cache` field fails\r\n * the build/boot instead of quietly shipping an unintended cache policy.\r\n */\r\nexport function resolvePageRouteCache(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n): PageCacheOptIn | undefined {\r\n if (route === undefined || typeof route === \"string\") return undefined;\r\n\r\n const { cache } = route;\r\n\r\n if (cache === undefined) return undefined;\r\n\r\n if (cache.public !== true || typeof cache.maxAge !== \"number\") {\r\n throw new InvalidPageCacheOptInError(pageFile);\r\n }\r\n\r\n return cache;\r\n}\r\n\r\n/**\r\n * Canonicalizes a declared `route` export — string or `{ path, name? }` —\r\n * into `{ path, name? }`. Requires well-formed input; an export that is\r\n * neither a string nor an object with a `path` is the extractor's problem,\r\n * already rejected before this function is ever called.\r\n *\r\n * This is also where the declared `path` is validated: `pageFile` names the\r\n * page whose `route` export is being canonicalized, and a `path` that\r\n * {@link classifyPageRoutePath} rejects raises\r\n * {@link PageRoutePathNotSupportedError} naming it — rather than being\r\n * published literally. Both installers reach every declared path through\r\n * here, so this is the one place that check has to live.\r\n */\r\nexport function canonicalizeRouteExport(\r\n route: DeclaredRouteExport,\r\n pageFile: string,\r\n): CanonicalRoute {\r\n const path = typeof route === \"string\" ? route : route.path;\r\n const verdict = classifyPageRoutePath(path);\r\n\r\n if (verdict.type === \"rejected\") {\r\n throw new PageRoutePathNotSupportedError(pageFile, path, verdict.reason);\r\n }\r\n\r\n if (typeof route === \"string\") {\r\n return { path: route };\r\n }\r\n\r\n return route.name === undefined ? { path: route.path } : { path: route.path, name: route.name };\r\n}\r\n\r\n/**\r\n * Resolves a page's route NAME — the single derivation `install-page-routes.ts`,\r\n * `install-page-routes-from-manifest.ts` and `discover-pages.ts` all delegate\r\n * to, so dev, the production manifest installer and build discovery cannot\r\n * derive three different names for the same page (see the module doc comment\r\n * for why the file path, not `route.path`, is the source of truth).\r\n *\r\n * An explicit `name` on `route` always wins. Otherwise the name is derived\r\n * from `pageFile` via {@link deriveFilesystemRouteName} — `pageFile` must\r\n * already be canonical: a POSIX path relative to the page's web root (e.g.\r\n * `\"blog/archive.page.tsx\"`), the same value each caller already computes as\r\n * `filesystemPageFileFor`/`webRelativeSourceFile`/`relativePageFile`.\r\n */\r\nexport function resolvePageRouteName(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n): string {\r\n if (route === undefined) {\r\n return deriveFilesystemRouteName(pageFile);\r\n }\r\n\r\n return canonicalizeRouteExport(route, pageFile).name ?? deriveFilesystemRouteName(pageFile);\r\n}\r\n\r\n/**\r\n * Resolves a page's route IDENTITY — its declared path (before layout-prefix\r\n * composition) and its name — the one derivation both page installers\r\n * (`../server/install-page-routes.ts`'s `resolvePageRouteIdentity`,\r\n * `../server/install-page-routes-from-manifest.ts`'s `resolveRoute`) call\r\n * before composing a layout prefix into it. Proven to agree between the two\r\n * callers by `route-name-parity.spec.ts`, which calls each installer's own\r\n * wrapper rather than reimplementing either.\r\n *\r\n * `pageFile` must already be canonical (see {@link resolvePageRouteName}).\r\n * `errorContextFile` is the identifier {@link canonicalizeRouteExport} names\r\n * in a rejected-path error — dev's absolute page file, production's manifest\r\n * `sourceFile` — which is why it is taken separately from `pageFile` rather\r\n * than reused from it.\r\n */\r\nexport function resolvePageRouteIdentity(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n errorContextFile: string,\r\n): { path: string; name: string } {\r\n if (route === undefined) {\r\n return {\r\n path: deriveFilesystemRoutePath({ pageFile }),\r\n name: resolvePageRouteName(route, pageFile),\r\n };\r\n }\r\n\r\n return {\r\n path: canonicalizeRouteExport(route, errorContextFile).path,\r\n name: resolvePageRouteName(route, pageFile),\r\n };\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyFA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,IAAI,SAAS,uQAIf;EANiC;EAOjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;AAWA,SAAgB,sBACd,OACA,UAC4B;CAC5B,IAAI,UAAU,UAAa,OAAO,UAAU,UAAU,OAAO;CAE7D,MAAM,EAAE,UAAU;CAElB,IAAI,UAAU,QAAW,OAAO;CAEhC,IAAI,MAAM,WAAW,QAAQ,OAAO,MAAM,WAAW,UACnD,MAAM,IAAI,2BAA2B,QAAQ;CAG/C,OAAO;AACT;;;;;;;;;;;;;;AAeA,SAAgB,wBACd,OACA,UACgB;CAChB,MAAM,OAAO,OAAO,UAAU,WAAW,QAAQ,MAAM;CACvD,MAAM,UAAU,sBAAsB,IAAI;CAE1C,IAAI,QAAQ,SAAS,YACnB,MAAM,IAAI,+BAA+B,UAAU,MAAM,QAAQ,MAAM;CAGzE,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,MAAM,MAAM;CAGvB,OAAO,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,KAAK,IAAI;EAAE,MAAM,MAAM;EAAM,MAAM,MAAM;CAAK;AAChG;;;;;;;;;;;;;;AAeA,SAAgB,qBACd,OACA,UACQ;CACR,IAAI,UAAU,QACZ,OAAO,0BAA0B,QAAQ;CAG3C,OAAO,wBAAwB,OAAO,QAAQ,CAAC,CAAC,QAAQ,0BAA0B,QAAQ;AAC5F;;;;;;;;;;;;;;;;AAiBA,SAAgB,yBACd,OACA,UACA,kBACgC;CAChC,IAAI,UAAU,QACZ,OAAO;EACL,MAAM,0BAA0B,EAAE,SAAS,CAAC;EAC5C,MAAM,qBAAqB,OAAO,QAAQ;CAC5C;CAGF,OAAO;EACL,MAAM,wBAAwB,OAAO,gBAAgB,CAAC,CAAC;EACvD,MAAM,qBAAqB,OAAO,QAAQ;CAC5C;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-table.mjs","names":[],"sources":["../../../../../../../web/src/routing/route-table.ts"],"sourcesContent":["/**\n * The name→URL primitive, and the one route table both sides read.\n *\n * `href(name, params, query)` is the DURABLE primitive; `<Link>` is sugar over\n * it. That ordering is deliberate and ratified: a function serves emails,\n * redirects, `Location` headers and non-React callers, none of which can render\n * a component — and it keeps client-side navigation, when it lands, a behaviour\n * change rather than an API change.\n *\n * ── Why a process-global table is correct HERE ───────────────────────────────\n * Module-level mutable state is normally a defect in a server that handles\n * concurrent requests, and this codebase has a real example of that defect to\n * point at: `@mongez/react-router`'s `RouterWrapper` keeps the RENDERED PAGE\n * CONTENT in a module-level binding, so request B overwrites the tree request A\n * is about to serialize. That is why it is not on the SSR path.\n *\n * A route table is the opposite kind of value. It is derived from the file\n * system at boot, identical for every request in the process, and never written\n * during a request — the same category as the compiled route table the server's\n * own router holds. Nothing here is per-request, so there is nothing for two\n * requests to race over.\n *\n * The rule that keeps it that way: `publishRouteTable` is called at INSTALL\n * time (server) or at hydration entry (browser), and never from a loader, a\n * middleware, or a component.\n */\n\nimport { queryStringOf, type QueryStringInput } from \"./query-string\";\n\n/** The two fields `href` needs. Callers may pass richer entries; the rest is ignored. */\nexport type RouteTableEntry = {\n readonly name: string;\n readonly path: string;\n};\n\n/** What `href` accepts for a `:param` segment. Rendered with `String(value)`. */\nexport type RouteParameters = Readonly<Record<string, unknown>>;\n\n/**\n * Query values; an `undefined` value is omitted rather than serialized.\n *\n * A value may be a scalar, an array of scalars, or an object one level deep —\n * the shapes `@warlock.js/core` parses back out of the URL. Anything deeper\n * throws `UnserializableQueryValueError`; the grammar and the measurements\n * behind it are documented in query-string.ts.\n */\nexport type RouteQuery = QueryStringInput;\n\nconst PARAMETER_PATTERN = /:([A-Za-z0-9_]+)|\\*/g;\n\n/**\n * ── WHY THIS LIVES ON `globalThis` AND NOT IN A MODULE BINDING ───────────────\n *\n * A plain `let` here does not work in development, and the failure is silent\n * enough to be worth spelling out.\n *\n * In dev the process runs TWO module graphs over the same files. Route\n * installation is loaded by tsx/Node (it is CLI bootstrap and imports core\n * directly), while page and layout modules are evaluated by Vite's SSR module\n * runner — which keeps its own registry and its own instance of every module it\n * transforms, `@warlock.js/web` included. So a module-level binding written by\n * the installer is not the binding `<Link>` reads during render: the installer\n * publishes into one instance and the component finds the other one empty.\n *\n * That was measured, not theorised. With a module-level `let`, every anchor on\n * a server-rendered page threw `RouteTableNotPublishedError` with an empty\n * known-names list while installation had demonstrably run.\n *\n * `Symbol.for` resolves through the per-ISOLATE symbol registry, which both\n * graphs share because they are the same isolate. So the table is one value no\n * matter which graph reaches it first, and the module keeps its module-shaped\n * API. `publishedBy` is carried for diagnosis only.\n */\nconst ROUTE_TABLE_SLOT = Symbol.for(\"warlock.web.routeTable\");\n\ntype RouteTableSlot = {\n table: Map<string, string>;\n publishedBy: string;\n};\n\ntype RouteTableHost = typeof globalThis & {\n [ROUTE_TABLE_SLOT]?: RouteTableSlot;\n};\n\n/**\n * `undefined` means \"nobody has published yet\", which is a DIFFERENT fault from\n * \"the table is published and this name is not in it\" — an empty Map would\n * conflate them and send the reader hunting a route that was never missing.\n */\nfunction readSlot(): RouteTableSlot | undefined {\n return (globalThis as RouteTableHost)[ROUTE_TABLE_SLOT];\n}\n\nexport class RouteTableNotPublishedError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) was called before the route table was ` +\n \"published, so no route name resolves yet. The table is published once at boot — by \" +\n \"the server when it installs page routes, and by the hydration entry before it mounts. \" +\n \"Seeing this means href() ran outside both: typically a module evaluating at import \" +\n \"time, or a unit test that renders a component without publishing a table first.\",\n );\n this.name = \"RouteTableNotPublishedError\";\n }\n}\n\nexport class UnknownRouteNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly knownRouteNames: readonly string[],\n ) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) does not name a known route. ` +\n (knownRouteNames.length === 0\n ? \"The route table is published but empty, so no page declared a `route` discovery could see.\"\n : `The table knows: ${knownRouteNames.map(name => JSON.stringify(name)).join(\", \")}.`),\n );\n this.name = \"UnknownRouteNameError\";\n }\n}\n\nexport class MissingRouteParameterError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly parameterName: string,\n public readonly routePath: string,\n ) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) is missing the parameter ` +\n `${JSON.stringify(parameterName)}, required by the route path \"${routePath}\". ` +\n \"It is not defaulted: a missing parameter would otherwise be interpolated as the \" +\n \"literal text `undefined`, producing a link that renders correctly and 404s for a \" +\n \"visitor.\",\n );\n this.name = \"MissingRouteParameterError\";\n }\n}\n\nexport class UnknownRouteParameterError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly parameterNames: readonly string[],\n public readonly routePath: string,\n ) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) was given ` +\n `${parameterNames.map(name => JSON.stringify(name)).join(\", \")}, which the route path ` +\n `\"${routePath}\" does not declare. Passing an undeclared parameter is a typo often ` +\n \"enough that it is refused rather than dropped; values meant for the query string go \" +\n \"in the third argument.\",\n );\n this.name = \"UnknownRouteParameterError\";\n }\n}\n\nexport class DuplicateRouteNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly paths: readonly [string, string],\n ) {\n super(\n `Warlock route table: two routes both claim the name ${JSON.stringify(routeName)} — ` +\n `\"${paths[0]}\" and \"${paths[1]}\". A name resolves to exactly one URL, so one of the ` +\n \"two would silently win and every link to it would be wrong half the time.\",\n );\n this.name = \"DuplicateRouteNameError\";\n }\n}\n\n/**\n * Publish the table. WHOLESALE — the previous one is discarded, not merged\n * into.\n *\n * Merging looks harmless until a page is deleted and the dev server restarts:\n * the dead name would stay resolvable, and `<Link>` would go on rendering a URL\n * the server no longer routes. The table has to be able to shrink.\n */\nexport function publishRouteTable(\n entries: readonly RouteTableEntry[],\n publishedBy = \"unnamed\",\n): void {\n const table = new Map<string, string>();\n\n for (const entry of entries) {\n const existing = table.get(entry.name);\n\n if (existing !== undefined) {\n throw new DuplicateRouteNameError(entry.name, [existing, entry.path]);\n }\n\n table.set(entry.name, entry.path);\n }\n\n (globalThis as RouteTableHost)[ROUTE_TABLE_SLOT] = { table, publishedBy };\n}\n\n/**\n * Drop the table, returning the module to its pre-boot state.\n *\n * Exists for tests: the table is process-global, so a suite that published one\n * would otherwise leak it into every later test in the same worker and pass in\n * file order while failing under `--shuffle`.\n */\nexport function resetRouteTable(): void {\n delete (globalThis as RouteTableHost)[ROUTE_TABLE_SLOT];\n}\n\n/** The published names, for diagnostics. Empty when nothing is published. */\nexport function knownRouteNames(): readonly string[] {\n const slot = readSlot();\n\n return slot === undefined ? [] : [...slot.table.keys()];\n}\n\n/** Who published the live table, for diagnosis. `undefined` when nothing has. */\nexport function routeTablePublisher(): string | undefined {\n return readSlot()?.publishedBy;\n}\n\nfunction parameterNamesOf(routePath: string): readonly string[] {\n const names: string[] = [];\n\n for (const match of routePath.matchAll(PARAMETER_PATTERN)) {\n names.push(match[1] ?? \"*\");\n }\n\n return names;\n}\n\nfunction interpolate(\n routeName: string,\n routePath: string,\n params: RouteParameters | undefined,\n): string {\n const declared = parameterNamesOf(routePath);\n const supplied = Object.keys(params ?? {}).filter(key => params?.[key] !== undefined);\n const undeclared = supplied.filter(key => !declared.includes(key));\n\n if (undeclared.length > 0) {\n throw new UnknownRouteParameterError(routeName, undeclared, routePath);\n }\n\n return routePath.replace(PARAMETER_PATTERN, match => {\n const name = match === \"*\" ? \"*\" : match.slice(1);\n const value = params?.[name];\n\n if (value === undefined) {\n throw new MissingRouteParameterError(routeName, name, routePath);\n }\n\n return encodeURIComponent(String(value));\n });\n}\n\n/**\n * Resolve a route NAME to a URL.\n *\n * A name, never a path: a moved page changes its URL and keeps its name, so\n * every call site keeps working. That is the property the hardcoded table in\n * the previous `<Link>` could not offer, because it restated six URLs by hand\n * and silently refused every other page in the app.\n *\n * The query half is delegated to `queryStringOf`, which lives next to the\n * DECODER it has to agree with. It used to live here, and being module-private\n * meant the read half could not share it — the one-writer rule held only\n * because nobody had written the second writer yet.\n *\n * @throws {UnserializableQueryValueError} when a query value nests deeper than\n * the wire format core parses can carry.\n */\nexport function href(\n name: string,\n params?: RouteParameters,\n query?: RouteQuery,\n): string {\n const slot = readSlot();\n\n if (slot === undefined) throw new RouteTableNotPublishedError(name);\n\n const routePath = slot.table.get(name);\n\n if (routePath === undefined) {\n throw new UnknownRouteNameError(name, [...slot.table.keys()]);\n }\n\n return `${interpolate(name, routePath, params)}${queryStringOf(query)}`;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"route-table.mjs","names":[],"sources":["../../../../../../../web/src/routing/route-table.ts"],"sourcesContent":["/**\n * The name→URL primitive, and the one route table both sides read.\n *\n * `href(name, params, query)` is the DURABLE primitive; `<Link>` is sugar over\n * it. That ordering is deliberate and ratified: a function serves emails,\n * redirects, `Location` headers and non-React callers, none of which can render\n * a component — and it keeps client-side navigation, when it lands, a behaviour\n * change rather than an API change.\n *\n * ── Why a process-global table is correct HERE ───────────────────────────────\n * Module-level mutable state is normally a defect in a server that handles\n * concurrent requests, and this codebase has a real example of that defect to\n * point at: `@mongez/react-router`'s `RouterWrapper` keeps the RENDERED PAGE\n * CONTENT in a module-level binding, so request B overwrites the tree request A\n * is about to serialize. That is why it is not on the SSR path.\n *\n * A route table is the opposite kind of value. It is derived from the file\n * system at boot, identical for every request in the process, and never written\n * during a request — the same category as the compiled route table the server's\n * own router holds. Nothing here is per-request, so there is nothing for two\n * requests to race over.\n *\n * The rule that keeps it that way: `publishRouteTable` is called at INSTALL\n * time (server) or at hydration entry (browser), and never from a loader, a\n * middleware, or a component.\n */\n\nimport { queryStringOf, type QueryStringInput } from \"./query-string\";\n\n/** The two fields `href` needs. Callers may pass richer entries; the rest is ignored. */\nexport type RouteTableEntry = {\n readonly name: string;\n readonly path: string;\n};\n\n/** What `href` accepts for a `:param` segment. Rendered with `String(value)`. */\nexport type RouteParameters = Readonly<Record<string, unknown>>;\n\n/**\n * Query values; an `undefined` value is omitted rather than serialized.\n *\n * A value may be a scalar, an array of scalars, or an object one level deep —\n * the shapes `@warlock.js/core` parses back out of the URL. Anything deeper\n * throws `UnserializableQueryValueError`; the grammar and the measurements\n * behind it are documented in query-string.ts.\n */\nexport type RouteQuery = QueryStringInput;\n\nconst PARAMETER_PATTERN = /:([A-Za-z0-9_]+)|\\*/g;\n\n/**\n * ── WHY THIS LIVES ON `globalThis` AND NOT IN A MODULE BINDING ───────────────\n *\n * A plain `let` here does not work in development, and the failure is silent\n * enough to be worth spelling out.\n *\n * In dev the process runs TWO module graphs over the same files. Route\n * installation is loaded by tsx/Node (it is CLI bootstrap and imports core\n * directly), while page and layout modules are evaluated by Vite's SSR module\n * runner — which keeps its own registry and its own instance of every module it\n * transforms, `@warlock.js/web` included. So a module-level binding written by\n * the installer is not the binding `<Link>` reads during render: the installer\n * publishes into one instance and the component finds the other one empty.\n *\n * That was measured, not theorised. With a module-level `let`, every anchor on\n * a server-rendered page threw `RouteTableNotPublishedError` with an empty\n * known-names list while installation had demonstrably run.\n *\n * `Symbol.for` resolves through the per-ISOLATE symbol registry, which both\n * graphs share because they are the same isolate. So the table is one value no\n * matter which graph reaches it first, and the module keeps its module-shaped\n * API. `publishedBy` is carried for diagnosis only.\n */\nconst ROUTE_TABLE_SLOT = Symbol.for(\"warlock.web.routeTable\");\n\ntype RouteTableSlot = {\n table: Map<string, string>;\n publishedBy: string;\n};\n\ntype RouteTableHost = typeof globalThis & {\n [ROUTE_TABLE_SLOT]?: RouteTableSlot;\n};\n\n/**\n * `undefined` means \"nobody has published yet\", which is a DIFFERENT fault from\n * \"the table is published and this name is not in it\" — an empty Map would\n * conflate them and send the reader hunting a route that was never missing.\n */\nfunction readSlot(): RouteTableSlot | undefined {\n return (globalThis as RouteTableHost)[ROUTE_TABLE_SLOT];\n}\n\nexport class RouteTableNotPublishedError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) was called before the route table was ` +\n \"published, so no route name resolves yet. The table is published once at boot — by \" +\n \"the server when it installs page routes, and by the hydration entry before it mounts. \" +\n \"Seeing this means href() ran outside both: typically a module evaluating at import \" +\n \"time, or a unit test that renders a component without publishing a table first.\",\n );\n this.name = \"RouteTableNotPublishedError\";\n }\n}\n\nexport class UnknownRouteNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly knownRouteNames: readonly string[],\n ) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) does not name a known route. ` +\n (knownRouteNames.length === 0\n ? \"The route table is published but empty, so no page declared a `route` discovery could see.\"\n : `The table knows: ${knownRouteNames.map((name) => JSON.stringify(name)).join(\", \")}.`),\n );\n this.name = \"UnknownRouteNameError\";\n }\n}\n\nexport class MissingRouteParameterError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly parameterName: string,\n public readonly routePath: string,\n ) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) is missing the parameter ` +\n `${JSON.stringify(parameterName)}, required by the route path \"${routePath}\". ` +\n \"It is not defaulted: a missing parameter would otherwise be interpolated as the \" +\n \"literal text `undefined`, producing a link that renders correctly and 404s for a \" +\n \"visitor.\",\n );\n this.name = \"MissingRouteParameterError\";\n }\n}\n\nexport class UnknownRouteParameterError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly parameterNames: readonly string[],\n public readonly routePath: string,\n ) {\n super(\n `Warlock href(${JSON.stringify(routeName)}) was given ` +\n `${parameterNames.map((name) => JSON.stringify(name)).join(\", \")}, which the route path ` +\n `\"${routePath}\" does not declare. Passing an undeclared parameter is a typo often ` +\n \"enough that it is refused rather than dropped; values meant for the query string go \" +\n \"in the third argument.\",\n );\n this.name = \"UnknownRouteParameterError\";\n }\n}\n\nexport class DuplicateRouteNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly paths: readonly [string, string],\n ) {\n super(\n `Warlock route table: two routes both claim the name ${JSON.stringify(routeName)} — ` +\n `\"${paths[0]}\" and \"${paths[1]}\". A name resolves to exactly one URL, so one of the ` +\n \"two would silently win and every link to it would be wrong half the time.\",\n );\n this.name = \"DuplicateRouteNameError\";\n }\n}\n\n/**\n * Publish the table. WHOLESALE — the previous one is discarded, not merged\n * into.\n *\n * Merging looks harmless until a page is deleted and the dev server restarts:\n * the dead name would stay resolvable, and `<Link>` would go on rendering a URL\n * the server no longer routes. The table has to be able to shrink.\n */\nexport function publishRouteTable(\n entries: readonly RouteTableEntry[],\n publishedBy = \"unnamed\",\n): void {\n const table = new Map<string, string>();\n\n for (const entry of entries) {\n const existing = table.get(entry.name);\n\n if (existing !== undefined) {\n throw new DuplicateRouteNameError(entry.name, [existing, entry.path]);\n }\n\n table.set(entry.name, entry.path);\n }\n\n (globalThis as RouteTableHost)[ROUTE_TABLE_SLOT] = { table, publishedBy };\n}\n\n/**\n * Drop the table, returning the module to its pre-boot state.\n *\n * Exists for tests: the table is process-global, so a suite that published one\n * would otherwise leak it into every later test in the same worker and pass in\n * file order while failing under `--shuffle`.\n */\nexport function resetRouteTable(): void {\n delete (globalThis as RouteTableHost)[ROUTE_TABLE_SLOT];\n}\n\n/** The published names, for diagnostics. Empty when nothing is published. */\nexport function knownRouteNames(): readonly string[] {\n const slot = readSlot();\n\n return slot === undefined ? [] : [...slot.table.keys()];\n}\n\n/** Who published the live table, for diagnosis. `undefined` when nothing has. */\nexport function routeTablePublisher(): string | undefined {\n return readSlot()?.publishedBy;\n}\n\nfunction parameterNamesOf(routePath: string): readonly string[] {\n const names: string[] = [];\n\n for (const match of routePath.matchAll(PARAMETER_PATTERN)) {\n names.push(match[1] ?? \"*\");\n }\n\n return names;\n}\n\nfunction interpolate(\n routeName: string,\n routePath: string,\n params: RouteParameters | undefined,\n): string {\n const declared = parameterNamesOf(routePath);\n const supplied = Object.keys(params ?? {}).filter((key) => params?.[key] !== undefined);\n const undeclared = supplied.filter((key) => !declared.includes(key));\n\n if (undeclared.length > 0) {\n throw new UnknownRouteParameterError(routeName, undeclared, routePath);\n }\n\n return routePath.replace(PARAMETER_PATTERN, (match) => {\n const name = match === \"*\" ? \"*\" : match.slice(1);\n const value = params?.[name];\n\n if (value === undefined) {\n throw new MissingRouteParameterError(routeName, name, routePath);\n }\n\n return encodeURIComponent(String(value));\n });\n}\n\n/**\n * Resolve a route NAME to a URL.\n *\n * A name, never a path: a moved page changes its URL and keeps its name, so\n * every call site keeps working. That is the property the hardcoded table in\n * the previous `<Link>` could not offer, because it restated six URLs by hand\n * and silently refused every other page in the app.\n *\n * The query half is delegated to `queryStringOf`, which lives next to the\n * DECODER it has to agree with. It used to live here, and being module-private\n * meant the read half could not share it — the one-writer rule held only\n * because nobody had written the second writer yet.\n *\n * @throws {UnserializableQueryValueError} when a query value nests deeper than\n * the wire format core parses can carry.\n */\nexport function href(name: string, params?: RouteParameters, query?: RouteQuery): string {\n const slot = readSlot();\n\n if (slot === undefined) throw new RouteTableNotPublishedError(name);\n\n const routePath = slot.table.get(name);\n\n if (routePath === undefined) {\n throw new UnknownRouteNameError(name, [...slot.table.keys()]);\n }\n\n return `${interpolate(name, routePath, params)}${queryStringOf(query)}`;\n}\n"],"mappings":"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"}
|
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ## This module ships the emitter only
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* anything in `client
|
|
17
|
+
* `navigation-root.tsx` calls the `emit*` methods — `emitNavigating` before a
|
|
18
|
+
* navigation's fetch starts, `emitNavigated` after the tree swap commits,
|
|
19
|
+
* `emitNavigationError` on either path's failure. This module still has no
|
|
20
|
+
* dependency on anything in `client/`: the wiring runs the other direction,
|
|
21
|
+
* `navigation-root.tsx` imports `routerEvents` and calls it, never the reverse.
|
|
21
22
|
*
|
|
22
23
|
* ## What it deliberately does NOT do
|
|
23
24
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router-events.mjs","names":[],"sources":["../../../../../../../web/src/routing/router-events.ts"],"sourcesContent":["/**\n * The navigation lifecycle emitter — what a progress bar subscribes to.\n *\n * A client navigation is a fetch followed by a tree swap\n * (`client/navigation/navigation-root.tsx`). Nothing about that is visible to\n * the user while it is in flight, which is the whole reason this module\n * exists: a progress bar, an analytics hook or a scroll restorer needs to know\n * that a navigation STARTED, that it FINISHED, and that it FAILED — and none\n * of them should have to reach into the navigation runtime to find out.\n *\n * So the runtime announces, and anyone may listen. The listeners are strangers\n * to each other and to the runtime; that is the point.\n *\n * ## This module ships the emitter only\n *\n * Nothing here is wired into the navigation runtime yet — `navigation-root.tsx`\n * calls the `emit*` methods in a later change. Until then this is a complete,\n * self-contained emitter with no callers, which is why it has no dependency on\n * anything in `client/`.\n *\n * ## What it deliberately does NOT do\n *\n * It carries URLs as OPAQUE STRINGS. There is no matching, no parsing, no\n * \"which route is this\" — Warlock's server router is the only matcher (canon\n * 9c8f878b), and an emitter that started parsing paths would be a second one.\n * A listener that wants a route name gets it from the payload the navigation\n * produced, not from this module.\n *\n * DIRECTORY CONTRACT — see `route-identity.ts`: nothing in `web/src/routing/`\n * may import `node:fs`, `node:path`, `vite` or `fastify`. This module also\n * touches no DOM global, which is what makes it importable from a server\n * render (see below).\n *\n * ## Named for its ancestor\n *\n * `routerEvents` is the name `@mongez/react-router` used\n * (`@mongez/react-router/src/events.ts`), and the subscription ergonomics are\n * deliberately familiar: `routerEvents.onNavigating(callback)` returns\n * something you call to stop listening. The implementation is NOT ported —\n * MRR's delegates to a global `@mongez/events` bus keyed by string\n * (`\"router.navigating\"`), which reaches its router singleton and gives up\n * per-event payload typing in the process. Here the events are the object's\n * own methods, so each one carries its own payload type and a typo is a\n * compile error rather than a listener that never fires.\n *\n * MRR returns an `EventSubscription` object; this returns the unsubscribe\n * FUNCTION itself, because that is what a React `useEffect` cleanup wants to\n * be handed:\n *\n * ```ts\n * useEffect(() => routerEvents.onNavigating(() => setLoading(true)), []);\n * ```\n */\n\n/**\n * How the navigation will be written to browser history.\n *\n * `\"replace\"` covers both an explicit `<Link replace>` and a Back/Forward\n * press — the runtime replaces in both cases, because the history entry\n * already exists. A listener that wants to stay quiet during Back/Forward\n * cannot tell the two apart from here, and does not need to: what it actually\n * cares about is that no new entry is being pushed.\n */\nexport type NavigationMode = \"push\" | \"replace\";\n\n/** Emitted when a navigation begins — before anything has been fetched. */\nexport type NavigationStartPayload = {\n /** The URL the navigation was requested for, verbatim and unparsed. */\n url: string;\n mode: NavigationMode;\n};\n\n/** Emitted when a navigation has completed and the new page is on screen. */\nexport type NavigationEndPayload = {\n /** The URL the navigation was requested for — the same string {@link NavigationStartPayload} carried. */\n url: string;\n /**\n * The URL the page data actually came from, which is what landed in the\n * address bar. It differs from `url` whenever the server redirected — a\n * page that requires auth answers from `/login` (see\n * `client/navigation/fetch-page-data.ts`).\n */\n resolvedUrl: string;\n mode: NavigationMode;\n};\n\n/**\n * Emitted when a navigation could not complete.\n *\n * A failed client navigation is not a dead end — the runtime degrades to a\n * full browser load — so a listener should treat this as \"the in-flight\n * navigation is over\", not as an error to render. A progress bar hides on it.\n */\nexport type NavigationErrorPayload = {\n /** The URL the navigation was requested for. */\n url: string;\n mode: NavigationMode;\n /**\n * What went wrong. `unknown` rather than `Error` because a caught value has\n * no such guarantee — narrow it before reading `.message`.\n */\n error: unknown;\n};\n\n/** A listener for one navigation event. Its return value is ignored. */\nexport type RouterEventListener<Payload> = (payload: Payload) => void;\n\n/** Call it to stop listening. Calling it more than once is a no-op. */\nexport type RouterEventUnsubscribe = () => void;\n\n/**\n * The navigation lifecycle surface: three events, each with its own payload\n * type, each subscribable and emittable.\n *\n * The `emit*` half belongs to the navigation runtime. Nothing stops other code\n * from calling it, and nothing needs to — an emitter that lies about\n * navigations is a bug in whoever called it, not a boundary worth policing.\n */\nexport type RouterEvents = {\n /** Subscribe to the start of every navigation. @returns the unsubscribe function. */\n onNavigating: (listener: RouterEventListener<NavigationStartPayload>) => RouterEventUnsubscribe;\n /** Subscribe to every navigation that completed. @returns the unsubscribe function. */\n onNavigated: (listener: RouterEventListener<NavigationEndPayload>) => RouterEventUnsubscribe;\n /** Subscribe to every navigation that failed. @returns the unsubscribe function. */\n onNavigationError: (\n listener: RouterEventListener<NavigationErrorPayload>,\n ) => RouterEventUnsubscribe;\n /** Announce that a navigation has begun. Called by the navigation runtime. */\n emitNavigating: (payload: NavigationStartPayload) => void;\n /** Announce that a navigation has completed. Called by the navigation runtime. */\n emitNavigated: (payload: NavigationEndPayload) => void;\n /** Announce that a navigation has failed. Called by the navigation runtime. */\n emitNavigationError: (payload: NavigationErrorPayload) => void;\n};\n\n/**\n * One registration. An OBJECT rather than the callback itself, so that\n * subscribing the same function twice is two independent registrations — a\n * registry keyed by the callback would collapse them, and then one component's\n * cleanup would silently deafen another's.\n */\ntype Registration<Payload> = { listener: RouterEventListener<Payload> };\n\ntype Signal<Payload> = {\n subscribe: (listener: RouterEventListener<Payload>) => RouterEventUnsubscribe;\n emit: (payload: Payload) => void;\n};\n\n/**\n * One event's registrations and its delivery loop. A `Set` because insertion\n * order is preserved (listeners fire in subscription order) and removal is by\n * identity, which is exactly what an unsubscribe closure holds.\n */\nfunction createSignal<Payload>(eventName: string): Signal<Payload> {\n const registrations = new Set<Registration<Payload>>();\n\n return {\n subscribe: listener => {\n const registration: Registration<Payload> = { listener };\n\n registrations.add(registration);\n\n // `Set.delete` on an absent member is a no-op, so this is idempotent for\n // free — which matters because React StrictMode runs an effect's cleanup\n // twice in development.\n return () => {\n registrations.delete(registration);\n };\n },\n emit: payload => {\n /*\n A SNAPSHOT, not the live set. Listeners subscribe and unsubscribe from\n inside other listeners — a progress bar that hides itself, a one-shot\n analytics hook — and mutating the collection being iterated is how an\n emitter starts skipping listeners. Iterating a copy also fixes the\n cohort: a listener added during this emit belongs to the next\n navigation, not to the one already in flight.\n\n Removals during the emit are still honoured (the deleted-check below),\n because a listener that has just unsubscribed has said it no longer\n wants this event, and delivering it anyway is the bug that outlives the\n component.\n */\n for (const registration of [...registrations]) {\n if (!registrations.has(registration)) continue;\n\n try {\n registration.listener(payload);\n } catch (error) {\n /*\n THE POINT OF THE TRY. Everything downstream of an emit — the\n history entry, the tree swap — happens after this loop returns, so\n a listener that throws would otherwise take the navigation down\n with it. One broken progress bar is a broken progress bar; it is\n not a stuck page.\n\n Reported rather than swallowed: a listener failing silently on\n every navigation is worse than noisy. The listener STAYS\n subscribed, because one bad emit is not consent to deregister\n something the component still owns and will still try to clean up.\n */\n console.error(`Warlock routerEvents: a ${eventName} listener threw:`, error);\n }\n }\n },\n };\n}\n\n/**\n * Builds an independent emitter with no listeners.\n *\n * This is what makes the module TESTABLE without cross-test bleed, and it is\n * the escape hatch for anything that needs a private lifecycle bus. The\n * shared {@link routerEvents} is one of these, created once.\n */\nexport function createRouterEvents(): RouterEvents {\n const navigating = createSignal<NavigationStartPayload>(\"navigating\");\n const navigated = createSignal<NavigationEndPayload>(\"navigated\");\n const navigationError = createSignal<NavigationErrorPayload>(\"navigation-error\");\n\n return {\n onNavigating: navigating.subscribe,\n onNavigated: navigated.subscribe,\n onNavigationError: navigationError.subscribe,\n emitNavigating: navigating.emit,\n emitNavigated: navigated.emit,\n emitNavigationError: navigationError.emit,\n };\n}\n\n/**\n * The shared navigation lifecycle emitter — the one a progress bar subscribes\n * to and the one the navigation runtime emits on.\n *\n * ## Why a module-level singleton is safe here, when module-level state is a known SSR hazard\n *\n * Canon records MRR's `RouterWrapper.tsx:63` as the cautionary case: module\n * state that holds RENDER state is shared by every concurrent request on the\n * server, so one visitor's page leaks into another's. That hazard is about\n * *what* is held, not about module scope itself — `routing/navigator.ts` in\n * this same directory already holds a module-level registration for the same\n * reason.\n *\n * This object holds ONLY listener registrations. No current route, no payload,\n * no request-scoped anything — nothing a render reads and nothing a response\n * is built from. Two concurrent SSR requests observe the same empty listener\n * sets and neither can learn a thing about the other.\n *\n * It is also safe to IMPORT on the server: constructing it touches no DOM\n * global, no `window`, no `history`, and performs no work beyond allocating\n * three empty sets. On the server nothing subscribes and nothing emits, so it\n * simply sits there — which is the correct server behaviour for a progress\n * bar.\n *\n * A singleton (rather than a context or a factory at the call site) is what\n * lets a progress bar living anywhere in the tree — or outside React\n * entirely — hear a navigation without being handed a bus by every component\n * between it and the root. That plumbing is the reason MRR made this global\n * too. Use {@link createRouterEvents} when you want an isolated one.\n */\nexport const routerEvents: RouterEvents = createRouterEvents();\n"],"mappings":";;;;;;AAyJA,SAAS,aAAsB,WAAoC;CACjE,MAAM,gCAAgB,IAAI,IAA2B;CAErD,OAAO;EACL,YAAW,aAAY;GACrB,MAAM,eAAsC,EAAE,SAAS;GAEvD,cAAc,IAAI,YAAY;GAK9B,aAAa;IACX,cAAc,OAAO,YAAY;GACnC;EACF;EACA,OAAM,YAAW;GAcf,KAAK,MAAM,gBAAgB,CAAC,GAAG,aAAa,GAAG;IAC7C,IAAI,CAAC,cAAc,IAAI,YAAY,GAAG;IAEtC,IAAI;KACF,aAAa,SAAS,OAAO;IAC/B,SAAS,OAAO;KAad,QAAQ,MAAM,2BAA2B,UAAU,mBAAmB,KAAK;IAC7E;GACF;EACF;CACF;AACF;;;;;;;;AASA,SAAgB,qBAAmC;CACjD,MAAM,aAAa,aAAqC,YAAY;CACpE,MAAM,YAAY,aAAmC,WAAW;CAChE,MAAM,kBAAkB,aAAqC,kBAAkB;CAE/E,OAAO;EACL,cAAc,WAAW;EACzB,aAAa,UAAU;EACvB,mBAAmB,gBAAgB;EACnC,gBAAgB,WAAW;EAC3B,eAAe,UAAU;EACzB,qBAAqB,gBAAgB;CACvC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAa,eAA6B,mBAAmB"}
|
|
1
|
+
{"version":3,"file":"router-events.mjs","names":[],"sources":["../../../../../../../web/src/routing/router-events.ts"],"sourcesContent":["/**\n * The navigation lifecycle emitter — what a progress bar subscribes to.\n *\n * A client navigation is a fetch followed by a tree swap\n * (`client/navigation/navigation-root.tsx`). Nothing about that is visible to\n * the user while it is in flight, which is the whole reason this module\n * exists: a progress bar, an analytics hook or a scroll restorer needs to know\n * that a navigation STARTED, that it FINISHED, and that it FAILED — and none\n * of them should have to reach into the navigation runtime to find out.\n *\n * So the runtime announces, and anyone may listen. The listeners are strangers\n * to each other and to the runtime; that is the point.\n *\n * ## This module ships the emitter only\n *\n * `navigation-root.tsx` calls the `emit*` methods — `emitNavigating` before a\n * navigation's fetch starts, `emitNavigated` after the tree swap commits,\n * `emitNavigationError` on either path's failure. This module still has no\n * dependency on anything in `client/`: the wiring runs the other direction,\n * `navigation-root.tsx` imports `routerEvents` and calls it, never the reverse.\n *\n * ## What it deliberately does NOT do\n *\n * It carries URLs as OPAQUE STRINGS. There is no matching, no parsing, no\n * \"which route is this\" — Warlock's server router is the only matcher (canon\n * 9c8f878b), and an emitter that started parsing paths would be a second one.\n * A listener that wants a route name gets it from the payload the navigation\n * produced, not from this module.\n *\n * DIRECTORY CONTRACT — see `route-identity.ts`: nothing in `web/src/routing/`\n * may import `node:fs`, `node:path`, `vite` or `fastify`. This module also\n * touches no DOM global, which is what makes it importable from a server\n * render (see below).\n *\n * ## Named for its ancestor\n *\n * `routerEvents` is the name `@mongez/react-router` used\n * (`@mongez/react-router/src/events.ts`), and the subscription ergonomics are\n * deliberately familiar: `routerEvents.onNavigating(callback)` returns\n * something you call to stop listening. The implementation is NOT ported —\n * MRR's delegates to a global `@mongez/events` bus keyed by string\n * (`\"router.navigating\"`), which reaches its router singleton and gives up\n * per-event payload typing in the process. Here the events are the object's\n * own methods, so each one carries its own payload type and a typo is a\n * compile error rather than a listener that never fires.\n *\n * MRR returns an `EventSubscription` object; this returns the unsubscribe\n * FUNCTION itself, because that is what a React `useEffect` cleanup wants to\n * be handed:\n *\n * ```ts\n * useEffect(() => routerEvents.onNavigating(() => setLoading(true)), []);\n * ```\n */\n\n/**\n * How the navigation will be written to browser history.\n *\n * `\"replace\"` covers both an explicit `<Link replace>` and a Back/Forward\n * press — the runtime replaces in both cases, because the history entry\n * already exists. A listener that wants to stay quiet during Back/Forward\n * cannot tell the two apart from here, and does not need to: what it actually\n * cares about is that no new entry is being pushed.\n */\nexport type NavigationMode = \"push\" | \"replace\";\n\n/** Emitted when a navigation begins — before anything has been fetched. */\nexport type NavigationStartPayload = {\n /** The URL the navigation was requested for, verbatim and unparsed. */\n url: string;\n mode: NavigationMode;\n};\n\n/** Emitted when a navigation has completed and the new page is on screen. */\nexport type NavigationEndPayload = {\n /** The URL the navigation was requested for — the same string {@link NavigationStartPayload} carried. */\n url: string;\n /**\n * The URL the page data actually came from, which is what landed in the\n * address bar. It differs from `url` whenever the server redirected — a\n * page that requires auth answers from `/login` (see\n * `client/navigation/fetch-page-data.ts`).\n */\n resolvedUrl: string;\n mode: NavigationMode;\n};\n\n/**\n * Emitted when a navigation could not complete.\n *\n * A failed client navigation is not a dead end — the runtime degrades to a\n * full browser load — so a listener should treat this as \"the in-flight\n * navigation is over\", not as an error to render. A progress bar hides on it.\n */\nexport type NavigationErrorPayload = {\n /** The URL the navigation was requested for. */\n url: string;\n mode: NavigationMode;\n /**\n * What went wrong. `unknown` rather than `Error` because a caught value has\n * no such guarantee — narrow it before reading `.message`.\n */\n error: unknown;\n};\n\n/** A listener for one navigation event. Its return value is ignored. */\nexport type RouterEventListener<Payload> = (payload: Payload) => void;\n\n/** Call it to stop listening. Calling it more than once is a no-op. */\nexport type RouterEventUnsubscribe = () => void;\n\n/**\n * The navigation lifecycle surface: three events, each with its own payload\n * type, each subscribable and emittable.\n *\n * The `emit*` half belongs to the navigation runtime. Nothing stops other code\n * from calling it, and nothing needs to — an emitter that lies about\n * navigations is a bug in whoever called it, not a boundary worth policing.\n */\nexport type RouterEvents = {\n /** Subscribe to the start of every navigation. @returns the unsubscribe function. */\n onNavigating: (listener: RouterEventListener<NavigationStartPayload>) => RouterEventUnsubscribe;\n /** Subscribe to every navigation that completed. @returns the unsubscribe function. */\n onNavigated: (listener: RouterEventListener<NavigationEndPayload>) => RouterEventUnsubscribe;\n /** Subscribe to every navigation that failed. @returns the unsubscribe function. */\n onNavigationError: (\n listener: RouterEventListener<NavigationErrorPayload>,\n ) => RouterEventUnsubscribe;\n /** Announce that a navigation has begun. Called by the navigation runtime. */\n emitNavigating: (payload: NavigationStartPayload) => void;\n /** Announce that a navigation has completed. Called by the navigation runtime. */\n emitNavigated: (payload: NavigationEndPayload) => void;\n /** Announce that a navigation has failed. Called by the navigation runtime. */\n emitNavigationError: (payload: NavigationErrorPayload) => void;\n};\n\n/**\n * One registration. An OBJECT rather than the callback itself, so that\n * subscribing the same function twice is two independent registrations — a\n * registry keyed by the callback would collapse them, and then one component's\n * cleanup would silently deafen another's.\n */\ntype Registration<Payload> = { listener: RouterEventListener<Payload> };\n\ntype Signal<Payload> = {\n subscribe: (listener: RouterEventListener<Payload>) => RouterEventUnsubscribe;\n emit: (payload: Payload) => void;\n};\n\n/**\n * One event's registrations and its delivery loop. A `Set` because insertion\n * order is preserved (listeners fire in subscription order) and removal is by\n * identity, which is exactly what an unsubscribe closure holds.\n */\nfunction createSignal<Payload>(eventName: string): Signal<Payload> {\n const registrations = new Set<Registration<Payload>>();\n\n return {\n subscribe: (listener) => {\n const registration: Registration<Payload> = { listener };\n\n registrations.add(registration);\n\n // `Set.delete` on an absent member is a no-op, so this is idempotent for\n // free — which matters because React StrictMode runs an effect's cleanup\n // twice in development.\n return () => {\n registrations.delete(registration);\n };\n },\n emit: (payload) => {\n /*\n A SNAPSHOT, not the live set. Listeners subscribe and unsubscribe from\n inside other listeners — a progress bar that hides itself, a one-shot\n analytics hook — and mutating the collection being iterated is how an\n emitter starts skipping listeners. Iterating a copy also fixes the\n cohort: a listener added during this emit belongs to the next\n navigation, not to the one already in flight.\n\n Removals during the emit are still honoured (the deleted-check below),\n because a listener that has just unsubscribed has said it no longer\n wants this event, and delivering it anyway is the bug that outlives the\n component.\n */\n for (const registration of [...registrations]) {\n if (!registrations.has(registration)) continue;\n\n try {\n registration.listener(payload);\n } catch (error) {\n /*\n THE POINT OF THE TRY. Everything downstream of an emit — the\n history entry, the tree swap — happens after this loop returns, so\n a listener that throws would otherwise take the navigation down\n with it. One broken progress bar is a broken progress bar; it is\n not a stuck page.\n\n Reported rather than swallowed: a listener failing silently on\n every navigation is worse than noisy. The listener STAYS\n subscribed, because one bad emit is not consent to deregister\n something the component still owns and will still try to clean up.\n */\n console.error(`Warlock routerEvents: a ${eventName} listener threw:`, error);\n }\n }\n },\n };\n}\n\n/**\n * Builds an independent emitter with no listeners.\n *\n * This is what makes the module TESTABLE without cross-test bleed, and it is\n * the escape hatch for anything that needs a private lifecycle bus. The\n * shared {@link routerEvents} is one of these, created once.\n */\nexport function createRouterEvents(): RouterEvents {\n const navigating = createSignal<NavigationStartPayload>(\"navigating\");\n const navigated = createSignal<NavigationEndPayload>(\"navigated\");\n const navigationError = createSignal<NavigationErrorPayload>(\"navigation-error\");\n\n return {\n onNavigating: navigating.subscribe,\n onNavigated: navigated.subscribe,\n onNavigationError: navigationError.subscribe,\n emitNavigating: navigating.emit,\n emitNavigated: navigated.emit,\n emitNavigationError: navigationError.emit,\n };\n}\n\n/**\n * The shared navigation lifecycle emitter — the one a progress bar subscribes\n * to and the one the navigation runtime emits on.\n *\n * ## Why a module-level singleton is safe here, when module-level state is a known SSR hazard\n *\n * Canon records MRR's `RouterWrapper.tsx:63` as the cautionary case: module\n * state that holds RENDER state is shared by every concurrent request on the\n * server, so one visitor's page leaks into another's. That hazard is about\n * *what* is held, not about module scope itself — `routing/navigator.ts` in\n * this same directory already holds a module-level registration for the same\n * reason.\n *\n * This object holds ONLY listener registrations. No current route, no payload,\n * no request-scoped anything — nothing a render reads and nothing a response\n * is built from. Two concurrent SSR requests observe the same empty listener\n * sets and neither can learn a thing about the other.\n *\n * It is also safe to IMPORT on the server: constructing it touches no DOM\n * global, no `window`, no `history`, and performs no work beyond allocating\n * three empty sets. On the server nothing subscribes and nothing emits, so it\n * simply sits there — which is the correct server behaviour for a progress\n * bar.\n *\n * A singleton (rather than a context or a factory at the call site) is what\n * lets a progress bar living anywhere in the tree — or outside React\n * entirely — hear a navigation without being handed a bus by every component\n * between it and the root. That plumbing is the reason MRR made this global\n * too. Use {@link createRouterEvents} when you want an isolated one.\n */\nexport const routerEvents: RouterEvents = createRouterEvents();\n"],"mappings":";;;;;;AA0JA,SAAS,aAAsB,WAAoC;CACjE,MAAM,gCAAgB,IAAI,IAA2B;CAErD,OAAO;EACL,YAAY,aAAa;GACvB,MAAM,eAAsC,EAAE,SAAS;GAEvD,cAAc,IAAI,YAAY;GAK9B,aAAa;IACX,cAAc,OAAO,YAAY;GACnC;EACF;EACA,OAAO,YAAY;GAcjB,KAAK,MAAM,gBAAgB,CAAC,GAAG,aAAa,GAAG;IAC7C,IAAI,CAAC,cAAc,IAAI,YAAY,GAAG;IAEtC,IAAI;KACF,aAAa,SAAS,OAAO;IAC/B,SAAS,OAAO;KAad,QAAQ,MAAM,2BAA2B,UAAU,mBAAmB,KAAK;IAC7E;GACF;EACF;CACF;AACF;;;;;;;;AASA,SAAgB,qBAAmC;CACjD,MAAM,aAAa,aAAqC,YAAY;CACpE,MAAM,YAAY,aAAmC,WAAW;CAChE,MAAM,kBAAkB,aAAqC,kBAAkB;CAE/E,OAAO;EACL,cAAc,WAAW;EACzB,aAAa,UAAU;EACvB,mBAAmB,gBAAgB;EACnC,gBAAgB,WAAW;EAC3B,eAAe,UAAU;EACzB,qBAAqB,gBAAgB;CACvC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAa,eAA6B,mBAAmB"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RegisterableModuleNamespace } from "../
|
|
1
|
+
import { RegisterableModuleNamespace } from "../register-modules.mjs";
|
|
2
2
|
import { PageCacheOptIn } from "../routing/route-identity.mjs";
|
|
3
3
|
import { BufferedCookie } from "./settle-page-response.mjs";
|
|
4
4
|
import { ErrorPageModuleLoader } from "./error-page.mjs";
|
|
@@ -46,7 +46,7 @@ type PageRouteHandlerOptions = {
|
|
|
46
46
|
* a stylesheet failed to resolve, which is the build's job to report.
|
|
47
47
|
*/
|
|
48
48
|
stylesheetUrls?: readonly string[];
|
|
49
|
-
/** Same helper `dev-
|
|
49
|
+
/** Same helper `dev-error-transport.ts` exports — passed in, never imported. */
|
|
50
50
|
/**
|
|
51
51
|
* The pattern stage 1 matches `request.path` against, when it differs from
|
|
52
52
|
* the REGISTERED path. Defaults to `path`, which is right for every route
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER, isDataRequest } from "../routing/data-request.mjs";
|
|
2
2
|
import { isNonHydrating } from "./page-render-bundle.mjs";
|
|
3
|
-
import { registerModules } from "../
|
|
3
|
+
import { registerModules } from "../register-modules.mjs";
|
|
4
4
|
import { buildHydrationPayload } from "./build-hydration-payload.mjs";
|
|
5
|
-
import { renderPageFailure, renderPageRequest } from "./render-page.mjs";
|
|
6
5
|
import { applyResponseCacheFloor } from "./response-cache-floor.mjs";
|
|
7
6
|
import { ensureSetCookieCacheFloorHook, markPageResponse } from "./set-cookie-cache-floor-hook.mjs";
|
|
7
|
+
import { renderPageFailure, renderPageRequest } from "./render-page.mjs";
|
|
8
8
|
import { Response, container } from "@warlock.js/core";
|
|
9
9
|
|
|
10
10
|
//#region ../web/src/server/create-page-route-handler.ts
|
|
@@ -52,10 +52,10 @@ var MissingHttpServerForPageRouteError = class extends Error {
|
|
|
52
52
|
/**
|
|
53
53
|
* Replay ONE committed cookie through core's own `Response.cookie()` — the
|
|
54
54
|
* same serializer every ordinary controller's cookie goes through, so there
|
|
55
|
-
* is nothing here for a second implementation to drift from.
|
|
56
|
-
* `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* is nothing here for a second implementation to drift from. Passed in
|
|
56
|
+
* (`applyBufferedCookie` option, below) rather than imported at the call site
|
|
57
|
+
* so this file stays free of anything Vite-shaped. Exported for tests: this
|
|
58
|
+
* is the only production implementation of the cookie commit.
|
|
59
59
|
*/
|
|
60
60
|
function defaultApplyBufferedCookie(response, cookie) {
|
|
61
61
|
response.cookie(cookie.name, cookie.value, cookie.options ?? {});
|
|
@@ -86,7 +86,7 @@ function escapeHtmlAttribute(value) {
|
|
|
86
86
|
function installHydrationClientModule(html, moduleUrl, nonce) {
|
|
87
87
|
if (moduleUrl === void 0 || html === "") return html;
|
|
88
88
|
const closingBodyIndex = html.lastIndexOf("</body>");
|
|
89
|
-
if (closingBodyIndex === -1) throw new Error("
|
|
89
|
+
if (closingBodyIndex === -1) throw new Error("createPageRouteHandler: cannot install the hydration client module because the rendered document has no closing </body> tag.");
|
|
90
90
|
const script = `<script type="module"${nonce === void 0 ? "" : ` nonce="${escapeHtmlAttribute(nonce)}"`} src="${escapeHtmlAttribute(moduleUrl)}"><\/script>`;
|
|
91
91
|
return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;
|
|
92
92
|
}
|
|
@@ -160,12 +160,18 @@ function createPageRouteHandler(options) {
|
|
|
160
160
|
};
|
|
161
161
|
const requestUrl = request.path;
|
|
162
162
|
const [requestPathname] = requestUrl.split("?");
|
|
163
|
+
const entry = {
|
|
164
|
+
path: matchPath === void 0 ? path : matchPath(requestPathname),
|
|
165
|
+
name,
|
|
166
|
+
triple
|
|
167
|
+
};
|
|
168
|
+
const params = matchPath === void 0 ? request.params : {};
|
|
163
169
|
const rendered = await renderPageRequest(requestUrl, {
|
|
164
|
-
routes: [
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
170
|
+
routes: [entry],
|
|
171
|
+
matched: {
|
|
172
|
+
entry,
|
|
173
|
+
params
|
|
174
|
+
},
|
|
169
175
|
createHttp: () => ({
|
|
170
176
|
request,
|
|
171
177
|
response
|