@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133
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/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/prerender-utils.ts +17 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Script handle for injecting <script> tags into the document from
|
|
3
|
+
* route/layout handlers.
|
|
4
|
+
*
|
|
5
|
+
* Push from a SERVER handler with `ctx.use(Script)(config)`; render with the
|
|
6
|
+
* `<Scripts />` component (from `@rangojs/router/client`) placed in the Document
|
|
7
|
+
* `<head>` (and optionally a second `<Scripts position="body" />` at the top of
|
|
8
|
+
* `<body>`). This mirrors the Meta / <MetaTags> pair.
|
|
9
|
+
*
|
|
10
|
+
* The request CSP nonce is applied AUTOMATICALLY by <Scripts> to document-rendered
|
|
11
|
+
* scripts; consumers never pass a nonce. (An async script first loaded on a soft
|
|
12
|
+
* navigation is injected client-side without a nonce — it relies on
|
|
13
|
+
* 'strict-dynamic' or a host allowance; see the EXECUTION CONTRACT below and the
|
|
14
|
+
* /scripts skill.) A ScriptConfig is fully serializable (it crosses the
|
|
15
|
+
* server -> client handle-collection boundary), so callbacks like onLoad are NOT
|
|
16
|
+
* supported — a consumer needing them renders their own "use client" script.
|
|
17
|
+
*
|
|
18
|
+
* EXECUTION CONTRACT (see the /scripts skill for the full story):
|
|
19
|
+
* - Inline (`children`) and ordered external (`src`, optional `defer`) scripts
|
|
20
|
+
* are DOCUMENT-LOAD scripts: they execute only when present in the initial HTML
|
|
21
|
+
* response. <Scripts> freezes them after hydration, so a later client (soft)
|
|
22
|
+
* navigation never inserts an inert copy — React creates client-mounted
|
|
23
|
+
* <script> elements via innerHTML, which the HTML spec makes non-executing.
|
|
24
|
+
* - Async external scripts (`src` + `async: true`) are React RESOURCES: they load
|
|
25
|
+
* once when first encountered, including after a soft navigation, deduped by
|
|
26
|
+
* `src`. Use this for a vendor that should load on first visit to a route.
|
|
27
|
+
* - Reusing an `id` shapes the INITIAL document output (last-push-wins); it does
|
|
28
|
+
* not re-run a script during navigation. Per-navigation behavior belongs in a
|
|
29
|
+
* "use client" component or hook (see the GtmPageViews pattern in the demo).
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* // External async loader (React resource — loads on first visit, even soft nav):
|
|
34
|
+
* ctx.use(Script)({ id: "stripe", src: "https://js.stripe.com/v3", async: true });
|
|
35
|
+
*
|
|
36
|
+
* // Inline bootstrap that self-injects its loader (GTM/GA4) — keep it inline so
|
|
37
|
+
* // React cannot hoist a declarative loader above the bootstrap:
|
|
38
|
+
* ctx.use(Script)({ id: "gtm", children: gtmBootstrap(containerId) });
|
|
39
|
+
*
|
|
40
|
+
* // External ordered (defer) with vendor attributes (document-load):
|
|
41
|
+
* ctx.use(Script)({
|
|
42
|
+
* id: "plausible",
|
|
43
|
+
* src: "https://plausible.io/js/script.js",
|
|
44
|
+
* defer: true,
|
|
45
|
+
* attributes: { "data-domain": "example.com" },
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import type { ScriptHTMLAttributes } from "react";
|
|
51
|
+
import { createHandle, type Handle } from "../handle.js";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extra attributes forwarded onto the emitted <script>. Typed by React, so the
|
|
55
|
+
* casing is React's (`crossOrigin`, not `crossorigin`) and value shapes are
|
|
56
|
+
* checked at compile time. `data-*` attributes are allowed. Two groups are
|
|
57
|
+
* excluded: the fields the Script handle manages itself (`id`, `src`, `async`,
|
|
58
|
+
* `defer`, `type`, `children`, `nonce`, `dangerouslySetInnerHTML` — set those via
|
|
59
|
+
* the ScriptConfig fields), and ALL `on*` event handlers (`onLoad`, `onError`,
|
|
60
|
+
* …): a ScriptConfig is serialized across the server -> client handle boundary, so
|
|
61
|
+
* a function cannot survive it — render your own "use client" script for callbacks.
|
|
62
|
+
*/
|
|
63
|
+
export type ScriptAttributes = Omit<
|
|
64
|
+
ScriptHTMLAttributes<HTMLScriptElement>,
|
|
65
|
+
| "id"
|
|
66
|
+
| "src"
|
|
67
|
+
| "async"
|
|
68
|
+
| "defer"
|
|
69
|
+
| "type"
|
|
70
|
+
| "children"
|
|
71
|
+
| "nonce"
|
|
72
|
+
| "dangerouslySetInnerHTML"
|
|
73
|
+
| `on${string}`
|
|
74
|
+
> & {
|
|
75
|
+
[dataAttr: `data-${string}`]: string | number | boolean | undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Fields shared by every script shape. */
|
|
79
|
+
interface ScriptConfigBase {
|
|
80
|
+
/**
|
|
81
|
+
* Where <Scripts> renders this script.
|
|
82
|
+
* - "head" (default): the `<head>` <Scripts> site.
|
|
83
|
+
* - "body": the `<Scripts position="body" />` site at the top of <body>.
|
|
84
|
+
* Note: an external `async` script is hoisted into <head> by React regardless.
|
|
85
|
+
*/
|
|
86
|
+
position?: "head" | "body";
|
|
87
|
+
/**
|
|
88
|
+
* The `type` attribute, as a free string: "module", "application/ld+json",
|
|
89
|
+
* "text/partytown", etc. Omitted means a classic script.
|
|
90
|
+
*/
|
|
91
|
+
type?: string;
|
|
92
|
+
/** Extra React-cased attributes (`data-*`, `crossOrigin`, `integrity`, ...). */
|
|
93
|
+
attributes?: ScriptAttributes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Inline script: a raw JS body rendered in place, escaped against `</script>`
|
|
98
|
+
* breakout. DOCUMENT-LOAD only (executes when present in the initial HTML;
|
|
99
|
+
* <Scripts> freezes it after hydration so navigation never inserts an inert
|
|
100
|
+
* copy). `id` is REQUIRED — inline scripts are never deduped by React, so a
|
|
101
|
+
* layout and a child pushing the same bootstrap would inject it twice. It is also
|
|
102
|
+
* rendered as the script's DOM `id`. Forbids `src`/`async`/`defer`. For analytics
|
|
103
|
+
* vendors (GTM/GA4/Segment) the body should
|
|
104
|
+
* create+append its own loader, so the loader is never a separate declarative tag
|
|
105
|
+
* React could hoist out of order.
|
|
106
|
+
*/
|
|
107
|
+
export interface InlineScriptConfig extends ScriptConfigBase {
|
|
108
|
+
id: string;
|
|
109
|
+
children: string;
|
|
110
|
+
src?: never;
|
|
111
|
+
async?: never;
|
|
112
|
+
defer?: never;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* External async script: a React-hoisted, `src`-deduped RESOURCE (the
|
|
117
|
+
* fire-and-forget loader case). Loads once when first encountered, including
|
|
118
|
+
* after a soft navigation. Deduped by `src` (matching React); `id` is optional
|
|
119
|
+
* and, when set, is rendered as the DOM `id` (not used as the dedup key here).
|
|
120
|
+
* Forbids `children`/`defer`.
|
|
121
|
+
*/
|
|
122
|
+
export interface AsyncScriptConfig extends ScriptConfigBase {
|
|
123
|
+
src: string;
|
|
124
|
+
async: true;
|
|
125
|
+
id?: string;
|
|
126
|
+
children?: never;
|
|
127
|
+
defer?: never;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* External ordered script: in-place, optionally `defer`. DOCUMENT-LOAD only
|
|
132
|
+
* (executes when present in the initial HTML; not re-run on navigation). `id` is
|
|
133
|
+
* optional (the dedup key falls back to `src`) and, when set, is rendered as the
|
|
134
|
+
* DOM `id`. Forbids `children`/`async`.
|
|
135
|
+
*/
|
|
136
|
+
export interface OrderedScriptConfig extends ScriptConfigBase {
|
|
137
|
+
src: string;
|
|
138
|
+
defer?: boolean;
|
|
139
|
+
id?: string;
|
|
140
|
+
children?: never;
|
|
141
|
+
async?: never;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A single script to inject, as a discriminated union — exactly one of:
|
|
146
|
+
* inline (`id` + `children`), external async (`src` + `async: true`), or external
|
|
147
|
+
* ordered (`src`, optional `defer`). Invalid combinations (both `src`+`children`,
|
|
148
|
+
* `async`+`defer`, inline without `id`) are compile errors. The CSP nonce is
|
|
149
|
+
* applied by <Scripts>, never here.
|
|
150
|
+
*/
|
|
151
|
+
export type ScriptConfig =
|
|
152
|
+
| InlineScriptConfig
|
|
153
|
+
| AsyncScriptConfig
|
|
154
|
+
| OrderedScriptConfig;
|
|
155
|
+
|
|
156
|
+
/** A config's runtime view, for validating untyped/serialized input. */
|
|
157
|
+
type LooseScriptConfig = {
|
|
158
|
+
id?: string;
|
|
159
|
+
src?: string;
|
|
160
|
+
children?: string;
|
|
161
|
+
async?: boolean;
|
|
162
|
+
defer?: boolean;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Dev-only validation. The discriminated union makes these states unrepresentable
|
|
167
|
+
* in TypeScript; the runtime checks exist only for untyped JavaScript callers and
|
|
168
|
+
* malformed serialized input, not as the primary contract.
|
|
169
|
+
*/
|
|
170
|
+
function validateConfigDev(config: ScriptConfig): void {
|
|
171
|
+
if (process.env.NODE_ENV === "production") return;
|
|
172
|
+
const c = config as LooseScriptConfig;
|
|
173
|
+
if (c.src != null && c.children != null) {
|
|
174
|
+
console.warn(
|
|
175
|
+
`[Script] A config has both "src" and "children"; they are mutually ` +
|
|
176
|
+
`exclusive — "src" wins and the inline body is ignored.`,
|
|
177
|
+
);
|
|
178
|
+
} else if (c.src == null && c.children == null) {
|
|
179
|
+
console.warn(
|
|
180
|
+
`[Script] A config has neither "src" nor "children"; it injects nothing.`,
|
|
181
|
+
);
|
|
182
|
+
} else if (c.src == null && c.id == null) {
|
|
183
|
+
console.warn(
|
|
184
|
+
`[Script] An inline script was pushed without an "id" and cannot be ` +
|
|
185
|
+
`deduplicated. Pass an "id" so a layout + child pushing the same script ` +
|
|
186
|
+
`inject it only once.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (c.async && c.defer) {
|
|
190
|
+
console.warn(
|
|
191
|
+
`[Script] A config has both "async" and "defer"; they are mutually ` +
|
|
192
|
+
`exclusive.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Accumulate scripts across matched segments, parent -> child, preserving push
|
|
199
|
+
* order, last-push-wins per dedup key (mirroring the Meta handle).
|
|
200
|
+
*
|
|
201
|
+
* Dedup key:
|
|
202
|
+
* - async resources key by `src` ONLY — React itself dedups async scripts by src,
|
|
203
|
+
* so two async configs with different ids but the same src must collapse to one
|
|
204
|
+
* here (last wins) for a single, deterministic winner; otherwise React would
|
|
205
|
+
* silently pick one with undefined attribute precedence.
|
|
206
|
+
* - everything else keys by `id ?? src`.
|
|
207
|
+
*
|
|
208
|
+
* An (untyped) inline script with neither `id` nor `src` cannot be deduplicated;
|
|
209
|
+
* it is kept and validateConfigDev warns.
|
|
210
|
+
*/
|
|
211
|
+
function collectScripts(segments: ScriptConfig[][]): ScriptConfig[] {
|
|
212
|
+
const result: ScriptConfig[] = [];
|
|
213
|
+
const keyToIndex = new Map<string, number>();
|
|
214
|
+
|
|
215
|
+
for (const configs of segments) {
|
|
216
|
+
for (const config of configs) {
|
|
217
|
+
validateConfigDev(config);
|
|
218
|
+
const isAsyncResource = config.src != null && config.async === true;
|
|
219
|
+
const key = isAsyncResource ? config.src : (config.id ?? config.src);
|
|
220
|
+
if (key === undefined) {
|
|
221
|
+
result.push(config);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const existing = keyToIndex.get(key);
|
|
225
|
+
if (existing !== undefined) {
|
|
226
|
+
result[existing] = config;
|
|
227
|
+
} else {
|
|
228
|
+
keyToIndex.set(key, result.length);
|
|
229
|
+
result.push(config);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Built-in handle for injecting scripts. Uses an explicit stable id (built-ins
|
|
239
|
+
* do not rely on the Vite id-injection plugin, which only covers consumer code).
|
|
240
|
+
*/
|
|
241
|
+
export const Script: Handle<ScriptConfig, ScriptConfig[]> = createHandle<
|
|
242
|
+
ScriptConfig,
|
|
243
|
+
ScriptConfig[]
|
|
244
|
+
>(collectScripts, "__rsc_router_script__");
|
|
@@ -91,20 +91,24 @@ export function handleCookieOverride(
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// URL.hostname ASCII-lowercases the host, so compare the cookie value against
|
|
95
|
+
// its canonical lowercase form (a mixed-case host is valid) and reject only
|
|
96
|
+
// when it carries a path/port. Return the canonical host so downstream
|
|
97
|
+
// matching, which assumes lowercase, sees a consistent value.
|
|
94
98
|
try {
|
|
95
99
|
const testUrl = new URL(`https://${cookieValue}`);
|
|
96
100
|
|
|
97
|
-
if (testUrl.hostname !== cookieValue) {
|
|
101
|
+
if (testUrl.hostname !== cookieValue.toLowerCase()) {
|
|
98
102
|
throw new InvalidHostnameError(cookieValue, {
|
|
99
103
|
cause: { original: cookieValue, normalized: testUrl.hostname },
|
|
100
104
|
});
|
|
101
105
|
}
|
|
106
|
+
|
|
107
|
+
return testUrl.hostname;
|
|
102
108
|
} catch (error) {
|
|
103
109
|
if (error instanceof InvalidHostnameError) {
|
|
104
110
|
throw error;
|
|
105
111
|
}
|
|
106
112
|
throw new InvalidHostnameError(cookieValue, { cause: error });
|
|
107
113
|
}
|
|
108
|
-
|
|
109
|
-
return cookieValue;
|
|
110
114
|
}
|
|
@@ -64,10 +64,24 @@ export function matchPattern(
|
|
|
64
64
|
|
|
65
65
|
const slashIndex = normalized.indexOf("/");
|
|
66
66
|
const hasPath = slashIndex !== -1;
|
|
67
|
-
|
|
67
|
+
// Hosts are case-insensitive (RFC 3986): lowercase the domain literal and the
|
|
68
|
+
// request host once so matching folds case. Wildcards (*, **, .) are
|
|
69
|
+
// unaffected by lowercasing. The path is left untouched (paths are
|
|
70
|
+
// case-sensitive).
|
|
71
|
+
const domainPattern = (
|
|
72
|
+
hasPath ? normalized.slice(0, slashIndex) : normalized
|
|
73
|
+
).toLowerCase();
|
|
68
74
|
const pathPattern = hasPath ? normalized.slice(slashIndex) : null;
|
|
69
75
|
|
|
70
|
-
const
|
|
76
|
+
const lowerHostname = hostname.toLowerCase();
|
|
77
|
+
const lowerParts =
|
|
78
|
+
lowerHostname === hostname ? parts : lowerHostname.split(".");
|
|
79
|
+
|
|
80
|
+
const domainMatch = matchDomainPattern(
|
|
81
|
+
domainPattern,
|
|
82
|
+
lowerHostname,
|
|
83
|
+
lowerParts,
|
|
84
|
+
);
|
|
71
85
|
if (!domainMatch) {
|
|
72
86
|
return false;
|
|
73
87
|
}
|
package/src/index.rsc.ts
CHANGED
|
@@ -181,6 +181,11 @@ export type { HandlerCacheConfig } from "./rsc/types.js";
|
|
|
181
181
|
|
|
182
182
|
// Built-in handles (server-side)
|
|
183
183
|
export { Meta } from "./handles/meta.js";
|
|
184
|
+
export {
|
|
185
|
+
Script,
|
|
186
|
+
type ScriptConfig,
|
|
187
|
+
type ScriptAttributes,
|
|
188
|
+
} from "./handles/script.js";
|
|
184
189
|
export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
|
|
185
190
|
|
|
186
191
|
// Request context (for accessing request data in server actions/components).
|
package/src/index.ts
CHANGED
|
@@ -307,6 +307,11 @@ export type {
|
|
|
307
307
|
|
|
308
308
|
// Built-in handles (universal — work on both server and client)
|
|
309
309
|
export { Meta } from "./handles/meta.js";
|
|
310
|
+
export {
|
|
311
|
+
Script,
|
|
312
|
+
type ScriptConfig,
|
|
313
|
+
type ScriptAttributes,
|
|
314
|
+
} from "./handles/script.js";
|
|
310
315
|
export { Breadcrumbs } from "./handles/breadcrumbs.js";
|
|
311
316
|
|
|
312
317
|
// Meta types
|
package/src/response-utils.ts
CHANGED
|
@@ -27,6 +27,31 @@ export function isWebSocketUpgradeResponse(response: Response): boolean {
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Append `Accept` to a response's `Vary` header without duplicating it.
|
|
32
|
+
*
|
|
33
|
+
* Content-negotiated responses already carry `Vary: Accept` from the
|
|
34
|
+
* upstream layer (response-route-handler's callHandlerWithVary, or
|
|
35
|
+
* handleRscRendering baking `accept` into its vary list). The negotiated
|
|
36
|
+
* post-append in the handler would otherwise emit `Vary: Accept, Accept`,
|
|
37
|
+
* a redundant token some proxies/CDNs treat as a distinct cache key.
|
|
38
|
+
* Token match is case-insensitive (HTTP field tokens are case-insensitive)
|
|
39
|
+
* and whitespace-tolerant.
|
|
40
|
+
*/
|
|
41
|
+
export function appendVaryAccept(response: Response): void {
|
|
42
|
+
const existing = response.headers.get("Vary");
|
|
43
|
+
if (!existing) {
|
|
44
|
+
response.headers.set("Vary", "Accept");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const hasAccept = existing
|
|
48
|
+
.split(",")
|
|
49
|
+
.some((token) => token.trim().toLowerCase() === "accept");
|
|
50
|
+
if (!hasAccept) {
|
|
51
|
+
response.headers.append("Vary", "Accept");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
// Location truthiness (not presence) so an empty `Location: ""` is not a redirect.
|
|
31
56
|
export function isRedirectResponse(response: Response): boolean {
|
|
32
57
|
return (
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type InterceptEntry,
|
|
20
20
|
} from "../server/context";
|
|
21
21
|
import { invariant } from "../errors";
|
|
22
|
+
import { validateUserRouteName } from "../route-name.js";
|
|
22
23
|
import { isCachedFunction } from "../cache/taint.js";
|
|
23
24
|
import { RangoContext } from "../server/context";
|
|
24
25
|
import { isStaticHandler } from "../static-handler.js";
|
|
@@ -944,6 +945,12 @@ const route: RouteHelpers<any, any>["route"] = (name, handler, use) => {
|
|
|
944
945
|
"route() must be called inside urls()",
|
|
945
946
|
);
|
|
946
947
|
|
|
948
|
+
// Reject names colliding with reserved internal prefixes ($path_, $prefix_),
|
|
949
|
+
// the same guard path() and include() enforce. Without it such a name
|
|
950
|
+
// type-checks on an untyped router, then silently vanishes from generated
|
|
951
|
+
// route-types and public reverse() (isAutoGeneratedRouteName filters it out).
|
|
952
|
+
validateUserRouteName(name);
|
|
953
|
+
|
|
947
954
|
const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${name}`;
|
|
948
955
|
|
|
949
956
|
const entry = {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
2
2
|
import {
|
|
3
|
-
requireRequestContext,
|
|
4
3
|
getRequestContext,
|
|
5
4
|
_getRequestContext,
|
|
6
5
|
} from "../server/request-context.js";
|
|
@@ -76,7 +75,7 @@ export function redirect(
|
|
|
76
75
|
typeof statusOrOptions === "object" ? statusOrOptions?.external : undefined;
|
|
77
76
|
|
|
78
77
|
if (state) {
|
|
79
|
-
const ctx =
|
|
78
|
+
const ctx = getRequestContext();
|
|
80
79
|
ctx.setLocationState(state);
|
|
81
80
|
|
|
82
81
|
if (process.env.NODE_ENV !== "production") {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { EntryData } from "../server/context.js";
|
|
10
|
+
import type { NegotiateVariant } from "../build/route-trie.js";
|
|
10
11
|
import type { CollectedMiddleware } from "./middleware-types.js";
|
|
11
12
|
import { collectRouteMiddleware } from "./middleware.js";
|
|
12
13
|
import { loadManifest } from "./manifest.js";
|
|
@@ -81,14 +82,10 @@ export const RSC_RESPONSE_TYPE = "__rsc__";
|
|
|
81
82
|
* candidate serves that type. Wildcards match the first candidate.
|
|
82
83
|
* Falls back to the first candidate if nothing matches.
|
|
83
84
|
*/
|
|
84
|
-
export function pickNegotiateVariant
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const byCandidateMime = new Map<
|
|
89
|
-
string,
|
|
90
|
-
{ routeKey: string; responseType: string }
|
|
91
|
-
>();
|
|
85
|
+
export function pickNegotiateVariant<
|
|
86
|
+
T extends { routeKey: string; responseType: string },
|
|
87
|
+
>(acceptEntries: AcceptEntry[], candidates: T[]): T {
|
|
88
|
+
const byCandidateMime = new Map<string, T>();
|
|
92
89
|
for (const c of candidates) {
|
|
93
90
|
const mime =
|
|
94
91
|
c.responseType === RSC_RESPONSE_TYPE
|
|
@@ -115,6 +112,49 @@ export function pickNegotiateVariant(
|
|
|
115
112
|
return candidates[0]!;
|
|
116
113
|
}
|
|
117
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Re-key params from the primary leaf's names to a winning variant's names.
|
|
117
|
+
*
|
|
118
|
+
* The trie match builds `params` positionally under the primary leaf's pa, so
|
|
119
|
+
* `/widgets/:id` matched as the primary yields `{ id }` even when the winning
|
|
120
|
+
* `/widgets/:file` response variant expects `{ file }`. Both share the same trie
|
|
121
|
+
* terminal, so they bind the same number of positional named params; we zip the
|
|
122
|
+
* variant's pa against the named values in insertion order (which is the
|
|
123
|
+
* primary's pa order). The wildcard key (`*`) is positional-independent and left
|
|
124
|
+
* untouched.
|
|
125
|
+
*
|
|
126
|
+
* Mutates `params` in place. No-op when `variantPa` is absent, when names already
|
|
127
|
+
* match (the common case), or when the positional count diverges (defensive:
|
|
128
|
+
* never corrupt params by zipping mismatched lengths).
|
|
129
|
+
*/
|
|
130
|
+
export function rekeyParamsForVariant(
|
|
131
|
+
params: Record<string, string>,
|
|
132
|
+
variantPa: string[] | undefined,
|
|
133
|
+
): void {
|
|
134
|
+
if (!variantPa || variantPa.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const namedKeys: string[] = [];
|
|
137
|
+
for (const key in params) {
|
|
138
|
+
if (key !== "*") namedKeys.push(key);
|
|
139
|
+
}
|
|
140
|
+
if (namedKeys.length !== variantPa.length) return;
|
|
141
|
+
|
|
142
|
+
let identical = true;
|
|
143
|
+
for (let i = 0; i < variantPa.length; i++) {
|
|
144
|
+
if (namedKeys[i] !== variantPa[i]) {
|
|
145
|
+
identical = false;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (identical) return;
|
|
150
|
+
|
|
151
|
+
const values = namedKeys.map((k) => params[k]!);
|
|
152
|
+
for (const key of namedKeys) delete params[key];
|
|
153
|
+
for (let i = 0; i < variantPa.length; i++) {
|
|
154
|
+
params[variantPa[i]!] = values[i]!;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
118
158
|
/**
|
|
119
159
|
* Result of content negotiation for a route with negotiate variants.
|
|
120
160
|
*/
|
|
@@ -165,8 +205,10 @@ export async function negotiateRoute(
|
|
|
165
205
|
|
|
166
206
|
const acceptEntries = parseAcceptTypes(request.headers.get("accept") || "");
|
|
167
207
|
|
|
168
|
-
|
|
169
|
-
|
|
208
|
+
// Variants carry the variant's own pa (positional param names); the synthetic
|
|
209
|
+
// primary/RSC candidates have none (their params are already keyed correctly).
|
|
210
|
+
const variants = matched.negotiateVariants as NegotiateVariant[];
|
|
211
|
+
let candidates: NegotiateVariant[];
|
|
170
212
|
if (responseType) {
|
|
171
213
|
candidates = [...variants, { routeKey: matched.routeKey, responseType }];
|
|
172
214
|
} else {
|
|
@@ -194,6 +236,12 @@ export async function negotiateRoute(
|
|
|
194
236
|
negotiated: true,
|
|
195
237
|
};
|
|
196
238
|
}
|
|
239
|
+
// The trie extracted params under the PRIMARY leaf's pa, but the winning
|
|
240
|
+
// variant's handler is keyed by the variant's own param names. Re-key in place
|
|
241
|
+
// so plan.route.params (and the variant middleware collected just below) see
|
|
242
|
+
// the variant's names. No-op when the variant has no pa or shares the primary's
|
|
243
|
+
// names (the common case).
|
|
244
|
+
rekeyParamsForVariant(matched.params, variant.pa);
|
|
197
245
|
const negotiateEntry = await loadManifest(
|
|
198
246
|
matched.entry,
|
|
199
247
|
variant.routeKey,
|
|
@@ -24,6 +24,7 @@ import { getGlobalRouteMap } from "../route-map-builder.js";
|
|
|
24
24
|
import {
|
|
25
25
|
handleHandlerResult,
|
|
26
26
|
warnOnStreamedResponse,
|
|
27
|
+
buildLoaderErrorContext,
|
|
27
28
|
} from "./segment-resolution.js";
|
|
28
29
|
import type { SegmentResolutionDeps } from "./types.js";
|
|
29
30
|
import { debugLog } from "./logging.js";
|
|
@@ -220,6 +221,9 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
220
221
|
parentEntry,
|
|
221
222
|
segmentId,
|
|
222
223
|
context.pathname,
|
|
224
|
+
// Report a throwing intercept loader to onError + loader.error telemetry,
|
|
225
|
+
// matching the fresh/revalidation paths.
|
|
226
|
+
buildLoaderErrorContext(context),
|
|
223
227
|
),
|
|
224
228
|
);
|
|
225
229
|
}
|
|
@@ -393,6 +397,11 @@ export async function resolveInterceptLoadersOnly<TEnv>(
|
|
|
393
397
|
parentEntry,
|
|
394
398
|
segmentId,
|
|
395
399
|
context.pathname,
|
|
400
|
+
// Report a throwing intercept loader to onError + loader.error telemetry,
|
|
401
|
+
// matching the fresh/revalidation paths. resolveInterceptLoadersOnly is
|
|
402
|
+
// only called on the cache-hit partial-update path (handleCacheHitIntercept),
|
|
403
|
+
// so flag isPartial:true exactly like revalidation.ts's partial path.
|
|
404
|
+
{ ...buildLoaderErrorContext(context), isPartial: true },
|
|
396
405
|
),
|
|
397
406
|
);
|
|
398
407
|
}
|
|
@@ -155,7 +155,16 @@ export function withCacheStore<TEnv>(
|
|
|
155
155
|
createHandleStore,
|
|
156
156
|
} = getRouterContext<TEnv>();
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
// On a fresh intercept miss the intercept slot segments flow through
|
|
159
|
+
// `source` into allSegments AND are also recorded on state.interceptSegments.
|
|
160
|
+
// Dedup by id so each segment is cached once — cacheRoute serializes the
|
|
161
|
+
// array verbatim (no id-dedup), so an un-deduped append doubles the intercept
|
|
162
|
+
// segment in the cached entry, re-rendering the slot twice on the next hit.
|
|
163
|
+
const seenSegmentIds = new Set(allSegments.map((s) => s.id));
|
|
164
|
+
const allSegmentsToCache = [
|
|
165
|
+
...allSegments,
|
|
166
|
+
...state.interceptSegments.filter((s) => !seenSegmentIds.has(s.id)),
|
|
167
|
+
];
|
|
159
168
|
|
|
160
169
|
const hasNullComponents = allSegmentsToCache.some(
|
|
161
170
|
(s) =>
|
package/src/router/middleware.ts
CHANGED
|
@@ -322,9 +322,16 @@ export function matchMiddleware<TEnv>(
|
|
|
322
322
|
continue;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
325
|
+
// Run the scope regex ONCE per entry. The old code ran test() then, on a
|
|
326
|
+
// hit, extractParams' match() — a second full pass over the same string for
|
|
327
|
+
// every matching entry on the per-request hot path. The regexes carry no
|
|
328
|
+
// `g` flag, so there is no lastIndex statefulness across this single match.
|
|
329
|
+
const m = pathname.match(entry.regex);
|
|
330
|
+
if (m) {
|
|
331
|
+
const params: Record<string, string> = {};
|
|
332
|
+
for (let i = 0; i < entry.paramNames.length; i++) {
|
|
333
|
+
params[entry.paramNames[i]] = safeDecodeURIComponent(m[i + 1] || "");
|
|
334
|
+
}
|
|
328
335
|
matches.push({ entry, params });
|
|
329
336
|
}
|
|
330
337
|
}
|
|
@@ -267,32 +267,34 @@ function buildParamsFromMatch(
|
|
|
267
267
|
export function extractStaticPrefix(pattern: string): string {
|
|
268
268
|
if (!pattern || pattern === "/") return "";
|
|
269
269
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return "";
|
|
270
|
+
// Walk segments and stop at the first that is a real param (`:name`) or a
|
|
271
|
+
// wildcard (`*`). A literal `:` or `*` not at a segment boundary (e.g. the
|
|
272
|
+
// `a:b` in `/a:b/c/:id`, or `tel:+1`) is a STATIC segment and must NOT
|
|
273
|
+
// terminate the prefix — `pattern.indexOf(":")` misread it as a param marker,
|
|
274
|
+
// returning "" and dropping the findMatch fast-skip optimization for that
|
|
275
|
+
// entry on every request. Classification mirrors parsePattern: a leading `:`
|
|
276
|
+
// marks a param, a leading `*` marks a wildcard.
|
|
277
|
+
const hasLeadingSlash = pattern.startsWith("/");
|
|
278
|
+
const body = hasLeadingSlash ? pattern.slice(1) : pattern;
|
|
279
|
+
const segments = body.split("/");
|
|
280
|
+
|
|
281
|
+
const staticSegments: string[] = [];
|
|
282
|
+
for (const segment of segments) {
|
|
283
|
+
if (segment.startsWith(":") || segment.startsWith("*")) {
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
staticSegments.push(segment);
|
|
288
287
|
}
|
|
289
288
|
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
289
|
+
// No leading static segment (first segment is a param/wildcard) -> no prefix.
|
|
290
|
+
if (staticSegments.length === 0) return "";
|
|
291
|
+
// Every segment was static (no param/wildcard) -> the whole pattern is the
|
|
292
|
+
// prefix. Preserve a trailing slash only when it existed in the input; a
|
|
293
|
+
// split of "/a/b" yields ["a","b"] (no empty tail) so a re-join is exact.
|
|
294
|
+
if (staticSegments.length === segments.length) return pattern;
|
|
294
295
|
|
|
295
|
-
|
|
296
|
+
const prefix = staticSegments.join("/");
|
|
297
|
+
return hasLeadingSlash ? "/" + prefix : prefix;
|
|
296
298
|
}
|
|
297
299
|
|
|
298
300
|
/**
|