@rangojs/router 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join, normalize, relative } from "node:path";
1
3
  import type { Plugin, ResolvedConfig } from "vite";
2
4
  import { createRangoDebugger, NS } from "../debug.js";
3
5
 
@@ -5,23 +7,21 @@ const debug = createRangoDebugger(NS.transform);
5
7
 
6
8
  const CLIENT_IN_SERVER_PROXY_PREFIX =
7
9
  "virtual:vite-rsc/client-in-server-package-proxy/";
10
+ const DEDUP_PREFIX = "\0rango:dedup/";
11
+
12
+ interface PackageMetadata {
13
+ root: string;
14
+ selfName: string;
15
+ exports: unknown;
16
+ }
17
+
18
+ type PackageResolver = ReturnType<ResolvedConfig["createResolver"]>;
8
19
 
9
20
  /**
10
21
  * Extract the bare package name from an absolute node_modules path.
11
22
  * Handles scoped packages (@org/name) and nested node_modules.
12
23
  * Returns null if the path doesn't contain a valid package reference.
13
24
  *
14
- * NOTE: This is a lossy transformation. It maps a specific submodule path
15
- * (e.g., pkg/internal/context.js) to the package root (pkg). The load()
16
- * hook then re-exports via the bare specifier, which resolves to the
17
- * package entry point. This works for packages that barrel-export their
18
- * "use client" symbols from the root, which covers the common case
19
- * (component libraries like @mantine/core, @chakra-ui/react, etc.).
20
- * Packages whose client symbols are only available from deep subpaths
21
- * (not re-exported from the root) would lose those symbols after the
22
- * rewrite. A more precise approach would resolve through the package's
23
- * exports map to find the correct entry point, but that adds significant
24
- * complexity for a rare edge case.
25
25
  * See: https://github.com/cloudflare/vinext/pull/413
26
26
  */
