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
|
@@ -0,0 +1,1705 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createElement, type ReactElement, type ReactNode } from "react";
|
|
3
|
+
import { createBunRouteAdapter, type BunRouteAdapter } from "./bun-route-adapter";
|
|
4
|
+
import {
|
|
5
|
+
isDeferredLoaderResult,
|
|
6
|
+
prepareDeferredPayload,
|
|
7
|
+
type DeferredSettleEntry,
|
|
8
|
+
} from "./deferred";
|
|
9
|
+
import { isRedirectResult, json } from "./helpers";
|
|
10
|
+
import { statPath } from "./io";
|
|
11
|
+
import { runMiddlewareChain } from "./middleware";
|
|
12
|
+
import {
|
|
13
|
+
extractRouteMiddleware,
|
|
14
|
+
loadApiRouteModule,
|
|
15
|
+
loadGlobalMiddleware,
|
|
16
|
+
loadNestedMiddleware,
|
|
17
|
+
loadRouteModule,
|
|
18
|
+
loadRouteModules,
|
|
19
|
+
type RouteModuleLoadOptions,
|
|
20
|
+
} from "./module-loader";
|
|
21
|
+
import {
|
|
22
|
+
collectHeadMarkup,
|
|
23
|
+
collectHeadElements,
|
|
24
|
+
createManagedHeadMarkup,
|
|
25
|
+
renderDocumentStream as defaultRenderDocumentStream,
|
|
26
|
+
} from "./render";
|
|
27
|
+
import {
|
|
28
|
+
sanitizeRouteErrorResponse,
|
|
29
|
+
toRouteErrorHttpResponse,
|
|
30
|
+
toRouteErrorResponse,
|
|
31
|
+
} from "./route-errors";
|
|
32
|
+
import { sortRoutesBySpecificity } from "./route-order";
|
|
33
|
+
import { applyResponseContext, createResponseContext } from "./response-context";
|
|
34
|
+
import {
|
|
35
|
+
createCatchAppTree,
|
|
36
|
+
createErrorAppTree,
|
|
37
|
+
createNotFoundAppTree,
|
|
38
|
+
createPageAppTree,
|
|
39
|
+
} from "./tree";
|
|
40
|
+
import type {
|
|
41
|
+
ActionContext,
|
|
42
|
+
ActionResponseEnvelope,
|
|
43
|
+
ApiRouteModule,
|
|
44
|
+
BuildRouteAsset,
|
|
45
|
+
ClientRouteSnapshot,
|
|
46
|
+
ClientRouterSnapshot,
|
|
47
|
+
HydrationDocumentAssets,
|
|
48
|
+
LoaderContext,
|
|
49
|
+
Middleware,
|
|
50
|
+
PageRouteDefinition,
|
|
51
|
+
RenderPayload,
|
|
52
|
+
RequestContext,
|
|
53
|
+
ResolvedConfig,
|
|
54
|
+
RouteCatchContext,
|
|
55
|
+
RouteErrorContext,
|
|
56
|
+
RouteErrorPhase,
|
|
57
|
+
RouteErrorResponse,
|
|
58
|
+
RouteManifest,
|
|
59
|
+
RouteModule,
|
|
60
|
+
RouteModuleBundle,
|
|
61
|
+
ServerRuntimeOptions,
|
|
62
|
+
TransitionChunk,
|
|
63
|
+
TransitionDeferredChunk,
|
|
64
|
+
TransitionDocumentChunk,
|
|
65
|
+
TransitionInitialChunk,
|
|
66
|
+
TransitionRedirectChunk,
|
|
67
|
+
} from "./types";
|
|
68
|
+
import {
|
|
69
|
+
ensureWithin,
|
|
70
|
+
isMutatingMethod,
|
|
71
|
+
normalizeSlashes,
|
|
72
|
+
parseCookieHeader,
|
|
73
|
+
sanitizeErrorMessage,
|
|
74
|
+
stableHash,
|
|
75
|
+
} from "./utils";
|
|
76
|
+
|
|
77
|
+
export interface RequestExecutor {
|
|
78
|
+
fetch(request: Request): Promise<Response>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RequestExecutorDeps {
|
|
82
|
+
getRouteAdapter(activeConfig: ResolvedConfig): Promise<BunRouteAdapter>;
|
|
83
|
+
loadRouteBundle(options: {
|
|
84
|
+
rootFilePath: string;
|
|
85
|
+
layoutFiles: string[];
|
|
86
|
+
routeFilePath: string;
|
|
87
|
+
routeServerFilePath?: string;
|
|
88
|
+
cacheBustKey?: string;
|
|
89
|
+
serverBytecode?: boolean;
|
|
90
|
+
devSourceImports?: boolean;
|
|
91
|
+
nodeEnv?: "development" | "production";
|
|
92
|
+
}): Promise<RouteModuleBundle>;
|
|
93
|
+
loadApiModule(filePath: string, options: RouteModuleLoadOptions): Promise<ApiRouteModule>;
|
|
94
|
+
loadGlobalMiddleware(
|
|
95
|
+
filePath: string,
|
|
96
|
+
options: RouteModuleLoadOptions | string,
|
|
97
|
+
): Promise<Middleware[]>;
|
|
98
|
+
loadNestedMiddleware(
|
|
99
|
+
filePaths: string[],
|
|
100
|
+
options: RouteModuleLoadOptions | string,
|
|
101
|
+
): Promise<Middleware[]>;
|
|
102
|
+
renderDocumentStream(options: {
|
|
103
|
+
appTree: ReactElement;
|
|
104
|
+
payload: RenderPayload;
|
|
105
|
+
assets: HydrationDocumentAssets;
|
|
106
|
+
headElements: ReactNode[];
|
|
107
|
+
routerSnapshot: ClientRouterSnapshot;
|
|
108
|
+
deferredSettleEntries?: DeferredSettleEntry[];
|
|
109
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type ResponseKind =
|
|
113
|
+
| "static"
|
|
114
|
+
| "html"
|
|
115
|
+
| "api"
|
|
116
|
+
| "internal-dev"
|
|
117
|
+
| "internal-transition"
|
|
118
|
+
| "internal-action";
|
|
119
|
+
|
|
120
|
+
const HASHED_CLIENT_CHUNK_RE = /^\/client\/.+-[A-Za-z0-9]{6,}\.(?:js|css)$/;
|
|
121
|
+
const STATIC_IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
122
|
+
const STATIC_DEFAULT_CACHE = "public, max-age=3600";
|
|
123
|
+
|
|
124
|
+
function toRedirectResponse(location: string, status = 302): Response {
|
|
125
|
+
return Response.redirect(location, status);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isResponse(value: unknown): value is Response {
|
|
129
|
+
return value instanceof Response;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveThrownRedirect(error: unknown): Response | null {
|
|
133
|
+
if (!(error instanceof Response)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const location = error.headers.get("location");
|
|
138
|
+
if (location && isRedirectStatus(error.status)) {
|
|
139
|
+
return Response.redirect(location, error.status);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getLifecycleModules(modules: RouteModuleBundle): RouteModule[] {
|
|
146
|
+
return [
|
|
147
|
+
modules.route,
|
|
148
|
+
...[...modules.layouts].reverse(),
|
|
149
|
+
modules.root,
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toRouteErrorContextBase(options: {
|
|
154
|
+
requestContext: RequestContext;
|
|
155
|
+
routeId: string;
|
|
156
|
+
phase: RouteErrorPhase;
|
|
157
|
+
dev: boolean;
|
|
158
|
+
}): Omit<RouteErrorContext, "error"> {
|
|
159
|
+
return {
|
|
160
|
+
request: options.requestContext.request,
|
|
161
|
+
url: options.requestContext.url,
|
|
162
|
+
params: options.requestContext.params,
|
|
163
|
+
routeId: options.routeId,
|
|
164
|
+
phase: options.phase,
|
|
165
|
+
dev: options.dev,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function notifyErrorHooks(options: {
|
|
170
|
+
modules: RouteModuleBundle;
|
|
171
|
+
context: RouteErrorContext;
|
|
172
|
+
}): Promise<void> {
|
|
173
|
+
const targets = getLifecycleModules(options.modules);
|
|
174
|
+
for (const moduleValue of targets) {
|
|
175
|
+
if (typeof moduleValue.onError !== "function") {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await moduleValue.onError(options.context);
|
|
181
|
+
} catch (hookError) {
|
|
182
|
+
// eslint-disable-next-line no-console
|
|
183
|
+
console.warn("[rbssr] route onError hook failed", Bun.inspect(hookError));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function notifyCatchHooks(options: {
|
|
189
|
+
modules: RouteModuleBundle;
|
|
190
|
+
context: RouteCatchContext;
|
|
191
|
+
}): Promise<void> {
|
|
192
|
+
const targets = getLifecycleModules(options.modules);
|
|
193
|
+
for (const moduleValue of targets) {
|
|
194
|
+
if (typeof moduleValue.onCatch !== "function") {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await moduleValue.onCatch(options.context);
|
|
200
|
+
} catch (hookError) {
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.warn("[rbssr] route onCatch hook failed", Bun.inspect(hookError));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function toUncaughtErrorPayload(
|
|
208
|
+
error: unknown,
|
|
209
|
+
production: boolean,
|
|
210
|
+
): { message: string } {
|
|
211
|
+
return {
|
|
212
|
+
message: sanitizeErrorMessage(error, production),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function toCaughtErrorPayload(
|
|
217
|
+
routeErrorResponse: RouteErrorResponse,
|
|
218
|
+
production: boolean,
|
|
219
|
+
): RouteErrorResponse {
|
|
220
|
+
return sanitizeRouteErrorResponse(routeErrorResponse, production);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toHtmlStreamResponse(stream: ReadableStream<Uint8Array>, status: number): Response {
|
|
224
|
+
return new Response(stream, {
|
|
225
|
+
status,
|
|
226
|
+
headers: {
|
|
227
|
+
"content-type": "text/html; charset=utf-8",
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function applyFrameworkDefaultHeaders(options: {
|
|
233
|
+
headers: Headers;
|
|
234
|
+
dev: boolean;
|
|
235
|
+
kind: ResponseKind;
|
|
236
|
+
pathname: string;
|
|
237
|
+
}): void {
|
|
238
|
+
const { headers, dev, kind, pathname } = options;
|
|
239
|
+
|
|
240
|
+
if (kind === "internal-dev" || kind === "internal-transition" || kind === "internal-action") {
|
|
241
|
+
if (!headers.has("cache-control")) {
|
|
242
|
+
headers.set("cache-control", "no-store");
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (dev) {
|
|
248
|
+
if (kind === "static") {
|
|
249
|
+
headers.set("cache-control", "no-store");
|
|
250
|
+
headers.set("pragma", "no-cache");
|
|
251
|
+
headers.set("expires", "0");
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (kind === "static" && !headers.has("cache-control")) {
|
|
257
|
+
headers.set(
|
|
258
|
+
"cache-control",
|
|
259
|
+
HASHED_CLIENT_CHUNK_RE.test(pathname) ? STATIC_IMMUTABLE_CACHE : STATIC_DEFAULT_CACHE,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function applyConfiguredHeaders(options: {
|
|
265
|
+
headers: Headers;
|
|
266
|
+
pathname: string;
|
|
267
|
+
config: ResolvedConfig;
|
|
268
|
+
}): void {
|
|
269
|
+
const { headers, pathname, config } = options;
|
|
270
|
+
for (const rule of config.headerRules) {
|
|
271
|
+
if (!rule.matcher.test(pathname)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [name, value] of Object.entries(rule.headers)) {
|
|
276
|
+
if (value === null) {
|
|
277
|
+
headers.delete(name);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
headers.set(name, value);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function finalizeResponseHeaders(options: {
|
|
287
|
+
response: Response;
|
|
288
|
+
request: Request;
|
|
289
|
+
pathname: string;
|
|
290
|
+
kind: ResponseKind;
|
|
291
|
+
dev: boolean;
|
|
292
|
+
config: ResolvedConfig;
|
|
293
|
+
}): Response {
|
|
294
|
+
const headers = new Headers(options.response.headers);
|
|
295
|
+
|
|
296
|
+
applyFrameworkDefaultHeaders({
|
|
297
|
+
headers,
|
|
298
|
+
dev: options.dev,
|
|
299
|
+
kind: options.kind,
|
|
300
|
+
pathname: options.pathname,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
applyConfiguredHeaders({
|
|
304
|
+
headers,
|
|
305
|
+
pathname: options.pathname,
|
|
306
|
+
config: options.config,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return new Response(options.request.method.toUpperCase() === "HEAD" ? null : options.response.body, {
|
|
310
|
+
status: options.response.status,
|
|
311
|
+
statusText: options.response.statusText,
|
|
312
|
+
headers,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function tryServeStatic(baseDir: string, pathname: string): Promise<Response | null> {
|
|
317
|
+
if (!pathname || pathname === "/") {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const decodedPath = decodeURIComponent(pathname);
|
|
322
|
+
const relativePath = decodedPath.replace(/^\/+/, "");
|
|
323
|
+
|
|
324
|
+
if (!relativePath) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const resolved = ensureWithin(baseDir, relativePath);
|
|
329
|
+
if (!resolved) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const stat = await statPath(resolved);
|
|
334
|
+
if (!stat?.isFile()) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return new Response(Bun.file(resolved));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function getMethodHandler(moduleValue: Record<string, unknown>, method: string): unknown {
|
|
342
|
+
const upper = method.toUpperCase();
|
|
343
|
+
if (upper === "HEAD" && typeof moduleValue.HEAD !== "function" && typeof moduleValue.GET === "function") {
|
|
344
|
+
return moduleValue.GET;
|
|
345
|
+
}
|
|
346
|
+
return moduleValue[upper];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function getAllowedMethods(moduleValue: Record<string, unknown>): string[] {
|
|
350
|
+
return ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].filter(method => {
|
|
351
|
+
return typeof moduleValue[method] === "function";
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function parseActionBody(request: Request): Promise<Pick<ActionContext, "formData" | "json">> {
|
|
356
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
357
|
+
|
|
358
|
+
if (contentType.includes("application/json")) {
|
|
359
|
+
try {
|
|
360
|
+
return { json: await request.json() };
|
|
361
|
+
} catch {
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
contentType.includes("multipart/form-data")
|
|
368
|
+
|| contentType.includes("application/x-www-form-urlencoded")
|
|
369
|
+
) {
|
|
370
|
+
try {
|
|
371
|
+
return { formData: await request.formData() };
|
|
372
|
+
} catch {
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function createRequestContext(options: {
|
|
381
|
+
request: Request;
|
|
382
|
+
url: URL;
|
|
383
|
+
params: RequestContext["params"];
|
|
384
|
+
}): RequestContext {
|
|
385
|
+
const cookies = parseCookieHeader(options.request.headers.get("cookie"));
|
|
386
|
+
return {
|
|
387
|
+
request: options.request,
|
|
388
|
+
url: options.url,
|
|
389
|
+
params: options.params,
|
|
390
|
+
cookies,
|
|
391
|
+
locals: {},
|
|
392
|
+
response: createResponseContext(cookies),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function loadRootOnlyModule(
|
|
397
|
+
rootModulePath: string,
|
|
398
|
+
options: RouteModuleLoadOptions,
|
|
399
|
+
): Promise<RouteModule> {
|
|
400
|
+
return loadRouteModule(rootModulePath, options);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function resolveAllRouteAssets(options: {
|
|
404
|
+
dev: boolean;
|
|
405
|
+
runtimeOptions: ServerRuntimeOptions;
|
|
406
|
+
}): Record<string, BuildRouteAsset> {
|
|
407
|
+
if (options.dev) {
|
|
408
|
+
return options.runtimeOptions.getDevAssets?.() ?? options.runtimeOptions.devAssets ?? {};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return options.runtimeOptions.buildManifest?.routes ?? {};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function toClientRouteSnapshots(routes: PageRouteDefinition[]): ClientRouteSnapshot[] {
|
|
415
|
+
return sortRoutesBySpecificity([...routes]).map(route => ({
|
|
416
|
+
id: route.id,
|
|
417
|
+
routePath: route.routePath,
|
|
418
|
+
segments: route.segments,
|
|
419
|
+
score: route.score,
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function createRouterSnapshot(options: {
|
|
424
|
+
manifest: RouteManifest;
|
|
425
|
+
routeAssets: Record<string, BuildRouteAsset>;
|
|
426
|
+
devVersion?: number;
|
|
427
|
+
}): ClientRouterSnapshot {
|
|
428
|
+
return {
|
|
429
|
+
pages: toClientRouteSnapshots(options.manifest.pages),
|
|
430
|
+
assets: options.routeAssets,
|
|
431
|
+
devVersion: options.devVersion,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function resolveFallbackHtmlAssets(options: {
|
|
436
|
+
routeAssets: Record<string, BuildRouteAsset>;
|
|
437
|
+
devVersion?: number;
|
|
438
|
+
}): HydrationDocumentAssets {
|
|
439
|
+
const css = Array.from(
|
|
440
|
+
new Set(
|
|
441
|
+
Object.values(options.routeAssets).flatMap(asset => asset.css),
|
|
442
|
+
),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
script: undefined,
|
|
447
|
+
css,
|
|
448
|
+
devVersion: options.devVersion,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function toTransitionStreamResponse(
|
|
453
|
+
stream: ReadableStream<Uint8Array>,
|
|
454
|
+
baseHeaders?: HeadersInit,
|
|
455
|
+
): Response {
|
|
456
|
+
const headers = new Headers(baseHeaders);
|
|
457
|
+
headers.set("content-type", "application/x-ndjson; charset=utf-8");
|
|
458
|
+
if (!headers.has("cache-control")) {
|
|
459
|
+
headers.set("cache-control", "no-store");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return new Response(stream, {
|
|
463
|
+
headers,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function toTransitionChunkLine(chunk: TransitionChunk): Uint8Array {
|
|
468
|
+
return new TextEncoder().encode(`${JSON.stringify(chunk)}\n`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isRedirectStatus(status: number): boolean {
|
|
472
|
+
return status >= 300 && status < 400;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function toRedirectChunk(location: string, status: number): TransitionRedirectChunk {
|
|
476
|
+
return {
|
|
477
|
+
type: "redirect",
|
|
478
|
+
location,
|
|
479
|
+
status,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function toDocumentChunk(location: string, status: number): TransitionDocumentChunk {
|
|
484
|
+
return {
|
|
485
|
+
type: "document",
|
|
486
|
+
location,
|
|
487
|
+
status,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function createTransitionStream(options: {
|
|
492
|
+
initialChunk?: TransitionInitialChunk;
|
|
493
|
+
controlChunk?: TransitionRedirectChunk | TransitionDocumentChunk;
|
|
494
|
+
deferredSettleEntries?: DeferredSettleEntry[];
|
|
495
|
+
sanitizeDeferredError: (message: string) => string;
|
|
496
|
+
}): ReadableStream<Uint8Array> {
|
|
497
|
+
return new ReadableStream<Uint8Array>({
|
|
498
|
+
async start(controller) {
|
|
499
|
+
try {
|
|
500
|
+
if (options.controlChunk) {
|
|
501
|
+
controller.enqueue(toTransitionChunkLine(options.controlChunk));
|
|
502
|
+
controller.close();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (options.initialChunk) {
|
|
507
|
+
controller.enqueue(toTransitionChunkLine(options.initialChunk));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const settleEntries = options.deferredSettleEntries ?? [];
|
|
511
|
+
if (settleEntries.length === 0) {
|
|
512
|
+
controller.close();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
await Promise.all(
|
|
517
|
+
settleEntries.map(async entry => {
|
|
518
|
+
const settled = await entry.settled;
|
|
519
|
+
const chunk: TransitionDeferredChunk = settled.ok
|
|
520
|
+
? {
|
|
521
|
+
type: "deferred",
|
|
522
|
+
id: entry.id,
|
|
523
|
+
ok: true,
|
|
524
|
+
value: settled.value,
|
|
525
|
+
}
|
|
526
|
+
: {
|
|
527
|
+
type: "deferred",
|
|
528
|
+
id: entry.id,
|
|
529
|
+
ok: false,
|
|
530
|
+
error: options.sanitizeDeferredError(settled.error),
|
|
531
|
+
};
|
|
532
|
+
controller.enqueue(toTransitionChunkLine(chunk));
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
controller.close();
|
|
537
|
+
} catch (error) {
|
|
538
|
+
controller.error(error);
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function toActionEnvelopeResponse(
|
|
545
|
+
envelope: ActionResponseEnvelope,
|
|
546
|
+
options: {
|
|
547
|
+
status?: number;
|
|
548
|
+
headers?: HeadersInit;
|
|
549
|
+
} = {},
|
|
550
|
+
): Response {
|
|
551
|
+
const headers = new Headers(options.headers);
|
|
552
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
553
|
+
|
|
554
|
+
return new Response(JSON.stringify(envelope), {
|
|
555
|
+
status: options.status ?? envelope.status,
|
|
556
|
+
headers,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function readActionResponsePayload(response: Response): Promise<unknown> {
|
|
561
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
562
|
+
if (contentType.includes("application/json")) {
|
|
563
|
+
try {
|
|
564
|
+
return await response.json();
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
return await response.text();
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function resolveResponseRedirect(response: Response): { location: string; status: number } | null {
|
|
578
|
+
const location = response.headers.get("location");
|
|
579
|
+
if (!location || !isRedirectStatus(response.status)) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
location,
|
|
585
|
+
status: response.status,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isActionResponseEnvelopePayload(value: unknown): value is ActionResponseEnvelope {
|
|
590
|
+
if (!value || typeof value !== "object") {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const candidate = value as {
|
|
595
|
+
type?: unknown;
|
|
596
|
+
status?: unknown;
|
|
597
|
+
};
|
|
598
|
+
return typeof candidate.type === "string" && typeof candidate.status === "number";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function createDefaultGetRouteAdapter(options: {
|
|
602
|
+
baseConfig: ResolvedConfig;
|
|
603
|
+
runtimeOptions: ServerRuntimeOptions;
|
|
604
|
+
dev: boolean;
|
|
605
|
+
}): RequestExecutorDeps["getRouteAdapter"] {
|
|
606
|
+
const { baseConfig, runtimeOptions, dev } = options;
|
|
607
|
+
const adapterCache = new Map<string, BunRouteAdapter>();
|
|
608
|
+
const pendingAdapterCache = new Map<string, Promise<BunRouteAdapter>>();
|
|
609
|
+
|
|
610
|
+
const getAdapterKey = (activeConfig: ResolvedConfig): string => {
|
|
611
|
+
const routeVersion = dev
|
|
612
|
+
? runtimeOptions.routeManifestVersion?.() ?? runtimeOptions.reloadVersion?.() ?? 0
|
|
613
|
+
: 0;
|
|
614
|
+
return `${normalizeSlashes(activeConfig.routesDir)}|${dev ? "dev" : "prod"}|${routeVersion}`;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const trimAdapterCache = (): void => {
|
|
618
|
+
if (!dev || adapterCache.size <= 3) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const keys = [...adapterCache.keys()];
|
|
623
|
+
while (keys.length > 3) {
|
|
624
|
+
const oldestKey = keys.shift();
|
|
625
|
+
if (!oldestKey) {
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
adapterCache.delete(oldestKey);
|
|
629
|
+
pendingAdapterCache.delete(oldestKey);
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
return async (activeConfig: ResolvedConfig): Promise<BunRouteAdapter> => {
|
|
634
|
+
const cacheKey = getAdapterKey(activeConfig);
|
|
635
|
+
const cached = adapterCache.get(cacheKey);
|
|
636
|
+
if (cached) {
|
|
637
|
+
return cached;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const pending = pendingAdapterCache.get(cacheKey);
|
|
641
|
+
if (pending) {
|
|
642
|
+
return pending;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const routesHash = stableHash(normalizeSlashes(activeConfig.routesDir));
|
|
646
|
+
const projectionRootDir = dev
|
|
647
|
+
? path.resolve(baseConfig.cwd, ".rbssr/generated/router-projection", `dev-${routesHash}`)
|
|
648
|
+
: path.resolve(baseConfig.cwd, ".rbssr/generated/router-projection", "prod", routesHash);
|
|
649
|
+
|
|
650
|
+
const buildAdapterPromise = createBunRouteAdapter({
|
|
651
|
+
routesDir: activeConfig.routesDir,
|
|
652
|
+
generatedMarkdownRootDir: path.resolve(baseConfig.cwd, ".rbssr/generated/markdown-routes"),
|
|
653
|
+
projectionRootDir,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
pendingAdapterCache.set(cacheKey, buildAdapterPromise);
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
const adapter = await buildAdapterPromise;
|
|
660
|
+
adapterCache.set(cacheKey, adapter);
|
|
661
|
+
trimAdapterCache();
|
|
662
|
+
return adapter;
|
|
663
|
+
} finally {
|
|
664
|
+
pendingAdapterCache.delete(cacheKey);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function createDefaultRequestExecutorDeps(options: {
|
|
670
|
+
baseConfig: ResolvedConfig;
|
|
671
|
+
runtimeOptions: ServerRuntimeOptions;
|
|
672
|
+
dev: boolean;
|
|
673
|
+
}): RequestExecutorDeps {
|
|
674
|
+
return {
|
|
675
|
+
getRouteAdapter: createDefaultGetRouteAdapter(options),
|
|
676
|
+
loadRouteBundle: loadRouteModules,
|
|
677
|
+
loadApiModule: loadApiRouteModule,
|
|
678
|
+
loadGlobalMiddleware,
|
|
679
|
+
loadNestedMiddleware,
|
|
680
|
+
renderDocumentStream: defaultRenderDocumentStream,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function createRequestExecutor(options: {
|
|
685
|
+
config: ResolvedConfig;
|
|
686
|
+
runtimeOptions: ServerRuntimeOptions;
|
|
687
|
+
deps?: Partial<RequestExecutorDeps>;
|
|
688
|
+
}): RequestExecutor {
|
|
689
|
+
const resolvedConfig = options.config;
|
|
690
|
+
const runtimeOptions = options.runtimeOptions;
|
|
691
|
+
const dev = runtimeOptions.dev ?? resolvedConfig.mode !== "production";
|
|
692
|
+
const deps: RequestExecutorDeps = {
|
|
693
|
+
...createDefaultRequestExecutorDeps({
|
|
694
|
+
baseConfig: resolvedConfig,
|
|
695
|
+
runtimeOptions,
|
|
696
|
+
dev,
|
|
697
|
+
}),
|
|
698
|
+
...options.deps,
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const fetchHandler = async (request: Request): Promise<Response> => {
|
|
702
|
+
const runtimePaths = runtimeOptions.resolvePaths?.() ?? {};
|
|
703
|
+
const activeConfig: ResolvedConfig = {
|
|
704
|
+
...resolvedConfig,
|
|
705
|
+
...runtimePaths,
|
|
706
|
+
};
|
|
707
|
+
const devClientDir = path.resolve(resolvedConfig.cwd, ".rbssr/dev/client");
|
|
708
|
+
|
|
709
|
+
const url = new URL(request.url);
|
|
710
|
+
const finalize = (
|
|
711
|
+
response: Response,
|
|
712
|
+
kind: ResponseKind,
|
|
713
|
+
requestContext?: RequestContext,
|
|
714
|
+
): Response => {
|
|
715
|
+
const finalResponse = requestContext
|
|
716
|
+
? applyResponseContext(response, requestContext.response)
|
|
717
|
+
: response;
|
|
718
|
+
return finalizeResponseHeaders({
|
|
719
|
+
response: finalResponse,
|
|
720
|
+
request,
|
|
721
|
+
pathname: url.pathname,
|
|
722
|
+
kind,
|
|
723
|
+
dev,
|
|
724
|
+
config: activeConfig,
|
|
725
|
+
});
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
if (dev && url.pathname === "/__rbssr/version") {
|
|
729
|
+
const version = runtimeOptions.reloadVersion?.() ?? 0;
|
|
730
|
+
return finalize(new Response(String(version), {
|
|
731
|
+
headers: {
|
|
732
|
+
"content-type": "text/plain; charset=utf-8",
|
|
733
|
+
"cache-control": "no-store",
|
|
734
|
+
},
|
|
735
|
+
}), "internal-dev");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (dev && url.pathname.startsWith("/__rbssr/client/")) {
|
|
739
|
+
const relative = url.pathname.replace(/^\/__rbssr\/client\//, "");
|
|
740
|
+
const response = await tryServeStatic(devClientDir, relative);
|
|
741
|
+
if (response) {
|
|
742
|
+
return finalize(response, "static");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!dev && url.pathname.startsWith("/client/")) {
|
|
747
|
+
const relative = url.pathname.replace(/^\/client\//, "");
|
|
748
|
+
const response = await tryServeStatic(path.join(activeConfig.distDir, "client"), relative);
|
|
749
|
+
if (response) {
|
|
750
|
+
return finalize(response, "static");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!dev) {
|
|
755
|
+
const builtPublicResponse = await tryServeStatic(path.join(activeConfig.distDir, "client"), url.pathname);
|
|
756
|
+
if (builtPublicResponse) {
|
|
757
|
+
return finalize(builtPublicResponse, "static");
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const publicResponse = await tryServeStatic(activeConfig.publicDir, url.pathname);
|
|
762
|
+
if (publicResponse) {
|
|
763
|
+
return finalize(publicResponse, "static");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
await runtimeOptions.onBeforeRequest?.();
|
|
767
|
+
|
|
768
|
+
const routeAdapter = await deps.getRouteAdapter(activeConfig);
|
|
769
|
+
const devCacheBustKey = dev ? String(runtimeOptions.reloadVersion?.() ?? 0) : undefined;
|
|
770
|
+
const nodeEnv: "development" | "production" = dev ? "development" : "production";
|
|
771
|
+
const routeModuleLoadOptions: RouteModuleLoadOptions = {
|
|
772
|
+
cacheBustKey: devCacheBustKey,
|
|
773
|
+
serverBytecode: activeConfig.serverBytecode,
|
|
774
|
+
devSourceImports: false,
|
|
775
|
+
nodeEnv,
|
|
776
|
+
};
|
|
777
|
+
const requestModuleLoadOptions: RouteModuleLoadOptions = {
|
|
778
|
+
cacheBustKey: devCacheBustKey,
|
|
779
|
+
serverBytecode: activeConfig.serverBytecode,
|
|
780
|
+
devSourceImports: dev,
|
|
781
|
+
nodeEnv,
|
|
782
|
+
};
|
|
783
|
+
const routeAssetsById = resolveAllRouteAssets({
|
|
784
|
+
dev,
|
|
785
|
+
runtimeOptions,
|
|
786
|
+
});
|
|
787
|
+
const routerSnapshot = createRouterSnapshot({
|
|
788
|
+
manifest: routeAdapter.manifest,
|
|
789
|
+
routeAssets: routeAssetsById,
|
|
790
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
791
|
+
});
|
|
792
|
+
const fallbackHtmlAssets = resolveFallbackHtmlAssets({
|
|
793
|
+
routeAssets: routeAssetsById,
|
|
794
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
if (request.method.toUpperCase() === "POST" && url.pathname === "/__rbssr/action") {
|
|
798
|
+
const toParam = url.searchParams.get("to");
|
|
799
|
+
if (!toParam) {
|
|
800
|
+
return finalize(
|
|
801
|
+
toActionEnvelopeResponse({
|
|
802
|
+
type: "error",
|
|
803
|
+
status: 400,
|
|
804
|
+
message: "Missing required `to` query parameter.",
|
|
805
|
+
}, { status: 400 }),
|
|
806
|
+
"internal-action",
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let targetUrl: URL;
|
|
811
|
+
try {
|
|
812
|
+
targetUrl = new URL(toParam, url);
|
|
813
|
+
} catch {
|
|
814
|
+
return finalize(
|
|
815
|
+
toActionEnvelopeResponse({
|
|
816
|
+
type: "error",
|
|
817
|
+
status: 400,
|
|
818
|
+
message: "Invalid `to` URL.",
|
|
819
|
+
}, { status: 400 }),
|
|
820
|
+
"internal-action",
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (targetUrl.origin !== url.origin) {
|
|
825
|
+
return finalize(
|
|
826
|
+
toActionEnvelopeResponse({
|
|
827
|
+
type: "error",
|
|
828
|
+
status: 400,
|
|
829
|
+
message: "Cross-origin action targets are not allowed.",
|
|
830
|
+
}, { status: 400 }),
|
|
831
|
+
"internal-action",
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const actionPageMatch = routeAdapter.matchPage(targetUrl.pathname);
|
|
836
|
+
if (!actionPageMatch) {
|
|
837
|
+
return finalize(
|
|
838
|
+
toActionEnvelopeResponse({
|
|
839
|
+
type: "error",
|
|
840
|
+
status: 404,
|
|
841
|
+
message: "No page route matched the action target.",
|
|
842
|
+
}, { status: 404 }),
|
|
843
|
+
"internal-action",
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const [routeModules, globalMiddleware, nestedMiddleware, actionBody] = await Promise.all([
|
|
848
|
+
deps.loadRouteBundle({
|
|
849
|
+
rootFilePath: activeConfig.rootModule,
|
|
850
|
+
layoutFiles: actionPageMatch.route.layoutFiles,
|
|
851
|
+
routeFilePath: actionPageMatch.route.filePath,
|
|
852
|
+
routeServerFilePath: actionPageMatch.route.serverFilePath,
|
|
853
|
+
...routeModuleLoadOptions,
|
|
854
|
+
}),
|
|
855
|
+
deps.loadGlobalMiddleware(activeConfig.middlewareFile, requestModuleLoadOptions),
|
|
856
|
+
deps.loadNestedMiddleware(actionPageMatch.route.middlewareFiles, requestModuleLoadOptions),
|
|
857
|
+
parseActionBody(request.clone()),
|
|
858
|
+
]);
|
|
859
|
+
|
|
860
|
+
const moduleMiddleware = extractRouteMiddleware(routeModules.route);
|
|
861
|
+
const actionRequest = new Request(targetUrl.toString(), {
|
|
862
|
+
method: request.method,
|
|
863
|
+
headers: request.headers,
|
|
864
|
+
});
|
|
865
|
+
const requestContext = createRequestContext({
|
|
866
|
+
request: actionRequest,
|
|
867
|
+
url: targetUrl,
|
|
868
|
+
params: actionPageMatch.params,
|
|
869
|
+
});
|
|
870
|
+
const contextBase = toRouteErrorContextBase({
|
|
871
|
+
requestContext,
|
|
872
|
+
routeId: actionPageMatch.route.id,
|
|
873
|
+
phase: "action",
|
|
874
|
+
dev,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
try {
|
|
878
|
+
const middlewareResponse = await runMiddlewareChain(
|
|
879
|
+
[...globalMiddleware, ...nestedMiddleware, ...moduleMiddleware],
|
|
880
|
+
requestContext,
|
|
881
|
+
async () => {
|
|
882
|
+
if (!routeModules.route.action) {
|
|
883
|
+
return toActionEnvelopeResponse({
|
|
884
|
+
type: "error",
|
|
885
|
+
status: 405,
|
|
886
|
+
message:
|
|
887
|
+
"Method Not Allowed: route has no server action export. "
|
|
888
|
+
+ "Define a server action (for example in a *.server.tsx companion) "
|
|
889
|
+
+ "and call it from useActionState(action, initialState) with createRouteAction().",
|
|
890
|
+
}, { status: 405 });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const actionCtx: ActionContext = {
|
|
894
|
+
...requestContext,
|
|
895
|
+
...actionBody,
|
|
896
|
+
};
|
|
897
|
+
const actionResult = await routeModules.route.action(actionCtx);
|
|
898
|
+
|
|
899
|
+
if (isDeferredLoaderResult(actionResult)) {
|
|
900
|
+
return toActionEnvelopeResponse({
|
|
901
|
+
type: "error",
|
|
902
|
+
status: 500,
|
|
903
|
+
message: "defer() is only supported in route loaders.",
|
|
904
|
+
}, { status: 500 });
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (isRedirectResult(actionResult)) {
|
|
908
|
+
return toActionEnvelopeResponse({
|
|
909
|
+
type: "redirect",
|
|
910
|
+
status: actionResult.status ?? 302,
|
|
911
|
+
location: actionResult.location,
|
|
912
|
+
}, { status: 200 });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (isResponse(actionResult)) {
|
|
916
|
+
const responseRedirect = resolveResponseRedirect(actionResult);
|
|
917
|
+
if (responseRedirect) {
|
|
918
|
+
return toActionEnvelopeResponse({
|
|
919
|
+
type: "redirect",
|
|
920
|
+
status: responseRedirect.status,
|
|
921
|
+
location: responseRedirect.location,
|
|
922
|
+
}, {
|
|
923
|
+
headers: actionResult.headers,
|
|
924
|
+
status: 200,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const data = await readActionResponsePayload(actionResult.clone());
|
|
929
|
+
return toActionEnvelopeResponse({
|
|
930
|
+
type: "data",
|
|
931
|
+
status: actionResult.status,
|
|
932
|
+
data,
|
|
933
|
+
}, {
|
|
934
|
+
headers: actionResult.headers,
|
|
935
|
+
status: 200,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return toActionEnvelopeResponse({
|
|
940
|
+
type: "data",
|
|
941
|
+
status: 200,
|
|
942
|
+
data: actionResult,
|
|
943
|
+
}, { status: 200 });
|
|
944
|
+
},
|
|
945
|
+
);
|
|
946
|
+
const middlewareRedirect = resolveResponseRedirect(middlewareResponse);
|
|
947
|
+
if (middlewareRedirect) {
|
|
948
|
+
return finalize(
|
|
949
|
+
toActionEnvelopeResponse({
|
|
950
|
+
type: "redirect",
|
|
951
|
+
status: middlewareRedirect.status,
|
|
952
|
+
location: middlewareRedirect.location,
|
|
953
|
+
}, {
|
|
954
|
+
headers: middlewareResponse.headers,
|
|
955
|
+
status: 200,
|
|
956
|
+
}),
|
|
957
|
+
"internal-action",
|
|
958
|
+
requestContext,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const parsedPayload = await readActionResponsePayload(middlewareResponse.clone());
|
|
963
|
+
if (isActionResponseEnvelopePayload(parsedPayload)) {
|
|
964
|
+
return finalize(middlewareResponse, "internal-action", requestContext);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (middlewareResponse.status >= 400) {
|
|
968
|
+
const message = typeof parsedPayload === "string"
|
|
969
|
+
? parsedPayload
|
|
970
|
+
: sanitizeErrorMessage(parsedPayload, !dev);
|
|
971
|
+
return finalize(
|
|
972
|
+
toActionEnvelopeResponse({
|
|
973
|
+
type: "error",
|
|
974
|
+
status: middlewareResponse.status,
|
|
975
|
+
message,
|
|
976
|
+
}, {
|
|
977
|
+
headers: middlewareResponse.headers,
|
|
978
|
+
status: 200,
|
|
979
|
+
}),
|
|
980
|
+
"internal-action",
|
|
981
|
+
requestContext,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return finalize(
|
|
986
|
+
toActionEnvelopeResponse({
|
|
987
|
+
type: "data",
|
|
988
|
+
status: middlewareResponse.status,
|
|
989
|
+
data: parsedPayload,
|
|
990
|
+
}, {
|
|
991
|
+
headers: middlewareResponse.headers,
|
|
992
|
+
status: 200,
|
|
993
|
+
}),
|
|
994
|
+
"internal-action",
|
|
995
|
+
requestContext,
|
|
996
|
+
);
|
|
997
|
+
} catch (error) {
|
|
998
|
+
const redirectResponse = resolveThrownRedirect(error);
|
|
999
|
+
if (redirectResponse) {
|
|
1000
|
+
const location = redirectResponse.headers.get("location");
|
|
1001
|
+
if (location) {
|
|
1002
|
+
return finalize(
|
|
1003
|
+
toActionEnvelopeResponse({
|
|
1004
|
+
type: "redirect",
|
|
1005
|
+
status: redirectResponse.status,
|
|
1006
|
+
location,
|
|
1007
|
+
}, {
|
|
1008
|
+
headers: redirectResponse.headers,
|
|
1009
|
+
status: 200,
|
|
1010
|
+
}),
|
|
1011
|
+
"internal-action",
|
|
1012
|
+
requestContext,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const caught = toRouteErrorResponse(error);
|
|
1018
|
+
if (caught) {
|
|
1019
|
+
await notifyCatchHooks({
|
|
1020
|
+
modules: routeModules,
|
|
1021
|
+
context: {
|
|
1022
|
+
...contextBase,
|
|
1023
|
+
error: caught,
|
|
1024
|
+
},
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
return finalize(
|
|
1028
|
+
toActionEnvelopeResponse({
|
|
1029
|
+
type: "catch",
|
|
1030
|
+
status: caught.status,
|
|
1031
|
+
error: toCaughtErrorPayload(caught, !dev),
|
|
1032
|
+
}, { status: 200 }),
|
|
1033
|
+
"internal-action",
|
|
1034
|
+
requestContext,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
await notifyErrorHooks({
|
|
1039
|
+
modules: routeModules,
|
|
1040
|
+
context: {
|
|
1041
|
+
...contextBase,
|
|
1042
|
+
error,
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
return finalize(
|
|
1047
|
+
toActionEnvelopeResponse({
|
|
1048
|
+
type: "error",
|
|
1049
|
+
status: 500,
|
|
1050
|
+
message: sanitizeErrorMessage(error, !dev),
|
|
1051
|
+
}, { status: 200 }),
|
|
1052
|
+
"internal-action",
|
|
1053
|
+
requestContext,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (request.method.toUpperCase() === "GET" && url.pathname === "/__rbssr/transition") {
|
|
1059
|
+
const toParam = url.searchParams.get("to");
|
|
1060
|
+
if (!toParam) {
|
|
1061
|
+
return finalize(
|
|
1062
|
+
new Response("Missing required `to` query parameter.", { status: 400 }),
|
|
1063
|
+
"internal-transition",
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
let targetUrl: URL;
|
|
1068
|
+
try {
|
|
1069
|
+
targetUrl = new URL(toParam, url);
|
|
1070
|
+
} catch {
|
|
1071
|
+
return finalize(new Response("Invalid `to` URL.", { status: 400 }), "internal-transition");
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (targetUrl.origin !== url.origin) {
|
|
1075
|
+
return finalize(
|
|
1076
|
+
new Response("Cross-origin transitions are not allowed.", { status: 400 }),
|
|
1077
|
+
"internal-transition",
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const transitionPageMatch = routeAdapter.matchPage(targetUrl.pathname);
|
|
1082
|
+
if (!transitionPageMatch) {
|
|
1083
|
+
const rootModule = await loadRootOnlyModule(activeConfig.rootModule, {
|
|
1084
|
+
...routeModuleLoadOptions,
|
|
1085
|
+
});
|
|
1086
|
+
const fallbackRoute: RouteModule = {
|
|
1087
|
+
default: () => null,
|
|
1088
|
+
NotFound: rootModule.NotFound,
|
|
1089
|
+
};
|
|
1090
|
+
const payload = {
|
|
1091
|
+
routeId: "__not_found__",
|
|
1092
|
+
loaderData: null,
|
|
1093
|
+
params: {},
|
|
1094
|
+
url: targetUrl.toString(),
|
|
1095
|
+
};
|
|
1096
|
+
const modules = {
|
|
1097
|
+
root: rootModule,
|
|
1098
|
+
layouts: [],
|
|
1099
|
+
route: fallbackRoute,
|
|
1100
|
+
};
|
|
1101
|
+
const initialChunk: TransitionInitialChunk = {
|
|
1102
|
+
type: "initial",
|
|
1103
|
+
kind: "not_found",
|
|
1104
|
+
status: 404,
|
|
1105
|
+
payload,
|
|
1106
|
+
head: createManagedHeadMarkup({
|
|
1107
|
+
headMarkup: collectHeadMarkup(modules, payload),
|
|
1108
|
+
assets: fallbackHtmlAssets,
|
|
1109
|
+
}),
|
|
1110
|
+
redirected: false,
|
|
1111
|
+
};
|
|
1112
|
+
const stream = createTransitionStream({
|
|
1113
|
+
initialChunk,
|
|
1114
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1115
|
+
});
|
|
1116
|
+
return finalize(toTransitionStreamResponse(stream), "internal-transition");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const [routeModules, globalMiddleware, nestedMiddleware] = await Promise.all([
|
|
1120
|
+
deps.loadRouteBundle({
|
|
1121
|
+
rootFilePath: activeConfig.rootModule,
|
|
1122
|
+
layoutFiles: transitionPageMatch.route.layoutFiles,
|
|
1123
|
+
routeFilePath: transitionPageMatch.route.filePath,
|
|
1124
|
+
routeServerFilePath: transitionPageMatch.route.serverFilePath,
|
|
1125
|
+
...routeModuleLoadOptions,
|
|
1126
|
+
}),
|
|
1127
|
+
deps.loadGlobalMiddleware(activeConfig.middlewareFile, requestModuleLoadOptions),
|
|
1128
|
+
deps.loadNestedMiddleware(transitionPageMatch.route.middlewareFiles, requestModuleLoadOptions),
|
|
1129
|
+
]);
|
|
1130
|
+
const moduleMiddleware = extractRouteMiddleware(routeModules.route);
|
|
1131
|
+
const routeAssets = routeAssetsById[transitionPageMatch.route.id] ?? null;
|
|
1132
|
+
const transitionRequest = new Request(targetUrl.toString(), {
|
|
1133
|
+
method: "GET",
|
|
1134
|
+
headers: request.headers,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const requestContext = createRequestContext({
|
|
1138
|
+
request: transitionRequest,
|
|
1139
|
+
url: targetUrl,
|
|
1140
|
+
params: transitionPageMatch.params,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
let transitionInitialChunk: TransitionInitialChunk | undefined;
|
|
1144
|
+
let deferredSettleEntries: DeferredSettleEntry[] = [];
|
|
1145
|
+
let transitionPhase: RouteErrorPhase = "middleware";
|
|
1146
|
+
|
|
1147
|
+
let middlewareResponse: Response;
|
|
1148
|
+
try {
|
|
1149
|
+
middlewareResponse = await runMiddlewareChain(
|
|
1150
|
+
[...globalMiddleware, ...nestedMiddleware, ...moduleMiddleware],
|
|
1151
|
+
requestContext,
|
|
1152
|
+
async () => {
|
|
1153
|
+
let loaderDataForRender: unknown = null;
|
|
1154
|
+
let loaderDataForPayload: unknown = null;
|
|
1155
|
+
|
|
1156
|
+
if (routeModules.route.loader) {
|
|
1157
|
+
transitionPhase = "loader";
|
|
1158
|
+
const loaderCtx: LoaderContext = requestContext;
|
|
1159
|
+
const loaderResult = await routeModules.route.loader(loaderCtx);
|
|
1160
|
+
|
|
1161
|
+
if (isResponse(loaderResult)) {
|
|
1162
|
+
return loaderResult;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (isRedirectResult(loaderResult)) {
|
|
1166
|
+
return toRedirectResponse(loaderResult.location, loaderResult.status);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (isDeferredLoaderResult(loaderResult)) {
|
|
1170
|
+
const prepared = prepareDeferredPayload(transitionPageMatch.route.id, loaderResult);
|
|
1171
|
+
loaderDataForRender = prepared.dataForRender;
|
|
1172
|
+
loaderDataForPayload = prepared.dataForPayload;
|
|
1173
|
+
deferredSettleEntries = prepared.settleEntries;
|
|
1174
|
+
} else {
|
|
1175
|
+
loaderDataForRender = loaderResult;
|
|
1176
|
+
loaderDataForPayload = loaderResult;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const renderPayload = {
|
|
1181
|
+
routeId: transitionPageMatch.route.id,
|
|
1182
|
+
loaderData: loaderDataForRender,
|
|
1183
|
+
params: transitionPageMatch.params,
|
|
1184
|
+
url: targetUrl.toString(),
|
|
1185
|
+
};
|
|
1186
|
+
const payload = {
|
|
1187
|
+
...renderPayload,
|
|
1188
|
+
loaderData: loaderDataForPayload,
|
|
1189
|
+
};
|
|
1190
|
+
transitionInitialChunk = {
|
|
1191
|
+
type: "initial",
|
|
1192
|
+
kind: "page",
|
|
1193
|
+
status: 200,
|
|
1194
|
+
payload,
|
|
1195
|
+
head: createManagedHeadMarkup({
|
|
1196
|
+
headMarkup: collectHeadMarkup(routeModules, renderPayload),
|
|
1197
|
+
assets: {
|
|
1198
|
+
script: routeAssets?.script,
|
|
1199
|
+
css: routeAssets?.css ?? [],
|
|
1200
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1201
|
+
},
|
|
1202
|
+
}),
|
|
1203
|
+
redirected: false,
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
return new Response(null, { status: 204 });
|
|
1207
|
+
},
|
|
1208
|
+
);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
const redirectResponse = resolveThrownRedirect(error);
|
|
1211
|
+
if (redirectResponse) {
|
|
1212
|
+
const location = redirectResponse.headers.get("location");
|
|
1213
|
+
if (location) {
|
|
1214
|
+
const stream = createTransitionStream({
|
|
1215
|
+
controlChunk: toRedirectChunk(location, redirectResponse.status),
|
|
1216
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1217
|
+
});
|
|
1218
|
+
return finalize(
|
|
1219
|
+
toTransitionStreamResponse(stream, redirectResponse.headers),
|
|
1220
|
+
"internal-transition",
|
|
1221
|
+
requestContext,
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const contextBase = toRouteErrorContextBase({
|
|
1227
|
+
requestContext,
|
|
1228
|
+
routeId: transitionPageMatch.route.id,
|
|
1229
|
+
phase: transitionPhase,
|
|
1230
|
+
dev,
|
|
1231
|
+
});
|
|
1232
|
+
const caught = toRouteErrorResponse(error);
|
|
1233
|
+
if (caught) {
|
|
1234
|
+
const payload = {
|
|
1235
|
+
routeId: transitionPageMatch.route.id,
|
|
1236
|
+
loaderData: null,
|
|
1237
|
+
params: transitionPageMatch.params,
|
|
1238
|
+
url: targetUrl.toString(),
|
|
1239
|
+
error: toCaughtErrorPayload(caught, !dev),
|
|
1240
|
+
};
|
|
1241
|
+
await notifyCatchHooks({
|
|
1242
|
+
modules: routeModules,
|
|
1243
|
+
context: {
|
|
1244
|
+
...contextBase,
|
|
1245
|
+
error: caught,
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
const initialChunk: TransitionInitialChunk = {
|
|
1249
|
+
type: "initial",
|
|
1250
|
+
kind: "catch",
|
|
1251
|
+
status: caught.status,
|
|
1252
|
+
payload,
|
|
1253
|
+
head: createManagedHeadMarkup({
|
|
1254
|
+
headMarkup: collectHeadMarkup(routeModules, payload),
|
|
1255
|
+
assets: {
|
|
1256
|
+
script: routeAssets?.script,
|
|
1257
|
+
css: routeAssets?.css ?? [],
|
|
1258
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1259
|
+
},
|
|
1260
|
+
}),
|
|
1261
|
+
redirected: false,
|
|
1262
|
+
};
|
|
1263
|
+
const stream = createTransitionStream({
|
|
1264
|
+
initialChunk,
|
|
1265
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1266
|
+
});
|
|
1267
|
+
return finalize(toTransitionStreamResponse(stream), "internal-transition", requestContext);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
await notifyErrorHooks({
|
|
1271
|
+
modules: routeModules,
|
|
1272
|
+
context: {
|
|
1273
|
+
...contextBase,
|
|
1274
|
+
error,
|
|
1275
|
+
},
|
|
1276
|
+
});
|
|
1277
|
+
const renderPayload = {
|
|
1278
|
+
routeId: transitionPageMatch.route.id,
|
|
1279
|
+
loaderData: null,
|
|
1280
|
+
params: transitionPageMatch.params,
|
|
1281
|
+
url: targetUrl.toString(),
|
|
1282
|
+
error: toUncaughtErrorPayload(error, !dev),
|
|
1283
|
+
};
|
|
1284
|
+
const initialChunk: TransitionInitialChunk = {
|
|
1285
|
+
type: "initial",
|
|
1286
|
+
kind: "error",
|
|
1287
|
+
status: 500,
|
|
1288
|
+
payload: renderPayload,
|
|
1289
|
+
head: createManagedHeadMarkup({
|
|
1290
|
+
headMarkup: collectHeadMarkup(routeModules, renderPayload),
|
|
1291
|
+
assets: {
|
|
1292
|
+
script: routeAssets?.script,
|
|
1293
|
+
css: routeAssets?.css ?? [],
|
|
1294
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1295
|
+
},
|
|
1296
|
+
}),
|
|
1297
|
+
redirected: false,
|
|
1298
|
+
};
|
|
1299
|
+
const stream = createTransitionStream({
|
|
1300
|
+
initialChunk,
|
|
1301
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1302
|
+
});
|
|
1303
|
+
return finalize(toTransitionStreamResponse(stream), "internal-transition", requestContext);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const redirectLocation = middlewareResponse.headers.get("location");
|
|
1307
|
+
if (redirectLocation && isRedirectStatus(middlewareResponse.status)) {
|
|
1308
|
+
const stream = createTransitionStream({
|
|
1309
|
+
controlChunk: toRedirectChunk(redirectLocation, middlewareResponse.status),
|
|
1310
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1311
|
+
});
|
|
1312
|
+
return finalize(
|
|
1313
|
+
toTransitionStreamResponse(stream, middlewareResponse.headers),
|
|
1314
|
+
"internal-transition",
|
|
1315
|
+
requestContext,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (!transitionInitialChunk) {
|
|
1320
|
+
const stream = createTransitionStream({
|
|
1321
|
+
controlChunk: toDocumentChunk(targetUrl.toString(), middlewareResponse.status),
|
|
1322
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1323
|
+
});
|
|
1324
|
+
return finalize(
|
|
1325
|
+
toTransitionStreamResponse(stream, middlewareResponse.headers),
|
|
1326
|
+
"internal-transition",
|
|
1327
|
+
requestContext,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const stream = createTransitionStream({
|
|
1332
|
+
initialChunk: transitionInitialChunk,
|
|
1333
|
+
deferredSettleEntries,
|
|
1334
|
+
sanitizeDeferredError: message => sanitizeErrorMessage(message, !dev),
|
|
1335
|
+
});
|
|
1336
|
+
return finalize(toTransitionStreamResponse(stream, middlewareResponse.headers), "internal-transition", requestContext);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const apiMatch = routeAdapter.matchApi(url.pathname);
|
|
1340
|
+
if (apiMatch) {
|
|
1341
|
+
const apiModule = await deps.loadApiModule(apiMatch.route.filePath, requestModuleLoadOptions);
|
|
1342
|
+
const methodHandler = getMethodHandler(apiModule as Record<string, unknown>, request.method);
|
|
1343
|
+
|
|
1344
|
+
if (typeof methodHandler !== "function") {
|
|
1345
|
+
const allow = getAllowedMethods(apiModule as Record<string, unknown>);
|
|
1346
|
+
return finalize(new Response("Method Not Allowed", {
|
|
1347
|
+
status: 405,
|
|
1348
|
+
headers: {
|
|
1349
|
+
allow: allow.join(", "),
|
|
1350
|
+
},
|
|
1351
|
+
}), "api");
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const requestContext = createRequestContext({
|
|
1355
|
+
request,
|
|
1356
|
+
url,
|
|
1357
|
+
params: apiMatch.params,
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
const [globalMiddleware, routeMiddleware] = await Promise.all([
|
|
1361
|
+
deps.loadGlobalMiddleware(activeConfig.middlewareFile, requestModuleLoadOptions),
|
|
1362
|
+
deps.loadNestedMiddleware(apiMatch.route.middlewareFiles, requestModuleLoadOptions),
|
|
1363
|
+
]);
|
|
1364
|
+
const allMiddleware = [...globalMiddleware, ...routeMiddleware];
|
|
1365
|
+
let apiPhase: RouteErrorPhase = "middleware";
|
|
1366
|
+
|
|
1367
|
+
let response: Response;
|
|
1368
|
+
try {
|
|
1369
|
+
response = await runMiddlewareChain(allMiddleware, requestContext, async () => {
|
|
1370
|
+
apiPhase = "api";
|
|
1371
|
+
const result = await (methodHandler as (ctx: RequestContext) => unknown)(requestContext);
|
|
1372
|
+
|
|
1373
|
+
if (isResponse(result)) {
|
|
1374
|
+
return result;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (isRedirectResult(result)) {
|
|
1378
|
+
return toRedirectResponse(result.location, result.status);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
return json(result);
|
|
1382
|
+
});
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
const redirectResponse = resolveThrownRedirect(error);
|
|
1385
|
+
if (redirectResponse) {
|
|
1386
|
+
return finalize(redirectResponse, "api", requestContext);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const caught = toRouteErrorResponse(error);
|
|
1390
|
+
if (caught) {
|
|
1391
|
+
return finalize(toRouteErrorHttpResponse(toCaughtErrorPayload(caught, !dev)), "api", requestContext);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const apiErrorHook = (apiModule as Record<string, unknown>).onError;
|
|
1395
|
+
if (typeof apiErrorHook === "function") {
|
|
1396
|
+
try {
|
|
1397
|
+
await (apiErrorHook as (ctx: RouteErrorContext) => void | Promise<void>)({
|
|
1398
|
+
error,
|
|
1399
|
+
request: requestContext.request,
|
|
1400
|
+
url: requestContext.url,
|
|
1401
|
+
params: requestContext.params,
|
|
1402
|
+
routeId: apiMatch.route.id,
|
|
1403
|
+
phase: apiPhase,
|
|
1404
|
+
dev,
|
|
1405
|
+
});
|
|
1406
|
+
} catch (hookError) {
|
|
1407
|
+
// eslint-disable-next-line no-console
|
|
1408
|
+
console.warn("[rbssr] api onError hook failed", Bun.inspect(hookError));
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
return finalize(json(
|
|
1413
|
+
{
|
|
1414
|
+
error: sanitizeErrorMessage(error, !dev),
|
|
1415
|
+
},
|
|
1416
|
+
{ status: 500 },
|
|
1417
|
+
), "api", requestContext);
|
|
1418
|
+
}
|
|
1419
|
+
return finalize(response, "api", requestContext);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const pageMatch = routeAdapter.matchPage(url.pathname);
|
|
1423
|
+
|
|
1424
|
+
if (!pageMatch) {
|
|
1425
|
+
const rootModule = await loadRootOnlyModule(activeConfig.rootModule, {
|
|
1426
|
+
...routeModuleLoadOptions,
|
|
1427
|
+
});
|
|
1428
|
+
const fallbackRoute: RouteModule = {
|
|
1429
|
+
default: () => null,
|
|
1430
|
+
NotFound: rootModule.NotFound,
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
const payload = {
|
|
1434
|
+
routeId: "__not_found__",
|
|
1435
|
+
loaderData: null,
|
|
1436
|
+
params: {},
|
|
1437
|
+
url: url.toString(),
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const modules = {
|
|
1441
|
+
root: rootModule,
|
|
1442
|
+
layouts: [],
|
|
1443
|
+
route: fallbackRoute,
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
const appTree = createNotFoundAppTree(modules, payload) ?? createElement(
|
|
1447
|
+
"main",
|
|
1448
|
+
null,
|
|
1449
|
+
createElement("h1", null, "404"),
|
|
1450
|
+
createElement("p", null, "Page not found."),
|
|
1451
|
+
);
|
|
1452
|
+
const stream = await deps.renderDocumentStream({
|
|
1453
|
+
appTree,
|
|
1454
|
+
payload,
|
|
1455
|
+
assets: fallbackHtmlAssets,
|
|
1456
|
+
headElements: collectHeadElements(modules, payload),
|
|
1457
|
+
routerSnapshot,
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
return finalize(toHtmlStreamResponse(stream, 404), "html");
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (isMutatingMethod(request.method)) {
|
|
1464
|
+
return finalize(
|
|
1465
|
+
new Response(
|
|
1466
|
+
"Page route mutations are not supported via document requests. "
|
|
1467
|
+
+ "Use useActionState(action, initialState) with createRouteAction() "
|
|
1468
|
+
+ "(or useRouteAction for backwards compatibility).",
|
|
1469
|
+
{
|
|
1470
|
+
status: 405,
|
|
1471
|
+
headers: {
|
|
1472
|
+
allow: "GET, HEAD",
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1475
|
+
),
|
|
1476
|
+
"html",
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const [routeModules, globalMiddleware, nestedMiddleware] = await Promise.all([
|
|
1481
|
+
deps.loadRouteBundle({
|
|
1482
|
+
rootFilePath: activeConfig.rootModule,
|
|
1483
|
+
layoutFiles: pageMatch.route.layoutFiles,
|
|
1484
|
+
routeFilePath: pageMatch.route.filePath,
|
|
1485
|
+
routeServerFilePath: pageMatch.route.serverFilePath,
|
|
1486
|
+
...routeModuleLoadOptions,
|
|
1487
|
+
}),
|
|
1488
|
+
deps.loadGlobalMiddleware(activeConfig.middlewareFile, requestModuleLoadOptions),
|
|
1489
|
+
deps.loadNestedMiddleware(pageMatch.route.middlewareFiles, requestModuleLoadOptions),
|
|
1490
|
+
]);
|
|
1491
|
+
const moduleMiddleware = extractRouteMiddleware(routeModules.route);
|
|
1492
|
+
|
|
1493
|
+
const requestContext = createRequestContext({
|
|
1494
|
+
request,
|
|
1495
|
+
url,
|
|
1496
|
+
params: pageMatch.params,
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
const routeAssets = routeAssetsById[pageMatch.route.id] ?? null;
|
|
1500
|
+
let pagePhase: RouteErrorPhase = "middleware";
|
|
1501
|
+
|
|
1502
|
+
const renderFailureDocument = async (
|
|
1503
|
+
failure: unknown,
|
|
1504
|
+
phase: RouteErrorPhase,
|
|
1505
|
+
): Promise<Response> => {
|
|
1506
|
+
const contextBase = toRouteErrorContextBase({
|
|
1507
|
+
requestContext,
|
|
1508
|
+
routeId: pageMatch.route.id,
|
|
1509
|
+
phase,
|
|
1510
|
+
dev,
|
|
1511
|
+
});
|
|
1512
|
+
const caught = toRouteErrorResponse(failure);
|
|
1513
|
+
if (caught) {
|
|
1514
|
+
const serializedCatch = toCaughtErrorPayload(caught, !dev);
|
|
1515
|
+
await notifyCatchHooks({
|
|
1516
|
+
modules: routeModules,
|
|
1517
|
+
context: {
|
|
1518
|
+
...contextBase,
|
|
1519
|
+
error: caught,
|
|
1520
|
+
},
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
const basePayload = {
|
|
1524
|
+
routeId: pageMatch.route.id,
|
|
1525
|
+
loaderData: null,
|
|
1526
|
+
params: pageMatch.params,
|
|
1527
|
+
url: url.toString(),
|
|
1528
|
+
};
|
|
1529
|
+
const catchPayload = {
|
|
1530
|
+
...basePayload,
|
|
1531
|
+
error: serializedCatch,
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
if (serializedCatch.status === 404) {
|
|
1535
|
+
const notFoundTree = createNotFoundAppTree(routeModules, catchPayload);
|
|
1536
|
+
if (notFoundTree) {
|
|
1537
|
+
const stream = await deps.renderDocumentStream({
|
|
1538
|
+
appTree: notFoundTree,
|
|
1539
|
+
payload: catchPayload,
|
|
1540
|
+
assets: {
|
|
1541
|
+
script: routeAssets?.script,
|
|
1542
|
+
css: routeAssets?.css ?? [],
|
|
1543
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1544
|
+
},
|
|
1545
|
+
headElements: collectHeadElements(routeModules, catchPayload),
|
|
1546
|
+
routerSnapshot,
|
|
1547
|
+
});
|
|
1548
|
+
return toHtmlStreamResponse(stream, 404);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const catchTree = createCatchAppTree(routeModules, catchPayload, serializedCatch);
|
|
1553
|
+
if (catchTree) {
|
|
1554
|
+
const stream = await deps.renderDocumentStream({
|
|
1555
|
+
appTree: catchTree,
|
|
1556
|
+
payload: catchPayload,
|
|
1557
|
+
assets: {
|
|
1558
|
+
script: routeAssets?.script,
|
|
1559
|
+
css: routeAssets?.css ?? [],
|
|
1560
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1561
|
+
},
|
|
1562
|
+
headElements: collectHeadElements(routeModules, catchPayload),
|
|
1563
|
+
routerSnapshot,
|
|
1564
|
+
});
|
|
1565
|
+
return toHtmlStreamResponse(stream, serializedCatch.status);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
return toRouteErrorHttpResponse(serializedCatch);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
await notifyErrorHooks({
|
|
1572
|
+
modules: routeModules,
|
|
1573
|
+
context: {
|
|
1574
|
+
...contextBase,
|
|
1575
|
+
error: failure,
|
|
1576
|
+
},
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
const renderPayload = {
|
|
1580
|
+
routeId: pageMatch.route.id,
|
|
1581
|
+
loaderData: null,
|
|
1582
|
+
params: pageMatch.params,
|
|
1583
|
+
url: url.toString(),
|
|
1584
|
+
};
|
|
1585
|
+
const errorPayload = {
|
|
1586
|
+
...renderPayload,
|
|
1587
|
+
error: toUncaughtErrorPayload(failure, !dev),
|
|
1588
|
+
};
|
|
1589
|
+
const boundaryTree = createErrorAppTree(routeModules, errorPayload, failure);
|
|
1590
|
+
if (boundaryTree) {
|
|
1591
|
+
const stream = await deps.renderDocumentStream({
|
|
1592
|
+
appTree: boundaryTree,
|
|
1593
|
+
payload: errorPayload,
|
|
1594
|
+
assets: {
|
|
1595
|
+
script: routeAssets?.script,
|
|
1596
|
+
css: routeAssets?.css ?? [],
|
|
1597
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1598
|
+
},
|
|
1599
|
+
headElements: collectHeadElements(routeModules, errorPayload),
|
|
1600
|
+
routerSnapshot,
|
|
1601
|
+
});
|
|
1602
|
+
return toHtmlStreamResponse(stream, 500);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
return new Response(sanitizeErrorMessage(failure, !dev), { status: 500 });
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
let response: Response;
|
|
1609
|
+
try {
|
|
1610
|
+
response = await runMiddlewareChain(
|
|
1611
|
+
[...globalMiddleware, ...nestedMiddleware, ...moduleMiddleware],
|
|
1612
|
+
requestContext,
|
|
1613
|
+
async () => {
|
|
1614
|
+
let loaderDataForRender: unknown = null;
|
|
1615
|
+
let loaderDataForPayload: unknown = null;
|
|
1616
|
+
let deferredSettleEntries: DeferredSettleEntry[] = [];
|
|
1617
|
+
|
|
1618
|
+
const resolveLoaderData = async (): Promise<Response | null> => {
|
|
1619
|
+
if (!routeModules.route.loader) {
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
pagePhase = "loader";
|
|
1624
|
+
const loaderCtx: LoaderContext = requestContext;
|
|
1625
|
+
const loaderResult = await routeModules.route.loader(loaderCtx);
|
|
1626
|
+
|
|
1627
|
+
if (isResponse(loaderResult)) {
|
|
1628
|
+
return loaderResult;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (isRedirectResult(loaderResult)) {
|
|
1632
|
+
return toRedirectResponse(loaderResult.location, loaderResult.status);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (isDeferredLoaderResult(loaderResult)) {
|
|
1636
|
+
const prepared = prepareDeferredPayload(pageMatch.route.id, loaderResult);
|
|
1637
|
+
loaderDataForRender = prepared.dataForRender;
|
|
1638
|
+
loaderDataForPayload = prepared.dataForPayload;
|
|
1639
|
+
deferredSettleEntries = prepared.settleEntries;
|
|
1640
|
+
} else {
|
|
1641
|
+
loaderDataForRender = loaderResult;
|
|
1642
|
+
loaderDataForPayload = loaderResult;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return null;
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
const loaderResponse = await resolveLoaderData();
|
|
1649
|
+
if (loaderResponse) {
|
|
1650
|
+
return loaderResponse;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const renderPayload = {
|
|
1654
|
+
routeId: pageMatch.route.id,
|
|
1655
|
+
loaderData: loaderDataForRender,
|
|
1656
|
+
params: pageMatch.params,
|
|
1657
|
+
url: url.toString(),
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const clientPayload = {
|
|
1661
|
+
...renderPayload,
|
|
1662
|
+
loaderData: loaderDataForPayload,
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
let appTree: ReturnType<typeof createPageAppTree>;
|
|
1666
|
+
try {
|
|
1667
|
+
pagePhase = "render";
|
|
1668
|
+
appTree = createPageAppTree(routeModules, renderPayload);
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
return renderFailureDocument(error, pagePhase);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const stream = await deps.renderDocumentStream({
|
|
1674
|
+
appTree,
|
|
1675
|
+
payload: clientPayload,
|
|
1676
|
+
assets: {
|
|
1677
|
+
script: routeAssets?.script,
|
|
1678
|
+
css: routeAssets?.css ?? [],
|
|
1679
|
+
devVersion: dev ? runtimeOptions.reloadVersion?.() ?? 0 : undefined,
|
|
1680
|
+
},
|
|
1681
|
+
headElements: collectHeadElements(routeModules, renderPayload),
|
|
1682
|
+
routerSnapshot,
|
|
1683
|
+
deferredSettleEntries,
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
return toHtmlStreamResponse(stream, 200);
|
|
1687
|
+
},
|
|
1688
|
+
);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
const redirectResponse = resolveThrownRedirect(error);
|
|
1691
|
+
if (redirectResponse) {
|
|
1692
|
+
return finalize(redirectResponse, "html", requestContext);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const fallbackResponse = await renderFailureDocument(error, pagePhase);
|
|
1696
|
+
return finalize(fallbackResponse, "html", requestContext);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
return finalize(response, "html", requestContext);
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
return {
|
|
1703
|
+
fetch: fetchHandler,
|
|
1704
|
+
};
|
|
1705
|
+
}
|