@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.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
package/src/deps/rsc.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/// <reference types="@vitejs/plugin-rsc/types" />
|
|
2
|
+
// Re-export @vitejs/plugin-rsc/rsc for internal use by virtual entries
|
|
3
|
+
export {
|
|
4
|
+
renderToReadableStream,
|
|
5
|
+
decodeReply,
|
|
6
|
+
createTemporaryReferenceSet,
|
|
7
|
+
loadServerAction,
|
|
8
|
+
decodeAction,
|
|
9
|
+
decodeFormState,
|
|
10
|
+
} from "@vitejs/plugin-rsc/rsc";
|
package/src/deps/ssr.ts
ADDED
package/src/errors.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error classes for RSC Router
|
|
3
|
+
*
|
|
4
|
+
* All errors include:
|
|
5
|
+
* - Descriptive names for easy identification
|
|
6
|
+
* - Cause property for structured debugging data
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Options for error construction
|
|
11
|
+
*/
|
|
12
|
+
interface ErrorOptions {
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when no route matches the requested pathname
|
|
18
|
+
*/
|
|
19
|
+
export class RouteNotFoundError extends Error {
|
|
20
|
+
name = "RouteNotFoundError" as const;
|
|
21
|
+
cause?: unknown;
|
|
22
|
+
|
|
23
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.cause = options?.cause;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thrown when data is not found (e.g., product with ID doesn't exist)
|
|
31
|
+
* Use this in handlers/loaders to trigger the nearest notFoundBoundary
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* route("products.detail", async (ctx) => {
|
|
36
|
+
* const product = await db.products.get(ctx.params.slug);
|
|
37
|
+
* if (!product) throw new DataNotFoundError("Product not found");
|
|
38
|
+
* return <ProductDetail product={product} />;
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class DataNotFoundError extends Error {
|
|
43
|
+
name = "DataNotFoundError" as const;
|
|
44
|
+
cause?: unknown;
|
|
45
|
+
|
|
46
|
+
constructor(message: string = "Not found", options?: ErrorOptions) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.cause = options?.cause;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convenience function to throw a DataNotFoundError
|
|
54
|
+
* Shorter syntax for common not-found scenarios
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* const product = await db.products.get(slug);
|
|
59
|
+
* if (!product) throw notFound("Product not found");
|
|
60
|
+
* // or simply:
|
|
61
|
+
* if (!product) throw notFound();
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function notFound(message?: string): never {
|
|
65
|
+
throw new DataNotFoundError(message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Thrown when middleware execution fails
|
|
70
|
+
*/
|
|
71
|
+
export class MiddlewareError extends Error {
|
|
72
|
+
name = "MiddlewareError" as const;
|
|
73
|
+
cause?: unknown;
|
|
74
|
+
|
|
75
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.cause = options?.cause;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Thrown when a route handler fails
|
|
83
|
+
*/
|
|
84
|
+
export class HandlerError extends Error {
|
|
85
|
+
name = "HandlerError" as const;
|
|
86
|
+
cause?: unknown;
|
|
87
|
+
|
|
88
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.cause = options?.cause;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Thrown when segment building fails
|
|
96
|
+
*/
|
|
97
|
+
export class BuildError extends Error {
|
|
98
|
+
name = "BuildError" as const;
|
|
99
|
+
cause?: unknown;
|
|
100
|
+
|
|
101
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.cause = options?.cause;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Thrown when a network request fails (server unreachable, no internet, etc.)
|
|
109
|
+
* This error triggers the root error boundary with retry capability.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* try {
|
|
114
|
+
* await fetch(url);
|
|
115
|
+
* } catch (error) {
|
|
116
|
+
* if (error instanceof TypeError) {
|
|
117
|
+
* throw new NetworkError("Unable to connect to server", { cause: error });
|
|
118
|
+
* }
|
|
119
|
+
* throw error;
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export class NetworkError extends Error {
|
|
124
|
+
name = "NetworkError" as const;
|
|
125
|
+
cause?: unknown;
|
|
126
|
+
/** The URL that failed to fetch */
|
|
127
|
+
url?: string;
|
|
128
|
+
/** Whether this was during an action, navigation, or revalidation */
|
|
129
|
+
operation?: "action" | "navigation" | "revalidation";
|
|
130
|
+
|
|
131
|
+
constructor(
|
|
132
|
+
message: string = "Network request failed",
|
|
133
|
+
options?: ErrorOptions & {
|
|
134
|
+
url?: string;
|
|
135
|
+
operation?: "action" | "navigation" | "revalidation";
|
|
136
|
+
}
|
|
137
|
+
) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.cause = options?.cause;
|
|
140
|
+
this.url = options?.url;
|
|
141
|
+
this.operation = options?.operation;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if an error is a network-level failure (server unreachable, no internet)
|
|
147
|
+
* These are typically TypeError from fetch when the network request itself fails.
|
|
148
|
+
*/
|
|
149
|
+
export function isNetworkError(error: unknown): boolean {
|
|
150
|
+
// NetworkError we throw ourselves
|
|
151
|
+
if (error instanceof NetworkError) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// TypeError from fetch() when network fails (e.g., "Failed to fetch")
|
|
156
|
+
if (error instanceof TypeError) {
|
|
157
|
+
const message = error.message.toLowerCase();
|
|
158
|
+
return (
|
|
159
|
+
message.includes("failed to fetch") ||
|
|
160
|
+
message.includes("network request failed") ||
|
|
161
|
+
message.includes("networkerror") ||
|
|
162
|
+
message.includes("load failed")
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// DOMException with network-related names
|
|
167
|
+
if (error instanceof DOMException) {
|
|
168
|
+
return error.name === "NetworkError";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Thrown when route handler returns invalid type
|
|
176
|
+
*/
|
|
177
|
+
export class InvalidHandlerError extends Error {
|
|
178
|
+
name = "InvalidHandlerError" as const;
|
|
179
|
+
cause?: unknown;
|
|
180
|
+
|
|
181
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
182
|
+
super(message);
|
|
183
|
+
this.cause = options?.cause;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Internal invariant assertion for development-time checks
|
|
189
|
+
*
|
|
190
|
+
* @internal
|
|
191
|
+
* @param condition - Condition that must be true
|
|
192
|
+
* @param message - Error message to throw if condition is false
|
|
193
|
+
* @throws {Error} If condition is false
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```typescript
|
|
197
|
+
* invariant(user !== null, 'User must be defined');
|
|
198
|
+
* invariant(node.type === 'layout', `Expected layout, got ${node.type}`);
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export function invariant(condition: any, message: string): asserts condition {
|
|
202
|
+
if (!condition) {
|
|
203
|
+
throw new Error(`Invariant: ${message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Sanitize errors for production - prevents leaking sensitive information
|
|
209
|
+
*
|
|
210
|
+
* SECURITY CRITICAL:
|
|
211
|
+
* - In production: NEVER send stack traces, file paths, or internal state
|
|
212
|
+
* - In development: Show full error details for debugging
|
|
213
|
+
* - ALWAYS consume error.stack to prevent memory leaks
|
|
214
|
+
*
|
|
215
|
+
* @param error - Error to sanitize
|
|
216
|
+
* @returns Response suitable for sending to client
|
|
217
|
+
*/
|
|
218
|
+
export function sanitizeError(error: unknown): Response {
|
|
219
|
+
// CRITICAL: Consume stack trace to prevent memory leaks
|
|
220
|
+
if (error && typeof error === "object" && "stack" in error) {
|
|
221
|
+
const _ = error.stack; // Force V8 to generate/consume stack trace
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Response objects are safe to return as-is
|
|
225
|
+
if (error instanceof Response) {
|
|
226
|
+
return error;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isDev = (import.meta as any).env?.DEV ?? true;
|
|
230
|
+
|
|
231
|
+
if (isDev) {
|
|
232
|
+
// Development: Send full error details for debugging
|
|
233
|
+
const errorDetails = {
|
|
234
|
+
name: error instanceof Error ? error.name : "Error",
|
|
235
|
+
message: error instanceof Error ? error.message : String(error),
|
|
236
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
237
|
+
cause:
|
|
238
|
+
error && typeof error === "object" && "cause" in error
|
|
239
|
+
? (error as any).cause
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return new Response(JSON.stringify(errorDetails, null, 2), {
|
|
244
|
+
status: 500,
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Production: Generic error only - NO internal details
|
|
252
|
+
// SECURITY: Never leak stack traces, file paths, or application state
|
|
253
|
+
return new Response("Internal Server Error", {
|
|
254
|
+
status: 500,
|
|
255
|
+
headers: {
|
|
256
|
+
"Content-Type": "text/plain",
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
package/src/handle.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handle definition for accumulating data across route segments.
|
|
3
|
+
*
|
|
4
|
+
* Handles allow server-side route handlers to pass accumulated data to client
|
|
5
|
+
* components. Unlike loaders (which fetch data for specific routes), handles
|
|
6
|
+
* accumulate data across all matched route segments.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // Define a handle (name auto-generated from file + export)
|
|
11
|
+
* export const Breadcrumbs = createHandle<BreadcrumbItem>();
|
|
12
|
+
*
|
|
13
|
+
* // Use in handler
|
|
14
|
+
* const push = ctx.use(Breadcrumbs);
|
|
15
|
+
* push({ label: "Home", href: "/" });
|
|
16
|
+
*
|
|
17
|
+
* // Consume on client
|
|
18
|
+
* const crumbs = useHandle(Breadcrumbs);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export interface Handle<TData, TAccumulated = TData[]> {
|
|
22
|
+
/**
|
|
23
|
+
* Brand to distinguish handles from loaders in ctx.use()
|
|
24
|
+
*/
|
|
25
|
+
readonly __brand: "handle";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Auto-generated unique ID for this handle (used as key in storage)
|
|
29
|
+
* Format: "filePath#ExportName" in dev, "hash#ExportName" in production
|
|
30
|
+
*/
|
|
31
|
+
readonly $$id: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Collect function to transform segment data into final value.
|
|
35
|
+
* Receives array of arrays - each inner array contains values pushed
|
|
36
|
+
* by one segment, ordered parent-to-child.
|
|
37
|
+
*
|
|
38
|
+
* @param segments - Array of segment data arrays, e.g. [[a, b], [c], [d, e]]
|
|
39
|
+
* @returns The accumulated value
|
|
40
|
+
*/
|
|
41
|
+
readonly collect: (segments: TData[][]) => TAccumulated;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default collect function that flattens segment arrays into a single array.
|
|
46
|
+
*/
|
|
47
|
+
function defaultCollect<T>(segments: T[][]): T[] {
|
|
48
|
+
return segments.flat();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a handle definition for accumulating data across route segments.
|
|
53
|
+
*
|
|
54
|
+
* The $$id is auto-generated by the Vite exposeHandleId plugin based on
|
|
55
|
+
* file path and export name. No manual naming required.
|
|
56
|
+
*
|
|
57
|
+
* @param collect - Optional collect function (default: flatten into array)
|
|
58
|
+
* @param __injectedId - Auto-injected by Vite plugin, do not provide manually
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* // Default: flatten into array
|
|
63
|
+
* export const Breadcrumbs = createHandle<BreadcrumbItem>();
|
|
64
|
+
* // Result type: BreadcrumbItem[]
|
|
65
|
+
*
|
|
66
|
+
* // Custom: last value wins
|
|
67
|
+
* export const PageTitle = createHandle<string, string>(
|
|
68
|
+
* (segments) => segments.flat().at(-1) ?? "Default Title"
|
|
69
|
+
* );
|
|
70
|
+
* // Result type: string
|
|
71
|
+
*
|
|
72
|
+
* // Custom: object merge
|
|
73
|
+
* export const Meta = createHandle<Partial<MetaTags>, MetaTags>(
|
|
74
|
+
* (segments) => Object.assign({ robots: "index,follow" }, ...segments.flat())
|
|
75
|
+
* );
|
|
76
|
+
* // Result type: MetaTags
|
|
77
|
+
*
|
|
78
|
+
* // Custom: dedupe by href
|
|
79
|
+
* export const Breadcrumbs = createHandle<BreadcrumbItem>(
|
|
80
|
+
* (segments) => {
|
|
81
|
+
* const all = segments.flat();
|
|
82
|
+
* return all.filter((item, i) => all.findIndex(x => x.href === item.href) === i);
|
|
83
|
+
* }
|
|
84
|
+
* );
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function createHandle<TData, TAccumulated = TData[]>(
|
|
88
|
+
collect?: (segments: TData[][]) => TAccumulated,
|
|
89
|
+
__injectedId?: string
|
|
90
|
+
): Handle<TData, TAccumulated> {
|
|
91
|
+
const handleId = __injectedId ?? "";
|
|
92
|
+
|
|
93
|
+
if (!handleId && process.env.NODE_ENV !== "production") {
|
|
94
|
+
console.warn(
|
|
95
|
+
"[rsc-router] Handle is missing $$id. " +
|
|
96
|
+
"Make sure the exposeHandleId Vite plugin is enabled and " +
|
|
97
|
+
"the handle is exported with: export const MyHandle = createHandle(...)"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
__brand: "handle" as const,
|
|
103
|
+
$$id: handleId,
|
|
104
|
+
collect:
|
|
105
|
+
collect ??
|
|
106
|
+
(defaultCollect as unknown as (segments: TData[][]) => TAccumulated),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Type guard to check if a value is a Handle.
|
|
112
|
+
*/
|
|
113
|
+
export function isHandle(value: unknown): value is Handle<unknown, unknown> {
|
|
114
|
+
return (
|
|
115
|
+
typeof value === "object" &&
|
|
116
|
+
value !== null &&
|
|
117
|
+
"__brand" in value &&
|
|
118
|
+
(value as { __brand: unknown }).__brand === "handle"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Component to render collected meta descriptors in the document head.
|
|
5
|
+
*
|
|
6
|
+
* Supports both sync and async meta descriptors. Async descriptors
|
|
7
|
+
* (Promise<MetaDescriptorBase>) are resolved using React's use() hook.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* function RootLayout() {
|
|
12
|
+
* return (
|
|
13
|
+
* <html>
|
|
14
|
+
* <head>
|
|
15
|
+
* <MetaTags />
|
|
16
|
+
* </head>
|
|
17
|
+
* <body>...</body>
|
|
18
|
+
* </html>
|
|
19
|
+
* );
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { use } from "react";
|
|
25
|
+
import { useHandle } from "../browser/react/use-handle.ts";
|
|
26
|
+
import { Meta } from "./meta.ts";
|
|
27
|
+
import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.ts";
|
|
28
|
+
|
|
29
|
+
// Type guards for MetaDescriptorBase variants
|
|
30
|
+
function hasCharSet(d: MetaDescriptorBase): d is { charSet: "utf-8" } {
|
|
31
|
+
return "charSet" in d && d.charSet === "utf-8";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasTitle(d: MetaDescriptorBase): d is { title: string } {
|
|
35
|
+
return "title" in d && typeof (d as { title?: unknown }).title === "string";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasNameContent(d: MetaDescriptorBase): d is { name: string; content: string } {
|
|
39
|
+
return "name" in d && "content" in d &&
|
|
40
|
+
typeof (d as { name?: unknown }).name === "string" &&
|
|
41
|
+
typeof (d as { content?: unknown }).content === "string";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasPropertyContent(d: MetaDescriptorBase): d is { property: string; content: string } {
|
|
45
|
+
return "property" in d && "content" in d &&
|
|
46
|
+
typeof (d as { property?: unknown }).property === "string" &&
|
|
47
|
+
typeof (d as { content?: unknown }).content === "string";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasHttpEquivContent(d: MetaDescriptorBase): d is { httpEquiv: string; content: string } {
|
|
51
|
+
return "httpEquiv" in d && "content" in d &&
|
|
52
|
+
typeof (d as { httpEquiv?: unknown }).httpEquiv === "string" &&
|
|
53
|
+
typeof (d as { content?: unknown }).content === "string";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasScriptLdJson(d: MetaDescriptorBase): d is { "script:ld+json": object } {
|
|
57
|
+
return "script:ld+json" in d;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasTagName(d: MetaDescriptorBase): d is { tagName: "meta" | "link"; [name: string]: string } {
|
|
61
|
+
return "tagName" in d && ((d as { tagName?: unknown }).tagName === "meta" || (d as { tagName?: unknown }).tagName === "link");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a value is a Promise.
|
|
66
|
+
*/
|
|
67
|
+
function isPromise(value: unknown): value is Promise<unknown> {
|
|
68
|
+
return value !== null && typeof value === "object" && "then" in value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render a single meta descriptor as a React element.
|
|
73
|
+
*/
|
|
74
|
+
function renderMetaDescriptor(
|
|
75
|
+
descriptor: MetaDescriptorBase,
|
|
76
|
+
index: number
|
|
77
|
+
): React.ReactNode {
|
|
78
|
+
// charset
|
|
79
|
+
if (hasCharSet(descriptor)) {
|
|
80
|
+
return <meta key="charSet" charSet={descriptor.charSet} />;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// title
|
|
84
|
+
if (hasTitle(descriptor)) {
|
|
85
|
+
return <title key="title">{descriptor.title}</title>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// name + content (description, viewport, etc.)
|
|
89
|
+
if (hasNameContent(descriptor)) {
|
|
90
|
+
return (
|
|
91
|
+
<meta
|
|
92
|
+
key={`name-${descriptor.name}`}
|
|
93
|
+
name={descriptor.name}
|
|
94
|
+
content={descriptor.content}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// property + content (Open Graph, etc.)
|
|
100
|
+
if (hasPropertyContent(descriptor)) {
|
|
101
|
+
return (
|
|
102
|
+
<meta
|
|
103
|
+
key={`property-${descriptor.property}`}
|
|
104
|
+
property={descriptor.property}
|
|
105
|
+
content={descriptor.content}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// http-equiv + content
|
|
111
|
+
if (hasHttpEquivContent(descriptor)) {
|
|
112
|
+
return (
|
|
113
|
+
<meta
|
|
114
|
+
key={`httpEquiv-${descriptor.httpEquiv}`}
|
|
115
|
+
httpEquiv={descriptor.httpEquiv}
|
|
116
|
+
content={descriptor.content}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// JSON-LD structured data
|
|
122
|
+
if (hasScriptLdJson(descriptor)) {
|
|
123
|
+
const json = JSON.stringify(descriptor["script:ld+json"]);
|
|
124
|
+
return (
|
|
125
|
+
<script
|
|
126
|
+
key={`ld-json-${index}`}
|
|
127
|
+
type="application/ld+json"
|
|
128
|
+
dangerouslySetInnerHTML={{ __html: json }}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Custom tagName (meta or link with arbitrary attributes)
|
|
134
|
+
if (hasTagName(descriptor)) {
|
|
135
|
+
const { tagName, ...rest } = descriptor;
|
|
136
|
+
if (tagName === "link") {
|
|
137
|
+
return <link key={`link-${index}`} {...(rest as React.LinkHTMLAttributes<HTMLLinkElement>)} />;
|
|
138
|
+
}
|
|
139
|
+
if (tagName === "meta") {
|
|
140
|
+
return <meta key={`meta-${index}`} {...(rest as React.MetaHTMLAttributes<HTMLMetaElement>)} />;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Fallback: treat as meta attributes
|
|
145
|
+
return <meta key={`meta-fallback-${index}`} {...(descriptor as React.MetaHTMLAttributes<HTMLMetaElement>)} />;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Wrapper component to resolve a Promise<MetaDescriptorBase> using use().
|
|
150
|
+
*/
|
|
151
|
+
function AsyncMetaTag({ promise, index }: { promise: Promise<MetaDescriptorBase>; index: number }): React.ReactNode {
|
|
152
|
+
const resolved = use(promise);
|
|
153
|
+
return renderMetaDescriptor(resolved, index);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Renders all collected meta descriptors from route handlers.
|
|
158
|
+
*
|
|
159
|
+
* Place this component inside the `<head>` element of your document.
|
|
160
|
+
* It will automatically update when meta descriptors change during navigation.
|
|
161
|
+
*
|
|
162
|
+
* Async meta descriptors (Promise<MetaDescriptorBase>) are resolved using
|
|
163
|
+
* React's use() hook. RSC streaming handles the Promise resolution.
|
|
164
|
+
*/
|
|
165
|
+
export function MetaTags(): React.ReactNode {
|
|
166
|
+
const descriptors = useHandle(Meta) as MetaDescriptor[];
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
{descriptors.map((descriptor, index) => {
|
|
171
|
+
if (isPromise(descriptor)) {
|
|
172
|
+
return <AsyncMetaTag key={`async-${index}`} promise={descriptor} index={index} />;
|
|
173
|
+
}
|
|
174
|
+
return renderMetaDescriptor(descriptor, index);
|
|
175
|
+
})}
|
|
176
|
+
</>
|
|
177
|
+
);
|
|
178
|
+
}
|