@ivogt/rsc-router 0.0.0-experimental.1 → 0.0.0-experimental.11

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.11",
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: {
@@ -432,8 +478,11 @@ export async function rscRouter(
432
478
  },
433
479
  },
434
480
  // Pre-bundle rsc-html-stream to prevent discovery during first request
481
+ // Exclude rsc-router modules to ensure same Context instance
435
482
  optimizeDeps: {
436
483
  include: ["rsc-html-stream/client"],
484
+ exclude: excludeDeps,
485
+ esbuildOptions: sharedEsbuildOptions,
437
486
  },
438
487
  },
439
488
  ssr: {
@@ -446,6 +495,7 @@ export async function rscRouter(
446
495
  dedupe: ["react", "react-dom"],
447
496
  },
448
497
  // Pre-bundle SSR entry and React for proper module linking with childEnvironments
498
+ // Exclude rsc-router modules to ensure same Context instance
449
499
  optimizeDeps: {
450
500
  entries: [finalEntries.ssr],
451
501
  include: [
@@ -454,6 +504,16 @@ export async function rscRouter(
454
504
  "react/jsx-runtime",
455
505
  "rsc-html-stream/server",
456
506
  ],
507
+ exclude: excludeDeps,
508
+ esbuildOptions: sharedEsbuildOptions,
509
+ },
510
+ },
511
+ rsc: {
512
+ // RSC environment needs exclude list and esbuild options
513
+ // Exclude rsc-router modules to prevent createContext in RSC environment
514
+ optimizeDeps: {
515
+ exclude: excludeDeps,
516
+ esbuildOptions: sharedEsbuildOptions,
457
517
  },
458
518
  },
459
519
  },
@@ -515,6 +575,15 @@ export async function rscRouter(
515
575
  const useVirtualRSC = finalEntries.rsc === VIRTUAL_IDS.rsc;
516
576
 
517
577
  return {
578
+ // Exclude rsc-router modules from optimization to prevent module duplication
579
+ // This ensures the same Context instance is used by both browser entry and RSC proxy modules
580
+ optimizeDeps: {
581
+ exclude: excludeDeps,
582
+ esbuildOptions: sharedEsbuildOptions,
583
+ },
584
+ resolve: {
585
+ alias: rscRouterAliases,
586
+ },
518
587
  environments: {
519
588
  client: {
520
589
  build: {
@@ -524,12 +593,15 @@ export async function rscRouter(
524
593
  },
525
594
  },
526
595
  },
527
- ...(useVirtualClient && {
528
- optimizeDeps: {
596
+ // Always exclude rsc-router modules, conditionally add virtual entry
597
+ optimizeDeps: {
598
+ exclude: excludeDeps,
599
+ esbuildOptions: sharedEsbuildOptions,
600
+ ...(useVirtualClient && {
529
601
  // Tell Vite to scan the virtual entry for dependencies
530
602
  entries: [VIRTUAL_IDS.browser],
531
- },
532
- }),
603
+ }),
604
+ },
533
605
  },
534
606
  ...(useVirtualSSR && {
535
607
  ssr: {
@@ -537,6 +609,8 @@ export async function rscRouter(
537
609
  entries: [VIRTUAL_IDS.ssr],
538
610
  // Pre-bundle React for SSR to ensure single instance
539
611
  include: ["react", "react-dom/server.edge", "react/jsx-runtime"],
612
+ exclude: excludeDeps,
613
+ esbuildOptions: sharedEsbuildOptions,
540
614
  },
541
615
  },
542
616
  }),
@@ -546,6 +620,7 @@ export async function rscRouter(
546
620
  entries: [VIRTUAL_IDS.rsc],
547
621
  // Pre-bundle React for RSC to ensure single instance
548
622
  include: ["react", "react/jsx-runtime"],
623
+ esbuildOptions: sharedEsbuildOptions,
549
624
  },
550
625
  },
551
626
  }),
