@ivogt/rsc-router 0.0.0-experimental.12 → 0.0.0-experimental.14
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/dist/vite/index.js +34 -34
- package/package.json +1 -1
- package/src/__tests__/component-utils.test.ts +76 -0
- package/src/cache/cf/cf-cache-store.ts +1 -1
- package/src/component-utils.ts +76 -0
- package/src/href.ts +13 -9
- package/src/route-definition.ts +24 -0
- package/src/route-map-builder.ts +16 -10
- package/src/router/manifest.ts +34 -12
- package/src/router.ts +212 -36
- package/src/rsc/handler.ts +5 -4
- package/src/rsc/types.ts +5 -2
- package/src/server.ts +7 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +7 -7
- package/src/vite/expose-action-id.ts +2 -2
- package/src/vite/expose-handle-id.ts +4 -4
- package/src/vite/expose-loader-id.ts +7 -7
- package/src/vite/expose-location-state-id.ts +4 -4
- package/src/vite/index.ts +11 -11
- package/src/vite/package-resolution.ts +3 -3
- package/src/vite/version.d.ts +1 -1
- package/src/vite/virtual-entries.ts +10 -10
package/dist/vite/index.js
CHANGED
|
@@ -102,7 +102,7 @@ function exposeActionId() {
|
|
|
102
102
|
let hashToFileMap;
|
|
103
103
|
let rscPluginApi;
|
|
104
104
|
return {
|
|
105
|
-
name: "rsc-router:expose-action-id",
|
|
105
|
+
name: "@ivogt/rsc-router:expose-action-id",
|
|
106
106
|
// Run after all other plugins (including RSC plugin's transforms)
|
|
107
107
|
enforce: "post",
|
|
108
108
|
configResolved(resolvedConfig) {
|
|
@@ -116,7 +116,7 @@ function exposeActionId() {
|
|
|
116
116
|
}
|
|
117
117
|
if (!rscPluginApi) {
|
|
118
118
|
throw new Error(
|
|
119
|
-
"[rsc-router] Could not find @vitejs/plugin-rsc. rsc-router requires the Vite RSC plugin.\nThe RSC plugin should be included automatically. If you disabled it with\nrscRouter({ rsc: false }), add rsc() before rscRouter() in your config."
|
|
119
|
+
"[rsc-router] Could not find @vitejs/plugin-rsc. @ivogt/rsc-router requires the Vite RSC plugin.\nThe RSC plugin should be included automatically. If you disabled it with\nrscRouter({ rsc: false }), add rsc() before rscRouter() in your config."
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
122
|
if (!isBuild) return;
|
|
@@ -189,7 +189,7 @@ function hashLoaderId(filePath, exportName) {
|
|
|
189
189
|
return `${hash.slice(0, 8)}#${exportName}`;
|
|
190
190
|
}
|
|
191
191
|
function hasCreateLoaderImport(code) {
|
|
192
|
-
const pattern = /import\s*\{[^}]*\bcreateLoader\b[^}]*\}\s*from\s*["']rsc-router(?:\/server)?["']/;
|
|
192
|
+
const pattern = /import\s*\{[^}]*\bcreateLoader\b[^}]*\}\s*from\s*["']@ivogt\/rsc-router(?:\/server)?["']/;
|
|
193
193
|
return pattern.test(code);
|
|
194
194
|
}
|
|
195
195
|
function countCreateLoaderArgs(code, startPos, endPos) {
|
|
@@ -265,7 +265,7 @@ function exposeLoaderId() {
|
|
|
265
265
|
const loaderRegistry = /* @__PURE__ */ new Map();
|
|
266
266
|
const pendingLoaderScans = /* @__PURE__ */ new Map();
|
|
267
267
|
return {
|
|
268
|
-
name: "rsc-router:expose-loader-id",
|
|
268
|
+
name: "@ivogt/rsc-router:expose-loader-id",
|
|
269
269
|
enforce: "post",
|
|
270
270
|
configResolved(resolvedConfig) {
|
|
271
271
|
config = resolvedConfig;
|
|
@@ -325,7 +325,7 @@ function exposeLoaderId() {
|
|
|
325
325
|
load(id) {
|
|
326
326
|
if (id === RESOLVED_VIRTUAL_LOADER_MANIFEST) {
|
|
327
327
|
if (!isBuild) {
|
|
328
|
-
return `import { setLoaderImports } from "rsc-router/server";
|
|
328
|
+
return `import { setLoaderImports } from "@ivogt/rsc-router/server";
|
|
329
329
|
|
|
330
330
|
// Dev mode: empty map, loaders are resolved dynamically via path parsing
|
|
331
331
|
setLoaderImports({});
|
|
@@ -338,13 +338,13 @@ setLoaderImports({});
|
|
|
338
338
|
);
|
|
339
339
|
}
|
|
340
340
|
if (lazyImports.length === 0) {
|
|
341
|
-
return `import { setLoaderImports } from "rsc-router/server";
|
|
341
|
+
return `import { setLoaderImports } from "@ivogt/rsc-router/server";
|
|
342
342
|
|
|
343
343
|
// No fetchable loaders discovered during build
|
|
344
344
|
setLoaderImports({});
|
|
345
345
|
`;
|
|
346
346
|
}
|
|
347
|
-
const code = `import { setLoaderImports } from "rsc-router/server";
|
|
347
|
+
const code = `import { setLoaderImports } from "@ivogt/rsc-router/server";
|
|
348
348
|
|
|
349
349
|
// Lazy import map - loaders are loaded on-demand when first requested
|
|
350
350
|
setLoaderImports({
|
|
@@ -394,7 +394,7 @@ function hashHandleId(filePath, exportName) {
|
|
|
394
394
|
return `${hash.slice(0, 8)}#${exportName}`;
|
|
395
395
|
}
|
|
396
396
|
function hasCreateHandleImport(code) {
|
|
397
|
-
const pattern = /import\s*\{[^}]*\bcreateHandle\b[^}]*\}\s*from\s*["']rsc-router(?:\/[^"']+)?["']/;
|
|
397
|
+
const pattern = /import\s*\{[^}]*\bcreateHandle\b[^}]*\}\s*from\s*["']@ivogt\/rsc-router(?:\/[^"']+)?["']/;
|
|
398
398
|
return pattern.test(code);
|
|
399
399
|
}
|
|
400
400
|
function analyzeCreateHandleArgs(code, startPos, endPos) {
|
|
@@ -462,7 +462,7 @@ function exposeHandleId() {
|
|
|
462
462
|
let config;
|
|
463
463
|
let isBuild = false;
|
|
464
464
|
return {
|
|
465
|
-
name: "rsc-router:expose-handle-id",
|
|
465
|
+
name: "@ivogt/rsc-router:expose-handle-id",
|
|
466
466
|
enforce: "post",
|
|
467
467
|
configResolved(resolvedConfig) {
|
|
468
468
|
config = resolvedConfig;
|
|
@@ -497,7 +497,7 @@ function hashLocationStateKey(filePath, exportName) {
|
|
|
497
497
|
return `${hash.slice(0, 8)}#${exportName}`;
|
|
498
498
|
}
|
|
499
499
|
function hasCreateLocationStateImport(code) {
|
|
500
|
-
const pattern = /import\s*\{[^}]*\bcreateLocationState\b[^}]*\}\s*from\s*["']rsc-router(?:\/[^"']+)?["']/;
|
|
500
|
+
const pattern = /import\s*\{[^}]*\bcreateLocationState\b[^}]*\}\s*from\s*["']@ivogt\/rsc-router(?:\/[^"']+)?["']/;
|
|
501
501
|
return pattern.test(code);
|
|
502
502
|
}
|
|
503
503
|
function transformLocationStateExports(code, filePath, sourceId, isBuild = false) {
|
|
@@ -554,7 +554,7 @@ function exposeLocationStateId() {
|
|
|
554
554
|
let config;
|
|
555
555
|
let isBuild = false;
|
|
556
556
|
return {
|
|
557
|
-
name: "rsc-router:expose-location-state-id",
|
|
557
|
+
name: "@ivogt/rsc-router:expose-location-state-id",
|
|
558
558
|
enforce: "post",
|
|
559
559
|
configResolved(resolvedConfig) {
|
|
560
560
|
config = resolvedConfig;
|
|
@@ -584,11 +584,11 @@ import {
|
|
|
584
584
|
setServerCallback,
|
|
585
585
|
encodeReply,
|
|
586
586
|
createTemporaryReferenceSet,
|
|
587
|
-
} from "rsc-router/internal/deps/browser";
|
|
587
|
+
} from "@ivogt/rsc-router/internal/deps/browser";
|
|
588
588
|
import { createElement, StrictMode } from "react";
|
|
589
589
|
import { hydrateRoot } from "react-dom/client";
|
|
590
|
-
import { rscStream } from "rsc-router/internal/deps/html-stream-client";
|
|
591
|
-
import { initBrowserApp, RSCRouter } from "rsc-router/browser";
|
|
590
|
+
import { rscStream } from "@ivogt/rsc-router/internal/deps/html-stream-client";
|
|
591
|
+
import { initBrowserApp, RSCRouter } from "@ivogt/rsc-router/browser";
|
|
592
592
|
|
|
593
593
|
async function initializeApp() {
|
|
594
594
|
const deps = {
|
|
@@ -610,10 +610,10 @@ async function initializeApp() {
|
|
|
610
610
|
initializeApp().catch(console.error);
|
|
611
611
|
`.trim();
|
|
612
612
|
var VIRTUAL_ENTRY_SSR = `
|
|
613
|
-
import { createFromReadableStream } from "rsc-router/internal/deps/ssr";
|
|
613
|
+
import { createFromReadableStream } from "@ivogt/rsc-router/internal/deps/ssr";
|
|
614
614
|
import { renderToReadableStream } from "react-dom/server.edge";
|
|
615
|
-
import { injectRSCPayload } from "rsc-router/internal/deps/html-stream-server";
|
|
616
|
-
import { createSSRHandler } from "rsc-router/ssr";
|
|
615
|
+
import { injectRSCPayload } from "@ivogt/rsc-router/internal/deps/html-stream-server";
|
|
616
|
+
import { createSSRHandler } from "@ivogt/rsc-router/ssr";
|
|
617
617
|
|
|
618
618
|
export const renderHTML = createSSRHandler({
|
|
619
619
|
createFromReadableStream,
|
|
@@ -632,10 +632,10 @@ import {
|
|
|
632
632
|
loadServerAction,
|
|
633
633
|
decodeAction,
|
|
634
634
|
decodeFormState,
|
|
635
|
-
} from "rsc-router/internal/deps/rsc";
|
|
635
|
+
} from "@ivogt/rsc-router/internal/deps/rsc";
|
|
636
636
|
import { router } from "${routerPath}";
|
|
637
|
-
import { createRSCHandler } from "rsc-router/rsc";
|
|
638
|
-
import { VERSION } from "rsc-router:version";
|
|
637
|
+
import { createRSCHandler } from "@ivogt/rsc-router/rsc";
|
|
638
|
+
import { VERSION } from "@ivogt/rsc-router:version";
|
|
639
639
|
|
|
640
640
|
// Import loader manifest to ensure all fetchable loaders are registered at startup
|
|
641
641
|
// This is critical for serverless/multi-process deployments where the loader module
|
|
@@ -662,7 +662,7 @@ var VIRTUAL_IDS = {
|
|
|
662
662
|
browser: "virtual:rsc-router/entry.browser.js",
|
|
663
663
|
ssr: "virtual:rsc-router/entry.ssr.js",
|
|
664
664
|
rsc: "virtual:rsc-router/entry.rsc.js",
|
|
665
|
-
version: "rsc-router:version"
|
|
665
|
+
version: "@ivogt/rsc-router:version"
|
|
666
666
|
};
|
|
667
667
|
function getVirtualVersionContent(version) {
|
|
668
668
|
return `export const VERSION = ${JSON.stringify(version)};`;
|
|
@@ -675,7 +675,7 @@ import { resolve } from "node:path";
|
|
|
675
675
|
// package.json
|
|
676
676
|
var package_default = {
|
|
677
677
|
name: "@ivogt/rsc-router",
|
|
678
|
-
version: "0.0.0-experimental.
|
|
678
|
+
version: "0.0.0-experimental.14",
|
|
679
679
|
type: "module",
|
|
680
680
|
description: "Type-safe RSC router with partial rendering support",
|
|
681
681
|
author: "Ivo Todorov",
|
|
@@ -810,7 +810,7 @@ var package_default = {
|
|
|
810
810
|
};
|
|
811
811
|
|
|
812
812
|
// src/vite/package-resolution.ts
|
|
813
|
-
var VIRTUAL_PACKAGE_NAME = "rsc-router";
|
|
813
|
+
var VIRTUAL_PACKAGE_NAME = "@ivogt/rsc-router";
|
|
814
814
|
function getPublishedPackageName() {
|
|
815
815
|
return package_default.name;
|
|
816
816
|
}
|
|
@@ -870,13 +870,13 @@ function getPackageAliases() {
|
|
|
870
870
|
|
|
871
871
|
// src/vite/index.ts
|
|
872
872
|
var versionEsbuildPlugin = {
|
|
873
|
-
name: "rsc-router-version",
|
|
873
|
+
name: "@ivogt/rsc-router-version",
|
|
874
874
|
setup(build) {
|
|
875
875
|
build.onResolve({ filter: /^rsc-router:version$/ }, (args) => ({
|
|
876
876
|
path: args.path,
|
|
877
|
-
namespace: "rsc-router-virtual"
|
|
877
|
+
namespace: "@ivogt/rsc-router-virtual"
|
|
878
878
|
}));
|
|
879
|
-
build.onLoad({ filter: /.*/, namespace: "rsc-router-virtual" }, () => ({
|
|
879
|
+
build.onLoad({ filter: /.*/, namespace: "@ivogt/rsc-router-virtual" }, () => ({
|
|
880
880
|
contents: `export const VERSION = "dev";`,
|
|
881
881
|
loader: "js"
|
|
882
882
|
}));
|
|
@@ -898,7 +898,7 @@ function createVirtualEntriesPlugin(entries, routerPath) {
|
|
|
898
898
|
virtualModules[VIRTUAL_IDS.rsc] = getVirtualEntryRSC(absoluteRouterPath);
|
|
899
899
|
}
|
|
900
900
|
return {
|
|
901
|
-
name: "rsc-router:virtual-entries",
|
|
901
|
+
name: "@ivogt/rsc-router:virtual-entries",
|
|
902
902
|
enforce: "pre",
|
|
903
903
|
resolveId(id) {
|
|
904
904
|
if (id in virtualModules) {
|
|
@@ -936,7 +936,7 @@ function createVersionPlugin() {
|
|
|
936
936
|
let isDev = false;
|
|
937
937
|
let server = null;
|
|
938
938
|
return {
|
|
939
|
-
name: "rsc-router:version",
|
|
939
|
+
name: "@ivogt/rsc-router:version",
|
|
940
940
|
enforce: "pre",
|
|
941
941
|
configResolved(config) {
|
|
942
942
|
isDev = config.command === "serve";
|
|
@@ -984,7 +984,7 @@ function createVersionInjectorPlugin(rscEntryPath) {
|
|
|
984
984
|
let projectRoot = "";
|
|
985
985
|
let resolvedEntryPath = "";
|
|
986
986
|
return {
|
|
987
|
-
name: "rsc-router:version-injector",
|
|
987
|
+
name: "@ivogt/rsc-router:version-injector",
|
|
988
988
|
enforce: "pre",
|
|
989
989
|
configResolved(config) {
|
|
990
990
|
projectRoot = config.root;
|
|
@@ -999,7 +999,7 @@ function createVersionInjectorPlugin(rscEntryPath) {
|
|
|
999
999
|
if (!code.includes("createRSCHandler")) {
|
|
1000
1000
|
return null;
|
|
1001
1001
|
}
|
|
1002
|
-
if (code.includes("rsc-router:version")) {
|
|
1002
|
+
if (code.includes("@ivogt/rsc-router:version")) {
|
|
1003
1003
|
return null;
|
|
1004
1004
|
}
|
|
1005
1005
|
const handlerCallMatch = code.match(/createRSCHandler\s*\(\s*\{/);
|
|
@@ -1020,7 +1020,7 @@ function createVersionInjectorPlugin(rscEntryPath) {
|
|
|
1020
1020
|
if (nextNewline === -1) break;
|
|
1021
1021
|
insertIndex = nextNewline + 1;
|
|
1022
1022
|
}
|
|
1023
|
-
const versionImport = `import { VERSION } from "rsc-router:version";
|
|
1023
|
+
const versionImport = `import { VERSION } from "@ivogt/rsc-router:version";
|
|
1024
1024
|
`;
|
|
1025
1025
|
let newCode = code.slice(0, insertIndex) + versionImport + code.slice(insertIndex);
|
|
1026
1026
|
newCode = newCode.replace(
|
|
@@ -1048,7 +1048,7 @@ async function rscRouter(options) {
|
|
|
1048
1048
|
ssr: VIRTUAL_IDS.ssr
|
|
1049
1049
|
};
|
|
1050
1050
|
plugins.push({
|
|
1051
|
-
name: "rsc-router:cloudflare-integration",
|
|
1051
|
+
name: "@ivogt/rsc-router:cloudflare-integration",
|
|
1052
1052
|
enforce: "pre",
|
|
1053
1053
|
config() {
|
|
1054
1054
|
return {
|
|
@@ -1137,7 +1137,7 @@ async function rscRouter(options) {
|
|
|
1137
1137
|
rscEntryPath = userEntries.rsc ?? null;
|
|
1138
1138
|
let hasWarnedDuplicate = false;
|
|
1139
1139
|
plugins.push({
|
|
1140
|
-
name: "rsc-router:rsc-integration",
|
|
1140
|
+
name: "@ivogt/rsc-router:rsc-integration",
|
|
1141
1141
|
enforce: "pre",
|
|
1142
1142
|
config() {
|
|
1143
1143
|
const useVirtualClient = finalEntries.client === VIRTUAL_IDS.browser;
|
|
@@ -1231,7 +1231,7 @@ async function rscRouter(options) {
|
|
|
1231
1231
|
}
|
|
1232
1232
|
function createCjsToEsmPlugin() {
|
|
1233
1233
|
return {
|
|
1234
|
-
name: "rsc-router:cjs-to-esm",
|
|
1234
|
+
name: "@ivogt/rsc-router:cjs-to-esm",
|
|
1235
1235
|
enforce: "pre",
|
|
1236
1236
|
transform(code, id) {
|
|
1237
1237
|
const cleanId = id.split("?")[0];
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isClientComponent, assertClientComponent } from "../component-utils";
|
|
3
|
+
|
|
4
|
+
describe("component-utils", () => {
|
|
5
|
+
describe("isClientComponent", () => {
|
|
6
|
+
it("should return false for regular functions", () => {
|
|
7
|
+
const ServerComponent = () => null;
|
|
8
|
+
expect(isClientComponent(ServerComponent)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("should return false for non-functions", () => {
|
|
12
|
+
expect(isClientComponent(null)).toBe(false);
|
|
13
|
+
expect(isClientComponent(undefined)).toBe(false);
|
|
14
|
+
expect(isClientComponent("string")).toBe(false);
|
|
15
|
+
expect(isClientComponent(123)).toBe(false);
|
|
16
|
+
expect(isClientComponent({})).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should return true for functions with client reference marker", () => {
|
|
20
|
+
const ClientComponent = () => null;
|
|
21
|
+
// Simulate what the bundler does for "use client" components
|
|
22
|
+
(ClientComponent as any).$$typeof = Symbol.for("react.client.reference");
|
|
23
|
+
(ClientComponent as any).$$id = "src/components/MyComponent.tsx#default";
|
|
24
|
+
|
|
25
|
+
expect(isClientComponent(ClientComponent)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should return false for functions with wrong $$typeof symbol", () => {
|
|
29
|
+
const Component = () => null;
|
|
30
|
+
(Component as any).$$typeof = Symbol.for("react.element");
|
|
31
|
+
|
|
32
|
+
expect(isClientComponent(Component)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("assertClientComponent", () => {
|
|
37
|
+
it("should throw for non-function values", () => {
|
|
38
|
+
expect(() => assertClientComponent(null, "document")).toThrow(
|
|
39
|
+
'document must be a client component function with "use client" directive'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(() => assertClientComponent({}, "document")).toThrow(
|
|
43
|
+
'document must be a client component function with "use client" directive'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should throw for server components (no client marker)", () => {
|
|
48
|
+
const ServerComponent = () => null;
|
|
49
|
+
|
|
50
|
+
expect(() => assertClientComponent(ServerComponent, "document")).toThrow(
|
|
51
|
+
'document must be a client component with "use client" directive'
|
|
52
|
+
);
|
|
53
|
+
expect(() => assertClientComponent(ServerComponent, "document")).toThrow(
|
|
54
|
+
"cannot be serialized in the RSC payload"
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should not throw for client components", () => {
|
|
59
|
+
const ClientComponent = () => null;
|
|
60
|
+
(ClientComponent as any).$$typeof = Symbol.for("react.client.reference");
|
|
61
|
+
(ClientComponent as any).$$id = "src/document.tsx#default";
|
|
62
|
+
|
|
63
|
+
expect(() =>
|
|
64
|
+
assertClientComponent(ClientComponent, "document")
|
|
65
|
+
).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should include component name in error message", () => {
|
|
69
|
+
const ServerComponent = () => null;
|
|
70
|
+
|
|
71
|
+
expect(() => assertClientComponent(ServerComponent, "myLayout")).toThrow(
|
|
72
|
+
"myLayout must be a client component"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
CacheGetResult,
|
|
20
20
|
} from "../types.js";
|
|
21
21
|
import type { RequestContext } from "../../server/request-context.js";
|
|
22
|
-
import { VERSION } from "rsc-router:version";
|
|
22
|
+
import { VERSION } from "@ivogt/rsc-router:version";
|
|
23
23
|
|
|
24
24
|
// ============================================================================
|
|
25
25
|
// Constants
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component utilities for RSC
|
|
3
|
+
*
|
|
4
|
+
* Helpers for working with React Server Components and client components.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ComponentType } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Symbol used by React to mark client component references.
|
|
11
|
+
* When a file has "use client" directive, the bundler transforms the exports
|
|
12
|
+
* to include this symbol on $$typeof.
|
|
13
|
+
*/
|
|
14
|
+
const CLIENT_REFERENCE = Symbol.for("react.client.reference");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if a component is a client component (has "use client" directive).
|
|
18
|
+
*
|
|
19
|
+
* Client components are marked by the bundler with:
|
|
20
|
+
* - $$typeof: Symbol.for("react.client.reference")
|
|
21
|
+
* - $$id: string (module identifier)
|
|
22
|
+
*
|
|
23
|
+
* @param component - The component to check
|
|
24
|
+
* @returns true if the component has client reference marker
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { MyComponent } from "./my-component"; // has "use client"
|
|
29
|
+
*
|
|
30
|
+
* if (!isClientComponent(MyComponent)) {
|
|
31
|
+
* throw new Error("MyComponent must be a client component");
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function isClientComponent(
|
|
36
|
+
component: ComponentType<unknown> | unknown
|
|
37
|
+
): boolean {
|
|
38
|
+
if (typeof component !== "function") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const withMeta = component as { $$typeof?: symbol };
|
|
42
|
+
return withMeta.$$typeof === CLIENT_REFERENCE;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Assert that a component is a client component.
|
|
47
|
+
* Throws a descriptive error if not.
|
|
48
|
+
*
|
|
49
|
+
* @param component - The component to check
|
|
50
|
+
* @param name - Name to use in error message (e.g., "document")
|
|
51
|
+
* @throws Error if the component is not a client component
|
|
52
|
+
*/
|
|
53
|
+
export function assertClientComponent(
|
|
54
|
+
component: ComponentType<unknown> | unknown,
|
|
55
|
+
name: string
|
|
56
|
+
): asserts component is ComponentType<unknown> {
|
|
57
|
+
if (typeof component !== "function") {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${name} must be a client component function with "use client" directive. ` +
|
|
60
|
+
`Make sure to pass the component itself, not a JSX element: ` +
|
|
61
|
+
`${name}: My${capitalize(name)} (correct) vs ${name}: <My${capitalize(name)} /> (incorrect)`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!isClientComponent(component)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${name} must be a client component with "use client" directive at the top of the file. ` +
|
|
68
|
+
`Server components cannot be used as the ${name} because their function reference ` +
|
|
69
|
+
`cannot be serialized in the RSC payload. Add "use client" to your ${name} file.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function capitalize(str: string): string {
|
|
75
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
76
|
+
}
|
package/src/href.ts
CHANGED
|
@@ -8,19 +8,22 @@ export type SanitizePrefix<T extends string> = T extends `/${infer P}` ? P : T;
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Helper type to merge multiple route definitions into a single accumulated type.
|
|
11
|
-
*
|
|
11
|
+
* Note: When using createRSCRouter, types accumulate automatically through the
|
|
12
|
+
* builder chain, so this type is typically not needed.
|
|
12
13
|
*
|
|
13
14
|
* @example
|
|
14
15
|
* ```typescript
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* // Manual type merging (rarely needed):
|
|
17
17
|
* type AppRoutes = MergeRoutes<[
|
|
18
18
|
* typeof homeRoutes,
|
|
19
|
-
*
|
|
20
|
-
* PrefixedRoutes<typeof shopRoutes, "shop">,
|
|
19
|
+
* PrefixRoutePatterns<typeof blogRoutes, "/blog">,
|
|
21
20
|
* ]>;
|
|
22
21
|
*
|
|
23
|
-
*
|
|
22
|
+
* // Preferred: Let router accumulate types automatically
|
|
23
|
+
* const router = createRSCRouter<AppEnv>()
|
|
24
|
+
* .routes(homeRoutes).map(...)
|
|
25
|
+
* .routes("/blog", blogRoutes).map(...);
|
|
26
|
+
* type AppRoutes = typeof router.routeMap;
|
|
24
27
|
* ```
|
|
25
28
|
*/
|
|
26
29
|
export type MergeRoutes<T extends Record<string, string>[]> = T extends [
|
|
@@ -111,9 +114,10 @@ export type HrefFunction<TRoutes extends Record<string, string>> = {
|
|
|
111
114
|
*
|
|
112
115
|
* @example
|
|
113
116
|
* ```typescript
|
|
114
|
-
*
|
|
115
|
-
* href(
|
|
116
|
-
* href("
|
|
117
|
+
* // Given routes: { cart: "/shop/cart", detail: "/shop/product/:slug" }
|
|
118
|
+
* const href = createHref(routeMap);
|
|
119
|
+
* href("cart"); // "/shop/cart"
|
|
120
|
+
* href("detail", { slug: "my-product" }); // "/shop/product/my-product"
|
|
117
121
|
* ```
|
|
118
122
|
*/
|
|
119
123
|
export function createHref<TRoutes extends Record<string, string>>(
|
package/src/route-definition.ts
CHANGED
|
@@ -1248,6 +1248,30 @@ export function map<const T extends RouteDefinition, TEnv = DefaultEnv>(
|
|
|
1248
1248
|
};
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Create RouteHelpers for inline route definitions
|
|
1253
|
+
* Used internally by router.map() for inline handler syntax
|
|
1254
|
+
*/
|
|
1255
|
+
export function createRouteHelpers<
|
|
1256
|
+
T extends RouteDefinition,
|
|
1257
|
+
TEnv,
|
|
1258
|
+
>(): RouteHelpers<T, TEnv> {
|
|
1259
|
+
return {
|
|
1260
|
+
route: createRouteHelper<T, TEnv>(),
|
|
1261
|
+
layout: createLayoutHelper<TEnv>(),
|
|
1262
|
+
parallel: createParallelHelper<TEnv>(),
|
|
1263
|
+
intercept: createInterceptHelper<T, TEnv>(),
|
|
1264
|
+
middleware: createMiddlewareHelper<TEnv>(),
|
|
1265
|
+
revalidate: createRevalidateHelper<TEnv>(),
|
|
1266
|
+
loader: createLoaderHelper<TEnv>(),
|
|
1267
|
+
loading: createLoadingHelper(),
|
|
1268
|
+
errorBoundary: createErrorBoundaryHelper<TEnv>(),
|
|
1269
|
+
notFoundBoundary: createNotFoundBoundaryHelper<TEnv>(),
|
|
1270
|
+
when: createWhenHelper(),
|
|
1271
|
+
cache: createCacheHelper(),
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1251
1275
|
/**
|
|
1252
1276
|
* Create a loader definition
|
|
1253
1277
|
*
|
package/src/route-map-builder.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type {
|
|
26
|
+
import type { PrefixRoutePatterns } from "./href.js";
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Route map builder interface
|
|
@@ -37,12 +37,14 @@ export interface RouteMapBuilder<TRoutes extends Record<string, string> = {}> {
|
|
|
37
37
|
add<T extends Record<string, string>>(routes: T): RouteMapBuilder<TRoutes & T>;
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Add routes with prefix
|
|
40
|
+
* Add routes with prefix (only URL patterns are prefixed, keys stay unchanged)
|
|
41
|
+
* @param routes - Route definitions to add
|
|
42
|
+
* @param prefix - URL prefix WITHOUT leading slash (e.g., "blog" not "/blog")
|
|
41
43
|
*/
|
|
42
44
|
add<T extends Record<string, string>, P extends string>(
|
|
43
45
|
routes: T,
|
|
44
46
|
prefix: P
|
|
45
|
-
): RouteMapBuilder<TRoutes &
|
|
47
|
+
): RouteMapBuilder<TRoutes & PrefixRoutePatterns<T, `/${P}`>>;
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* The accumulated route map (for typeof extraction in module augmentation)
|
|
@@ -52,25 +54,29 @@ export interface RouteMapBuilder<TRoutes extends Record<string, string> = {}> {
|
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* Add routes to a map with optional prefix
|
|
57
|
+
* Keys stay unchanged for composability - only URL patterns get prefixed.
|
|
55
58
|
*
|
|
56
59
|
* @param routeMap - The map to add routes to
|
|
57
60
|
* @param routes - Routes to add
|
|
58
|
-
* @param prefix - Optional prefix for keys
|
|
61
|
+
* @param prefix - Optional prefix for URL paths WITHOUT leading slash (keys stay unchanged)
|
|
59
62
|
*/
|
|
60
63
|
function addRoutes(
|
|
61
64
|
routeMap: Record<string, string>,
|
|
62
65
|
routes: Record<string, string>,
|
|
63
66
|
prefix: string = ""
|
|
64
67
|
): void {
|
|
68
|
+
// Normalize prefix: remove leading slash if accidentally provided
|
|
69
|
+
const normalizedPrefix = prefix.startsWith("/") ? prefix.slice(1) : prefix;
|
|
70
|
+
|
|
65
71
|
for (const [key, pattern] of Object.entries(routes)) {
|
|
66
|
-
const prefixedKey = prefix ? `${prefix}.${key}` : key;
|
|
67
72
|
const prefixedPattern =
|
|
68
|
-
|
|
69
|
-
? `/${
|
|
70
|
-
:
|
|
71
|
-
? `/${
|
|
73
|
+
normalizedPrefix && pattern !== "/"
|
|
74
|
+
? `/${normalizedPrefix}${pattern}`
|
|
75
|
+
: normalizedPrefix && pattern === "/"
|
|
76
|
+
? `/${normalizedPrefix}`
|
|
72
77
|
: pattern;
|
|
73
|
-
|
|
78
|
+
// Use original key - enables reusable route modules
|
|
79
|
+
routeMap[key] = prefixedPattern;
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
82
|
|
package/src/router/manifest.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { invariant, RouteNotFoundError } from "../errors";
|
|
8
|
+
import { createRouteHelpers } from "../route-definition";
|
|
8
9
|
import { getContext, type EntryData, type MetricsStore } from "../server/context";
|
|
10
|
+
import MapRootLayout from "../server/root-layout";
|
|
9
11
|
import type { RouteEntry } from "../types";
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -63,19 +65,39 @@ export async function loadManifest(
|
|
|
63
65
|
Store.namespace || namespaceWithMount,
|
|
64
66
|
Store.parent,
|
|
65
67
|
async () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
// Create helpers - inline handlers use them, lazy handlers ignore them
|
|
69
|
+
const helpers = createRouteHelpers();
|
|
70
|
+
|
|
71
|
+
// Call handler with helpers - works for both inline and lazy
|
|
72
|
+
const result = entry.handler(helpers);
|
|
73
|
+
|
|
74
|
+
// Handle based on return type
|
|
75
|
+
if (result instanceof Promise) {
|
|
76
|
+
// Lazy: () => import(...) - returns Promise
|
|
77
|
+
const load = await result;
|
|
78
|
+
if (
|
|
79
|
+
load &&
|
|
80
|
+
load !== null &&
|
|
81
|
+
typeof load === "object" &&
|
|
82
|
+
"default" in load
|
|
83
|
+
) {
|
|
84
|
+
// Promise<{ default: () => Array }> - e.g., dynamic import
|
|
85
|
+
// Pass helpers - functions that need them will use them,
|
|
86
|
+
// functions from route-definition's map() will ignore them
|
|
87
|
+
return load.default(helpers);
|
|
88
|
+
}
|
|
89
|
+
if (typeof load === "function") {
|
|
90
|
+
// Promise<() => Array>
|
|
91
|
+
return load(helpers);
|
|
92
|
+
}
|
|
93
|
+
// Promise<Array> - direct array from async handler
|
|
94
|
+
return load;
|
|
77
95
|
}
|
|
78
|
-
|
|
96
|
+
|
|
97
|
+
// Inline: ({ route }) => [...] - returns Array directly
|
|
98
|
+
// Wrap with layout (like map() from route-definition does)
|
|
99
|
+
// Flatten nested arrays from layout/route definitions
|
|
100
|
+
return [helpers.layout(MapRootLayout, () => result)].flat(3);
|
|
79
101
|
}
|
|
80
102
|
);
|
|
81
103
|
|