@ivogt/rsc-router 0.0.0-experimental.1

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.
Files changed (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,344 @@
1
+ import type { Plugin, ResolvedConfig } from "vite";
2
+ import MagicString from "magic-string";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+
6
+ /**
7
+ * Type for the RSC plugin's manager API
8
+ */
9
+ interface RscPluginManager {
10
+ serverReferenceMetaMap: Record<
11
+ string,
12
+ {
13
+ importId: string;
14
+ referenceKey: string;
15
+ exportNames: string[];
16
+ }
17
+ >;
18
+ config: ResolvedConfig;
19
+ }
20
+
21
+ interface RscPluginApi {
22
+ manager: RscPluginManager;
23
+ }
24
+
25
+ /**
26
+ * Get the RSC plugin's API from Vite config
27
+ */
28
+ function getRscPluginApi(config: ResolvedConfig): RscPluginApi | undefined {
29
+ // Try by name first
30
+ let plugin = config.plugins.find((p) => p.name === "rsc:minimal");
31
+
32
+ // Fallback: find by API structure if name lookup fails
33
+ if (!plugin) {
34
+ plugin = config.plugins.find(
35
+ (p) =>
36
+ (p.api as RscPluginApi | undefined)?.manager?.serverReferenceMetaMap !==
37
+ undefined
38
+ );
39
+ if (plugin) {
40
+ console.warn(
41
+ `[rsc-router:expose-action-id] RSC plugin found by API structure (name: "${plugin.name}"). ` +
42
+ `Consider updating the name lookup if the plugin was renamed.`
43
+ );
44
+ }
45
+ }
46
+
47
+ return plugin?.api as RscPluginApi | undefined;
48
+ }
49
+
50
+ /**
51
+ * Normalize path to forward slashes
52
+ */
53
+ function normalizePath(p: string): string {
54
+ return p.split(path.sep).join("/");
55
+ }
56
+
57
+ /**
58
+ * Check if a file is a "use server" module (has the directive at the module level).
59
+ * This distinguishes module-level server action files from files with inline actions.
60
+ *
61
+ * Module-level "use server" files should have their hash replaced with file paths
62
+ * for revalidation matching. Inline actions (defined in RSC components) should
63
+ * keep their hashed IDs for client security.
64
+ */
65
+ function isUseServerModule(filePath: string): boolean {
66
+ try {
67
+ const content = fs.readFileSync(filePath, "utf-8");
68
+ // Remove leading comments and whitespace to find the first meaningful content
69
+ const trimmed = content
70
+ .replace(/^\s*\/\/[^\n]*\n/gm, "") // Remove single-line comments
71
+ .replace(/^\s*\/\*[\s\S]*?\*\/\s*/gm, "") // Remove multi-line comments
72
+ .trimStart();
73
+
74
+ // Check if the file starts with "use server" directive
75
+ return (
76
+ trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'")
77
+ );
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Transform code to expose action IDs on createServerReference calls.
85
+ * Wraps each call with an IIFE that attaches $id to the returned function.
86
+ *
87
+ * @param code - The source code to transform
88
+ * @param sourceId - The source file identifier (for sourcemap)
89
+ * @param hashToFileMap - Optional mapping from hash to file path (for server bundles)
90
+ */
91
+ function transformServerReferences(
92
+ code: string,
93
+ sourceId?: string,
94
+ hashToFileMap?: Map<string, string>
95
+ ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
96
+ if (!code.includes("createServerReference(")) {
97
+ return null;
98
+ }
99
+
100
+ // Match: createServerReference("hash#actionName", ...) or $$ReactClient.createServerReference(...)
101
+ // The RSC plugin uses $$ReactClient namespace in transformed code
102
+ const pattern =
103
+ /((?:\$\$\w+\.)?createServerReference)\(("[^"]+#[^"]+")([^)]*)\)/g;
104
+
105
+ const s = new MagicString(code);
106
+ let hasChanges = false;
107
+ let match: RegExpExecArray | null;
108
+
109
+ while ((match = pattern.exec(code)) !== null) {
110
+ hasChanges = true;
111
+ const [fullMatch, fnCall, idArg, rest] = match;
112
+ const start = match.index;
113
+ const end = start + fullMatch.length;
114
+
115
+ // Parse the ID to potentially replace hash with file path
116
+ let finalIdArg = idArg;
117
+ if (hashToFileMap) {
118
+ // idArg is like '"hash#actionName"', extract the parts
119
+ const idValue = idArg.slice(1, -1); // Remove quotes
120
+ const hashMatch = idValue.match(/^([^#]+)#(.+)$/);
121
+ if (hashMatch) {
122
+ const [, hash, actionName] = hashMatch;
123
+ const filePath = hashToFileMap.get(hash);
124
+ if (filePath) {
125
+ // Replace hash with file path for server-side
126
+ finalIdArg = `"${filePath}#${actionName}"`;
127
+ }
128
+ }
129
+ }
130
+
131
+ // Wrap the createServerReference call to attach $$id to the returned function
132
+ const replacement = `(function(fn) { fn.$$id = ${finalIdArg}; return fn; })(${fnCall}(${idArg}${rest}))`;
133
+ s.overwrite(start, end, replacement);
134
+ }
135
+
136
+ if (!hasChanges) {
137
+ return null;
138
+ }
139
+
140
+ return {
141
+ code: s.toString(),
142
+ map: s.generateMap({ source: sourceId, includeContent: true }),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Transform registerServerReference calls in server bundles to use file paths instead of hashes.
148
+ * Pattern: registerServerReference(fn, "hash", "exportName")
149
+ * React's registerServerReference sets $$id = hash + "#" + exportName
150
+ * By replacing the hash with file path, $$id will contain the file path for revalidation matching.
151
+ *
152
+ * Only actions from module-level "use server" files are transformed.
153
+ * Inline actions (defined in RSC components with "use server" inside a function) are NOT in
154
+ * hashToFileMap and keep their hashed IDs. This is intentional for client security:
155
+ * - Module-level "use server" files: shared action modules, file path helps revalidation
156
+ * - Inline actions: one-off actions in RSC, hash ID prevents file path exposure to client
157
+ *
158
+ * @param code - The source code to transform
159
+ * @param sourceId - The source file identifier (for sourcemap)
160
+ * @param hashToFileMap - Mapping from hash to file path (only module-level "use server" files)
161
+ */
162
+ function transformRegisterServerReference(
163
+ code: string,
164
+ sourceId?: string,
165
+ hashToFileMap?: Map<string, string>
166
+ ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
167
+ if (!hashToFileMap || !code.includes("registerServerReference(")) {
168
+ return null;
169
+ }
170
+
171
+ // Match: registerServerReference(fn, "hash", "exportName")
172
+ // The hash is the second argument, exportName is the third
173
+ const pattern = /registerServerReference\(([^,]+),\s*"([^"]+)",\s*"([^"]+)"\)/g;
174
+
175
+ const s = new MagicString(code);
176
+ let hasChanges = false;
177
+ let match: RegExpExecArray | null;
178
+
179
+ while ((match = pattern.exec(code)) !== null) {
180
+ const [fullMatch, fnArg, hash, exportName] = match;
181
+ const start = match.index;
182
+ const end = start + fullMatch.length;
183
+
184
+ // Look up the file path for this hash
185
+ const filePath = hashToFileMap.get(hash);
186
+ if (filePath) {
187
+ hasChanges = true;
188
+ // WRAP the call to add $id property with file path
189
+ // Keep the original hash for React's action registry (so loadServerAction works)
190
+ // Add $id (single dollar) with file path for revalidation matching
191
+ // Note: We use $id instead of $$id because React's registerServerReference
192
+ // sets $$id as a non-writable property
193
+ const filePathId = `${filePath}#${exportName}`;
194
+ const replacement = `(function(fn) { fn.$id = "${filePathId}"; return fn; })(registerServerReference(${fnArg}, "${hash}", "${exportName}"))`;
195
+ s.overwrite(start, end, replacement);
196
+ }
197
+ }
198
+
199
+ if (!hasChanges) {
200
+ return null;
201
+ }
202
+
203
+ return {
204
+ code: s.toString(),
205
+ map: s.generateMap({ source: sourceId, includeContent: true }),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Vite plugin that exposes action IDs on server reference functions.
211
+ *
212
+ * When React Server Components creates server references via createServerReference(),
213
+ * the action ID (format: "hash#actionName") is passed as the first argument but not
214
+ * exposed on the returned function. This plugin transforms the output to attach
215
+ * the $id property to each server reference function, enabling the router to
216
+ * identify which action was called during revalidation.
217
+ *
218
+ * Server bundles (RSC/SSR) get file paths in $id for filtering (e.g., "src/actions.ts#add").
219
+ * Client bundles keep hashed IDs for security (e.g., "ec387bc704d4#add").
220
+ *
221
+ * Works in:
222
+ * - Build mode: uses renderChunk to transform bundled chunks
223
+ * - Dev mode: uses transform with enforce:"post" to transform after RSC plugin
224
+ */
225
+ export function exposeActionId(): Plugin {
226
+ let config: ResolvedConfig;
227
+ let isBuild = false;
228
+ let hashToFileMap: Map<string, string> | undefined;
229
+ let rscPluginApi: RscPluginApi | undefined;
230
+
231
+ return {
232
+ name: "rsc-router:expose-action-id",
233
+ // Run after all other plugins (including RSC plugin's transforms)
234
+ enforce: "post",
235
+
236
+ configResolved(resolvedConfig) {
237
+ config = resolvedConfig;
238
+ isBuild = config.command === "build";
239
+
240
+ // Get RSC plugin API - rsc-router requires @vitejs/plugin-rsc
241
+ rscPluginApi = getRscPluginApi(config);
242
+ },
243
+
244
+ buildStart() {
245
+ // Verify RSC plugin is present at build start (after all config hooks have run)
246
+ // This allows rsc-router:rsc-integration to dynamically add the RSC plugin
247
+ if (!rscPluginApi) {
248
+ rscPluginApi = getRscPluginApi(config);
249
+ }
250
+
251
+ if (!rscPluginApi) {
252
+ throw new Error(
253
+ "[rsc-router] Could not find @vitejs/plugin-rsc. " +
254
+ "rsc-router requires the Vite RSC plugin.\n" +
255
+ "The RSC plugin should be included automatically. If you disabled it with\n" +
256
+ "rscRouter({ rsc: false }), add rsc() before rscRouter() in your config."
257
+ );
258
+ }
259
+
260
+ if (!isBuild) return;
261
+
262
+ hashToFileMap = new Map();
263
+ const { serverReferenceMetaMap } = rscPluginApi.manager;
264
+
265
+ for (const [absolutePath, meta] of Object.entries(
266
+ serverReferenceMetaMap
267
+ )) {
268
+ // Only include module-level "use server" files
269
+ // Inline actions (defined in RSC components) should keep hashed IDs for client security
270
+ if (!isUseServerModule(absolutePath)) {
271
+ continue;
272
+ }
273
+
274
+ const relativePath = normalizePath(
275
+ path.relative(config.root, absolutePath)
276
+ );
277
+
278
+ // The referenceKey in build mode is the hash
279
+ // Map hash -> relative file path
280
+ hashToFileMap.set(meta.referenceKey, relativePath);
281
+ }
282
+ },
283
+
284
+
285
+ // Dev mode only: transform hook runs after RSC plugin creates server references
286
+ // In dev mode, IDs already contain file paths, not hashes
287
+ transform(code, id) {
288
+ // Skip in build mode - renderChunk handles it
289
+ if (isBuild) {
290
+ return;
291
+ }
292
+
293
+ // Quick bail-out: only process if code has createServerReference
294
+ if (!code.includes("createServerReference(")) {
295
+ return;
296
+ }
297
+
298
+ // Skip node_modules
299
+ if (id.includes("/node_modules/")) {
300
+ return;
301
+ }
302
+
303
+ // Dev mode: no hash-to-file mapping needed (IDs are already file paths)
304
+ return transformServerReferences(code, id);
305
+ },
306
+
307
+ // Build mode: renderChunk runs after all transforms and bundling complete
308
+ renderChunk(code, chunk) {
309
+ // Only RSC bundle should get file paths for revalidation matching
310
+ // SSR bundle must NOT use file paths because client components run there
311
+ // and need to match the client bundle during hydration (otherwise: error #418)
312
+ const isRscEnv = this.environment?.name === "rsc";
313
+
314
+ // Only use file path mapping for RSC environment
315
+ const effectiveMap = isRscEnv ? hashToFileMap : undefined;
316
+
317
+ // Transform createServerReference calls (client-side)
318
+ const result = transformServerReferences(
319
+ code,
320
+ chunk.fileName,
321
+ effectiveMap
322
+ );
323
+
324
+ // For RSC bundles, also transform registerServerReference calls
325
+ // This replaces hashed IDs with file paths so $id contains the actual path
326
+ if (isRscEnv && hashToFileMap) {
327
+ const codeToTransform = result ? result.code : code;
328
+ const registerResult = transformRegisterServerReference(
329
+ codeToTransform,
330
+ chunk.fileName,
331
+ hashToFileMap
332
+ );
333
+ if (registerResult) {
334
+ return { code: registerResult.code, map: registerResult.map };
335
+ }
336
+ }
337
+
338
+ if (result) {
339
+ return { code: result.code, map: result.map };
340
+ }
341
+ return null;
342
+ },
343
+ };
344
+ }
@@ -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 "rsc-router" or "rsc-router/..."
29
+ const pattern =
30
+ /import\s*\{[^}]*\bcreateHandle\b[^}]*\}\s*from\s*["']rsc-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 "rsc-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: "rsc-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
+ }