@rangojs/router 0.0.0-experimental.10
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/CLAUDE.md +43 -0
- package/README.md +19 -0
- package/dist/bin/rango.js +227 -0
- package/dist/vite/index.js +3039 -0
- package/package.json +171 -0
- package/skills/caching/SKILL.md +191 -0
- package/skills/debug-manifest/SKILL.md +108 -0
- package/skills/document-cache/SKILL.md +180 -0
- package/skills/fonts/SKILL.md +165 -0
- package/skills/hooks/SKILL.md +442 -0
- package/skills/intercept/SKILL.md +190 -0
- package/skills/layout/SKILL.md +213 -0
- package/skills/links/SKILL.md +180 -0
- package/skills/loader/SKILL.md +246 -0
- package/skills/middleware/SKILL.md +202 -0
- package/skills/mime-routes/SKILL.md +124 -0
- package/skills/parallel/SKILL.md +228 -0
- package/skills/prerender/SKILL.md +283 -0
- package/skills/rango/SKILL.md +54 -0
- package/skills/response-routes/SKILL.md +358 -0
- package/skills/route/SKILL.md +173 -0
- package/skills/router-setup/SKILL.md +346 -0
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +78 -0
- package/skills/typesafety/SKILL.md +394 -0
- package/src/__internal.ts +175 -0
- package/src/bin/rango.ts +24 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +913 -0
- package/src/browser/navigation-client.ts +165 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +600 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +346 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/mount-context.ts +32 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +203 -0
- package/src/browser/react/use-href.tsx +40 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-mount.ts +31 -0
- package/src/browser/react/use-navigation.ts +140 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +352 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/segment-structure-assert.ts +67 -0
- package/src/browser/server-action-bridge.ts +762 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +478 -0
- package/src/build/generate-manifest.ts +377 -0
- package/src/build/generate-route-types.ts +828 -0
- package/src/build/index.ts +36 -0
- package/src/build/route-trie.ts +239 -0
- package/src/cache/cache-scope.ts +563 -0
- package/src/cache/cf/cf-cache-store.ts +428 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/document-cache.ts +340 -0
- package/src/cache/index.ts +58 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +392 -0
- package/src/client.rsc.tsx +83 -0
- package/src/client.tsx +643 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/debug.ts +233 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +295 -0
- package/src/handle.ts +130 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/host/cookie-handler.ts +159 -0
- package/src/host/errors.ts +97 -0
- package/src/host/index.ts +56 -0
- package/src/host/pattern-matcher.ts +214 -0
- package/src/host/router.ts +330 -0
- package/src/host/testing.ts +79 -0
- package/src/host/types.ts +138 -0
- package/src/host/utils.ts +25 -0
- package/src/href-client.ts +202 -0
- package/src/href-context.ts +33 -0
- package/src/index.rsc.ts +121 -0
- package/src/index.ts +165 -0
- package/src/loader.rsc.ts +207 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/prerender/param-hash.ts +35 -0
- package/src/prerender/store.ts +40 -0
- package/src/prerender.ts +156 -0
- package/src/reverse.ts +267 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +193 -0
- package/src/route-definition.ts +1431 -0
- package/src/route-map-builder.ts +242 -0
- package/src/route-types.ts +220 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/intercept-resolution.ts +387 -0
- package/src/router/loader-resolution.ts +327 -0
- package/src/router/manifest.ts +216 -0
- package/src/router/match-api.ts +621 -0
- package/src/router/match-context.ts +264 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +382 -0
- package/src/router/match-middleware/cache-store.ts +276 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +281 -0
- package/src/router/match-middleware/segment-resolution.ts +184 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +213 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.ts +791 -0
- package/src/router/pattern-matching.ts +407 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +301 -0
- package/src/router/segment-resolution.ts +1315 -0
- package/src/router/trie-matching.ts +172 -0
- package/src/router/types.ts +163 -0
- package/src/router.gen.ts +6 -0
- package/src/router.ts +2423 -0
- package/src/rsc/handler.ts +1443 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +236 -0
- package/src/segment-system.tsx +442 -0
- package/src/server/context.ts +466 -0
- package/src/server/handle-store.ts +229 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +171 -0
- package/src/ssr/index.tsx +296 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/constants.ts +59 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/theme-context.ts +70 -0
- package/src/theme/theme-script.ts +152 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- package/src/types.ts +1757 -0
- package/src/urls.gen.ts +8 -0
- package/src/urls.ts +1282 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +426 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/expose-prerender-handler-id.ts +429 -0
- package/src/vite/index.ts +2068 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +114 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { Plugin, ResolvedConfig } from "vite";
|
|
2
|
+
import MagicString from "magic-string";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize path to forward slashes
|
|
8
|
+
*/
|
|
9
|
+
function normalizePath(p: string): string {
|
|
10
|
+
return p.split(path.sep).join("/");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a short hash for a handle ID
|
|
15
|
+
* Uses first 8 chars of SHA-256 hash for uniqueness while keeping IDs short
|
|
16
|
+
* Appends export name for easier debugging: "abc123#Breadcrumbs"
|
|
17
|
+
*/
|
|
18
|
+
function hashHandleId(filePath: string, exportName: string): string {
|
|
19
|
+
const input = `${filePath}#${exportName}`;
|
|
20
|
+
const hash = crypto.createHash("sha256").update(input).digest("hex");
|
|
21
|
+
return `${hash.slice(0, 8)}#${exportName}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if file imports createHandle from rsc-router
|
|
26
|
+
*/
|
|
27
|
+
function hasCreateHandleImport(code: string): boolean {
|
|
28
|
+
// Match: import { createHandle } from "@rangojs/router" or "@rangojs/router/..."
|
|
29
|
+
const pattern =
|
|
30
|
+
/import\s*\{[^}]*\bcreateHandle\b[^}]*\}\s*from\s*["']@rangojs\/router(?:\/[^"']+)?["']/;
|
|
31
|
+
return pattern.test(code);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Analyze createHandle arguments to determine injection strategy
|
|
36
|
+
* Returns: { hasArgs: boolean, firstArgIsString: boolean, firstArgIsFunction: boolean }
|
|
37
|
+
*/
|
|
38
|
+
function analyzeCreateHandleArgs(
|
|
39
|
+
code: string,
|
|
40
|
+
startPos: number,
|
|
41
|
+
endPos: number
|
|
42
|
+
): { hasArgs: boolean; firstArgIsString: boolean; firstArgIsFunction: boolean } {
|
|
43
|
+
// Extract the content between parentheses
|
|
44
|
+
const content = code.slice(startPos, endPos).trim();
|
|
45
|
+
|
|
46
|
+
if (!content) {
|
|
47
|
+
return { hasArgs: false, firstArgIsString: false, firstArgIsFunction: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if first arg starts with a quote (string literal)
|
|
51
|
+
const firstArgIsString = /^["']/.test(content);
|
|
52
|
+
|
|
53
|
+
// Check if first arg starts with ( for arrow function or function keyword
|
|
54
|
+
const firstArgIsFunction =
|
|
55
|
+
content.startsWith("(") ||
|
|
56
|
+
content.startsWith("function") ||
|
|
57
|
+
// Check for identifier that could be a collect function reference
|
|
58
|
+
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*(?:,|$)/.test(content);
|
|
59
|
+
|
|
60
|
+
return { hasArgs: true, firstArgIsString, firstArgIsFunction };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Transform export const X = createHandle(...) patterns to inject $$id
|
|
65
|
+
*
|
|
66
|
+
* Handles these cases:
|
|
67
|
+
* 1. createHandle() - no args -> inject (undefined, "id")
|
|
68
|
+
* 2. createHandle("name") - string name -> inject (, "id") after existing arg
|
|
69
|
+
* 3. createHandle(collectFn) - collect function -> inject (collectFn, "id")
|
|
70
|
+
* 4. createHandle("name", collectFn) - both -> inject (, "id") after existing args
|
|
71
|
+
*/
|
|
72
|
+
function transformHandleExports(
|
|
73
|
+
code: string,
|
|
74
|
+
filePath: string,
|
|
75
|
+
sourceId?: string,
|
|
76
|
+
isBuild: boolean = false
|
|
77
|
+
): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
|
|
78
|
+
// Quick bail-out
|
|
79
|
+
if (!code.includes("createHandle")) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Must have direct import from rsc-router
|
|
84
|
+
if (!hasCreateHandleImport(code)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Match: export const X = createHandle<...>(
|
|
89
|
+
// Captures the export name (X)
|
|
90
|
+
const pattern = /export\s+const\s+(\w+)\s*=\s*createHandle\s*(?:<[^>]*>)?\s*\(/g;
|
|
91
|
+
|
|
92
|
+
const s = new MagicString(code);
|
|
93
|
+
let hasChanges = false;
|
|
94
|
+
let match: RegExpExecArray | null;
|
|
95
|
+
|
|
96
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
97
|
+
const exportName = match[1];
|
|
98
|
+
const matchEnd = match.index + match[0].length;
|
|
99
|
+
|
|
100
|
+
// Find the end of the createHandle(...) call
|
|
101
|
+
let parenDepth = 1;
|
|
102
|
+
let i = matchEnd;
|
|
103
|
+
while (i < code.length && parenDepth > 0) {
|
|
104
|
+
if (code[i] === "(") parenDepth++;
|
|
105
|
+
if (code[i] === ")") parenDepth--;
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// i now points just after the closing )
|
|
110
|
+
const closeParenPos = i - 1;
|
|
111
|
+
|
|
112
|
+
// Analyze what arguments exist
|
|
113
|
+
const args = analyzeCreateHandleArgs(code, matchEnd, closeParenPos);
|
|
114
|
+
|
|
115
|
+
// Find the semicolon or end of statement
|
|
116
|
+
let statementEnd = i;
|
|
117
|
+
while (statementEnd < code.length && /\s/.test(code[statementEnd])) {
|
|
118
|
+
statementEnd++;
|
|
119
|
+
}
|
|
120
|
+
if (code[statementEnd] === ";") {
|
|
121
|
+
statementEnd++;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Generate ID: hashed in production, readable in dev
|
|
125
|
+
const handleId = isBuild
|
|
126
|
+
? hashHandleId(filePath, exportName)
|
|
127
|
+
: `${filePath}#${exportName}`;
|
|
128
|
+
|
|
129
|
+
// Inject $$id as the last parameter
|
|
130
|
+
let paramInjection: string;
|
|
131
|
+
if (!args.hasArgs) {
|
|
132
|
+
// No args: createHandle() -> createHandle(undefined, "id")
|
|
133
|
+
paramInjection = `undefined, "${handleId}"`;
|
|
134
|
+
} else {
|
|
135
|
+
// Has args: createHandle(x) -> createHandle(x, "id")
|
|
136
|
+
paramInjection = `, "${handleId}"`;
|
|
137
|
+
}
|
|
138
|
+
s.appendLeft(closeParenPos, paramInjection);
|
|
139
|
+
|
|
140
|
+
// Also set $$id property for external access
|
|
141
|
+
const propInjection = `\n${exportName}.$$id = "${handleId}";`;
|
|
142
|
+
s.appendRight(statementEnd, propInjection);
|
|
143
|
+
hasChanges = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!hasChanges) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
code: s.toString(),
|
|
152
|
+
map: s.generateMap({ source: sourceId, includeContent: true }),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Vite plugin that exposes $$id on createHandle calls.
|
|
158
|
+
*
|
|
159
|
+
* When users create handles with createHandle(), this plugin:
|
|
160
|
+
* 1. Injects a $$id as the last parameter (used as the handle name)
|
|
161
|
+
* 2. Sets $$id property on the exported constant for external access
|
|
162
|
+
*
|
|
163
|
+
* This allows handles to be created without explicit names:
|
|
164
|
+
* - Before: export const Breadcrumbs = createHandle<Item>("breadcrumbs")
|
|
165
|
+
* - After: export const Breadcrumbs = createHandle<Item>()
|
|
166
|
+
*
|
|
167
|
+
* The name is auto-generated from file path + export name.
|
|
168
|
+
*
|
|
169
|
+
* Requirements:
|
|
170
|
+
* - Must use direct import: import { createHandle } from "@rangojs/router"
|
|
171
|
+
* - Must use named export: export const MyHandle = createHandle(...)
|
|
172
|
+
*/
|
|
173
|
+
export function exposeHandleId(): Plugin {
|
|
174
|
+
let config: ResolvedConfig;
|
|
175
|
+
let isBuild = false;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
name: "@rangojs/router:expose-handle-id",
|
|
179
|
+
enforce: "post",
|
|
180
|
+
|
|
181
|
+
configResolved(resolvedConfig) {
|
|
182
|
+
config = resolvedConfig;
|
|
183
|
+
isBuild = config.command === "build";
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
transform(code, id) {
|
|
187
|
+
// Skip node_modules
|
|
188
|
+
if (id.includes("/node_modules/")) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Quick bail-out
|
|
193
|
+
if (!code.includes("createHandle")) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Must have direct import from rsc-router
|
|
198
|
+
if (!hasCreateHandleImport(code)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Get relative path for the ID
|
|
203
|
+
const relativePath = normalizePath(path.relative(config.root, id));
|
|
204
|
+
|
|
205
|
+
// Transform: inject $$id
|
|
206
|
+
return transformHandleExports(code, relativePath, id, isBuild);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { Plugin, ResolvedConfig } from "vite";
|
|
2
|
+
import MagicString from "magic-string";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize path to forward slashes
|
|
8
|
+
*/
|
|
9
|
+
function normalizePath(p: string): string {
|
|
10
|
+
return p.split(path.sep).join("/");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a short hash for a loader ID
|
|
15
|
+
* Uses first 8 chars of SHA-256 hash for uniqueness while keeping IDs short
|
|
16
|
+
* Appends export name for easier debugging in production: "abc123#CartLoader"
|
|
17
|
+
*/
|
|
18
|
+
function hashLoaderId(filePath: string, exportName: string): string {
|
|
19
|
+
const input = `${filePath}#${exportName}`;
|
|
20
|
+
const hash = crypto.createHash("sha256").update(input).digest("hex");
|
|
21
|
+
return `${hash.slice(0, 8)}#${exportName}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if file imports createLoader from rsc-router
|
|
26
|
+
*/
|
|
27
|
+
function hasCreateLoaderImport(code: string): boolean {
|
|
28
|
+
// Match: import { createLoader } from "@rangojs/router" or "@rangojs/router/server"
|
|
29
|
+
// Must be exact - no aliasing support
|
|
30
|
+
const pattern =
|
|
31
|
+
/import\s*\{[^}]*\bcreateLoader\b[^}]*\}\s*from\s*["']@rangojs\/router(?:\/server)?["']/;
|
|
32
|
+
return pattern.test(code);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count the number of arguments in a createLoader call
|
|
37
|
+
* Returns the count of top-level arguments (not counting nested commas)
|
|
38
|
+
*/
|
|
39
|
+
function countCreateLoaderArgs(code: string, startPos: number, endPos: number): number {
|
|
40
|
+
let depth = 0;
|
|
41
|
+
let argCount = 0;
|
|
42
|
+
let hasContent = false;
|
|
43
|
+
|
|
44
|
+
for (let i = startPos; i < endPos; i++) {
|
|
45
|
+
const char = code[i];
|
|
46
|
+
|
|
47
|
+
// Track nested structures
|
|
48
|
+
if (char === "(" || char === "[" || char === "{") {
|
|
49
|
+
depth++;
|
|
50
|
+
hasContent = true;
|
|
51
|
+
} else if (char === ")" || char === "]" || char === "}") {
|
|
52
|
+
depth--;
|
|
53
|
+
} else if (char === "," && depth === 0) {
|
|
54
|
+
// Top-level comma means another argument
|
|
55
|
+
argCount++;
|
|
56
|
+
} else if (!/\s/.test(char)) {
|
|
57
|
+
hasContent = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If there's content, we have at least one argument
|
|
62
|
+
return hasContent ? argCount + 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find all export const X = createLoader(...) patterns and inject $$id
|
|
67
|
+
* In production, IDs are hashed to avoid exposing file paths.
|
|
68
|
+
* In dev, IDs use filePath#exportName for easier debugging.
|
|
69
|
+
*
|
|
70
|
+
* The ID is injected in two ways:
|
|
71
|
+
* 1. As a hidden third parameter to createLoader() for registry registration
|
|
72
|
+
* 2. As a property assignment X.$$id = "..." for external access
|
|
73
|
+
*
|
|
74
|
+
* IMPORTANT: The $$id must always be the THIRD parameter to createLoader.
|
|
75
|
+
* createLoader(fn, fetchable?, __injectedId?)
|
|
76
|
+
* If the user only provides fn, we inject: undefined, "id"
|
|
77
|
+
* If the user provides fn and fetchable, we inject: , "id"
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* Generate lightweight client stubs for loader files.
|
|
81
|
+
*
|
|
82
|
+
* When a loader file is imported from a client component (e.g., for useLoader()),
|
|
83
|
+
* the client only needs { __brand: "loader", $$id: "..." } objects.
|
|
84
|
+
* This function replaces the entire file contents with just those stub exports,
|
|
85
|
+
* preventing server-only data (constants, DB queries, etc.) from leaking into
|
|
86
|
+
* the client bundle.
|
|
87
|
+
*
|
|
88
|
+
* Only applies when ALL named exports are createLoader() calls (plus type exports
|
|
89
|
+
* which are erased at compile time). Files with mixed exports are left untouched.
|
|
90
|
+
*/
|
|
91
|
+
function generateClientLoaderStubs(
|
|
92
|
+
code: string,
|
|
93
|
+
filePath: string,
|
|
94
|
+
isBuild: boolean
|
|
95
|
+
): { code: string; map?: undefined } | null {
|
|
96
|
+
const loaderPattern = /export\s+const\s+(\w+)\s*=\s*createLoader\s*\(/g;
|
|
97
|
+
const loaders: string[] = [];
|
|
98
|
+
let match: RegExpExecArray | null;
|
|
99
|
+
|
|
100
|
+
while ((match = loaderPattern.exec(code)) !== null) {
|
|
101
|
+
loaders.push(match[1]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (loaders.length === 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check that every non-type export is a createLoader call.
|
|
109
|
+
// If the file exports other values, we can't safely replace it.
|
|
110
|
+
const allExports = /export\s+(const|let|var|function|class|default)\s+(\w+)/g;
|
|
111
|
+
let exportMatch: RegExpExecArray | null;
|
|
112
|
+
const nonLoaderExports: string[] = [];
|
|
113
|
+
|
|
114
|
+
while ((exportMatch = allExports.exec(code)) !== null) {
|
|
115
|
+
const name = exportMatch[2];
|
|
116
|
+
if (!loaders.includes(name)) {
|
|
117
|
+
nonLoaderExports.push(name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (nonLoaderExports.length > 0) {
|
|
122
|
+
// Mixed exports - fall back to normal transform (inject $$id only)
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Generate stub file: only $$id references, no server code
|
|
127
|
+
const stubs = loaders.map((name) => {
|
|
128
|
+
const loaderId = isBuild
|
|
129
|
+
? hashLoaderId(filePath, name)
|
|
130
|
+
: `${filePath}#${name}`;
|
|
131
|
+
return `export const ${name} = { __brand: "loader", $$id: "${loaderId}" };`;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
code: stubs.join("\n") + "\n",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function transformLoaderExports(
|
|
140
|
+
code: string,
|
|
141
|
+
filePath: string,
|
|
142
|
+
sourceId?: string,
|
|
143
|
+
isBuild: boolean = false
|
|
144
|
+
): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
|
|
145
|
+
// Quick bail-out
|
|
146
|
+
if (!code.includes("createLoader")) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Must have direct import from rsc-router
|
|
151
|
+
if (!hasCreateLoaderImport(code)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Match: export const X = createLoader(
|
|
156
|
+
// Captures the export name (X)
|
|
157
|
+
const pattern = /export\s+const\s+(\w+)\s*=\s*createLoader\s*\(/g;
|
|
158
|
+
|
|
159
|
+
const s = new MagicString(code);
|
|
160
|
+
let hasChanges = false;
|
|
161
|
+
let match: RegExpExecArray | null;
|
|
162
|
+
|
|
163
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
164
|
+
const exportName = match[1];
|
|
165
|
+
const matchEnd = match.index + match[0].length;
|
|
166
|
+
|
|
167
|
+
// Find the end of the createLoader(...) call
|
|
168
|
+
// Need to count parentheses to find matching close
|
|
169
|
+
let parenDepth = 1;
|
|
170
|
+
let i = matchEnd;
|
|
171
|
+
while (i < code.length && parenDepth > 0) {
|
|
172
|
+
if (code[i] === "(") parenDepth++;
|
|
173
|
+
if (code[i] === ")") parenDepth--;
|
|
174
|
+
i++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// i now points just after the closing )
|
|
178
|
+
const closeParenPos = i - 1;
|
|
179
|
+
|
|
180
|
+
// Count existing arguments
|
|
181
|
+
const argCount = countCreateLoaderArgs(code, matchEnd, closeParenPos);
|
|
182
|
+
|
|
183
|
+
// Find the semicolon or end of statement
|
|
184
|
+
let statementEnd = i;
|
|
185
|
+
while (statementEnd < code.length && /\s/.test(code[statementEnd])) {
|
|
186
|
+
statementEnd++;
|
|
187
|
+
}
|
|
188
|
+
if (code[statementEnd] === ";") {
|
|
189
|
+
statementEnd++;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// In production: hash ID to avoid exposing file paths
|
|
193
|
+
// In dev: use readable format for easier debugging
|
|
194
|
+
const loaderId = isBuild
|
|
195
|
+
? hashLoaderId(filePath, exportName)
|
|
196
|
+
: `${filePath}#${exportName}`;
|
|
197
|
+
|
|
198
|
+
// Inject $$id as hidden third parameter before the closing paren
|
|
199
|
+
// If user only has 1 arg (fn), we need to add undefined for fetchable
|
|
200
|
+
// createLoader(fn) -> createLoader(fn, undefined, "id")
|
|
201
|
+
// createLoader(fn, true) -> createLoader(fn, true, "id")
|
|
202
|
+
const paramInjection = argCount === 1
|
|
203
|
+
? `, undefined, "${loaderId}"`
|
|
204
|
+
: `, "${loaderId}"`;
|
|
205
|
+
s.appendLeft(closeParenPos, paramInjection);
|
|
206
|
+
|
|
207
|
+
// Also set $$id property for external access (useLoader, useFetchLoader)
|
|
208
|
+
const propInjection = `\n${exportName}.$$id = "${loaderId}";`;
|
|
209
|
+
s.appendRight(statementEnd, propInjection);
|
|
210
|
+
hasChanges = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!hasChanges) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
code: s.toString(),
|
|
219
|
+
map: s.generateMap({ source: sourceId, includeContent: true }),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const VIRTUAL_LOADER_MANIFEST = "virtual:rsc-router/loader-manifest";
|
|
224
|
+
const RESOLVED_VIRTUAL_LOADER_MANIFEST = "\0" + VIRTUAL_LOADER_MANIFEST;
|
|
225
|
+
|
|
226
|
+
// Store for deferred manifest generation - populated during transform, used after build
|
|
227
|
+
let manifestGenerated = false;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Vite plugin that exposes $$id on createLoader calls and generates a loader manifest.
|
|
231
|
+
*
|
|
232
|
+
* When users create loaders with createLoader(), this plugin:
|
|
233
|
+
* 1. Injects a $$id property containing the file path and export name
|
|
234
|
+
* 2. Tracks all loaders and generates a virtual manifest module
|
|
235
|
+
*
|
|
236
|
+
* The manifest can be imported by the RSC handler to get all loaders.
|
|
237
|
+
*
|
|
238
|
+
* Requirements:
|
|
239
|
+
* - Must use direct import: import { createLoader } from "@rangojs/router"
|
|
240
|
+
* - No aliasing support (import { createLoader as cl } won't work)
|
|
241
|
+
* - Must use named export: export const MyLoader = createLoader(...)
|
|
242
|
+
*/
|
|
243
|
+
export function exposeLoaderId(): Plugin {
|
|
244
|
+
let config: ResolvedConfig;
|
|
245
|
+
let isBuild = false;
|
|
246
|
+
|
|
247
|
+
// Track discovered loaders: hashedId -> { filePath, exportName }
|
|
248
|
+
const loaderRegistry = new Map<
|
|
249
|
+
string,
|
|
250
|
+
{ filePath: string; exportName: string }
|
|
251
|
+
>();
|
|
252
|
+
|
|
253
|
+
// For build mode: pre-scan for loaders during buildStart
|
|
254
|
+
const pendingLoaderScans = new Map<string, Promise<void>>();
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
name: "@rangojs/router:expose-loader-id",
|
|
258
|
+
enforce: "post",
|
|
259
|
+
|
|
260
|
+
configResolved(resolvedConfig) {
|
|
261
|
+
config = resolvedConfig;
|
|
262
|
+
isBuild = config.command === "build";
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async buildStart() {
|
|
266
|
+
if (!isBuild) return;
|
|
267
|
+
|
|
268
|
+
// Pre-scan for loader files to populate registry before manifest is loaded
|
|
269
|
+
// This runs before module resolution, so manifest will have access to all loaders
|
|
270
|
+
const fs = await import("node:fs/promises");
|
|
271
|
+
|
|
272
|
+
async function scanDir(dir: string): Promise<string[]> {
|
|
273
|
+
const results: string[] = [];
|
|
274
|
+
try {
|
|
275
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
const fullPath = path.join(dir, entry.name);
|
|
278
|
+
if (entry.isDirectory()) {
|
|
279
|
+
if (entry.name !== "node_modules") {
|
|
280
|
+
results.push(...(await scanDir(fullPath)));
|
|
281
|
+
}
|
|
282
|
+
} else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) {
|
|
283
|
+
results.push(fullPath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// Directory doesn't exist or not readable
|
|
288
|
+
}
|
|
289
|
+
return results;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const srcDir = path.join(config.root, "src");
|
|
294
|
+
const files = await scanDir(srcDir);
|
|
295
|
+
|
|
296
|
+
for (const filePath of files) {
|
|
297
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
298
|
+
|
|
299
|
+
// Quick check for createLoader
|
|
300
|
+
if (!content.includes("createLoader")) continue;
|
|
301
|
+
if (!hasCreateLoaderImport(content)) continue;
|
|
302
|
+
|
|
303
|
+
// Extract loader exports
|
|
304
|
+
const pattern = /export\s+const\s+(\w+)\s*=\s*createLoader\s*\(/g;
|
|
305
|
+
const relativePath = normalizePath(
|
|
306
|
+
path.relative(config.root, filePath)
|
|
307
|
+
);
|
|
308
|
+
let match: RegExpExecArray | null;
|
|
309
|
+
|
|
310
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
311
|
+
const exportName = match[1];
|
|
312
|
+
const hashedId = hashLoaderId(relativePath, exportName);
|
|
313
|
+
loaderRegistry.set(hashedId, {
|
|
314
|
+
filePath: relativePath,
|
|
315
|
+
exportName,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// Fall back to transform-time discovery
|
|
321
|
+
console.warn("[exposeLoaderId] Pre-scan failed:", error);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
resolveId(id) {
|
|
326
|
+
if (id === VIRTUAL_LOADER_MANIFEST) {
|
|
327
|
+
return RESOLVED_VIRTUAL_LOADER_MANIFEST;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
load(id) {
|
|
332
|
+
if (id === RESOLVED_VIRTUAL_LOADER_MANIFEST) {
|
|
333
|
+
// Generate a lazy import map for on-demand loader loading
|
|
334
|
+
// This avoids importing all loader modules at startup
|
|
335
|
+
|
|
336
|
+
if (!isBuild) {
|
|
337
|
+
// Dev mode: empty map - use fallback path parsing in loader registry
|
|
338
|
+
// IDs in dev mode are "filePath#exportName" format for easier debugging
|
|
339
|
+
return `import { setLoaderImports } from "@rangojs/router/server";
|
|
340
|
+
|
|
341
|
+
// Dev mode: empty map, loaders are resolved dynamically via path parsing
|
|
342
|
+
setLoaderImports({});
|
|
343
|
+
`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Build mode: generate lazy import map
|
|
347
|
+
// Each loader is only imported when first requested
|
|
348
|
+
// Keys are hashed IDs to avoid exposing file paths
|
|
349
|
+
const lazyImports: string[] = [];
|
|
350
|
+
|
|
351
|
+
for (const [hashedId, { filePath, exportName }] of loaderRegistry) {
|
|
352
|
+
// Create a lazy import function for each loader
|
|
353
|
+
lazyImports.push(
|
|
354
|
+
` "${hashedId}": () => import("/${filePath}").then(m => m.${exportName})`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// If no loaders discovered, set empty map
|
|
359
|
+
if (lazyImports.length === 0) {
|
|
360
|
+
return `import { setLoaderImports } from "@rangojs/router/server";
|
|
361
|
+
|
|
362
|
+
// No fetchable loaders discovered during build
|
|
363
|
+
setLoaderImports({});
|
|
364
|
+
`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const code = `import { setLoaderImports } from "@rangojs/router/server";
|
|
368
|
+
|
|
369
|
+
// Lazy import map - loaders are loaded on-demand when first requested
|
|
370
|
+
setLoaderImports({
|
|
371
|
+
${lazyImports.join(",\n")}
|
|
372
|
+
});
|
|
373
|
+
`;
|
|
374
|
+
return code;
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
transform(code, id) {
|
|
379
|
+
// Skip node_modules
|
|
380
|
+
if (id.includes("/node_modules/")) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Quick bail-out
|
|
385
|
+
if (!code.includes("createLoader")) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Must have direct import from rsc-router
|
|
390
|
+
if (!hasCreateLoaderImport(code)) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Check if we're in RSC environment (server-side)
|
|
395
|
+
const envName = this.environment?.name;
|
|
396
|
+
const isRscEnv = envName === "rsc";
|
|
397
|
+
|
|
398
|
+
// Get relative path for the ID
|
|
399
|
+
const relativePath = normalizePath(path.relative(config.root, id));
|
|
400
|
+
|
|
401
|
+
// Track loaders for manifest (only in RSC env to avoid duplicate entries)
|
|
402
|
+
if (isRscEnv) {
|
|
403
|
+
const pattern = /export\s+const\s+(\w+)\s*=\s*createLoader\s*\(/g;
|
|
404
|
+
let match: RegExpExecArray | null;
|
|
405
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
406
|
+
const exportName = match[1];
|
|
407
|
+
const hashedId = hashLoaderId(relativePath, exportName);
|
|
408
|
+
loaderRegistry.set(hashedId, { filePath: relativePath, exportName });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// In client/ssr environments, replace loader files with lightweight stubs.
|
|
413
|
+
// This prevents server-only data (product lists, DB queries, etc.) from
|
|
414
|
+
// leaking into the client bundle. The client only needs { $$id } references.
|
|
415
|
+
if (!isRscEnv) {
|
|
416
|
+
const stubResult = generateClientLoaderStubs(code, relativePath, isBuild);
|
|
417
|
+
if (stubResult) {
|
|
418
|
+
return stubResult;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// RSC environment: inject $$id into createLoader calls, keeping full implementation
|
|
423
|
+
return transformLoaderExports(code, relativePath, id, isBuild);
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|