27
27
  export function extractPackageName(absolutePath: string): string | null {
@@ -44,6 +44,219 @@ export function extractPackageName(absolutePath: string): string | null {
44
44
  return name || null;
45
45
  }
46
46
 
47
+ function stripQueryAndHash(id: string): string {
48
+ const suffixIndex = id.search(/[?#]/);
49
+ return suffixIndex === -1 ? id : id.slice(0, suffixIndex);
50
+ }
51
+
52
+ function getPackageRoot(source: string): string | undefined {
53
+ const packageName = extractPackageName(source);
54
+ if (!packageName) return;
55
+
56
+ const marker = "/node_modules/";
57
+ const markerIndex = source.lastIndexOf(marker);
58
+ return source.slice(0, markerIndex + marker.length) + packageName;
59
+ }
60
+
61
+ function readPackageMetadata(
62
+ source: string,
63
+ cache: Map<string, PackageMetadata | null>,
64
+ ): PackageMetadata | undefined {
65
+ const root = getPackageRoot(source);
66
+ if (!root) return;
67
+
68
+ const cached = cache.get(root);
69
+ if (cached !== undefined) return cached ?? undefined;
70
+
71
+ const packageJsonPath = join(root, "package.json");
72
+ if (!existsSync(packageJsonPath)) {
73
+ cache.set(root, null);
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
79
+ name?: unknown;
80
+ exports?: unknown;
81
+ };
82
+ if (typeof packageJson.name !== "string" || !packageJson.name) {
83
+ cache.set(root, null);
84
+ return;
85
+ }
86
+ const metadata = {
87
+ root,
88
+ selfName: packageJson.name,
89
+ exports: packageJson.exports,
90
+ } satisfies PackageMetadata;
91
+ cache.set(root, metadata);
92
+ return metadata;
93
+ } catch {
94
+ cache.set(root, null);
95
+ return;
96
+ }
97
+ }
98
+
99
+ function collectStringTargets(value: unknown, targets: string[]): void {
100
+ if (typeof value === "string") {
101
+ targets.push(value);
102
+ return;
103
+ }
104
+ if (Array.isArray(value)) {
105
+ for (const item of value) collectStringTargets(item, targets);
106
+ return;
107
+ }
108
+ if (typeof value !== "object" || value === null) return;
109
+ for (const target of Object.values(value)) {
110
+ collectStringTargets(target, targets);
111
+ }
112
+ }
113
+
114
+ function singleStarParts(value: string): [string, string] | undefined {
115
+ const starIndex = value.indexOf("*");
116
+ if (starIndex === -1 || value.indexOf("*", starIndex + 1) !== -1) return;
117
+ return [value.slice(0, starIndex), value.slice(starIndex + 1)];
118
+ }
119
+
120
+ function targetCanMapToSource(target: string, sourceRelative: string): boolean {
121
+ const targetStar = singleStarParts(target);
122
+ if (!targetStar) return target === sourceRelative;
123
+ const [prefix, suffix] = targetStar;
124
+ return (
125
+ sourceRelative.startsWith(prefix) &&
126
+ sourceRelative.endsWith(suffix) &&
127
+ sourceRelative.length >= prefix.length + suffix.length
128
+ );
129
+ }
130
+
131
+ function getPublicSpecifierCandidates(
132
+ metadata: PackageMetadata,
133
+ packageSpecifier: string,
134
+ source: string,
135
+ ): string[] {
136
+ if (
137
+ typeof metadata.exports !== "object" ||
138
+ metadata.exports === null ||
139
+ Array.isArray(metadata.exports)
140
+ ) {
141
+ return [];
142
+ }
143
+
144
+ const candidates = new Set<string>();
145
+ const sourceRelative = `./${relative(metadata.root, source).replaceAll("\\", "/")}`;
146
+ if (
147
+ sourceRelative.startsWith("./../") ||
148
+ isAbsolute(sourceRelative.slice(2))
149
+ ) {
150
+ return [];
151
+ }
152
+
153
+ for (const [exportKey, target] of Object.entries(metadata.exports)) {
154
+ if (!exportKey.startsWith("./") || exportKey === ".") continue;
155
+
156
+ const keyStar = singleStarParts(exportKey);
157
+ if (!keyStar) {
158
+ const targets: string[] = [];
159
+ collectStringTargets(target, targets);
160
+ if (
161
+ targets.some((target) => targetCanMapToSource(target, sourceRelative))
162
+ ) {
163
+ candidates.add(`${packageSpecifier}${exportKey.slice(1)}`);
164
+ }
165
+ continue;
166
+ }
167
+
168
+ const captures = new Set<string>();
169
+ const targets: string[] = [];
170
+ collectStringTargets(target, targets);
171
+ for (const targetPattern of targets) {
172
+ const targetStar = singleStarParts(targetPattern);
173
+ if (!targetStar) continue;
174
+ const [prefix, suffix] = targetStar;
175
+ if (
176
+ !sourceRelative.startsWith(prefix) ||
177
+ !sourceRelative.endsWith(suffix) ||
178
+ sourceRelative.length < prefix.length + suffix.length
179
+ ) {
180
+ continue;
181
+ }
182
+ captures.add(
183
+ sourceRelative.slice(
184
+ prefix.length,
185
+ sourceRelative.length - suffix.length,
186
+ ),
187
+ );
188
+ }
189
+
190
+ if (captures.size === 1) {
191
+ const capture = captures.values().next().value;
192
+ if (capture !== undefined) {
193
+ const publicSubpath = `${keyStar[0]}${capture}${keyStar[1]}`;
194
+ candidates.add(`${packageSpecifier}${publicSubpath.slice(1)}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ return [...candidates];
200
+ }
201
+
202
+ function belongsToPackage(resolvedId: string, packageRoot: string): boolean {
203
+ return normalize(getPackageRoot(resolvedId) ?? "") === normalize(packageRoot);
204
+ }
205
+
206
+ async function resolvePublicSpecifier(
207
+ source: string,
208
+ metadata: PackageMetadata,
209
+ installedSpecifier: string,
210
+ resolvePackage: PackageResolver,
211
+ importer: string,
212
+ ): Promise<string | undefined> {
213
+ const packageSpecifiers =
214
+ installedSpecifier === metadata.selfName
215
+ ? [installedSpecifier]
216
+ : [installedSpecifier, metadata.selfName];
217
+
218
+ for (const packageSpecifier of packageSpecifiers) {
219
+ for (const candidate of getPublicSpecifierCandidates(
220
+ metadata,
221
+ packageSpecifier,
222
+ source,
223
+ )) {
224
+ try {
225
+ const resolved = await resolvePackage(candidate, importer);
226
+ if (!resolved) continue;
227
+ const resolvedId = stripQueryAndHash(resolved);
228
+ if (
229
+ normalize(resolvedId) === normalize(source) &&
230
+ belongsToPackage(resolvedId, metadata.root)
231
+ ) {
232
+ return candidate;
233
+ }
234
+ } catch {
235
+ continue;
236
+ }
237
+ }
238
+
239
+ // Fallback: the pre-exports-map behavior, and it is LOSSY. When no public
240
+ // subpath maps back to this exact file, re-export from the bare package
241
+ // root — correct only for packages that barrel-export their "use client"
242
+ // symbols from the entry point (the common component-library shape).
243
+ // A deep module whose symbols are NOT re-exported from the root loses
244
+ // them silently after this rewrite; the exports-map resolution above
245
+ // exists precisely to make that case rare.
246
+ try {
247
+ const rootResolved = await resolvePackage(packageSpecifier, importer);
248
+ if (
249
+ rootResolved &&
250
+ belongsToPackage(stripQueryAndHash(rootResolved), metadata.root)
251
+ ) {
252
+ return packageSpecifier;
253
+ }
254
+ } catch {
255
+ continue;
256
+ }
257
+ }
258
+ }
259
+
47
260
  /**
48
261
  * Vite plugin that deduplicates client references from third-party packages
49
262
  * in dev mode.
@@ -65,7 +278,11 @@ export function extractPackageName(absolutePath: string): string | null {
65
278
  */
66
279
  export function clientRefDedup(): Plugin {
67
280
  let clientExclude: string[] = [];
281
+ let rootImporter = "";
282
+ let resolvePackage: PackageResolver | undefined;
68
283
  const dedupedPackages = new Set<string>();
284
+ const packageMetadataCache = new Map<string, PackageMetadata | null>();
285
+ const publicSpecifierCache = new Map<string, Promise<string | undefined>>();
69
286
 
70
287
  return {
71
288
  name: "@rangojs/router:client-ref-dedup",
@@ -76,6 +293,8 @@ export function clientRefDedup(): Plugin {
76
293
  const clientEnv = config.environments?.["client"];
77
294
  clientExclude =
78
295
  clientEnv?.optimizeDeps?.exclude ?? config.optimizeDeps?.exclude ?? [];
296
+ rootImporter = join(config.root, "index.html");
297
+ resolvePackage = config.createResolver({ scan: true });
79
298
  },
80
299
 
81
300
  buildEnd() {
@@ -95,24 +314,67 @@ export function clientRefDedup(): Plugin {
95
314
 
96
315
  if (!source.includes("/node_modules/")) return;
97
316
 
98
- const packageName = extractPackageName(source);
99
- if (!packageName) return;
317
+ const cleanSource = stripQueryAndHash(source);
318
+ const packageName = extractPackageName(cleanSource);
319
+ if (!packageName || !resolvePackage) return;
100
320
 
101
321
  if (clientExclude.includes(packageName)) return;
102
322
 
103
- if (debug) dedupedPackages.add(packageName);
323
+ const metadata = readPackageMetadata(cleanSource, packageMetadataCache);
324
+ if (!metadata || clientExclude.includes(metadata.selfName)) return;
325
+
326
+ let specifierPromise = publicSpecifierCache.get(cleanSource);
327
+ if (!specifierPromise) {
328
+ specifierPromise = resolvePublicSpecifier(
329
+ cleanSource,
330
+ metadata,
331
+ packageName,
332
+ resolvePackage,
333
+ rootImporter,
334
+ );
335
+ publicSpecifierCache.set(cleanSource, specifierPromise);
336
+ // Only SUCCESSFUL resolutions stay cached. A failed one is evicted so
337
+ // the next resolveId retries — deliberate: dev-time failures can be
338
+ // transient (a dependency installed mid-session, an exports map fixed
339
+ // on disk). The cost is a repeated candidate scan per unresolvable
340
+ // module, bounded by resolveId call frequency in dev.
341
+ void specifierPromise.then(
342
+ (specifier) => {
343
+ if (
344
+ !specifier &&
345
+ publicSpecifierCache.get(cleanSource) === specifierPromise
346
+ ) {
347
+ publicSpecifierCache.delete(cleanSource);
348
+ }
349
+ },
350
+ () => {
351
+ if (publicSpecifierCache.get(cleanSource) === specifierPromise) {
352
+ publicSpecifierCache.delete(cleanSource);
353
+ }
354
+ },
355
+ );
356
+ }
104
357
 
105
- return `\0rango:dedup/${packageName}`;
358
+ return specifierPromise.then((specifier) => {
359
+ if (!specifier) return;
360
+ if (debug) dedupedPackages.add(packageName);
361
+ return `${DEDUP_PREFIX}${encodeURIComponent(specifier)}`;
362
+ });
106
363
  },
107
364
 
108
365
  load(id) {
109
- if (!id.startsWith("\0rango:dedup/")) return;
366
+ if (!id.startsWith(DEDUP_PREFIX)) return;
110
367
 
111
- const packageName = id.slice("\0rango:dedup/".length);
368
+ let specifier: string;
369
+ try {
370
+ specifier = decodeURIComponent(id.slice(DEDUP_PREFIX.length));
371
+ } catch {
372
+ return;
373
+ }
112
374
 
113
375
  return [
114
- `export * from ${JSON.stringify(packageName)};`,
115
- `import * as __all__ from ${JSON.stringify(packageName)};`,
376
+ `export * from ${JSON.stringify(specifier)};`,
377
+ `import * as __all__ from ${JSON.stringify(specifier)};`,
116
378
  `export default __all__.default;`,
117
379
  ].join("\n");
118
380
  },
@@ -1,4 +1,5 @@
1
1
  import type { Plugin, ResolvedConfig } from "vite";
2
+ import { getPluginApi } from "@vitejs/plugin-rsc";
2
3
  import MagicString from "magic-string";
3
4
  import path from "node:path";
4
5
  import fs from "node:fs";
@@ -8,15 +9,15 @@ import { createRangoDebugger, createCounter, NS } from "../debug.js";
8
9
 
9
10
  const debug = createRangoDebugger(NS.transform);
10
11
 
12
+ interface ServerReferenceMeta {
13
+ importId: string;
14
+ referenceKey: string;
15
+ exportNames: string[];
16
+ }
17
+
11
18
  interface RscPluginManager {
12
- serverReferenceMetaMap: Record<
13
- string,
14
- {
15
- importId: string;
16
- referenceKey: string;
17
- exportNames: string[];
18
- }
19
- >;
19
+ serverReferenceMetaMap?: Record<string, ServerReferenceMeta>;
20
+ serverReferences?: { metaMap: Map<string, ServerReferenceMeta> };
20
21
  config: ResolvedConfig;
21
22
  }
22
23
 
@@ -24,21 +25,44 @@ interface RscPluginApi {
24
25
  manager: RscPluginManager;
25
26
  }
26
27
 
28
+ function getServerReferenceMetaEntries(
29
+ manager: RscPluginManager | undefined,
30
+ ): Iterable<[string, ServerReferenceMeta]> {
31
+ if (manager?.serverReferences?.metaMap) {
32
+ return manager.serverReferences.metaMap;
33
+ }
34
+
35
+ if (manager?.serverReferenceMetaMap) {
36
+ return Object.entries(manager.serverReferenceMetaMap);
37
+ }
38
+
39
+ throw new Error(
40
+ "[rango] Unsupported @vitejs/plugin-rsc server reference metadata shape. " +
41
+ "Expected manager.serverReferences.metaMap or manager.serverReferenceMetaMap.",
42
+ );
43
+ }
44
+
27
45
  function getRscPluginApi(config: ResolvedConfig): RscPluginApi | undefined {
28
- let plugin = config.plugins.find((p) => p.name === "rsc:minimal");
46
+ const pluginApi = getPluginApi(config) as RscPluginApi | undefined;
47
+ if (pluginApi) {
48
+ return pluginApi;
49
+ }
29
50
 
30
- if (!plugin) {
31
- plugin = config.plugins.find(
32
- (p) =>
33
- (p.api as RscPluginApi | undefined)?.manager?.serverReferenceMetaMap !==
34
- undefined,
35
- );
36
- if (plugin) {
37
- console.warn(
38
- `[rango:expose-action-id] RSC plugin found by API structure (name: "${plugin.name}"). ` +
39
- `Consider updating the name lookup if the plugin was renamed.`,
51
+ const plugin = config.plugins.find((p) => {
52
+ try {
53
+ getServerReferenceMetaEntries(
54
+ (p.api as RscPluginApi | undefined)?.manager,
40
55
  );
56
+ return true;
57
+ } catch {
58
+ return false;
41
59
  }
60
+ });
61
+ if (plugin) {
62
+ console.warn(
63
+ `[rango:expose-action-id] RSC plugin found by API structure (name: "${plugin.name}"). ` +
64
+ `Consider updating the name lookup if the plugin was renamed.`,
65
+ );
42
66
  }
43
67
 
44
68
  return plugin?.api as RscPluginApi | undefined;
@@ -233,10 +257,8 @@ export function exposeActionId(): Plugin {
233
257
  if (!isBuild) return;
234
258
 
235
259
  hashToFileMap = new Map();
236
- const { serverReferenceMetaMap } = rscPluginApi.manager;
237
-
238
- for (const [absolutePath, meta] of Object.entries(
239
- serverReferenceMetaMap,
260
+ for (const [absolutePath, meta] of getServerReferenceMetaEntries(
261
+ rscPluginApi.manager,
240
262
  )) {
241
263
  // Only include module-level "use server" files
242
264
  // Inline actions (defined in RSC components) should keep hashed IDs for client security