@ivogt/rsc-router 0.0.0-experimental.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ivogt/rsc-router",
3
- "version": "0.0.0-experimental.1",
3
+ "version": "0.0.0-experimental.10",
4
4
  "type": "module",
5
5
  "description": "Type-safe RSC router with partial rendering support",
6
6
  "author": "Ivo Todorov",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "./vite": {
57
57
  "types": "./src/vite/index.ts",
58
- "import": "./src/vite/index.ts"
58
+ "import": "./dist/vite/index.js"
59
59
  },
60
60
  "./types": {
61
61
  "types": "./src/vite/version.d.ts"
@@ -89,6 +89,7 @@
89
89
  },
90
90
  "files": [
91
91
  "src",
92
+ "dist",
92
93
  "README.md"
93
94
  ],
94
95
  "peerDependencies": {
@@ -117,11 +118,13 @@
117
118
  "@types/react-dom": "^19.2.3",
118
119
  "react": "^19.2.1",
119
120
  "react-dom": "^19.2.1",
121
+ "esbuild": "^0.27.0",
120
122
  "tinyexec": "^0.3.2",
121
123
  "typescript": "^5.3.0",
122
124
  "vitest": "^2.1.8"
123
125
  },
124
126
  "scripts": {
127
+ "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external",
125
128
  "typecheck": "tsc --noEmit",
126
129
  "test": "playwright test",
127
130
  "test:ui": "playwright test --ui",
package/src/router.ts CHANGED
@@ -160,6 +160,39 @@ export interface RSCRouterOptions<TEnv = any> {
160
160
  */
161
161
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
162
162
 
163
+ /**
164
+ * Component to render when no route matches the requested URL.
165
+ *
166
+ * This is rendered within your document/app shell with a 404 status code.
167
+ * Use this for a custom 404 page that maintains your app's look and feel.
168
+ *
169
+ * If not provided, a default "Page not found" component is rendered.
170
+ *
171
+ * Can be a static ReactNode or a function receiving the pathname.
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * // Simple static component
176
+ * const router = createRSCRouter<AppEnv>({
177
+ * document: AppShell,
178
+ * notFound: <NotFound404 />,
179
+ * });
180
+ *
181
+ * // Dynamic component with pathname
182
+ * const router = createRSCRouter<AppEnv>({
183
+ * document: AppShell,
184
+ * notFound: ({ pathname }) => (
185
+ * <div>
186
+ * <h1>404 - Not Found</h1>
187
+ * <p>No page exists at {pathname}</p>
188
+ * <a href="/">Go home</a>
189
+ * </div>
190
+ * ),
191
+ * });
192
+ * ```
193
+ */
194
+ notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
195
+
163
196
  /**
164
197
  * Callback invoked when an error occurs during request handling.
165
198
  *
@@ -368,6 +401,11 @@ export interface RSCRouter<
368
401
  */
369
402
  readonly cache?: RSCRouterOptions<TEnv>["cache"];
370
403
 
404
+ /**
405
+ * Not found component to render when no route matches (for internal use by RSC handler)
406
+ */
407
+ readonly notFound?: RSCRouterOptions<TEnv>["notFound"];
408
+
371
409
  /**
372
410
  * App-level middleware entries (for internal use by RSC handler)
373
411
  * These wrap the entire request/response cycle
@@ -456,6 +494,7 @@ export function createRSCRouter<TEnv = any>(
456
494
  document: documentOption,
457
495
  defaultErrorBoundary,
458
496
  defaultNotFoundBoundary,
497
+ notFound,
459
498
  onError,
460
499
  cache,
461
500
  } = options;
@@ -3471,6 +3510,9 @@ export function createRSCRouter<TEnv = any>(
3471
3510
  // Expose cache configuration for RSC handler
3472
3511
  cache,
3473
3512
 
3513
+ // Expose notFound component for RSC handler
3514
+ notFound,
3515
+
3474
3516
  // Expose global middleware for RSC handler
3475
3517
  middleware: globalMiddleware,
3476
3518
 
@@ -7,6 +7,7 @@
7
7
  * and progressive enhancement (no-JS form submissions).
8
8
  */
9
9
 
10
+ import { createElement } from "react";
10
11
  import { renderSegments } from "../segment-system.js";
11
12
  import { RouteNotFoundError } from "../errors.js";
12
13
  import { getLoaderLazy } from "../server/loader-registry.js";
@@ -291,22 +292,85 @@ export function createRSCHandler<TEnv = unknown>(
291
292
  // ============================================================================
292
293
  // REGULAR RSC RENDERING (Navigation)
293
294
  // ============================================================================
294
- return handleRscRendering(request, env, url, isPartial, handleStore, nonce);
295
+ // Note: Must use "return await" for try/catch to catch async rejections
296
+ return await handleRscRendering(request, env, url, isPartial, handleStore, nonce);
295
297
  } catch (error) {
296
298
  // Check if middleware/handler returned Response
297
299
  if (error instanceof Response) {
298
300
  return error;
299
301
  }
300
302
 
301
- // Return 404 for unmatched routes instead of 500
302
- if (error instanceof RouteNotFoundError) {
303
+ // Render 404 page for unmatched routes
304
+ // Check both instanceof and error.name for cross-bundle compatibility
305
+ const isRouteNotFound =
306
+ error instanceof RouteNotFoundError ||
307
+ (error instanceof Error && error.name === "RouteNotFoundError");
308
+ if (isRouteNotFound) {
303
309
  callOnError(error, "routing", {
304
310
  request,
305
311
  url,
306
312
  env,
307
- handledByBoundary: false,
313
+ handledByBoundary: true, // Handled by notFound component
314
+ });
315
+
316
+ // Get notFound component from router options or use default
317
+ const notFoundOption = router.notFound;
318
+ const notFoundComponent =
319
+ typeof notFoundOption === "function"
320
+ ? notFoundOption({ pathname: url.pathname })
321
+ : notFoundOption ?? createElement("h1", null, "Not Found");
322
+
323
+ // Create a simple segment for the 404 page
324
+ const notFoundSegment = {
325
+ id: "notFound",
326
+ namespace: "notFound",
327
+ type: "route" as const,
328
+ index: 0,
329
+ component: notFoundComponent,
330
+ params: {},
331
+ };
332
+
333
+ // Render with rootLayout to maintain app shell
334
+ const root = await renderSegments([notFoundSegment], {
335
+ rootLayout: router.rootLayout,
336
+ });
337
+
338
+ const payload: RscPayload = {
339
+ root,
340
+ metadata: {
341
+ pathname: url.pathname,
342
+ segments: [notFoundSegment],
343
+ matched: [],
344
+ diff: [],
345
+ isPartial: false,
346
+ handles: handleStore.stream(),
347
+ version,
348
+ },
349
+ };
350
+
351
+ const rscStream = renderToReadableStream(payload);
352
+
353
+ // Determine if this is an RSC request or HTML request
354
+ const isRscRequest =
355
+ (!request.headers.get("accept")?.includes("text/html") &&
356
+ !url.searchParams.has("__html")) ||
357
+ url.searchParams.has("__rsc");
358
+
359
+ if (isRscRequest) {
360
+ return createResponseWithMergedHeaders(rscStream, {
361
+ status: 404,
362
+ headers: { "content-type": "text/x-component;charset=utf-8" },
363
+ });
364
+ }
365
+
366
+ // Delegate to SSR for HTML response
367
+ const ssrModule = await loadSSRModule();
368
+ const htmlStream = await ssrModule.renderHTML(rscStream, { nonce });
369
+
370
+ return createResponseWithMergedHeaders(htmlStream, {
371
+ status: 404,
372
+ headers: { "content-type": "text/html;charset=utf-8" },
308
373
  });
309
- return createResponseWithMergedHeaders("Not Found", { status: 404 });
310
374
  }
311
375
 
312
376
  // Report unhandled errors
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { Outlet } from "rsc-router/client";
2
+ import { Outlet } from "../client.js";
3
3
 
4
4
  const MapRootLayout = (
5
5
  <>
package/src/vite/index.ts CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  getVirtualVersionContent,
13
13
  VIRTUAL_IDS,
14
14
  } from "./virtual-entries.ts";
15
+ import {
16
+ getExcludeDeps,
17
+ getPackageAliases,
18
+ getPublishedPackageName,
19
+ isWorkspaceDevelopment,
20
+ } from "./package-resolution.ts";
15
21
 
16
22
  // Re-export plugins
17
23
  export { exposeActionId } from "./expose-action-id.ts";
@@ -21,6 +27,33 @@ export { exposeLocationStateId } from "./expose-location-state-id.ts";
21
27
 
22
28
  // Virtual module type declarations in ./version.d.ts
23
29
 
30
+ /**
31
+ * esbuild plugin to provide rsc-router:version virtual module during optimization.
32
+ * This is needed because esbuild runs during Vite's dependency optimization phase,
33
+ * before Vite's plugin system can handle virtual modules.
34
+ */
35
+ const versionEsbuildPlugin = {
36
+ name: "rsc-router-version",
37
+ setup(build: any) {
38
+ build.onResolve({ filter: /^rsc-router:version$/ }, (args: any) => ({
39
+ path: args.path,
40
+ namespace: "rsc-router-virtual",
41
+ }));
42
+ build.onLoad({ filter: /.*/, namespace: "rsc-router-virtual" }, () => ({
43
+ contents: `export const VERSION = "dev";`,
44
+ loader: "js",
45
+ }));
46
+ },
47
+ };
48
+
49
+ /**
50
+ * Shared esbuild options for dependency optimization.
51
+ * Includes the version stub plugin for all environments.
52
+ */
53
+ const sharedEsbuildOptions = {
54
+ plugins: [versionEsbuildPlugin],
55
+ };
56
+
24
57
  /**
25
58
  * RSC plugin entry points configuration.
26
59
  * All entries use virtual modules by default. Specify a path to use a custom entry file.
@@ -399,6 +432,10 @@ export async function rscRouter(
399
432
 
400
433
  const plugins: PluginOption[] = [];
401
434
 
435
+ // Get package resolution info (workspace vs npm install)
436
+ const rscRouterAliases = getPackageAliases();
437
+ const excludeDeps = getExcludeDeps();
438
+
402
439
  // Track RSC entry path for version injection
403
440
  let rscEntryPath: string | null = null;
404
441
 
@@ -422,6 +459,15 @@ export async function rscRouter(
422
459
  config() {
423
460
  // Configure environments for cloudflare deployment
424
461
  return {
462
+ // Exclude rsc-router modules from optimization to prevent module duplication
463
+ // This ensures the same Context instance is used by both browser entry and RSC proxy modules
464
+ optimizeDeps: {
465
+ exclude: excludeDeps,
466
+ esbuildOptions: sharedEsbuildOptions,
467
+ },
468
+ resolve: {
469
+ alias: rscRouterAliases,
470
+ },
425
471
  environments: {
426
472
  client: {
427
473
  build: {
@@ -431,9 +477,16 @@ export async function rscRouter(
431
477
  },
432
478
  },
433
479
  },
434
- // Pre-bundle rsc-html-stream to prevent discovery during first request
480
+ // Pre-bundle rsc-html-stream and react-server-dom vendor to prevent discovery during first request
481
+ // The vendor file is CJS and needs to be transformed to ESM by Vite's optimizer
482
+ // Exclude rsc-router modules to ensure same Context instance
435
483
  optimizeDeps: {
436
- include: ["rsc-html-stream/client"],
484
+ include: [
485
+ "rsc-html-stream/client",
486
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser",
487
+ ],
488
+ exclude: excludeDeps,
489
+ esbuildOptions: sharedEsbuildOptions,
437
490
  },
438
491
  },
439
492
  ssr: {
@@ -446,6 +499,7 @@ export async function rscRouter(
446
499
  dedupe: ["react", "react-dom"],
447
500
  },
448
501
  // Pre-bundle SSR entry and React for proper module linking with childEnvironments
502
+ // Exclude rsc-router modules to ensure same Context instance
449
503
  optimizeDeps: {
450
504
  entries: [finalEntries.ssr],
451
505
  include: [
@@ -454,6 +508,16 @@ export async function rscRouter(
454
508
  "react/jsx-runtime",
455
509
  "rsc-html-stream/server",
456
510
  ],
511
+ exclude: excludeDeps,
512
+ esbuildOptions: sharedEsbuildOptions,
513
+ },
514
+ },
515
+ rsc: {
516
+ // RSC environment needs exclude list and esbuild options
517
+ // Exclude rsc-router modules to prevent createContext in RSC environment
518
+ optimizeDeps: {
519
+ exclude: excludeDeps,
520
+ esbuildOptions: sharedEsbuildOptions,
457
521
  },
458
522
  },
459
523
  },
@@ -515,6 +579,15 @@ export async function rscRouter(
515
579
  const useVirtualRSC = finalEntries.rsc === VIRTUAL_IDS.rsc;
516
580
 
517
581
  return {
582
+ // Exclude rsc-router modules from optimization to prevent module duplication
583
+ // This ensures the same Context instance is used by both browser entry and RSC proxy modules
584
+ optimizeDeps: {
585
+ exclude: excludeDeps,
586
+ esbuildOptions: sharedEsbuildOptions,
587
+ },
588
+ resolve: {
589
+ alias: rscRouterAliases,
590
+ },
518
591
  environments: {
519
592
  client: {
520
593
  build: {
@@ -524,12 +597,17 @@ export async function rscRouter(
524
597
  },
525
598
  },
526
599
  },
527
- ...(useVirtualClient && {
528
- optimizeDeps: {
600
+ // Always exclude rsc-router modules, conditionally add virtual entry
601
+ // Include react-server-dom vendor to transform CJS to ESM
602
+ optimizeDeps: {
603
+ include: ["@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"],
604
+ exclude: excludeDeps,
605
+ esbuildOptions: sharedEsbuildOptions,
606
+ ...(useVirtualClient && {
529
607
  // Tell Vite to scan the virtual entry for dependencies
530
608
  entries: [VIRTUAL_IDS.browser],
531
- },
532
- }),
609
+ }),
610
+ },
533
611
  },
534
612
  ...(useVirtualSSR && {
535
613
  ssr: {
@@ -537,6 +615,8 @@ export async function rscRouter(
537
615
  entries: [VIRTUAL_IDS.ssr],
538
616
  // Pre-bundle React for SSR to ensure single instance
539
617
  include: ["react", "react-dom/server.edge", "react/jsx-runtime"],
618
+ exclude: excludeDeps,
619
+ esbuildOptions: sharedEsbuildOptions,
540
620
  },
541
621
  },
542
622
  }),
@@ -546,6 +626,7 @@ export async function rscRouter(
546
626
  entries: [VIRTUAL_IDS.rsc],
547
627
  // Pre-bundle React for RSC to ensure single instance
548
628
  include: ["react", "react/jsx-runtime"],
629
+ esbuildOptions: sharedEsbuildOptions,
549
630
  },
550
631
  },
551
632
  }),
@@ -603,6 +684,93 @@ export async function rscRouter(
603
684
  plugins.push(createVersionInjectorPlugin(rscEntryPath));
604
685
  }
605
686
 
687
+ // CJS to ESM transformation is now handled by Vite's optimizeDeps.include
688
+ // See client environment config where we include "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"
689
+ // plugins.push(createCjsToEsmPlugin());
690
+
606
691
  return plugins;
607
692
  }
608
693
 
694
+ /**
695
+ * Transform CJS vendor files from @vitejs/plugin-rsc to ESM for browser compatibility.
696
+ * The react-server-dom vendor files are shipped as CJS which doesn't work in browsers.
697
+ */
698
+ function createCjsToEsmPlugin(): Plugin {
699
+ return {
700
+ name: "rsc-router:cjs-to-esm",
701
+ enforce: "pre",
702
+ transform(code, id) {
703
+ const cleanId = id.split("?")[0];
704
+
705
+ // Transform the client.browser.js entry point to re-export from CJS
706
+ if (
707
+ cleanId.includes("vendor/react-server-dom/client.browser.js") ||
708
+ cleanId.includes("vendor\\react-server-dom\\client.browser.js")
709
+ ) {
710
+ const isProd = process.env.NODE_ENV === "production";
711
+ const cjsFile = isProd
712
+ ? "./cjs/react-server-dom-webpack-client.browser.production.js"
713
+ : "./cjs/react-server-dom-webpack-client.browser.development.js";
714
+
715
+ return {
716
+ code: `export * from "${cjsFile}";`,
717
+ map: null,
718
+ };
719
+ }
720
+
721
+ // Transform the actual CJS files to ESM
722
+ if (
723
+ (cleanId.includes("vendor/react-server-dom/cjs/") ||
724
+ cleanId.includes("vendor\\react-server-dom\\cjs\\")) &&
725
+ cleanId.includes("client.browser")
726
+ ) {
727
+ let transformed = code;
728
+
729
+ // Extract the license comment to preserve it
730
+ const licenseMatch = transformed.match(/^\/\*\*[\s\S]*?\*\//);
731
+ const license = licenseMatch ? licenseMatch[0] : "";
732
+ if (license) {
733
+ transformed = transformed.slice(license.length);
734
+ }
735
+
736
+ // Remove "use strict" and the conditional IIFE wrapper
737
+ transformed = transformed.replace(
738
+ /^\s*["']use strict["'];\s*["']production["']\s*!==\s*process\.env\.NODE_ENV\s*&&\s*\(function\s*\(\)\s*\{/,
739
+ ""
740
+ );
741
+
742
+ // Remove the closing of the conditional IIFE at the end
743
+ transformed = transformed.replace(/\}\)\(\);?\s*$/, "");
744
+
745
+ // Replace require('react') and require('react-dom') with imports
746
+ transformed = transformed.replace(
747
+ /var\s+React\s*=\s*require\s*\(\s*["']react["']\s*\)\s*,[\s\n]+ReactDOM\s*=\s*require\s*\(\s*["']react-dom["']\s*\)\s*,/g,
748
+ 'import React from "react";\nimport ReactDOM from "react-dom";\nvar '
749
+ );
750
+
751
+ // Transform exports.xyz = function() to export function xyz()
752
+ transformed = transformed.replace(
753
+ /exports\.(\w+)\s*=\s*function\s*\(/g,
754
+ "export function $1("
755
+ );
756
+
757
+ // Transform exports.xyz = value to export const xyz = value
758
+ transformed = transformed.replace(
759
+ /exports\.(\w+)\s*=/g,
760
+ "export const $1 ="
761
+ );
762
+
763
+ // Reconstruct with license at the top
764
+ transformed = license + "\n" + transformed;
765
+
766
+ return {
767
+ code: transformed,
768
+ map: null,
769
+ };
770
+ }
771
+
772
+ return null;
773
+ },
774
+ };
775
+ }
776
+
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Package Resolution Utilities
3
+ *
4
+ * Handles detection of workspace vs npm install context and generates
5
+ * appropriate aliases and exclude lists for Vite configuration.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import packageJson from "../../package.json" with { type: "json" };
11
+
12
+ /**
13
+ * The canonical name used in virtual entries (without scope)
14
+ */
15
+ const VIRTUAL_PACKAGE_NAME = "rsc-router";
16
+
17
+ /**
18
+ * Get the published package name (e.g., "@ivogt/rsc-router")
19
+ */
20
+ export function getPublishedPackageName(): string {
21
+ return packageJson.name;
22
+ }
23
+
24
+ /**
25
+ * Check if the package is installed from npm (scoped) vs workspace (unscoped)
26
+ *
27
+ * In workspace development:
28
+ * - Package is installed as "rsc-router" via pnpm workspace alias
29
+ * - The scoped name (@ivogt/rsc-router) doesn't exist in node_modules
30
+ *
31
+ * When installed from npm:
32
+ * - Package is installed as "@ivogt/rsc-router"
33
+ * - We need aliases to map "rsc-router/*" to "@ivogt/rsc-router/*"
34
+ */
35
+ export function isInstalledFromNpm(): boolean {
36
+ const packageName = getPublishedPackageName();
37
+ // Check if the scoped package exists in node_modules
38
+ return existsSync(resolve(process.cwd(), "node_modules", packageName));
39
+ }
40
+
41
+ /**
42
+ * Check if we're in a monorepo/workspace development context
43
+ */
44
+ export function isWorkspaceDevelopment(): boolean {
45
+ return !isInstalledFromNpm();
46
+ }
47
+
48
+ /**
49
+ * Subpaths that need to be excluded from Vite's dependency optimization
50
+ * and potentially aliased
51
+ */
52
+ const PACKAGE_SUBPATHS = [
53
+ "",
54
+ "/browser",
55
+ "/client",
56
+ "/server",
57
+ "/rsc",
58
+ "/ssr",
59
+ "/internal/deps/browser",
60
+ "/internal/deps/html-stream-client",
61
+ "/internal/deps/ssr",
62
+ "/internal/deps/rsc",
63
+ ] as const;
64
+
65
+ /**
66
+ * Generate the list of modules to exclude from Vite's dependency optimization.
67
+ *
68
+ * We include both the published name and the virtual name because
69
+ * Vite's optimizer runs before alias resolution.
70
+ */
71
+ export function getExcludeDeps(): string[] {
72
+ const packageName = getPublishedPackageName();
73
+ const excludes: string[] = [];
74
+
75
+ for (const subpath of PACKAGE_SUBPATHS) {
76
+ // Add scoped package paths
77
+ excludes.push(`${packageName}${subpath}`);
78
+ // Add virtual/aliased paths (before alias resolution)
79
+ if (packageName !== VIRTUAL_PACKAGE_NAME) {
80
+ excludes.push(`${VIRTUAL_PACKAGE_NAME}${subpath}`);
81
+ }
82
+ }
83
+
84
+ return excludes;
85
+ }
86
+
87
+ /**
88
+ * Subpaths that need aliasing (subset of PACKAGE_SUBPATHS)
89
+ */
90
+ const ALIAS_SUBPATHS = [
91
+ "/internal/deps/browser",
92
+ "/internal/deps/ssr",
93
+ "/internal/deps/rsc",
94
+ "/internal/deps/html-stream-client",
95
+ "/internal/deps/html-stream-server",
96
+ "/browser",
97
+ "/client",
98
+ "/server",
99
+ "/rsc",
100
+ "/ssr",
101
+ ] as const;
102
+
103
+ /**
104
+ * Generate aliases to map virtual package paths to the actual published package.
105
+ *
106
+ * Only needed when installed from npm, where the package is under @ivogt/rsc-router
107
+ * but virtual entries import from rsc-router/*.
108
+ *
109
+ * Returns empty object in workspace development where rsc-router resolves directly.
110
+ */
111
+ export function getPackageAliases(): Record<string, string> {
112
+ if (isWorkspaceDevelopment()) {
113
+ // No aliases needed - rsc-router resolves directly
114
+ return {};
115
+ }
116
+
117
+ const packageName = getPublishedPackageName();
118
+ const aliases: Record<string, string> = {};
119
+
120
+ for (const subpath of ALIAS_SUBPATHS) {
121
+ aliases[`${VIRTUAL_PACKAGE_NAME}${subpath}`] = `${packageName}${subpath}`;
122
+ }
123
+
124
+ return aliases;
125
+ }