@rangojs/router 0.30.1 → 0.32.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/dist/bin/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/dist/bin/rango.js +7891 -197
- package/dist/types/browser/history-state.d.ts +13 -2
- package/dist/types/browser/react/Link.d.ts +10 -13
- package/dist/types/browser/react/location-state-shared.d.ts +2 -1
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/include-resolution.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +3 -7
- package/dist/types/cache/document-cache-policy.d.ts +1 -1
- package/dist/types/route-definition/redirect.d.ts +5 -0
- package/dist/types/router/middleware-types.d.ts +6 -0
- package/dist/types/rsc/helpers.d.ts +5 -3
- package/dist/types/rsc/request-stages.d.ts +2 -0
- package/dist/types/server/document-location-state.d.ts +56 -0
- package/dist/types/server/request-context.d.ts +23 -6
- package/dist/types/testing/render-route.d.ts +17 -0
- package/dist/types/testing/serve-shell-request.d.ts +9 -0
- package/dist/types/types/handler-context.d.ts +18 -3
- package/dist/types/types/segments.d.ts +6 -0
- package/dist/types/vite/temp-rsc-server.d.ts +8 -4
- package/dist/types/vite/utils/forward-user-plugins.d.ts +33 -1
- package/dist/vite/index.js +121 -38
- package/package.json +2 -2
- package/skills/comparison/references/framework-comparison.md +3 -2
- package/skills/document-cache/SKILL.md +5 -0
- package/skills/hooks/state.md +110 -6
- package/skills/intercept/SKILL.md +2 -1
- package/skills/ppr/SKILL.md +12 -0
- package/skills/rango/SKILL.md +5 -5
- package/skills/server-actions/SKILL.md +8 -0
- package/skills/testing/client-components.md +44 -19
- package/skills/testing/serve-shell-request.md +38 -13
- package/skills/typesafety/generated-files-and-cli.md +2 -1
- package/skills/ux-architecture/SKILL.md +6 -3
- package/src/bin/rango.ts +13 -8
- package/src/browser/history-state.ts +24 -2
- package/src/browser/react/Link.tsx +10 -13
- package/src/browser/react/location-state-shared.ts +8 -1
- package/src/browser/rsc-router.tsx +5 -2
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-types/include-resolution.ts +2 -1
- package/src/build/route-types/router-processing.ts +213 -49
- package/src/build/runtime-discovery.ts +128 -41
- package/src/cache/document-cache-policy.ts +10 -1
- package/src/cache/document-cache.ts +7 -0
- package/src/client-urls/server-projection.ts +3 -1
- package/src/route-definition/redirect.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +23 -6
- package/src/router/match-result.ts +9 -1
- package/src/router/middleware-types.ts +6 -0
- package/src/router/revalidation.ts +23 -3
- package/src/rsc/helpers.ts +13 -4
- package/src/rsc/progressive-enhancement.ts +5 -0
- package/src/rsc/request-stages.ts +28 -1
- package/src/rsc/rsc-rendering.ts +39 -9
- package/src/server/document-location-state.ts +313 -0
- package/src/server/request-context.ts +24 -5
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +40 -6
- package/src/testing/run-revalidate.ts +11 -1
- package/src/testing/serve-shell-request.ts +16 -0
- package/src/types/handler-context.ts +18 -3
- package/src/types/segments.ts +6 -0
- package/src/vite/temp-rsc-server.ts +9 -4
- package/src/vite/utils/forward-user-plugins.ts +15 -5
|
@@ -30,19 +30,20 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
|
|
|
30
30
|
|
|
31
31
|
### Returns — `ServeShellRequestResult`
|
|
32
32
|
|
|
33
|
-
| Field
|
|
34
|
-
|
|
|
35
|
-
| `shellStatus`
|
|
36
|
-
| `replayStatus`
|
|
37
|
-
| `prelude`
|
|
38
|
-
| `flight`
|
|
39
|
-
| `key`
|
|
40
|
-
| `readEntry`
|
|
41
|
-
| `readHandles`
|
|
42
|
-
| `readDeferred`
|
|
43
|
-
| `readAction`
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
33
|
+
| Field | Type | Meaning |
|
|
34
|
+
| ------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
|
|
36
|
+
| `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
|
|
37
|
+
| `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
|
|
38
|
+
| `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
|
|
39
|
+
| `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
|
|
40
|
+
| `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
|
|
41
|
+
| `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
|
|
42
|
+
| `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
|
|
43
|
+
| `readAction` | `() => Promise<ShellRequestAction \| undefined>` | For an `action` request, the call's answer as the browser reads it: `{ ok, result, revalidationError }` (see "A server action called from the page"). `undefined` when no Flight rendered or the payload carries no action result. |
|
|
44
|
+
| `readLocationState` | `() => Promise<Record<string, unknown> \| undefined>` | The payload's location state as the browser reads it: `{ [Def.__rsc_ls_key]: value }` (see "Location state on a document"). `undefined` when it carries none or no Flight rendered. |
|
|
45
|
+
| `response` | `Response` | Status and headers. Its body is already read. |
|
|
46
|
+
| `body` | `string` | The body text. |
|
|
46
47
|
|
|
47
48
|
### `resetShellTestState(): Promise<void>`
|
|
48
49
|
|
|
@@ -415,6 +416,30 @@ expect(await result.readPayloadFormState()).toEqual(result.formState);
|
|
|
415
416
|
- Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
|
|
416
417
|
- Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
|
|
417
418
|
|
|
419
|
+
## Location state on a document
|
|
420
|
+
|
|
421
|
+
A document carries the location state its handler, middleware or no-JS form action set, in its bootstrap payload; `readLocationState()` reads it as the browser does. A document request that redirects carries its state to the target in a one-shot cookie instead (`Set-Cookie`, `<stateCookieName>-ls`, `__Host-` on https): pass that cookie on the target's request, as the browser does when it follows the redirect, and read what the target's payload carries.
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
const redirected = await serveShellRequest(router, "/payment/callback");
|
|
425
|
+
const setCookie = redirected.response.headers
|
|
426
|
+
.getSetCookie()
|
|
427
|
+
.find((c) => /-ls=/.test(c.split(";")[0]!))!;
|
|
428
|
+
|
|
429
|
+
const target = await serveShellRequest(router, "/checkout", {
|
|
430
|
+
headers: { cookie: setCookie.split(";")[0]!, "sec-fetch-dest": "document" },
|
|
431
|
+
});
|
|
432
|
+
expect(await target.readLocationState()).toEqual({
|
|
433
|
+
[FlashMessage.__rsc_ls_key]: { text: "Payment declined" },
|
|
434
|
+
});
|
|
435
|
+
expect(target.response.headers.get("cache-control")).toBe("private, no-store");
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
- Only a GET document request of the target's pathname reads the cookie, and clears it (`Max-Age=0` on its response). Where the request sends `Sec-Fetch-Dest`, it must be `document`, `iframe` or `frame`: a prefetch or `fetch()` (`empty`) leaves the cookie to the navigation. Without the header the request counts as a navigation.
|
|
439
|
+
- A document that carries state, and the redirect carrying it on, leave `Cache-Control: private, no-store`; the document cache neither stores nor serves them. On a `ppr` route, state set before its handler ran, by middleware or a redirect's cookie, rides the live payload: the request is still a `MISS` that captures or a `HIT`, and `prelude` holds none of it. State its handler sets keeps the route from being stored as a shell (`readEntry()` is null).
|
|
440
|
+
- For a `form` request, the re-rendered document carries what the action set; a redirecting action carries it in the cookie, as above.
|
|
441
|
+
- What a reader shows is client-side: seed it with `renderRoute({ hydrate: true, documentLocationState })` (see [`./client-components.md`](./client-components.md)).
|
|
442
|
+
|
|
418
443
|
## A server action called from the page
|
|
419
444
|
|
|
420
445
|
`serveShellRequest(router, url, { action: { ref, id, args, segments } })` sends the request a JS server action call makes from the page at `url`, as `browser/server-action-bridge.ts` sends it: a POST of `args` with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments`. `ref` is registered under `id` (`<module id>#<export name>`, any unique string of that shape in a test) so the handler loads it by id. The response is the action's Flight payload: its return value and the segments it re-rendered, `flight` as for any other request. Pass the segment ids the page holds as `segments`, so the request has segments to revalidate; without them the server holds none. Read them from the `metadata.matched` of a `partial` request's payload (`flight`), as in "Testing `prefetch: false`" above.
|
|
@@ -111,7 +111,8 @@ imports it directly, so `Rango.Env`, `Rango.Vars`, and optional
|
|
|
111
111
|
and client helpers.
|
|
112
112
|
|
|
113
113
|
CLI flags: the default mode fails (and writes nothing) when an `include()` cannot
|
|
114
|
-
be resolved statically (factory functions, dynamic expressions)
|
|
114
|
+
be resolved statically (factory functions, dynamic expressions) or an explicit
|
|
115
|
+
`createRouter` `basename` is not a string literal or top-level const; `--static`
|
|
115
116
|
accepts partial output with warnings; `--runtime` uses Vite-based discovery for
|
|
116
117
|
full coverage (needs `vite`, optional
|
|
117
118
|
`--config <path>`). Passing a directory scans it for router and `urls()` files.
|
|
@@ -313,9 +313,12 @@ The rule: **prefetch broadly, and flag what is expensive.**
|
|
|
313
313
|
- On a server route, `loading(fallback, { prefetch: false })` keeps the whole
|
|
314
314
|
entry behind that fallback out of the prefetch (`/route`, "`loading()`
|
|
315
315
|
options"). A group's `loading()` takes no options.
|
|
316
|
-
-
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
- The default prefetch entry is shared by every source page. When the
|
|
317
|
+
target's `revalidate()` reads `currentParams`, `currentUrl` or
|
|
318
|
+
`fromRouteName` and keeps the segment, the router scopes that prefetch to
|
|
319
|
+
the page that sent it (`x-rsc-prefetch-scope: source`): another page
|
|
320
|
+
prefetches its own. `prefetchKey=":source"` on the Link scopes every
|
|
321
|
+
prefetch of it (`src/browser/react/Link.tsx`, `prefetchKey`).
|
|
319
322
|
|
|
320
323
|
### 2.7 transition() and view transitions
|
|
321
324
|
|
package/src/bin/rango.ts
CHANGED
|
@@ -189,23 +189,23 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
|
|
|
189
189
|
|
|
190
190
|
if (uniqueDiagnostics.length > 0 && mode === "default") {
|
|
191
191
|
// Hard error: no files written
|
|
192
|
-
console.error("\n[rango] Unresolvable
|
|
192
|
+
console.error("\n[rango] Unresolvable routes detected:\n");
|
|
193
193
|
formatDiagnostics(uniqueDiagnostics);
|
|
194
194
|
console.error(
|
|
195
|
-
"\nThe static parser cannot resolve these includes
|
|
196
|
-
"factory functions or dynamic expressions
|
|
195
|
+
"\nThe static parser cannot resolve these includes or basename because " +
|
|
196
|
+
"they use factory functions or dynamic expressions. A basename must be " +
|
|
197
|
+
"a string literal or a top-level const (in the router file or imported " +
|
|
198
|
+
"from a relative module).\n\n" +
|
|
197
199
|
"Options:\n" +
|
|
198
200
|
" rango generate <path> --runtime Use Vite-based discovery (requires vite)\n" +
|
|
199
|
-
" rango generate <path> --static Accept partial output (missing routes above)\n",
|
|
201
|
+
" rango generate <path> --static Accept partial output (missing routes or basename prefix above)\n",
|
|
200
202
|
);
|
|
201
203
|
process.exit(1);
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
if (uniqueDiagnostics.length > 0 && mode === "static") {
|
|
205
207
|
// Warning: partial output accepted
|
|
206
|
-
console.warn(
|
|
207
|
-
"\n[rango] Warning: partial output (unresolvable includes):\n",
|
|
208
|
-
);
|
|
208
|
+
console.warn("\n[rango] Warning: partial output (unresolvable routes):\n");
|
|
209
209
|
formatDiagnostics(uniqueDiagnostics);
|
|
210
210
|
console.warn("");
|
|
211
211
|
}
|
|
@@ -317,7 +317,12 @@ function formatDiagnostics(
|
|
|
317
317
|
diagnostics: Array<UnresolvableInclude & { routerFile: string }>,
|
|
318
318
|
) {
|
|
319
319
|
for (const d of diagnostics) {
|
|
320
|
-
const prefix =
|
|
320
|
+
const prefix =
|
|
321
|
+
d.reason === "unresolvable-basename"
|
|
322
|
+
? "createRouter basename"
|
|
323
|
+
: d.namePrefix
|
|
324
|
+
? `${d.namePrefix}.*`
|
|
325
|
+
: `${d.pathPrefix}*`;
|
|
321
326
|
console.error(` ${prefix}`);
|
|
322
327
|
console.error(` Reason: ${d.reason} -- ${d.detail}`);
|
|
323
328
|
console.error(` Source: ${d.sourceFile}`);
|
|
@@ -158,8 +158,9 @@ export function stripShellMissMarker(): void {
|
|
|
158
158
|
* the server rendered this document without them. One pass before hydration
|
|
159
159
|
* (initBrowserApp; renderRoute's `hydrate` mode), so no reader needs to know
|
|
160
160
|
* the page began as a document load, and a slot nobody reads is removed too.
|
|
161
|
-
*
|
|
162
|
-
* (
|
|
161
|
+
* Server-set state is not lost to it: the state a document response carries
|
|
162
|
+
* is merged after this pass (applyDocumentLocationState), so a `~r` slot the
|
|
163
|
+
* server set for this load survives this load and no later one.
|
|
163
164
|
*
|
|
164
165
|
* A slot is recognised by its key alone (the `~r` suffix): at start-up the
|
|
165
166
|
* module that defines it may not be loaded.
|
|
@@ -185,6 +186,27 @@ export function clearLocationStateOnDocumentLoad(): void {
|
|
|
185
186
|
if (next) window.history.replaceState(next, "");
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Merge the location state a document response carried (its bootstrap
|
|
191
|
+
* payload's `metadata.locationState`, server/document-location-state.ts)
|
|
192
|
+
* into the entry the document loaded on. Start-up runs it after
|
|
193
|
+
* clearLocationStateOnDocumentLoad and the version is set, before the entry
|
|
194
|
+
* is committed for readers (initBrowserApp; renderRoute's `hydrate` mode),
|
|
195
|
+
* so readers get it once they have hydrated, as stored state. Only typed
|
|
196
|
+
* slots are taken.
|
|
197
|
+
*/
|
|
198
|
+
export function applyDocumentLocationState(
|
|
199
|
+
locationState: Record<string, unknown> | undefined,
|
|
200
|
+
): void {
|
|
201
|
+
if (!locationState) return;
|
|
202
|
+
let slots: Record<string, unknown> | undefined;
|
|
203
|
+
for (const key of Object.keys(locationState)) {
|
|
204
|
+
if (!isLocationStateKey(key)) continue;
|
|
205
|
+
(slots ??= {})[key] = locationState[key];
|
|
206
|
+
}
|
|
207
|
+
if (slots) mergeLocationState(slots);
|
|
208
|
+
}
|
|
209
|
+
|
|
188
210
|
/**
|
|
189
211
|
* Merge server-set location state into the current history entry and return
|
|
190
212
|
* the entry state written. Readers see it when the caller commits it
|
|
@@ -110,23 +110,20 @@ export interface LinkProps extends Omit<
|
|
|
110
110
|
* keyed on Rango state + target URL. This is correct for routes whose
|
|
111
111
|
* response shape doesn't depend on where the user navigates from.
|
|
112
112
|
*
|
|
113
|
-
* Set `":source"`
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* `currentUrl` / `currentParams`, and the wildcard entry would
|
|
117
|
-
* therefore serve the wrong diff to a navigation from a different
|
|
118
|
-
* source.
|
|
113
|
+
* Set `":source"` to scope every prefetch of this Link to the page that
|
|
114
|
+
* sent it, when its response would differ by source page in a way the
|
|
115
|
+
* server cannot see.
|
|
119
116
|
*
|
|
120
|
-
*
|
|
121
|
-
* (
|
|
122
|
-
*
|
|
123
|
-
* both source-specific
|
|
124
|
-
*
|
|
117
|
+
* The server scopes a prefetch to its source automatically
|
|
118
|
+
* (`x-rsc-prefetch-scope: source`) when an intercept targets the route,
|
|
119
|
+
* whether or not it applies from this page (the modal and the full page
|
|
120
|
+
* are both source-specific), and when a `revalidate()` of the target
|
|
121
|
+
* read `currentParams`, `currentUrl` or `fromRouteName` and kept the
|
|
122
|
+
* segment: that diff is the source page's (#1331). Every other response
|
|
123
|
+
* stays shared.
|
|
125
124
|
*
|
|
126
125
|
* @example
|
|
127
126
|
* ```tsx
|
|
128
|
-
* // Route uses a `revalidate()` that branches on currentUrl — opt in
|
|
129
|
-
* // so prefetches don't bleed across source pages.
|
|
130
127
|
* <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
|
|
131
128
|
* ```
|
|
132
129
|
*/
|
|
@@ -462,6 +462,12 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
|
|
|
462
462
|
* The key is auto-injected by the Vite exposeInternalIds plugin as a property
|
|
463
463
|
* based on file path and export name. No manual key required.
|
|
464
464
|
*
|
|
465
|
+
* One definition is one slot in `history.state`. Entries of different
|
|
466
|
+
* definitions accumulate, but for the same definition the last write wins:
|
|
467
|
+
* two `ctx.setLocationState(Def(...))` calls in one response, or
|
|
468
|
+
* `redirect(url, { state: [Def(a), Def(b)] })`, deliver only the later
|
|
469
|
+
* value. To combine writes, make the state an array and write it once.
|
|
470
|
+
*
|
|
465
471
|
* @param options Optional configuration
|
|
466
472
|
* @returns A typed state definition for use with Link and useLocationState
|
|
467
473
|
*
|
|
@@ -675,7 +681,8 @@ export function isLocationStateEntry(
|
|
|
675
681
|
}
|
|
676
682
|
|
|
677
683
|
/**
|
|
678
|
-
* Resolve state entries into a flat object for history.state
|
|
684
|
+
* Resolve state entries into a flat object for history.state. One slot per
|
|
685
|
+
* definition: a later entry of the same definition replaces an earlier one.
|
|
679
686
|
*/
|
|
680
687
|
export function resolveLocationStateEntries(
|
|
681
688
|
entries: LocationStateEntry[],
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { createAppShellRef } from "./app-shell.js";
|
|
48
48
|
import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
|
|
49
49
|
import {
|
|
50
|
+
applyDocumentLocationState,
|
|
50
51
|
clearLocationStateOnDocumentLoad,
|
|
51
52
|
stripShellMissMarker,
|
|
52
53
|
} from "./history-state.js";
|
|
@@ -274,8 +275,10 @@ export async function initBrowserApp(
|
|
|
274
275
|
setSuspenseAuditStreamProbe(() => eventController.getState().isStreaming);
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
// The document
|
|
278
|
-
//
|
|
278
|
+
// The state the document response carried, after the clearOnReload pass
|
|
279
|
+
// and under the version set above.
|
|
280
|
+
applyDocumentLocationState(initialPayload.metadata?.locationState);
|
|
281
|
+
// The document's entry: what readers get once they have hydrated.
|
|
279
282
|
eventController.commitLocationState(window.history.state);
|
|
280
283
|
|
|
281
284
|
// Initialize event controller with segment order (even without handles)
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from "./include-resolution.js";
|
|
27
27
|
import { findUrlsVariableNames } from "./per-module-writer.js";
|
|
28
28
|
import { isAutoGeneratedRouteName } from "../../route-name.js";
|
|
29
|
+
import { normalizeBasename } from "../../router/basename.js";
|
|
30
|
+
import { getStringValue } from "./ast-helpers.js";
|
|
29
31
|
|
|
30
32
|
function countPublicRouteEntries(source: string): number {
|
|
31
33
|
const matches =
|
|
@@ -232,15 +234,16 @@ function collectCreateRouterCallChain(
|
|
|
232
234
|
}
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
237
|
+
// The first createRouter chain (in source order) that mounts urls, with its
|
|
238
|
+
// extractions in runtime order. Route types and basename both come from it.
|
|
239
|
+
function findRouterUrls(sourceFile: ts.SourceFile): {
|
|
240
|
+
createRouterCall: ts.CallExpression;
|
|
241
|
+
extractions: UrlsExtractionResult[];
|
|
242
|
+
} | null {
|
|
243
|
+
let result: {
|
|
244
|
+
createRouterCall: ts.CallExpression;
|
|
245
|
+
extractions: UrlsExtractionResult[];
|
|
246
|
+
} | null = null;
|
|
244
247
|
|
|
245
248
|
function visit(node: ts.Node) {
|
|
246
249
|
if (result) return;
|
|
@@ -279,7 +282,7 @@ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
|
|
|
279
282
|
}
|
|
280
283
|
|
|
281
284
|
if (extracted.length > 0) {
|
|
282
|
-
result = extracted;
|
|
285
|
+
result = { createRouterCall, extractions: extracted };
|
|
283
286
|
return;
|
|
284
287
|
}
|
|
285
288
|
}
|
|
@@ -288,7 +291,18 @@ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
|
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
visit(sourceFile);
|
|
291
|
-
return result
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
|
|
298
|
+
const sourceFile = ts.createSourceFile(
|
|
299
|
+
"router.tsx",
|
|
300
|
+
code,
|
|
301
|
+
ts.ScriptTarget.Latest,
|
|
302
|
+
true,
|
|
303
|
+
ts.ScriptKind.TSX,
|
|
304
|
+
);
|
|
305
|
+
return findRouterUrls(sourceFile)?.extractions ?? [];
|
|
292
306
|
}
|
|
293
307
|
|
|
294
308
|
/**
|
|
@@ -307,48 +321,189 @@ export function extractUrlsFromRouter(
|
|
|
307
321
|
return extractions[extractions.length - 1] ?? null;
|
|
308
322
|
}
|
|
309
323
|
|
|
324
|
+
function unwrapTypeOperators(expr: ts.Expression): ts.Expression {
|
|
325
|
+
while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) {
|
|
326
|
+
expr = expr.expression;
|
|
327
|
+
}
|
|
328
|
+
return expr;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasModifier(statement: ts.Statement, kind: ts.SyntaxKind): boolean {
|
|
332
|
+
return (
|
|
333
|
+
ts.canHaveModifiers(statement) &&
|
|
334
|
+
(ts.getModifiers(statement)?.some((m) => m.kind === kind) ?? false)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// `exported` limits the lookup to `export const name = ...`; an export list or
|
|
339
|
+
// re-export is not followed.
|
|
340
|
+
function findTopLevelConst(
|
|
341
|
+
sourceFile: ts.SourceFile,
|
|
342
|
+
name: string,
|
|
343
|
+
exported: boolean,
|
|
344
|
+
): ts.Expression | undefined {
|
|
345
|
+
for (const statement of sourceFile.statements) {
|
|
346
|
+
if (
|
|
347
|
+
ts.isVariableStatement(statement) &&
|
|
348
|
+
statement.declarationList.flags & ts.NodeFlags.Const &&
|
|
349
|
+
!hasModifier(statement, ts.SyntaxKind.DeclareKeyword) &&
|
|
350
|
+
(!exported || hasModifier(statement, ts.SyntaxKind.ExportKeyword))
|
|
351
|
+
) {
|
|
352
|
+
for (const decl of statement.declarationList.declarations) {
|
|
353
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === name) {
|
|
354
|
+
return decl.initializer && unwrapTypeOperators(decl.initializer);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The value import binding `name` in the router file: a named import
|
|
363
|
+
// (`import { X }` / `import { Y as X }`). Read from the AST, so a commented-out
|
|
364
|
+
// or quoted import never matches. Default and namespace imports are not read.
|
|
365
|
+
function findNamedImport(
|
|
366
|
+
sourceFile: ts.SourceFile,
|
|
367
|
+
name: string,
|
|
368
|
+
): { specifier: string; exportedName: string } | undefined {
|
|
369
|
+
for (const statement of sourceFile.statements) {
|
|
370
|
+
if (
|
|
371
|
+
!ts.isImportDeclaration(statement) ||
|
|
372
|
+
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
373
|
+
statement.importClause?.isTypeOnly
|
|
374
|
+
) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const bindings = statement.importClause?.namedBindings;
|
|
378
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
379
|
+
for (const el of bindings.elements) {
|
|
380
|
+
if (!el.isTypeOnly && el.name.text === name) {
|
|
381
|
+
return {
|
|
382
|
+
specifier: statement.moduleSpecifier.text,
|
|
383
|
+
exportedName: el.propertyName?.text ?? name,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// The top-level const `name` refers to: declared in the router file or
|
|
392
|
+
// exported as a const by a relative module it is imported from by name.
|
|
393
|
+
function resolveTopLevelConst(
|
|
394
|
+
name: string,
|
|
395
|
+
sourceFile: ts.SourceFile,
|
|
396
|
+
routerFilePath: string,
|
|
397
|
+
): ts.Expression | undefined {
|
|
398
|
+
const local = findTopLevelConst(sourceFile, name, false);
|
|
399
|
+
if (local) return local;
|
|
400
|
+
const imported = findNamedImport(sourceFile, name);
|
|
401
|
+
if (!imported) return undefined;
|
|
402
|
+
const target = resolveImportPath(imported.specifier, routerFilePath);
|
|
403
|
+
if (!target) return undefined;
|
|
404
|
+
const targetSource = readFileSync(target, "utf-8");
|
|
405
|
+
return findTopLevelConst(
|
|
406
|
+
ts.createSourceFile(target, targetSource, ts.ScriptTarget.Latest, true),
|
|
407
|
+
imported.exportedName,
|
|
408
|
+
true,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Inside any nested scope a local declaration can shadow the top-level
|
|
413
|
+
// binding, and resolving it anyway would emit a wrong prefix.
|
|
414
|
+
function isTopLevelScope(node: ts.Node): boolean {
|
|
415
|
+
for (let p = node.parent; !ts.isSourceFile(p); p = p.parent) {
|
|
416
|
+
if (
|
|
417
|
+
ts.isFunctionLike(p) ||
|
|
418
|
+
ts.isClassLike(p) ||
|
|
419
|
+
ts.isBlock(p) ||
|
|
420
|
+
ts.isCaseBlock(p) ||
|
|
421
|
+
ts.isModuleBlock(p) ||
|
|
422
|
+
ts.isIterationStatement(p, false)
|
|
423
|
+
) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
310
430
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
431
|
+
* Read inline createRouter({ basename: X }) where X is a string literal, a
|
|
432
|
+
* top-level const, or `Const.prop` on a top-level const object literal.
|
|
433
|
+
* `unresolved` is the source of any other X.
|
|
313
434
|
*/
|
|
314
|
-
|
|
435
|
+
function resolveRouterBasename(
|
|
436
|
+
routerSource: string,
|
|
437
|
+
routerFilePath: string,
|
|
438
|
+
): { value?: string; unresolved?: string } {
|
|
315
439
|
const sourceFile = ts.createSourceFile(
|
|
316
|
-
|
|
317
|
-
|
|
440
|
+
routerFilePath,
|
|
441
|
+
routerSource,
|
|
318
442
|
ts.ScriptTarget.Latest,
|
|
319
443
|
true,
|
|
320
444
|
ts.ScriptKind.TSX,
|
|
321
445
|
);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
446
|
+
// Read from the createRouter call whose urls the route types use. Only an
|
|
447
|
+
// explicit `basename` key counts: one inside a spread
|
|
448
|
+
// (`createRouter({ ...shared })`) or a non-literal options argument stays
|
|
449
|
+
// unread and unreported.
|
|
450
|
+
const options = findRouterUrls(sourceFile)?.createRouterCall.arguments[0];
|
|
451
|
+
const prop =
|
|
452
|
+
options && ts.isObjectLiteralExpression(options)
|
|
453
|
+
? options.properties.find(
|
|
454
|
+
(p): p is ts.PropertyAssignment | ts.ShorthandPropertyAssignment =>
|
|
455
|
+
(ts.isPropertyAssignment(p) ||
|
|
456
|
+
ts.isShorthandPropertyAssignment(p)) &&
|
|
457
|
+
ts.isIdentifier(p.name) &&
|
|
458
|
+
p.name.text === "basename",
|
|
459
|
+
)
|
|
460
|
+
: undefined;
|
|
461
|
+
if (!prop) return {};
|
|
462
|
+
|
|
463
|
+
const valueExpr = ts.isPropertyAssignment(prop)
|
|
464
|
+
? prop.initializer
|
|
465
|
+
: prop.name;
|
|
466
|
+
const expr = unwrapTypeOperators(valueExpr);
|
|
467
|
+
let value = getStringValue(expr);
|
|
468
|
+
if (value === null && isTopLevelScope(prop)) {
|
|
469
|
+
if (ts.isIdentifier(expr)) {
|
|
470
|
+
const init = resolveTopLevelConst(expr.text, sourceFile, routerFilePath);
|
|
471
|
+
value = init ? getStringValue(init) : null;
|
|
472
|
+
} else if (
|
|
473
|
+
ts.isPropertyAccessExpression(expr) &&
|
|
474
|
+
ts.isIdentifier(expr.expression)
|
|
330
475
|
) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
prop.name.text === "basename" &&
|
|
338
|
-
ts.isStringLiteral(prop.initializer)
|
|
339
|
-
) {
|
|
340
|
-
result = prop.initializer.text;
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
476
|
+
const owner = resolveTopLevelConst(
|
|
477
|
+
expr.expression.text,
|
|
478
|
+
sourceFile,
|
|
479
|
+
routerFilePath,
|
|
480
|
+
);
|
|
481
|
+
value = owner ? resolveConstMember(owner, expr.name.text) : null;
|
|
346
482
|
}
|
|
347
|
-
ts.forEachChild(node, visit);
|
|
348
483
|
}
|
|
484
|
+
return value === null
|
|
485
|
+
? { unresolved: valueExpr.getText(sourceFile).replace(/\s+/g, " ") }
|
|
486
|
+
: { value };
|
|
487
|
+
}
|
|
349
488
|
|
|
350
|
-
|
|
351
|
-
|
|
489
|
+
// String value of `owner.name` when owner is an object literal. A spread may
|
|
490
|
+
// override the property, so an owner containing one is not resolved.
|
|
491
|
+
function resolveConstMember(owner: ts.Expression, name: string): string | null {
|
|
492
|
+
if (
|
|
493
|
+
!ts.isObjectLiteralExpression(owner) ||
|
|
494
|
+
owner.properties.some(ts.isSpreadAssignment)
|
|
495
|
+
) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const member = owner.properties.find(
|
|
499
|
+
(p): p is ts.PropertyAssignment =>
|
|
500
|
+
ts.isPropertyAssignment(p) &&
|
|
501
|
+
ts.isIdentifier(p.name) &&
|
|
502
|
+
p.name.text === name,
|
|
503
|
+
);
|
|
504
|
+
return member
|
|
505
|
+
? getStringValue(unwrapTypeOperators(member.initializer))
|
|
506
|
+
: null;
|
|
352
507
|
}
|
|
353
508
|
|
|
354
509
|
/** Apply a basename prefix to all route patterns in a result set. */
|
|
@@ -496,11 +651,9 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
|
|
|
496
651
|
return { routes: {}, searchSchemas: {} };
|
|
497
652
|
}
|
|
498
653
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
? ("/" + rawBasename.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "")
|
|
503
|
-
: undefined;
|
|
654
|
+
const basename = normalizeBasename(
|
|
655
|
+
resolveRouterBasename(routerSource, routerFilePath).value,
|
|
656
|
+
);
|
|
504
657
|
|
|
505
658
|
const result: RouteMapWithSearch = { routes: {}, searchSchemas: {} };
|
|
506
659
|
const memo = createScanMemo();
|
|
@@ -531,8 +684,9 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
|
|
|
531
684
|
|
|
532
685
|
/**
|
|
533
686
|
* Walk the full include tree starting from a router file and detect
|
|
534
|
-
* all includes that the static parser cannot resolve
|
|
535
|
-
*
|
|
687
|
+
* all includes that the static parser cannot resolve, plus an explicit
|
|
688
|
+
* createRouter `basename` it cannot read (every pattern would lack the
|
|
689
|
+
* prefix). Returns an array of diagnostics; empty means fully resolvable.
|
|
536
690
|
*/
|
|
537
691
|
export function detectUnresolvableIncludes(
|
|
538
692
|
routerFilePath: string,
|
|
@@ -559,6 +713,16 @@ export function detectUnresolvableIncludes(
|
|
|
559
713
|
memo,
|
|
560
714
|
);
|
|
561
715
|
}
|
|
716
|
+
const { unresolved } = resolveRouterBasename(source, realPath);
|
|
717
|
+
if (unresolved !== undefined) {
|
|
718
|
+
diagnostics.push({
|
|
719
|
+
pathPrefix: "/",
|
|
720
|
+
namePrefix: null,
|
|
721
|
+
reason: "unresolvable-basename",
|
|
722
|
+
sourceFile: realPath,
|
|
723
|
+
detail: `basename \`${unresolved}\` is not a string literal or top-level const; every pattern would lack the prefix`,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
562
726
|
return diagnostics;
|
|
563
727
|
}
|
|
564
728
|
|