@@ -603,6 +678,93 @@ export async function rscRouter(
603
678
  plugins.push(createVersionInjectorPlugin(rscEntryPath));
604
679
  }
605
680
 
681
+ // Transform CJS vendor files to ESM for browser compatibility
682
+ // optimizeDeps.include doesn't work because the file is loaded after initial optimization
683
+ plugins.push(createCjsToEsmPlugin());
684
+
606
685
  return plugins;
607
686
  }
608
687
 
688
+ /**
689
+ * Transform CJS vendor files from @vitejs/plugin-rsc to ESM for browser compatibility.
690
+ * The react-server-dom vendor files are shipped as CJS which doesn't work in browsers.
691
+ */
692
+ function createCjsToEsmPlugin(): Plugin {
693
+ return {
694
+ name: "rsc-router:cjs-to-esm",
695
+ enforce: "pre",
696
+ transform(code, id) {
697
+ const cleanId = id.split("?")[0];
698
+
699
+ // Transform the client.browser.js entry point to re-export from CJS
700
+ if (
701
+ cleanId.includes("vendor/react-server-dom/client.browser.js") ||
702
+ cleanId.includes("vendor\\react-server-dom\\client.browser.js")
703
+ ) {
704
+ const isProd = process.env.NODE_ENV === "production";
705
+ const cjsFile = isProd
706
+ ? "./cjs/react-server-dom-webpack-client.browser.production.js"
707
+ : "./cjs/react-server-dom-webpack-client.browser.development.js";
708
+
709
+ return {
710
+ code: `export * from "${cjsFile}";`,
711
+ map: null,
712
+ };
713
+ }
714
+
715
+ // Transform the actual CJS files to ESM
716
+ if (
717
+ (cleanId.includes("vendor/react-server-dom/cjs/") ||
718
+ cleanId.includes("vendor\\react-server-dom\\cjs\\")) &&
719
+ cleanId.includes("client.browser")
720
+ ) {
721
+ let transformed = code;
722
+
723
+ // Extract the license comment to preserve it
724
+ const licenseMatch = transformed.match(/^\/\*\*[\s\S]*?\*\//);
725
+ const license = licenseMatch ? licenseMatch[0] : "";
726
+ if (license) {
727
+ transformed = transformed.slice(license.length);
728
+ }
729
+
730
+ // Remove "use strict" and the conditional IIFE wrapper
731
+ transformed = transformed.replace(
732
+ /^\s*["']use strict["'];\s*["']production["']\s*!==\s*process\.env\.NODE_ENV\s*&&\s*\(function\s*\(\)\s*\{/,
733
+ ""
734
+ );
735
+
736
+ // Remove the closing of the conditional IIFE at the end
737
+ transformed = transformed.replace(/\}\)\(\);?\s*$/, "");
738
+
739
+ // Replace require('react') and require('react-dom') with imports
740
+ transformed = transformed.replace(
741
+ /var\s+React\s*=\s*require\s*\(\s*["']react["']\s*\)\s*,[\s\n]+ReactDOM\s*=\s*require\s*\(\s*["']react-dom["']\s*\)\s*,/g,
742
+ 'import React from "react";\nimport ReactDOM from "react-dom";\nvar '
743
+ );
744
+
745
+ // Transform exports.xyz = function() to export function xyz()
746
+ transformed = transformed.replace(
747
+ /exports\.(\w+)\s*=\s*function\s*\(/g,
748
+ "export function $1("
749
+ );
750
+
751
+ // Transform exports.xyz = value to export const xyz = value
752
+ transformed = transformed.replace(
753
+ /exports\.(\w+)\s*=/g,
754
+ "export const $1 ="
755
+ );
756
+
757
+ // Reconstruct with license at the top
758
+ transformed = license + "\n" + transformed;
759
+
760
+ return {
761
+ code: transformed,
762
+ map: null,
763
+ };
764
+ }
765
+
766
+ return null;
767
+ },
768
+ };
769
+ }
770
+
@@ -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
+ }