@rangojs/router 0.0.0-experimental.80 → 0.0.0-experimental.81
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/vite/index.js +128 -3
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +14 -15
- package/skills/hooks/SKILL.md +24 -18
- package/skills/migrate-react-router/SKILL.md +1 -0
- package/src/browser/react/NavigationProvider.tsx +6 -2
- package/src/browser/react/use-params.ts +11 -1
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/router-discovery.ts +60 -1
package/dist/vite/index.js
CHANGED
|
@@ -1864,7 +1864,7 @@ import { resolve } from "node:path";
|
|
|
1864
1864
|
// package.json
|
|
1865
1865
|
var package_default = {
|
|
1866
1866
|
name: "@rangojs/router",
|
|
1867
|
-
version: "0.0.0-experimental.
|
|
1867
|
+
version: "0.0.0-experimental.81",
|
|
1868
1868
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1869
1869
|
keywords: [
|
|
1870
1870
|
"react",
|
|
@@ -1997,7 +1997,7 @@ var package_default = {
|
|
|
1997
1997
|
tag: "experimental"
|
|
1998
1998
|
},
|
|
1999
1999
|
scripts: {
|
|
2000
|
-
build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
2000
|
+
build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
2001
2001
|
prepublishOnly: "pnpm build",
|
|
2002
2002
|
typecheck: "tsc --noEmit",
|
|
2003
2003
|
test: "playwright test",
|
|
@@ -3260,7 +3260,7 @@ function createCjsToEsmPlugin() {
|
|
|
3260
3260
|
import { createServer as createViteServer } from "vite";
|
|
3261
3261
|
import { resolve as resolve8 } from "node:path";
|
|
3262
3262
|
import { readFileSync as readFileSync6 } from "node:fs";
|
|
3263
|
-
import { createRequire } from "node:module";
|
|
3263
|
+
import { createRequire, register } from "node:module";
|
|
3264
3264
|
import { pathToFileURL } from "node:url";
|
|
3265
3265
|
|
|
3266
3266
|
// src/vite/plugins/virtual-stub-plugin.ts
|
|
@@ -3287,6 +3287,113 @@ function createVirtualStubPlugin() {
|
|
|
3287
3287
|
};
|
|
3288
3288
|
}
|
|
3289
3289
|
|
|
3290
|
+
// src/vite/plugins/cloudflare-protocol-stub.ts
|
|
3291
|
+
var VIRTUAL_PREFIX = "virtual:rango-cloudflare-stub-";
|
|
3292
|
+
var NULL_PREFIX = "\0" + VIRTUAL_PREFIX;
|
|
3293
|
+
var CF_PREFIX = "cloudflare:";
|
|
3294
|
+
var BUILD_ENV_GLOBAL_KEY = "__rango_build_env__";
|
|
3295
|
+
var SOURCE_EXT_RE = /\.[mc]?[jt]sx?$/;
|
|
3296
|
+
var IMPORT_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
3297
|
+
"ImportDeclaration",
|
|
3298
|
+
"ImportExpression",
|
|
3299
|
+
"ExportNamedDeclaration",
|
|
3300
|
+
"ExportAllDeclaration"
|
|
3301
|
+
]);
|
|
3302
|
+
var STUBS = {
|
|
3303
|
+
"cloudflare:workers": `
|
|
3304
|
+
export class DurableObject { constructor(_ctx, _env) {} }
|
|
3305
|
+
export class WorkerEntrypoint { constructor(_ctx, _env) {} }
|
|
3306
|
+
export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
|
|
3307
|
+
export class RpcTarget {}
|
|
3308
|
+
export const env = globalThis[${JSON.stringify(BUILD_ENV_GLOBAL_KEY)}] ?? {};
|
|
3309
|
+
export default {};
|
|
3310
|
+
`,
|
|
3311
|
+
"cloudflare:email": `
|
|
3312
|
+
export class EmailMessage { constructor(_from, _to, _raw) {} }
|
|
3313
|
+
export default {};
|
|
3314
|
+
`,
|
|
3315
|
+
"cloudflare:sockets": `
|
|
3316
|
+
export function connect() { return {}; }
|
|
3317
|
+
export default {};
|
|
3318
|
+
`,
|
|
3319
|
+
"cloudflare:workflows": `
|
|
3320
|
+
export class NonRetryableError extends Error {
|
|
3321
|
+
constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
|
|
3322
|
+
}
|
|
3323
|
+
export default {};
|
|
3324
|
+
`
|
|
3325
|
+
};
|
|
3326
|
+
var FALLBACK_STUB = `export default {};
|
|
3327
|
+
`;
|
|
3328
|
+
function createCloudflareProtocolStubPlugin() {
|
|
3329
|
+
return {
|
|
3330
|
+
name: "@rangojs/router:cloudflare-protocol-stub",
|
|
3331
|
+
transform(code, id) {
|
|
3332
|
+
const cleanId = id.split("?")[0] ?? id;
|
|
3333
|
+
if (!SOURCE_EXT_RE.test(cleanId)) return null;
|
|
3334
|
+
if (!code.includes(CF_PREFIX)) return null;
|
|
3335
|
+
let ast;
|
|
3336
|
+
try {
|
|
3337
|
+
ast = this.parse(code);
|
|
3338
|
+
} catch {
|
|
3339
|
+
return null;
|
|
3340
|
+
}
|
|
3341
|
+
const hits = [];
|
|
3342
|
+
walk(ast, (node) => {
|
|
3343
|
+
if (!IMPORT_NODE_TYPES.has(node.type)) return;
|
|
3344
|
+
const source = node.source;
|
|
3345
|
+
if (!source || source.type !== "Literal") return;
|
|
3346
|
+
if (typeof source.value !== "string") return;
|
|
3347
|
+
if (!source.value.startsWith(CF_PREFIX)) return;
|
|
3348
|
+
if (typeof source.start !== "number" || typeof source.end !== "number")
|
|
3349
|
+
return;
|
|
3350
|
+
hits.push({
|
|
3351
|
+
start: source.start,
|
|
3352
|
+
end: source.end,
|
|
3353
|
+
value: source.value
|
|
3354
|
+
});
|
|
3355
|
+
});
|
|
3356
|
+
if (hits.length === 0) return null;
|
|
3357
|
+
hits.sort((a, b) => b.start - a.start);
|
|
3358
|
+
let out = code;
|
|
3359
|
+
for (const hit of hits) {
|
|
3360
|
+
const submodule = hit.value.slice(CF_PREFIX.length);
|
|
3361
|
+
const quote = code[hit.start] === "'" ? "'" : '"';
|
|
3362
|
+
out = out.slice(0, hit.start) + quote + VIRTUAL_PREFIX + submodule + quote + out.slice(hit.end);
|
|
3363
|
+
}
|
|
3364
|
+
return { code: out, map: null };
|
|
3365
|
+
},
|
|
3366
|
+
resolveId(id) {
|
|
3367
|
+
if (id.startsWith(VIRTUAL_PREFIX)) {
|
|
3368
|
+
return "\0" + id;
|
|
3369
|
+
}
|
|
3370
|
+
return null;
|
|
3371
|
+
},
|
|
3372
|
+
load(id) {
|
|
3373
|
+
if (!id.startsWith(NULL_PREFIX)) return null;
|
|
3374
|
+
const submodule = id.slice(NULL_PREFIX.length);
|
|
3375
|
+
const specifier = CF_PREFIX + submodule;
|
|
3376
|
+
return STUBS[specifier] ?? FALLBACK_STUB;
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
function walk(node, visit) {
|
|
3381
|
+
if (!node || typeof node !== "object") return;
|
|
3382
|
+
if (Array.isArray(node)) {
|
|
3383
|
+
for (const child of node) walk(child, visit);
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
const n = node;
|
|
3387
|
+
if (typeof n.type !== "string") return;
|
|
3388
|
+
visit(n);
|
|
3389
|
+
for (const key in n) {
|
|
3390
|
+
if (key === "loc" || key === "start" || key === "end" || key === "range") {
|
|
3391
|
+
continue;
|
|
3392
|
+
}
|
|
3393
|
+
walk(n[key], visit);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3290
3397
|
// src/vite/plugins/client-ref-hashing.ts
|
|
3291
3398
|
import { relative } from "node:path";
|
|
3292
3399
|
import { createHash as createHash2 } from "node:crypto";
|
|
@@ -4682,7 +4789,22 @@ function postprocessBundle(state) {
|
|
|
4682
4789
|
}
|
|
4683
4790
|
|
|
4684
4791
|
// src/vite/router-discovery.ts
|
|
4792
|
+
var loaderHookRegistered = false;
|
|
4793
|
+
function ensureCloudflareProtocolLoaderRegistered() {
|
|
4794
|
+
if (loaderHookRegistered) return;
|
|
4795
|
+
loaderHookRegistered = true;
|
|
4796
|
+
try {
|
|
4797
|
+
register(
|
|
4798
|
+
new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url)
|
|
4799
|
+
);
|
|
4800
|
+
} catch (err) {
|
|
4801
|
+
console.warn(
|
|
4802
|
+
`[rsc-router] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`
|
|
4803
|
+
);
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4685
4806
|
async function createTempRscServer(state, options = {}) {
|
|
4807
|
+
ensureCloudflareProtocolLoaderRegistered();
|
|
4686
4808
|
const { default: rsc } = await import("@vitejs/plugin-rsc");
|
|
4687
4809
|
return createViteServer({
|
|
4688
4810
|
root: state.projectRoot,
|
|
@@ -4705,6 +4827,7 @@ async function createTempRscServer(state, options = {}) {
|
|
|
4705
4827
|
...options.forceBuild ? [hashClientRefs(state.projectRoot)] : [],
|
|
4706
4828
|
createVersionPlugin(),
|
|
4707
4829
|
createVirtualStubPlugin(),
|
|
4830
|
+
createCloudflareProtocolStubPlugin(),
|
|
4708
4831
|
// Dev prerender must use dev-mode IDs (path-based) to match the workerd
|
|
4709
4832
|
// runtime. forceBuild produces hashed IDs for production bundle consistency.
|
|
4710
4833
|
exposeInternalIds(options.forceBuild ? { forceBuild: true } : void 0),
|
|
@@ -4756,6 +4879,7 @@ async function acquireBuildEnv(s, command, mode) {
|
|
|
4756
4879
|
if (!result) return false;
|
|
4757
4880
|
s.resolvedBuildEnv = result.env;
|
|
4758
4881
|
s.buildEnvDispose = result.dispose ?? null;
|
|
4882
|
+
globalThis[BUILD_ENV_GLOBAL_KEY] = result.env;
|
|
4759
4883
|
return true;
|
|
4760
4884
|
}
|
|
4761
4885
|
async function releaseBuildEnv(s) {
|
|
@@ -4768,6 +4892,7 @@ async function releaseBuildEnv(s) {
|
|
|
4768
4892
|
s.buildEnvDispose = null;
|
|
4769
4893
|
}
|
|
4770
4894
|
s.resolvedBuildEnv = void 0;
|
|
4895
|
+
delete globalThis[BUILD_ENV_GLOBAL_KEY];
|
|
4771
4896
|
}
|
|
4772
4897
|
function createRouterDiscoveryPlugin(entryPath, opts) {
|
|
4773
4898
|
const s = createDiscoveryState(entryPath, opts);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Node ESM loader hook that resolves `cloudflare:*` imports to the same
|
|
2
|
+
// stub ESM the Vite transform produces for rewritten specifiers.
|
|
3
|
+
//
|
|
4
|
+
// Why both? The Vite transform (cloudflare-protocol-stub.ts) catches
|
|
5
|
+
// imports in modules that flow through Vite's plugin pipeline — covers
|
|
6
|
+
// user source and any node_modules package Vite fetches and transforms.
|
|
7
|
+
// But Vite/Rollup externalize certain packages (e.g. `partyserver`,
|
|
8
|
+
// which has `import { DurableObject, env } from "cloudflare:workers"`
|
|
9
|
+
// at its top level, and similar "workerd-native" libraries). Externalized
|
|
10
|
+
// modules bypass the transform: Rollup hands their resolution to Node's
|
|
11
|
+
// native ESM loader, which rejects URL-scheme specifiers. This loader
|
|
12
|
+
// hook registers via `module.register()` from `createTempRscServer` and
|
|
13
|
+
// intercepts `cloudflare:*` at Node's resolve layer — before the default
|
|
14
|
+
// loader throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
15
|
+
//
|
|
16
|
+
// Lifecycle: the hook runs in a dedicated worker thread (Node ESM loader
|
|
17
|
+
// architecture) with its own globalThis. It cannot see the main thread's
|
|
18
|
+
// `__rango_build_env__` bridge, so the `env` export here is always `{}`.
|
|
19
|
+
// That's fine in practice — externalized libraries don't typically touch
|
|
20
|
+
// `env` at module top level; they read it at request time in workerd
|
|
21
|
+
// where the real module exists. Build-time prerender handlers in user
|
|
22
|
+
// source DO read `env`, but they flow through the Vite transform (which
|
|
23
|
+
// does bridge `env` from `getPlatformProxy()`), not through this loader.
|
|
24
|
+
//
|
|
25
|
+
// Keep STUBS in sync with cloudflare-protocol-stub.ts — both paths need
|
|
26
|
+
// to hand out the same base classes.
|
|
27
|
+
|
|
28
|
+
const CF_PREFIX = "cloudflare:";
|
|
29
|
+
|
|
30
|
+
const STUBS = {
|
|
31
|
+
"cloudflare:workers": `
|
|
32
|
+
export class DurableObject { constructor(_ctx, _env) {} }
|
|
33
|
+
export class WorkerEntrypoint { constructor(_ctx, _env) {} }
|
|
34
|
+
export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
|
|
35
|
+
export class RpcTarget {}
|
|
36
|
+
export const env = {};
|
|
37
|
+
export default {};
|
|
38
|
+
`,
|
|
39
|
+
"cloudflare:email": `
|
|
40
|
+
export class EmailMessage { constructor(_from, _to, _raw) {} }
|
|
41
|
+
export default {};
|
|
42
|
+
`,
|
|
43
|
+
"cloudflare:sockets": `
|
|
44
|
+
export function connect() { return {}; }
|
|
45
|
+
export default {};
|
|
46
|
+
`,
|
|
47
|
+
"cloudflare:workflows": `
|
|
48
|
+
export class NonRetryableError extends Error {
|
|
49
|
+
constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
|
|
50
|
+
}
|
|
51
|
+
export default {};
|
|
52
|
+
`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Policy: unknown `cloudflare:*` specifiers resolve permissively to an
|
|
56
|
+
// empty default export rather than throwing. Same reasoning as
|
|
57
|
+
// cloudflare-protocol-stub.ts's FALLBACK_STUB — we prioritize
|
|
58
|
+
// dependency-graph resilience over strict validation, because third-party
|
|
59
|
+
// packages can pull `cloudflare:*` modules we haven't curated.
|
|
60
|
+
const FALLBACK_STUB = `export default {};\n`;
|
|
61
|
+
|
|
62
|
+
function dataUrlFor(specifier) {
|
|
63
|
+
const body = STUBS[specifier] ?? FALLBACK_STUB;
|
|
64
|
+
return "data:text/javascript;base64," + Buffer.from(body).toString("base64");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
68
|
+
if (specifier.startsWith(CF_PREFIX)) {
|
|
69
|
+
return {
|
|
70
|
+
shortCircuit: true,
|
|
71
|
+
url: dataUrlFor(specifier),
|
|
72
|
+
format: "module",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return nextResolve(specifier, context);
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.81",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -132,15 +132,6 @@
|
|
|
132
132
|
"access": "public",
|
|
133
133
|
"tag": "experimental"
|
|
134
134
|
},
|
|
135
|
-
"scripts": {
|
|
136
|
-
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
137
|
-
"prepublishOnly": "pnpm build",
|
|
138
|
-
"typecheck": "tsc --noEmit",
|
|
139
|
-
"test": "playwright test",
|
|
140
|
-
"test:ui": "playwright test --ui",
|
|
141
|
-
"test:unit": "vitest run",
|
|
142
|
-
"test:unit:watch": "vitest"
|
|
143
|
-
},
|
|
144
135
|
"dependencies": {
|
|
145
136
|
"@vitejs/plugin-rsc": "^0.5.23",
|
|
146
137
|
"magic-string": "^0.30.17",
|
|
@@ -150,12 +141,12 @@
|
|
|
150
141
|
"devDependencies": {
|
|
151
142
|
"@playwright/test": "^1.49.1",
|
|
152
143
|
"@types/node": "^24.10.1",
|
|
153
|
-
"@types/react": "
|
|
154
|
-
"@types/react-dom": "
|
|
144
|
+
"@types/react": "^19.2.7",
|
|
145
|
+
"@types/react-dom": "^19.2.3",
|
|
155
146
|
"esbuild": "^0.27.0",
|
|
156
147
|
"jiti": "^2.6.1",
|
|
157
|
-
"react": "
|
|
158
|
-
"react-dom": "
|
|
148
|
+
"react": "^19.2.4",
|
|
149
|
+
"react-dom": "^19.2.4",
|
|
159
150
|
"tinyexec": "^0.3.2",
|
|
160
151
|
"typescript": "^5.3.0",
|
|
161
152
|
"vitest": "^4.0.0"
|
|
@@ -173,5 +164,13 @@
|
|
|
173
164
|
"vite": {
|
|
174
165
|
"optional": true
|
|
175
166
|
}
|
|
167
|
+
},
|
|
168
|
+
"scripts": {
|
|
169
|
+
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
170
|
+
"typecheck": "tsc --noEmit",
|
|
171
|
+
"test": "playwright test",
|
|
172
|
+
"test:ui": "playwright test --ui",
|
|
173
|
+
"test:unit": "vitest run",
|
|
174
|
+
"test:unit:watch": "vitest"
|
|
176
175
|
}
|
|
177
|
-
}
|
|
176
|
+
}
|
package/skills/hooks/SKILL.md
CHANGED
|
@@ -593,6 +593,12 @@ function ProductPage() {
|
|
|
593
593
|
return <h1>Product {params.productId}</h1>;
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
// Annotate the expected shape via a generic
|
|
597
|
+
function ProductPageTyped() {
|
|
598
|
+
const { productId } = useParams<{ productId: string }>();
|
|
599
|
+
return <h1>Product {productId}</h1>;
|
|
600
|
+
}
|
|
601
|
+
|
|
596
602
|
// With selector for performance (re-renders only when selected value changes)
|
|
597
603
|
function ProductId() {
|
|
598
604
|
const productId = useParams((p) => p.productId);
|
|
@@ -600,7 +606,7 @@ function ProductId() {
|
|
|
600
606
|
}
|
|
601
607
|
```
|
|
602
608
|
|
|
603
|
-
Returns merged params from all matched route segments. Updates on navigation commit (not during pending navigation).
|
|
609
|
+
Returns merged params from all matched route segments as a `Readonly<T>` map. Updates on navigation commit (not during pending navigation).
|
|
604
610
|
|
|
605
611
|
### usePathname()
|
|
606
612
|
|
|
@@ -685,20 +691,20 @@ See `/links` for full URL generation guide including server-side `ctx.reverse`.
|
|
|
685
691
|
|
|
686
692
|
## Hook Summary
|
|
687
693
|
|
|
688
|
-
| Hook | Purpose | Returns
|
|
689
|
-
| -------------------- | --------------------------------- |
|
|
690
|
-
| `useParams()` | Route params | `Record<string, string>` or selected value
|
|
691
|
-
| `usePathname()` | Current pathname | `string`
|
|
692
|
-
| `useSearchParams()` | URL search params | `ReadonlyURLSearchParams`
|
|
693
|
-
| `useHref()` | Mount-aware href | `(path) => string`
|
|
694
|
-
| `useMount()` | Current include() mount path | `string`
|
|
695
|
-
| `useNavigation()` | Reactive navigation state | state, location, isStreaming
|
|
696
|
-
| `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward
|
|
697
|
-
| `useSegments()` | URL path & segment IDs | path, segmentIds, location
|
|
698
|
-
| `useLinkStatus()` | Link pending state | { pending }
|
|
699
|
-
| `useLoader()` | Loader data (strict) | data, isLoading, error
|
|
700
|
-
| `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading
|
|
701
|
-
| `useHandle()` | Accumulated handle data | T (handle type)
|
|
702
|
-
| `useAction()` | Server action state | state, error, result
|
|
703
|
-
| `useLocationState()` | History state (persists or flash) | T \| undefined
|
|
704
|
-
| `useClientCache()` | Cache control | { clear }
|
|
694
|
+
| Hook | Purpose | Returns |
|
|
695
|
+
| -------------------- | --------------------------------- | ------------------------------------------------------------------ |
|
|
696
|
+
| `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
|
|
697
|
+
| `usePathname()` | Current pathname | `string` |
|
|
698
|
+
| `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
|
|
699
|
+
| `useHref()` | Mount-aware href | `(path) => string` |
|
|
700
|
+
| `useMount()` | Current include() mount path | `string` |
|
|
701
|
+
| `useNavigation()` | Reactive navigation state | state, location, isStreaming |
|
|
702
|
+
| `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
|
|
703
|
+
| `useSegments()` | URL path & segment IDs | path, segmentIds, location |
|
|
704
|
+
| `useLinkStatus()` | Link pending state | { pending } |
|
|
705
|
+
| `useLoader()` | Loader data (strict) | data, isLoading, error |
|
|
706
|
+
| `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
|
|
707
|
+
| `useHandle()` | Accumulated handle data | T (handle type) |
|
|
708
|
+
| `useAction()` | Server action state | state, error, result |
|
|
709
|
+
| `useLocationState()` | History state (persists or flash) | T \| undefined |
|
|
710
|
+
| `useClientCache()` | Cache control | { clear } |
|
|
@@ -635,6 +635,7 @@ layout(<ShopLayout />, () => [
|
|
|
635
635
|
| `useLocation().pathname` | `usePathname()` from `@rangojs/router/client` |
|
|
636
636
|
| `useSearchParams()` | `useSearchParams()` from `@rangojs/router/client` |
|
|
637
637
|
| `useParams()` | `useParams()` from `@rangojs/router/client` (or `ctx.params` in server handlers) |
|
|
638
|
+
| `useParams<T>()` | `useParams<T>()` — same generic annotation pattern |
|
|
638
639
|
| `<NavLink>` | `<Link>` with `usePathname()` for active state |
|
|
639
640
|
|
|
640
641
|
### useNavigate → useRouter
|
|
@@ -345,8 +345,12 @@ export function NavigationProvider({
|
|
|
345
345
|
metadata: update.metadata,
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
-
// Update route params
|
|
349
|
-
|
|
348
|
+
// Update route params. Only reset when the server actually sends a params
|
|
349
|
+
// map — an absent `params` field means "no change" (e.g., legacy action
|
|
350
|
+
// responses that omitted params). Explicit `{}` still clears correctly.
|
|
351
|
+
if (update.metadata.params !== undefined) {
|
|
352
|
+
eventController.setParams(update.metadata.params);
|
|
353
|
+
}
|
|
350
354
|
|
|
351
355
|
// Update handle data progressively as it streams in
|
|
352
356
|
if (update.metadata.handles) {
|
|
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
|
|
|
16
16
|
* const params = useParams();
|
|
17
17
|
* // { productId: "123" }
|
|
18
18
|
*
|
|
19
|
+
* // Annotate the expected shape via a generic
|
|
20
|
+
* const { productId } = useParams<{ productId: string }>();
|
|
21
|
+
*
|
|
19
22
|
* // With selector
|
|
20
23
|
* const productId = useParams(p => p.productId);
|
|
21
24
|
* ```
|
|
22
25
|
*/
|
|
23
|
-
|
|
26
|
+
// `T extends object` (not `Record<string, string | undefined>`) so that
|
|
27
|
+
// interface shapes pass the constraint — interfaces lack an implicit
|
|
28
|
+
// index signature and would otherwise be rejected. The generic is a
|
|
29
|
+
// shape annotation, not a runtime check; the body always returns the
|
|
30
|
+
// underlying params map unchanged.
|
|
31
|
+
export function useParams<
|
|
32
|
+
T extends object = Record<string, string>,
|
|
33
|
+
>(): Readonly<T>;
|
|
24
34
|
export function useParams<T>(
|
|
25
35
|
selector: (params: Record<string, string>) => T,
|
|
26
36
|
): T;
|
|
@@ -248,6 +248,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
248
248
|
segments: match.segments,
|
|
249
249
|
matched: match.matched,
|
|
250
250
|
diff: match.diff,
|
|
251
|
+
params: match.params,
|
|
251
252
|
isPartial: false,
|
|
252
253
|
rootLayout: ctx.router.rootLayout,
|
|
253
254
|
handles: handleStore.stream(),
|
|
@@ -353,6 +354,7 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
353
354
|
segments: errorResult.segments,
|
|
354
355
|
matched: errorResult.matched,
|
|
355
356
|
diff: errorResult.diff,
|
|
357
|
+
params: errorResult.params,
|
|
356
358
|
isPartial: false,
|
|
357
359
|
isError: true,
|
|
358
360
|
rootLayout: ctx.router.rootLayout,
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
|
|
|
213
213
|
isPartial: true,
|
|
214
214
|
matched: errorResult.matched,
|
|
215
215
|
diff: errorResult.diff,
|
|
216
|
+
params: errorResult.params,
|
|
216
217
|
isError: true,
|
|
217
218
|
handles: handleStore.stream(),
|
|
218
219
|
version: ctx.version,
|
|
@@ -323,6 +324,7 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
323
324
|
isPartial: true,
|
|
324
325
|
matched: matchResult.matched,
|
|
325
326
|
diff: matchResult.diff,
|
|
327
|
+
params: matchResult.params,
|
|
326
328
|
slots: matchResult.slots,
|
|
327
329
|
handles: handleStore.stream(),
|
|
328
330
|
version: ctx.version,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface LoaderResolveContext {
|
|
2
|
+
parentURL?: string;
|
|
3
|
+
conditions?: readonly string[];
|
|
4
|
+
importAttributes?: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface LoaderResolveResult {
|
|
8
|
+
shortCircuit?: boolean;
|
|
9
|
+
url: string;
|
|
10
|
+
format?: "module" | "commonjs" | "json" | "wasm" | null;
|
|
11
|
+
importAttributes?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type NextResolve = (
|
|
15
|
+
specifier: string,
|
|
16
|
+
context?: LoaderResolveContext,
|
|
17
|
+
) => Promise<LoaderResolveResult>;
|
|
18
|
+
|
|
19
|
+
export function resolve(
|
|
20
|
+
specifier: string,
|
|
21
|
+
context: LoaderResolveContext,
|
|
22
|
+
nextResolve: NextResolve,
|
|
23
|
+
): Promise<LoaderResolveResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Node ESM loader hook that resolves `cloudflare:*` imports to the same
|
|
2
|
+
// stub ESM the Vite transform produces for rewritten specifiers.
|
|
3
|
+
//
|
|
4
|
+
// Why both? The Vite transform (cloudflare-protocol-stub.ts) catches
|
|
5
|
+
// imports in modules that flow through Vite's plugin pipeline — covers
|
|
6
|
+
// user source and any node_modules package Vite fetches and transforms.
|
|
7
|
+
// But Vite/Rollup externalize certain packages (e.g. `partyserver`,
|
|
8
|
+
// which has `import { DurableObject, env } from "cloudflare:workers"`
|
|
9
|
+
// at its top level, and similar "workerd-native" libraries). Externalized
|
|
10
|
+
// modules bypass the transform: Rollup hands their resolution to Node's
|
|
11
|
+
// native ESM loader, which rejects URL-scheme specifiers. This loader
|
|
12
|
+
// hook registers via `module.register()` from `createTempRscServer` and
|
|
13
|
+
// intercepts `cloudflare:*` at Node's resolve layer — before the default
|
|
14
|
+
// loader throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
15
|
+
//
|
|
16
|
+
// Lifecycle: the hook runs in a dedicated worker thread (Node ESM loader
|
|
17
|
+
// architecture) with its own globalThis. It cannot see the main thread's
|
|
18
|
+
// `__rango_build_env__` bridge, so the `env` export here is always `{}`.
|
|
19
|
+
// That's fine in practice — externalized libraries don't typically touch
|
|
20
|
+
// `env` at module top level; they read it at request time in workerd
|
|
21
|
+
// where the real module exists. Build-time prerender handlers in user
|
|
22
|
+
// source DO read `env`, but they flow through the Vite transform (which
|
|
23
|
+
// does bridge `env` from `getPlatformProxy()`), not through this loader.
|
|
24
|
+
//
|
|
25
|
+
// Keep STUBS in sync with cloudflare-protocol-stub.ts — both paths need
|
|
26
|
+
// to hand out the same base classes.
|
|
27
|
+
|
|
28
|
+
const CF_PREFIX = "cloudflare:";
|
|
29
|
+
|
|
30
|
+
const STUBS = {
|
|
31
|
+
"cloudflare:workers": `
|
|
32
|
+
export class DurableObject { constructor(_ctx, _env) {} }
|
|
33
|
+
export class WorkerEntrypoint { constructor(_ctx, _env) {} }
|
|
34
|
+
export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
|
|
35
|
+
export class RpcTarget {}
|
|
36
|
+
export const env = {};
|
|
37
|
+
export default {};
|
|
38
|
+
`,
|
|
39
|
+
"cloudflare:email": `
|
|
40
|
+
export class EmailMessage { constructor(_from, _to, _raw) {} }
|
|
41
|
+
export default {};
|
|
42
|
+
`,
|
|
43
|
+
"cloudflare:sockets": `
|
|
44
|
+
export function connect() { return {}; }
|
|
45
|
+
export default {};
|
|
46
|
+
`,
|
|
47
|
+
"cloudflare:workflows": `
|
|
48
|
+
export class NonRetryableError extends Error {
|
|
49
|
+
constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
|
|
50
|
+
}
|
|
51
|
+
export default {};
|
|
52
|
+
`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Policy: unknown `cloudflare:*` specifiers resolve permissively to an
|
|
56
|
+
// empty default export rather than throwing. Same reasoning as
|
|
57
|
+
// cloudflare-protocol-stub.ts's FALLBACK_STUB — we prioritize
|
|
58
|
+
// dependency-graph resilience over strict validation, because third-party
|
|
59
|
+
// packages can pull `cloudflare:*` modules we haven't curated.
|
|
60
|
+
const FALLBACK_STUB = `export default {};\n`;
|
|
61
|
+
|
|
62
|
+
function dataUrlFor(specifier) {
|
|
63
|
+
const body = STUBS[specifier] ?? FALLBACK_STUB;
|
|
64
|
+
return "data:text/javascript;base64," + Buffer.from(body).toString("base64");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
68
|
+
if (specifier.startsWith(CF_PREFIX)) {
|
|
69
|
+
return {
|
|
70
|
+
shortCircuit: true,
|
|
71
|
+
url: dataUrlFor(specifier),
|
|
72
|
+
format: "module",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return nextResolve(specifier, context);
|
|
76
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
|
|
3
|
+
const VIRTUAL_PREFIX = "virtual:rango-cloudflare-stub-";
|
|
4
|
+
const NULL_PREFIX = "\0" + VIRTUAL_PREFIX;
|
|
5
|
+
const CF_PREFIX = "cloudflare:";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `globalThis` key the `cloudflare:workers` stub reads to populate its
|
|
9
|
+
* `env` export. Router discovery sets this to the resolved `buildEnv`
|
|
10
|
+
* proxy (from `wrangler.getPlatformProxy()` when `buildEnv: "auto"` is
|
|
11
|
+
* configured, or a user-supplied object otherwise) before importing the
|
|
12
|
+
* worker entry, and clears it after discovery disposes the proxy. When
|
|
13
|
+
* unset, the stub's `env` falls back to `{}`.
|
|
14
|
+
*
|
|
15
|
+
* Using `globalThis` is the only cross-module bridge that works here:
|
|
16
|
+
* the stub's `load` hook returns source text, not a live closure, but
|
|
17
|
+
* the stub module is evaluated in the same Node process as the
|
|
18
|
+
* discovery plugin — so reading a global at module-evaluation time
|
|
19
|
+
* reaches whatever the plugin assigned there. A symbol key would be
|
|
20
|
+
* cleaner in-process but awkward to name from the stub source.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export const BUILD_ENV_GLOBAL_KEY = "__rango_build_env__";
|
|
25
|
+
|
|
26
|
+
const SOURCE_EXT_RE = /\.[mc]?[jt]sx?$/;
|
|
27
|
+
|
|
28
|
+
const IMPORT_NODE_TYPES = new Set([
|
|
29
|
+
"ImportDeclaration",
|
|
30
|
+
"ImportExpression",
|
|
31
|
+
"ExportNamedDeclaration",
|
|
32
|
+
"ExportAllDeclaration",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Keep in sync with `STUBS` in cloudflare-protocol-loader-hook.mjs —
|
|
36
|
+
// both paths (Vite transform and Node loader) need to hand out the same
|
|
37
|
+
// classes. Unknown `cloudflare:*` modules fall back to an empty default
|
|
38
|
+
// export so third-party packages (e.g. the Cloudflare Agents SDK) can
|
|
39
|
+
// pull them into the graph without crashing discovery. Discovery only
|
|
40
|
+
// evaluates module top-level code — no handlers run — so missing named
|
|
41
|
+
// exports only fail if something does `class X extends Missing {}` at
|
|
42
|
+
// module scope, which is rare outside the already-stubbed classes.
|
|
43
|
+
const STUBS: Record<string, string> = {
|
|
44
|
+
"cloudflare:workers": `
|
|
45
|
+
export class DurableObject { constructor(_ctx, _env) {} }
|
|
46
|
+
export class WorkerEntrypoint { constructor(_ctx, _env) {} }
|
|
47
|
+
export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
|
|
48
|
+
export class RpcTarget {}
|
|
49
|
+
export const env = globalThis[${JSON.stringify(BUILD_ENV_GLOBAL_KEY)}] ?? {};
|
|
50
|
+
export default {};
|
|
51
|
+
`,
|
|
52
|
+
"cloudflare:email": `
|
|
53
|
+
export class EmailMessage { constructor(_from, _to, _raw) {} }
|
|
54
|
+
export default {};
|
|
55
|
+
`,
|
|
56
|
+
"cloudflare:sockets": `
|
|
57
|
+
export function connect() { return {}; }
|
|
58
|
+
export default {};
|
|
59
|
+
`,
|
|
60
|
+
"cloudflare:workflows": `
|
|
61
|
+
export class NonRetryableError extends Error {
|
|
62
|
+
constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
|
|
63
|
+
}
|
|
64
|
+
export default {};
|
|
65
|
+
`,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Policy: unknown `cloudflare:*` specifiers resolve permissively (empty
|
|
69
|
+
// default export) rather than throwing. We prioritize dependency-graph
|
|
70
|
+
// resilience over strict validation of user imports because third-party
|
|
71
|
+
// packages can pull `cloudflare:*` modules we haven't curated, and
|
|
72
|
+
// discovery should not fail just because those modules appear in the graph.
|
|
73
|
+
// Tradeoff: unsupported user-authored `cloudflare:*` imports may fail later
|
|
74
|
+
// with a generic JS/module error instead of a tailored rango-branded hint.
|
|
75
|
+
// The test below pins this behavior so dependency compatibility is not
|
|
76
|
+
// regressed accidentally.
|
|
77
|
+
const FALLBACK_STUB = `export default {};\n`;
|
|
78
|
+
|
|
79
|
+
interface AstNode {
|
|
80
|
+
type: string;
|
|
81
|
+
start?: number;
|
|
82
|
+
end?: number;
|
|
83
|
+
source?: AstNode | null;
|
|
84
|
+
value?: unknown;
|
|
85
|
+
[key: string]: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stubs `cloudflare:*` imports for the discovery-time Node Vite server.
|
|
90
|
+
*
|
|
91
|
+
* Discovery only evaluates user module top-level code — it never invokes
|
|
92
|
+
* DurableObject / WorkerEntrypoint / Workflow handlers — so empty base
|
|
93
|
+
* classes are enough for `class X extends DurableObject {}` declarations
|
|
94
|
+
* to load in Node, where `cloudflare:*` is otherwise unresolvable.
|
|
95
|
+
*
|
|
96
|
+
* Interception point: a transform hook parses source with Rollup's
|
|
97
|
+
* plugin-context parser (`this.parse`) and rewrites only real import
|
|
98
|
+
* specifier spans (`import ... from "cloudflare:xxx"`,
|
|
99
|
+
* `import("cloudflare:xxx")`, `export ... from "cloudflare:xxx"`) to a
|
|
100
|
+
* plain virtual module name (`virtual:rango-cloudflare-stub-xxx`).
|
|
101
|
+
* This must be done in transform because Vite's module runner routes
|
|
102
|
+
* URL-scheme specifiers straight to Node's native ESM loader without
|
|
103
|
+
* consulting plugin `resolveId` hooks. Using the AST (instead of a
|
|
104
|
+
* text regex or a permissive lexer) guarantees that strings,
|
|
105
|
+
* comments, and template literals that merely contain import-like
|
|
106
|
+
* text are never mutated — the walker only looks at the four import
|
|
107
|
+
* node types.
|
|
108
|
+
*
|
|
109
|
+
* The transform runs on user source AND on compiled node_modules
|
|
110
|
+
* output: real-world CF packages (e.g. the Cloudflare Agents SDK)
|
|
111
|
+
* ship compiled JS that contains `import ... from "cloudflare:email"`
|
|
112
|
+
* and similar, so excluding node_modules would leave those imports
|
|
113
|
+
* unrewritten. Cost is small because the early exit (`code.includes`)
|
|
114
|
+
* skips files with no cloudflare: mention.
|
|
115
|
+
*
|
|
116
|
+
* The plugin intentionally runs at Vite's default ordering (no
|
|
117
|
+
* `enforce: "pre"`) so TS/JSX has already been compiled to plain JS
|
|
118
|
+
* by the time `this.parse` runs — acorn doesn't understand
|
|
119
|
+
* non-standard syntax.
|
|
120
|
+
*
|
|
121
|
+
* `cloudflare:workers`, `cloudflare:email`, `cloudflare:sockets`, and
|
|
122
|
+
* `cloudflare:workflows` each get curated stubs with the well-known
|
|
123
|
+
* symbols that appear in top-level `extends` positions. Any other
|
|
124
|
+
* `cloudflare:*` specifier falls back to an empty default export —
|
|
125
|
+
* discovery never executes the handlers, so an empty module is safe
|
|
126
|
+
* for anything the graph pulls in transitively.
|
|
127
|
+
*
|
|
128
|
+
* Only registered in the discovery temp server, not the user's runtime
|
|
129
|
+
* config.
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
export function createCloudflareProtocolStubPlugin(): Plugin {
|
|
133
|
+
return {
|
|
134
|
+
name: "@rangojs/router:cloudflare-protocol-stub",
|
|
135
|
+
transform(code, id) {
|
|
136
|
+
const cleanId = id.split("?")[0] ?? id;
|
|
137
|
+
if (!SOURCE_EXT_RE.test(cleanId)) return null;
|
|
138
|
+
if (!code.includes(CF_PREFIX)) return null;
|
|
139
|
+
|
|
140
|
+
let ast: AstNode;
|
|
141
|
+
try {
|
|
142
|
+
ast = this.parse(code) as unknown as AstNode;
|
|
143
|
+
} catch {
|
|
144
|
+
// Malformed source — let a downstream plugin surface the parse error.
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hits: Array<{ start: number; end: number; value: string }> = [];
|
|
149
|
+
walk(ast, (node) => {
|
|
150
|
+
if (!IMPORT_NODE_TYPES.has(node.type)) return;
|
|
151
|
+
const source = node.source;
|
|
152
|
+
if (!source || source.type !== "Literal") return;
|
|
153
|
+
if (typeof source.value !== "string") return;
|
|
154
|
+
if (!source.value.startsWith(CF_PREFIX)) return;
|
|
155
|
+
if (typeof source.start !== "number" || typeof source.end !== "number")
|
|
156
|
+
return;
|
|
157
|
+
hits.push({
|
|
158
|
+
start: source.start,
|
|
159
|
+
end: source.end,
|
|
160
|
+
value: source.value,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (hits.length === 0) return null;
|
|
165
|
+
|
|
166
|
+
// Rewrite from last to first so earlier offsets stay valid. `start`/
|
|
167
|
+
// `end` span the full literal including quotes, so we re-emit the
|
|
168
|
+
// same quote character around the new specifier.
|
|
169
|
+
hits.sort((a, b) => b.start - a.start);
|
|
170
|
+
let out = code;
|
|
171
|
+
for (const hit of hits) {
|
|
172
|
+
const submodule = hit.value.slice(CF_PREFIX.length);
|
|
173
|
+
const quote = code[hit.start] === "'" ? "'" : '"';
|
|
174
|
+
out =
|
|
175
|
+
out.slice(0, hit.start) +
|
|
176
|
+
quote +
|
|
177
|
+
VIRTUAL_PREFIX +
|
|
178
|
+
submodule +
|
|
179
|
+
quote +
|
|
180
|
+
out.slice(hit.end);
|
|
181
|
+
}
|
|
182
|
+
return { code: out, map: null };
|
|
183
|
+
},
|
|
184
|
+
resolveId(id) {
|
|
185
|
+
if (id.startsWith(VIRTUAL_PREFIX)) {
|
|
186
|
+
return "\0" + id;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
load(id) {
|
|
191
|
+
if (!id.startsWith(NULL_PREFIX)) return null;
|
|
192
|
+
const submodule = id.slice(NULL_PREFIX.length);
|
|
193
|
+
const specifier = CF_PREFIX + submodule;
|
|
194
|
+
return STUBS[specifier] ?? FALLBACK_STUB;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function walk(node: unknown, visit: (n: AstNode) => void): void {
|
|
200
|
+
if (!node || typeof node !== "object") return;
|
|
201
|
+
if (Array.isArray(node)) {
|
|
202
|
+
for (const child of node) walk(child, visit);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const n = node as AstNode;
|
|
206
|
+
if (typeof n.type !== "string") return;
|
|
207
|
+
visit(n);
|
|
208
|
+
for (const key in n) {
|
|
209
|
+
if (key === "loc" || key === "start" || key === "end" || key === "range") {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
walk(n[key], visit);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -10,7 +10,7 @@ import type { Plugin } from "vite";
|
|
|
10
10
|
import { createServer as createViteServer } from "vite";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
|
-
import { createRequire } from "node:module";
|
|
13
|
+
import { createRequire, register } from "node:module";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import {
|
|
16
16
|
formatNestedRouterConflictError,
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
} from "../build/generate-route-types.js";
|
|
20
20
|
import { createVersionPlugin } from "./plugins/version-plugin.js";
|
|
21
21
|
import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
|
|
22
|
+
import {
|
|
23
|
+
BUILD_ENV_GLOBAL_KEY,
|
|
24
|
+
createCloudflareProtocolStubPlugin,
|
|
25
|
+
} from "./plugins/cloudflare-protocol-stub.js";
|
|
22
26
|
import {
|
|
23
27
|
exposeInternalIds,
|
|
24
28
|
exposeRouterId,
|
|
@@ -47,6 +51,49 @@ import { resetStagedBuildAssets } from "./utils/prerender-utils.js";
|
|
|
47
51
|
|
|
48
52
|
export { VIRTUAL_ROUTES_MANIFEST_ID };
|
|
49
53
|
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Node ESM Loader Hook Registration
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Registers a Node ESM loader hook that resolves `cloudflare:*` specifiers
|
|
60
|
+
* to a data: URL stub. Defense-in-depth alongside the Vite transform in
|
|
61
|
+
* `cloudflare-protocol-stub.ts`:
|
|
62
|
+
*
|
|
63
|
+
* - The Vite transform catches `cloudflare:*` imports in modules that flow
|
|
64
|
+
* through Vite's plugin pipeline. That's the vast majority of cases.
|
|
65
|
+
* - The Node loader catches imports in modules that Vite/Rollup externalize
|
|
66
|
+
* (e.g. the `partyserver` package, which has a top-level
|
|
67
|
+
* `import { DurableObject, env } from "cloudflare:workers"` and ships
|
|
68
|
+
* shapes plugin-rsc marks as external). Externalized modules are loaded
|
|
69
|
+
* via Node's native ESM loader, which rejects URL schemes.
|
|
70
|
+
*
|
|
71
|
+
* Registration is process-global and one-shot. The hook only intercepts
|
|
72
|
+
* `cloudflare:*` specifiers; everything else passes through via
|
|
73
|
+
* `nextResolve()`. It runs in a separate worker thread (Node ESM loader
|
|
74
|
+
* architecture), so it can't read the `globalThis[BUILD_ENV_GLOBAL_KEY]`
|
|
75
|
+
* bridge that the Vite transform uses — the stubs served here always
|
|
76
|
+
* return `env = {}`. That's fine because externalized libraries don't
|
|
77
|
+
* typically access `env` at module top level; user source (where real
|
|
78
|
+
* `env` matters at build time) flows through the Vite transform.
|
|
79
|
+
*/
|
|
80
|
+
let loaderHookRegistered = false;
|
|
81
|
+
function ensureCloudflareProtocolLoaderRegistered(): void {
|
|
82
|
+
if (loaderHookRegistered) return;
|
|
83
|
+
loaderHookRegistered = true;
|
|
84
|
+
try {
|
|
85
|
+
register(
|
|
86
|
+
new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url),
|
|
87
|
+
);
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
// register() requires Node 18.19+ / 20.6+. Older Node still has the
|
|
90
|
+
// Vite transform as primary defense.
|
|
91
|
+
console.warn(
|
|
92
|
+
`[rsc-router] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
// ============================================================================
|
|
51
98
|
// Temp Server Factory
|
|
52
99
|
// ============================================================================
|
|
@@ -66,6 +113,11 @@ async function createTempRscServer(
|
|
|
66
113
|
state: DiscoveryState,
|
|
67
114
|
options: { forceBuild?: boolean; cacheDir?: string } = {},
|
|
68
115
|
) {
|
|
116
|
+
// Install the Node ESM loader hook before any module evaluation so
|
|
117
|
+
// `cloudflare:*` specifiers in externalized/loader-delegated modules
|
|
118
|
+
// (e.g. packages plugin-rsc marks as external) resolve to stubs
|
|
119
|
+
// instead of crashing Node's native loader.
|
|
120
|
+
ensureCloudflareProtocolLoaderRegistered();
|
|
69
121
|
const { default: rsc } = await import("@vitejs/plugin-rsc");
|
|
70
122
|
return createViteServer({
|
|
71
123
|
root: state.projectRoot,
|
|
@@ -88,6 +140,7 @@ async function createTempRscServer(
|
|
|
88
140
|
...(options.forceBuild ? [hashClientRefs(state.projectRoot)] : []),
|
|
89
141
|
createVersionPlugin(),
|
|
90
142
|
createVirtualStubPlugin(),
|
|
143
|
+
createCloudflareProtocolStubPlugin(),
|
|
91
144
|
// Dev prerender must use dev-mode IDs (path-based) to match the workerd
|
|
92
145
|
// runtime. forceBuild produces hashed IDs for production bundle consistency.
|
|
93
146
|
exposeInternalIds(options.forceBuild ? { forceBuild: true } : undefined),
|
|
@@ -177,6 +230,11 @@ async function acquireBuildEnv(
|
|
|
177
230
|
|
|
178
231
|
s.resolvedBuildEnv = result.env;
|
|
179
232
|
s.buildEnvDispose = result.dispose ?? null;
|
|
233
|
+
// Bridge the resolved env into `cloudflare:workers`'s stubbed `env`
|
|
234
|
+
// export so user code that does `import { env } from "cloudflare:workers"`
|
|
235
|
+
// sees the real bindings proxy during discovery + prerender instead of
|
|
236
|
+
// an empty object. The stub reads this global at module-evaluation time.
|
|
237
|
+
(globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY] = result.env;
|
|
180
238
|
return true;
|
|
181
239
|
}
|
|
182
240
|
|
|
@@ -193,6 +251,7 @@ async function releaseBuildEnv(s: DiscoveryState): Promise<void> {
|
|
|
193
251
|
s.buildEnvDispose = null;
|
|
194
252
|
}
|
|
195
253
|
s.resolvedBuildEnv = undefined;
|
|
254
|
+
delete (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY];
|
|
196
255
|
}
|
|
197
256
|
|
|
198
257
|
/**
|