react-bun-ssr 0.3.2 → 0.4.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 +39 -5
- package/framework/cli/dev-runtime.ts +18 -1
- package/framework/runtime/action-stub.ts +26 -0
- package/framework/runtime/client-runtime.tsx +66 -182
- package/framework/runtime/helpers.ts +75 -1
- package/framework/runtime/index.ts +53 -23
- package/framework/runtime/module-loader.ts +197 -35
- package/framework/runtime/render.tsx +1 -1
- package/framework/runtime/request-executor.ts +1705 -0
- package/framework/runtime/response-context.ts +206 -0
- package/framework/runtime/route-api.ts +51 -18
- package/framework/runtime/route-scanner.ts +104 -12
- package/framework/runtime/route-wire-protocol.ts +486 -0
- package/framework/runtime/server.ts +8 -1295
- package/framework/runtime/tree.tsx +45 -4
- package/framework/runtime/types.ts +71 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,11 +38,6 @@ Prerequisites:
|
|
|
38
38
|
- Bun `>= 1.3.10`
|
|
39
39
|
- `rbssr` available on PATH in the workflow you use to start a new app
|
|
40
40
|
|
|
41
|
-
Try in browser:
|
|
42
|
-
|
|
43
|
-
- StackBlitz (primary): https://stackblitz.com/github/react-formation/react-bun-ssr/tree/main/examples/sandbox-starter
|
|
44
|
-
- CodeSandbox (fallback): https://codesandbox.io/s/github/react-formation/react-bun-ssr/tree/main/examples/sandbox-starter
|
|
45
|
-
|
|
46
41
|
Minimal setup:
|
|
47
42
|
|
|
48
43
|
```bash
|
|
@@ -114,11 +109,50 @@ Read more:
|
|
|
114
109
|
|
|
115
110
|
For a page request, the framework resolves the matching route, runs global and nested middleware, executes the matched loader or action, and then renders an HTML response or returns a direct `Response` when the route short-circuits. API routes use the same route tree and middleware model, but return handler responses instead of page HTML.
|
|
116
111
|
|
|
112
|
+
Under the hood, page HTML, API, internal action, and internal transition requests now share the same runtime request boundary, which keeps middleware, redirects, and response finalization behavior aligned across request kinds.
|
|
113
|
+
|
|
117
114
|
Read more:
|
|
118
115
|
|
|
119
116
|
- https://react-bun-ssr.dev/docs/routing/middleware
|
|
120
117
|
- https://react-bun-ssr.dev/docs/data/loaders
|
|
121
118
|
|
|
119
|
+
### Actions with React `useActionState`
|
|
120
|
+
|
|
121
|
+
Page mutations use React 19 form actions (`useActionState`) with an explicit route stub:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
// app/routes/login.tsx
|
|
125
|
+
import { useActionState } from "react";
|
|
126
|
+
import { createRouteAction } from "react-bun-ssr/route";
|
|
127
|
+
|
|
128
|
+
type LoginState = { error?: string };
|
|
129
|
+
export const action = createRouteAction<LoginState>();
|
|
130
|
+
|
|
131
|
+
export default function LoginPage() {
|
|
132
|
+
const [state, formAction, pending] = useActionState(action, {});
|
|
133
|
+
return (
|
|
134
|
+
<form action={formAction}>
|
|
135
|
+
{state.error ? <p>{state.error}</p> : null}
|
|
136
|
+
<button disabled={pending}>Sign in</button>
|
|
137
|
+
</form>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
// app/routes/login.server.tsx
|
|
144
|
+
import { redirect } from "react-bun-ssr";
|
|
145
|
+
import type { Action } from "react-bun-ssr/route";
|
|
146
|
+
|
|
147
|
+
export const action: Action = async (ctx) => {
|
|
148
|
+
const email = String(ctx.formData?.get("email") ?? "").trim();
|
|
149
|
+
if (!email) return { error: "Email is required" };
|
|
150
|
+
return redirect("/dashboard");
|
|
151
|
+
};
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`createRouteAction` is the preferred pattern. `useRouteAction` remains available for backward compatibility.
|
|
155
|
+
|
|
122
156
|
### Rendering model
|
|
123
157
|
|
|
124
158
|
SSR is the default model. HTML responses stream, deferred loader data is supported, and soft client transitions are handled through `Link` and `useRouter`. The docs site in this repository uses the same routing, rendering, markdown, and transition model that framework users get.
|
|
@@ -26,13 +26,20 @@ function isConfigFileName(fileName: string): boolean {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function isTopLevelAppRuntimeFile(relativePath: string): boolean {
|
|
29
|
-
return /^root
|
|
29
|
+
return /^root(?:\.server)?\.(tsx|jsx|ts|js)$/.test(relativePath)
|
|
30
|
+
|| /^middleware(?:\.server)?\.(tsx|jsx|ts|js)$/.test(relativePath);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function isMarkdownRouteFile(relativePath: string): boolean {
|
|
33
34
|
return /^routes\/.+\.md$/.test(relativePath);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function isServerOnlyRuntimeFile(relativePath: string): boolean {
|
|
38
|
+
return /^root\.server\.(tsx|jsx|ts|js)$/.test(relativePath)
|
|
39
|
+
|| /^middleware\.server\.(tsx|jsx|ts|js)$/.test(relativePath)
|
|
40
|
+
|| /^routes\/.+\.server\.(tsx|jsx|ts|js)$/.test(relativePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
function isStructuralAppPath(relativePath: string): boolean {
|
|
37
44
|
return relativePath === "routes"
|
|
38
45
|
|| relativePath.startsWith("routes/")
|
|
@@ -265,11 +272,21 @@ export async function runHotDevChild(options: {
|
|
|
265
272
|
return;
|
|
266
273
|
}
|
|
267
274
|
|
|
275
|
+
if (eventType === "rename" && isServerOnlyRuntimeFile(relativePath)) {
|
|
276
|
+
publishReload("server-runtime");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
268
280
|
if (eventType === "rename" && isStructuralAppPath(relativePath)) {
|
|
269
281
|
scheduleStructuralSync();
|
|
270
282
|
return;
|
|
271
283
|
}
|
|
272
284
|
|
|
285
|
+
if (eventType === "change" && isServerOnlyRuntimeFile(relativePath)) {
|
|
286
|
+
publishReload("server-runtime");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
273
290
|
if (eventType !== "change" || !isMarkdownRouteFile(relativePath)) {
|
|
274
291
|
return;
|
|
275
292
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type RouteActionStateHandler<TState = unknown> = (
|
|
2
|
+
previousState: TState,
|
|
3
|
+
formData: FormData,
|
|
4
|
+
) => Promise<TState>;
|
|
5
|
+
|
|
6
|
+
const ROUTE_ACTION_STUB_MARKER = Symbol.for("react-bun-ssr.route-action-stub");
|
|
7
|
+
|
|
8
|
+
export function markRouteActionStub<TState>(
|
|
9
|
+
handler: RouteActionStateHandler<TState>,
|
|
10
|
+
): RouteActionStateHandler<TState> {
|
|
11
|
+
Object.defineProperty(handler, ROUTE_ACTION_STUB_MARKER, {
|
|
12
|
+
value: true,
|
|
13
|
+
enumerable: false,
|
|
14
|
+
configurable: false,
|
|
15
|
+
writable: false,
|
|
16
|
+
});
|
|
17
|
+
return handler;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isRouteActionStub(value: unknown): value is RouteActionStateHandler<unknown> {
|
|
21
|
+
if (typeof value !== "function") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (value as unknown as Record<PropertyKey, unknown>)[ROUTE_ACTION_STUB_MARKER] === true;
|
|
26
|
+
}
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { hydrateRoot, type Root } from "react-dom/client";
|
|
2
2
|
import {
|
|
3
|
-
consumeTransitionChunkText,
|
|
4
|
-
createTransitionChunkParserState,
|
|
5
|
-
flushTransitionChunkText,
|
|
6
3
|
isStaleNavigationToken,
|
|
7
4
|
matchClientPageRoute,
|
|
8
5
|
sanitizePrefetchCache,
|
|
9
|
-
shouldHardNavigateForRedirectDepth,
|
|
10
6
|
shouldSkipSoftNavigation,
|
|
11
7
|
} from "./client-transition-core";
|
|
12
|
-
import { isDeferredToken } from "./deferred";
|
|
13
8
|
import {
|
|
14
9
|
addNavigationNavigateListener,
|
|
15
10
|
canNavigationNavigateWithIntercept,
|
|
@@ -21,6 +16,12 @@ import {
|
|
|
21
16
|
RBSSR_ROUTER_SCRIPT_ID,
|
|
22
17
|
} from "./runtime-constants";
|
|
23
18
|
import { replaceManagedHead } from "./head-reconcile";
|
|
19
|
+
import {
|
|
20
|
+
applyRouteWireDeferredChunk,
|
|
21
|
+
completeRouteWireTransition,
|
|
22
|
+
createRouteWireProtocol,
|
|
23
|
+
reviveRouteWirePayload,
|
|
24
|
+
} from "./route-wire-protocol";
|
|
24
25
|
import {
|
|
25
26
|
createCatchAppTree,
|
|
26
27
|
createErrorAppTree,
|
|
@@ -34,7 +35,6 @@ import type {
|
|
|
34
35
|
RenderPayload,
|
|
35
36
|
RouteModule,
|
|
36
37
|
RouteModuleBundle,
|
|
37
|
-
TransitionDeferredChunk,
|
|
38
38
|
TransitionDocumentChunk,
|
|
39
39
|
TransitionInitialChunk,
|
|
40
40
|
TransitionRedirectChunk,
|
|
@@ -73,16 +73,6 @@ interface PrefetchEntry {
|
|
|
73
73
|
donePromise: Promise<void>;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
interface TransitionRequestOptions {
|
|
77
|
-
onDeferredChunk?: (chunk: TransitionDeferredChunk) => void;
|
|
78
|
-
signal?: AbortSignal;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface TransitionRequestHandle {
|
|
82
|
-
initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null>;
|
|
83
|
-
donePromise: Promise<void>;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
76
|
interface FrameworkNavigationInfo {
|
|
87
77
|
__rbssrTransition: true;
|
|
88
78
|
id: string;
|
|
@@ -173,6 +163,28 @@ function getClientRuntimeSingleton(): ClientRuntimeSingleton {
|
|
|
173
163
|
|
|
174
164
|
const clientRuntimeSingleton = getClientRuntimeSingleton();
|
|
175
165
|
|
|
166
|
+
function readCurrentWindowUrl(): URL | null {
|
|
167
|
+
if (typeof window === "undefined") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return new URL(window.location.href);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getClientRouteWireProtocol() {
|
|
175
|
+
return createRouteWireProtocol({
|
|
176
|
+
getCurrentUrl: readCurrentWindowUrl,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getDeferredRuntime(): DeferredClientRuntime | undefined {
|
|
181
|
+
if (typeof window === "undefined") {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return window.__RBSSR_DEFERRED__;
|
|
186
|
+
}
|
|
187
|
+
|
|
176
188
|
function emitNavigation(info: NavigateResult): void {
|
|
177
189
|
for (const listener of clientRuntimeSingleton.navigationListeners) {
|
|
178
190
|
try {
|
|
@@ -373,31 +385,6 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
|
|
|
373
385
|
return bestMatch;
|
|
374
386
|
}
|
|
375
387
|
|
|
376
|
-
function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
377
|
-
const sourceData = payload.data;
|
|
378
|
-
if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
|
|
379
|
-
return payload;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const runtime = window.__RBSSR_DEFERRED__;
|
|
383
|
-
if (!runtime) {
|
|
384
|
-
return payload;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const revivedData = { ...(sourceData as Record<string, unknown>) };
|
|
388
|
-
for (const [key, value] of Object.entries(revivedData)) {
|
|
389
|
-
if (!isDeferredToken(value)) {
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
revivedData[key] = runtime.get(value.__rbssrDeferred);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return {
|
|
396
|
-
...payload,
|
|
397
|
-
data: revivedData,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
388
|
function ensureRuntimeState(): RuntimeState {
|
|
402
389
|
if (!clientRuntimeSingleton.runtimeState) {
|
|
403
390
|
throw new Error("Client runtime is not initialized. Ensure hydrateInitialRoute() ran first.");
|
|
@@ -406,106 +393,6 @@ function ensureRuntimeState(): RuntimeState {
|
|
|
406
393
|
return clientRuntimeSingleton.runtimeState;
|
|
407
394
|
}
|
|
408
395
|
|
|
409
|
-
function createTransitionUrl(toUrl: URL): URL {
|
|
410
|
-
const transitionUrl = new URL("/__rbssr/transition", window.location.origin);
|
|
411
|
-
transitionUrl.searchParams.set("to", toUrl.pathname + toUrl.search + toUrl.hash);
|
|
412
|
-
return transitionUrl;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function startTransitionRequest(
|
|
416
|
-
toUrl: URL,
|
|
417
|
-
options: TransitionRequestOptions = {},
|
|
418
|
-
): TransitionRequestHandle {
|
|
419
|
-
let resolveInitial: (
|
|
420
|
-
value: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null,
|
|
421
|
-
) => void = () => undefined;
|
|
422
|
-
let rejectInitial: (reason?: unknown) => void = () => undefined;
|
|
423
|
-
const initialPromise = new Promise<
|
|
424
|
-
TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null
|
|
425
|
-
>((resolve, reject) => {
|
|
426
|
-
resolveInitial = resolve;
|
|
427
|
-
rejectInitial = reject;
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
const donePromise = (async () => {
|
|
431
|
-
const endpoint = createTransitionUrl(toUrl);
|
|
432
|
-
const response = await fetch(endpoint.toString(), {
|
|
433
|
-
method: "GET",
|
|
434
|
-
credentials: "same-origin",
|
|
435
|
-
signal: options.signal,
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
if (!response.ok || !response.body) {
|
|
439
|
-
throw new Error(`Transition request failed with status ${response.status}`);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const reader = response.body.getReader();
|
|
443
|
-
const decoder = new TextDecoder();
|
|
444
|
-
let parserState = createTransitionChunkParserState();
|
|
445
|
-
|
|
446
|
-
while (true) {
|
|
447
|
-
const { done, value } = await reader.read();
|
|
448
|
-
if (done) {
|
|
449
|
-
break;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const previousInitialChunk = parserState.initialChunk;
|
|
453
|
-
const previousDeferredCount = parserState.deferredChunks.length;
|
|
454
|
-
parserState = consumeTransitionChunkText(
|
|
455
|
-
parserState,
|
|
456
|
-
decoder.decode(value, { stream: true }),
|
|
457
|
-
);
|
|
458
|
-
|
|
459
|
-
if (!previousInitialChunk && parserState.initialChunk) {
|
|
460
|
-
resolveInitial(parserState.initialChunk);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
|
|
464
|
-
options.onDeferredChunk?.(chunk);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const previousInitialChunk = parserState.initialChunk;
|
|
469
|
-
const previousDeferredCount = parserState.deferredChunks.length;
|
|
470
|
-
parserState = flushTransitionChunkText(parserState);
|
|
471
|
-
|
|
472
|
-
if (!previousInitialChunk && parserState.initialChunk) {
|
|
473
|
-
resolveInitial(parserState.initialChunk);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
|
|
477
|
-
options.onDeferredChunk?.(chunk);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (!parserState.initialChunk) {
|
|
481
|
-
resolveInitial(null);
|
|
482
|
-
}
|
|
483
|
-
})();
|
|
484
|
-
|
|
485
|
-
donePromise.catch(error => {
|
|
486
|
-
rejectInitial(error);
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
return {
|
|
490
|
-
initialPromise,
|
|
491
|
-
donePromise,
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function applyDeferredChunk(chunk: TransitionDeferredChunk): void {
|
|
496
|
-
const runtime = window.__RBSSR_DEFERRED__;
|
|
497
|
-
if (!runtime) {
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (chunk.ok) {
|
|
502
|
-
runtime.resolve(chunk.id, chunk.value);
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
runtime.reject(chunk.id, chunk.error ?? "Deferred value rejected");
|
|
507
|
-
}
|
|
508
|
-
|
|
509
396
|
async function ensureRouteModuleLoaded(routeId: string, snapshot: ClientRouterSnapshot): Promise<void> {
|
|
510
397
|
if (clientRuntimeSingleton.moduleRegistry.has(routeId)) {
|
|
511
398
|
return;
|
|
@@ -538,8 +425,11 @@ function getOrCreatePrefetchEntry(
|
|
|
538
425
|
? ensureRouteModuleLoaded(routeId, snapshot).catch(() => undefined)
|
|
539
426
|
: Promise.resolve();
|
|
540
427
|
|
|
541
|
-
const transitionRequest =
|
|
542
|
-
|
|
428
|
+
const transitionRequest = getClientRouteWireProtocol().startTransition({
|
|
429
|
+
to: toUrl,
|
|
430
|
+
onDeferredChunk: chunk => {
|
|
431
|
+
applyRouteWireDeferredChunk(chunk, getDeferredRuntime());
|
|
432
|
+
},
|
|
543
433
|
signal,
|
|
544
434
|
});
|
|
545
435
|
const initialPromise = transitionRequest.initialPromise.catch(() => {
|
|
@@ -574,7 +464,7 @@ async function renderTransitionInitial(
|
|
|
574
464
|
options: NavigateOptions & { prefetched: boolean; fromPath: string },
|
|
575
465
|
): Promise<NavigateResult> {
|
|
576
466
|
const state = ensureRuntimeState();
|
|
577
|
-
const revivedPayload =
|
|
467
|
+
const revivedPayload = reviveRouteWirePayload(chunk.payload, getDeferredRuntime());
|
|
578
468
|
let modules: RouteModuleBundle | null = null;
|
|
579
469
|
let tree = null as ReturnType<typeof createPageAppTree> | ReturnType<typeof createNotFoundAppTree>;
|
|
580
470
|
|
|
@@ -713,7 +603,7 @@ async function navigateToInternal(
|
|
|
713
603
|
matchedModules,
|
|
714
604
|
{
|
|
715
605
|
routeId: matched.route.id,
|
|
716
|
-
|
|
606
|
+
loaderData: null,
|
|
717
607
|
params: matched.params,
|
|
718
608
|
url: toUrl.toString(),
|
|
719
609
|
},
|
|
@@ -733,43 +623,34 @@ async function navigateToInternal(
|
|
|
733
623
|
throw new Error("Transition response did not include an initial payload.");
|
|
734
624
|
}
|
|
735
625
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
const result = await renderTransitionInitial(initialChunk, toUrl, {
|
|
766
|
-
...options,
|
|
767
|
-
prefetched: usedPrefetch,
|
|
768
|
-
fromPath: currentPath,
|
|
626
|
+
return completeRouteWireTransition(initialChunk, {
|
|
627
|
+
currentUrl: new URL(window.location.href),
|
|
628
|
+
redirectDepth: options.redirectDepth,
|
|
629
|
+
render: async chunk => {
|
|
630
|
+
const result = await renderTransitionInitial(chunk, toUrl, {
|
|
631
|
+
...options,
|
|
632
|
+
prefetched: usedPrefetch,
|
|
633
|
+
fromPath: currentPath,
|
|
634
|
+
});
|
|
635
|
+
options.onNavigate?.(result);
|
|
636
|
+
emitNavigation(result);
|
|
637
|
+
return result;
|
|
638
|
+
},
|
|
639
|
+
softNavigate: async (location, redirectInfo) => {
|
|
640
|
+
return navigateToInternal(new URL(location, window.location.href), {
|
|
641
|
+
...options,
|
|
642
|
+
replace: redirectInfo.replace,
|
|
643
|
+
redirected: redirectInfo.redirected,
|
|
644
|
+
redirectDepth: redirectInfo.redirectDepth,
|
|
645
|
+
// The intercepted navigation has already committed the source URL.
|
|
646
|
+
// The redirected target must update history explicitly.
|
|
647
|
+
historyManagedByNavigationApi: false,
|
|
648
|
+
});
|
|
649
|
+
},
|
|
650
|
+
hardNavigate: location => {
|
|
651
|
+
hardNavigate(new URL(location, window.location.href));
|
|
652
|
+
},
|
|
769
653
|
});
|
|
770
|
-
options.onNavigate?.(result);
|
|
771
|
-
emitNavigation(result);
|
|
772
|
-
return result;
|
|
773
654
|
} catch {
|
|
774
655
|
hardNavigate(toUrl);
|
|
775
656
|
return null;
|
|
@@ -1060,7 +941,10 @@ export function hydrateInitialRoute(routeId: string): void {
|
|
|
1060
941
|
return;
|
|
1061
942
|
}
|
|
1062
943
|
|
|
1063
|
-
const payload =
|
|
944
|
+
const payload = reviveRouteWirePayload(
|
|
945
|
+
getScriptJson<RenderPayload>(RBSSR_PAYLOAD_SCRIPT_ID),
|
|
946
|
+
getDeferredRuntime(),
|
|
947
|
+
);
|
|
1064
948
|
const routerSnapshot = getScriptJson<ClientRouterSnapshot>(RBSSR_ROUTER_SCRIPT_ID);
|
|
1065
949
|
const modules = clientRuntimeSingleton.moduleRegistry.get(routeId);
|
|
1066
950
|
if (!modules) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { FrameworkConfig, RedirectResult } from "./types";
|
|
1
|
+
import type { FrameworkConfig, RedirectResult, RequestContext } from "./types";
|
|
2
2
|
import { defer as deferValue } from "./deferred";
|
|
3
|
+
import { routeError } from "./route-errors";
|
|
3
4
|
|
|
4
5
|
export function json(data: unknown, init: ResponseInit = {}): Response {
|
|
5
6
|
const headers = new Headers(init.headers);
|
|
@@ -30,6 +31,79 @@ export function defineConfig(config: FrameworkConfig): FrameworkConfig {
|
|
|
30
31
|
|
|
31
32
|
export const defer = deferValue;
|
|
32
33
|
|
|
34
|
+
function toNormalizedFallback(value: string): string {
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (!trimmed) {
|
|
37
|
+
return "/";
|
|
38
|
+
}
|
|
39
|
+
if (trimmed.startsWith("/")) {
|
|
40
|
+
return trimmed.startsWith("//") ? "/" : trimmed;
|
|
41
|
+
}
|
|
42
|
+
if (trimmed.startsWith("?") || trimmed.startsWith("#")) {
|
|
43
|
+
return `/${trimmed}`;
|
|
44
|
+
}
|
|
45
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)) {
|
|
46
|
+
return "/";
|
|
47
|
+
}
|
|
48
|
+
return `/${trimmed.replace(/^\/+/, "")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sanitizeRedirectTarget(value: string | null | undefined, fallback = "/"): string {
|
|
52
|
+
const normalizedFallback = toNormalizedFallback(fallback);
|
|
53
|
+
if (typeof value !== "string") {
|
|
54
|
+
return normalizedFallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed) {
|
|
59
|
+
return normalizedFallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("\\\\")) {
|
|
63
|
+
return normalizedFallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let parsed: URL;
|
|
67
|
+
try {
|
|
68
|
+
parsed = new URL(trimmed, "http://rbssr.local");
|
|
69
|
+
} catch {
|
|
70
|
+
return normalizedFallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (parsed.origin !== "http://rbssr.local") {
|
|
74
|
+
return normalizedFallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const normalizedTarget = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
78
|
+
if (!normalizedTarget.startsWith("/")) {
|
|
79
|
+
return normalizedFallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return normalizedTarget;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function assertSameOriginAction(ctx: Pick<RequestContext, "request" | "url">): void {
|
|
86
|
+
if (ctx.request.method.toUpperCase() === "GET" || ctx.request.method.toUpperCase() === "HEAD") {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const originHeader = ctx.request.headers.get("origin");
|
|
91
|
+
if (!originHeader) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let origin: URL;
|
|
96
|
+
try {
|
|
97
|
+
origin = new URL(originHeader);
|
|
98
|
+
} catch {
|
|
99
|
+
throw routeError(403, { message: "Invalid Origin header." });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (origin.origin !== ctx.url.origin) {
|
|
103
|
+
throw routeError(403, { message: "Cross-origin form submissions are not allowed." });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
33
107
|
export function isRedirectResult(value: unknown): value is RedirectResult {
|
|
34
108
|
return Boolean(
|
|
35
109
|
value &&
|
|
@@ -1,29 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
Action,
|
|
3
|
-
ActionContext,
|
|
4
|
-
ActionResult,
|
|
5
|
-
ApiRouteModule,
|
|
6
|
-
BuildManifest,
|
|
7
|
-
BuildRouteAsset,
|
|
8
|
-
DeferredLoaderResult,
|
|
9
|
-
DeferredToken,
|
|
10
|
-
FrameworkConfig,
|
|
11
|
-
Loader,
|
|
12
|
-
LoaderContext,
|
|
13
|
-
LoaderResult,
|
|
14
|
-
Middleware,
|
|
15
|
-
Params,
|
|
16
|
-
RedirectResult,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
import type {
|
|
2
|
+
Action as RuntimeAction,
|
|
3
|
+
ActionContext as RuntimeActionContext,
|
|
4
|
+
ActionResult as RuntimeActionResult,
|
|
5
|
+
ApiRouteModule as RuntimeApiRouteModule,
|
|
6
|
+
BuildManifest as RuntimeBuildManifest,
|
|
7
|
+
BuildRouteAsset as RuntimeBuildRouteAsset,
|
|
8
|
+
DeferredLoaderResult as RuntimeDeferredLoaderResult,
|
|
9
|
+
DeferredToken as RuntimeDeferredToken,
|
|
10
|
+
FrameworkConfig as RuntimeFrameworkConfig,
|
|
11
|
+
Loader as RuntimeLoader,
|
|
12
|
+
LoaderContext as RuntimeLoaderContext,
|
|
13
|
+
LoaderResult as RuntimeLoaderResult,
|
|
14
|
+
Middleware as RuntimeMiddleware,
|
|
15
|
+
Params as RuntimeParams,
|
|
16
|
+
RedirectResult as RuntimeRedirectResult,
|
|
17
|
+
RequestContext as RuntimeRequestContext,
|
|
18
|
+
ResponseContext as RuntimeResponseContext,
|
|
19
|
+
ResponseCookies as RuntimeResponseCookies,
|
|
20
|
+
ResponseCookieOptions as RuntimeResponseCookieOptions,
|
|
21
|
+
ResponseHeaderRule as RuntimeResponseHeaderRule,
|
|
22
|
+
RouteCatchContext as RuntimeRouteCatchContext,
|
|
23
|
+
RouteErrorContext as RuntimeRouteErrorContext,
|
|
24
|
+
RouteErrorResponse as RuntimeRouteErrorResponse,
|
|
25
|
+
RouteModule as RuntimeRouteModule,
|
|
23
26
|
} from "./types";
|
|
24
27
|
|
|
28
|
+
export interface AppRouteLocals extends Record<string, unknown> {}
|
|
29
|
+
|
|
30
|
+
export type Action = RuntimeAction<AppRouteLocals>;
|
|
31
|
+
export type ActionContext = RuntimeActionContext<AppRouteLocals>;
|
|
32
|
+
export type ActionResult = RuntimeActionResult;
|
|
33
|
+
export type ApiRouteModule = RuntimeApiRouteModule;
|
|
34
|
+
export type BuildManifest = RuntimeBuildManifest;
|
|
35
|
+
export type BuildRouteAsset = RuntimeBuildRouteAsset;
|
|
36
|
+
export type DeferredLoaderResult = RuntimeDeferredLoaderResult;
|
|
37
|
+
export type DeferredToken = RuntimeDeferredToken;
|
|
38
|
+
export type FrameworkConfig = RuntimeFrameworkConfig;
|
|
39
|
+
export type Loader = RuntimeLoader<AppRouteLocals>;
|
|
40
|
+
export type LoaderContext = RuntimeLoaderContext<AppRouteLocals>;
|
|
41
|
+
export type LoaderResult = RuntimeLoaderResult;
|
|
42
|
+
export type Middleware = RuntimeMiddleware<AppRouteLocals>;
|
|
43
|
+
export type Params = RuntimeParams;
|
|
44
|
+
export type RedirectResult = RuntimeRedirectResult;
|
|
45
|
+
export type RequestContext = RuntimeRequestContext<AppRouteLocals>;
|
|
46
|
+
export type ResponseContext = RuntimeResponseContext;
|
|
47
|
+
export type ResponseCookies = RuntimeResponseCookies;
|
|
48
|
+
export type ResponseCookieOptions = RuntimeResponseCookieOptions;
|
|
49
|
+
export type ResponseHeaderRule = RuntimeResponseHeaderRule;
|
|
50
|
+
export type RouteCatchContext = RuntimeRouteCatchContext;
|
|
51
|
+
export type RouteErrorContext = RuntimeRouteErrorContext;
|
|
52
|
+
export type RouteErrorResponse = RuntimeRouteErrorResponse;
|
|
53
|
+
export type RouteModule = RuntimeRouteModule;
|
|
54
|
+
|
|
25
55
|
export { createServer, startHttpServer } from "./server";
|
|
26
|
-
export { defer, json, redirect,
|
|
56
|
+
export { assertSameOriginAction, defer, defineConfig, json, redirect, sanitizeRedirectTarget } from "./helpers";
|
|
27
57
|
export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
|
|
28
58
|
export { Link, type LinkProps } from "./link";
|
|
29
59
|
export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
|