react-bun-ssr 0.3.2 → 0.4.0
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 +37 -5
- package/framework/cli/dev-runtime.ts +18 -1
- package/framework/runtime/action-stub.ts +26 -0
- package/framework/runtime/client-runtime.tsx +3 -3
- 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/response-context.ts +206 -0
- package/framework/runtime/route-api.ts +51 -18
- package/framework/runtime/route-scanner.ts +104 -12
- package/framework/runtime/server.ts +427 -91
- package/framework/runtime/tree.tsx +120 -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
|
|
@@ -119,6 +114,43 @@ Read more:
|
|
|
119
114
|
- https://react-bun-ssr.dev/docs/routing/middleware
|
|
120
115
|
- https://react-bun-ssr.dev/docs/data/loaders
|
|
121
116
|
|
|
117
|
+
### Actions with React `useActionState`
|
|
118
|
+
|
|
119
|
+
Page mutations use React 19 form actions (`useActionState`) with an explicit route stub:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// app/routes/login.tsx
|
|
123
|
+
import { useActionState } from "react";
|
|
124
|
+
import { createRouteAction } from "react-bun-ssr/route";
|
|
125
|
+
|
|
126
|
+
type LoginState = { error?: string };
|
|
127
|
+
export const action = createRouteAction<LoginState>();
|
|
128
|
+
|
|
129
|
+
export default function LoginPage() {
|
|
130
|
+
const [state, formAction, pending] = useActionState(action, {});
|
|
131
|
+
return (
|
|
132
|
+
<form action={formAction}>
|
|
133
|
+
{state.error ? <p>{state.error}</p> : null}
|
|
134
|
+
<button disabled={pending}>Sign in</button>
|
|
135
|
+
</form>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// app/routes/login.server.tsx
|
|
142
|
+
import { redirect } from "react-bun-ssr";
|
|
143
|
+
import type { Action } from "react-bun-ssr/route";
|
|
144
|
+
|
|
145
|
+
export const action: Action = async (ctx) => {
|
|
146
|
+
const email = String(ctx.formData?.get("email") ?? "").trim();
|
|
147
|
+
if (!email) return { error: "Email is required" };
|
|
148
|
+
return redirect("/dashboard");
|
|
149
|
+
};
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`createRouteAction` is the preferred pattern. `useRouteAction` remains available for backward compatibility.
|
|
153
|
+
|
|
122
154
|
### Rendering model
|
|
123
155
|
|
|
124
156
|
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
|
+
}
|
|
@@ -374,7 +374,7 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
|
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
377
|
-
const sourceData = payload.
|
|
377
|
+
const sourceData = payload.loaderData;
|
|
378
378
|
if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
|
|
379
379
|
return payload;
|
|
380
380
|
}
|
|
@@ -394,7 +394,7 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
|
394
394
|
|
|
395
395
|
return {
|
|
396
396
|
...payload,
|
|
397
|
-
|
|
397
|
+
loaderData: revivedData,
|
|
398
398
|
};
|
|
399
399
|
}
|
|
400
400
|
|
|
@@ -713,7 +713,7 @@ async function navigateToInternal(
|
|
|
713
713
|
matchedModules,
|
|
714
714
|
{
|
|
715
715
|
routeId: matched.route.id,
|
|
716
|
-
|
|
716
|
+
loaderData: null,
|
|
717
717
|
params: matched.params,
|
|
718
718
|
url: toUrl.toString(),
|
|
719
719
|
},
|
|
@@ -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";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { isRouteActionStub } from './action-stub';
|
|
2
3
|
import { ensureDir, existsPath } from './io';
|
|
3
4
|
import type {
|
|
4
5
|
ApiRouteModule,
|
|
@@ -30,8 +31,19 @@ export interface RouteModuleLoadOptions {
|
|
|
30
31
|
serverBytecode?: boolean;
|
|
31
32
|
devSourceImports?: boolean;
|
|
32
33
|
nodeEnv?: "development" | "production";
|
|
34
|
+
companionFilePath?: string | null;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
const ROUTE_SERVER_EXPORT_KEYS = new Set([
|
|
38
|
+
"loader",
|
|
39
|
+
"action",
|
|
40
|
+
"middleware",
|
|
41
|
+
"head",
|
|
42
|
+
"meta",
|
|
43
|
+
"onError",
|
|
44
|
+
"onCatch",
|
|
45
|
+
]);
|
|
46
|
+
|
|
35
47
|
export function createServerModuleCacheKey(options: {
|
|
36
48
|
absoluteFilePath: string;
|
|
37
49
|
cacheBustKey?: string;
|
|
@@ -88,6 +100,46 @@ function normalizeLoadOptions(
|
|
|
88
100
|
return options ?? {};
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
export function toServerCompanionPath(filePath: string): string {
|
|
104
|
+
const extension = path.extname(filePath);
|
|
105
|
+
if (!extension) {
|
|
106
|
+
return `${filePath}.server`;
|
|
107
|
+
}
|
|
108
|
+
return `${filePath.slice(0, -extension.length)}.server${extension}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveServerCompanionFilePath(
|
|
112
|
+
filePath: string,
|
|
113
|
+
options: RouteModuleLoadOptions,
|
|
114
|
+
): Promise<string | null> {
|
|
115
|
+
if (options.companionFilePath === null) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof options.companionFilePath === "string") {
|
|
120
|
+
return options.companionFilePath;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const companionFilePath = toServerCompanionPath(filePath);
|
|
124
|
+
if (await existsPath(companionFilePath)) {
|
|
125
|
+
return companionFilePath;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadRawModuleRecord(
|
|
131
|
+
filePath: string,
|
|
132
|
+
options: RouteModuleLoadOptions,
|
|
133
|
+
): Promise<Record<string, unknown>> {
|
|
134
|
+
const modulePath = options.devSourceImports
|
|
135
|
+
? path.resolve(filePath)
|
|
136
|
+
: await buildServerModule(filePath, options);
|
|
137
|
+
return unwrapModuleNamespace(await importModule<Record<string, unknown>>(
|
|
138
|
+
modulePath,
|
|
139
|
+
options.cacheBustKey,
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
|
|
91
143
|
function toRouteModule(filePath: string, moduleValue: unknown): RouteModule {
|
|
92
144
|
const rawValue = moduleValue as Record<string, unknown>;
|
|
93
145
|
const value = unwrapModuleNamespace(rawValue) as Partial<RouteModule>;
|
|
@@ -108,14 +160,103 @@ function toRouteModule(filePath: string, moduleValue: unknown): RouteModule {
|
|
|
108
160
|
} as RouteModule;
|
|
109
161
|
}
|
|
110
162
|
|
|
163
|
+
function toRouteServerCompanionExports(
|
|
164
|
+
filePath: string,
|
|
165
|
+
companionFilePath: string,
|
|
166
|
+
moduleValue: Record<string, unknown>,
|
|
167
|
+
): Partial<RouteModule> {
|
|
168
|
+
const exportKeys = Object.keys(moduleValue).filter(key => key !== "__esModule");
|
|
169
|
+
|
|
170
|
+
if (exportKeys.includes("default")) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Route companion module ${companionFilePath} cannot export default. ` +
|
|
173
|
+
`Move UI exports back to ${filePath}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const unsupportedExports = exportKeys.filter(key => !ROUTE_SERVER_EXPORT_KEYS.has(key));
|
|
178
|
+
if (unsupportedExports.length > 0) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Route companion module ${companionFilePath} has unsupported exports: ${unsupportedExports.join(", ")}. ` +
|
|
181
|
+
`Allowed exports: ${[...ROUTE_SERVER_EXPORT_KEYS].join(", ")}.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const companionExports: Partial<RouteModule> = {};
|
|
186
|
+
for (const key of exportKeys) {
|
|
187
|
+
(companionExports as Record<string, unknown>)[key] = moduleValue[key];
|
|
188
|
+
}
|
|
189
|
+
return companionExports;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mergeRouteModuleWithCompanion(options: {
|
|
193
|
+
filePath: string;
|
|
194
|
+
companionFilePath: string;
|
|
195
|
+
routeModule: RouteModule;
|
|
196
|
+
companionExports: Partial<RouteModule>;
|
|
197
|
+
}): RouteModule {
|
|
198
|
+
for (const key of ROUTE_SERVER_EXPORT_KEYS) {
|
|
199
|
+
if (options.companionExports[key as keyof RouteModule] === undefined) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
key === "action"
|
|
205
|
+
&& isRouteActionStub(options.routeModule.action)
|
|
206
|
+
) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (options.routeModule[key as keyof RouteModule] !== undefined) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Duplicate server export "${key}" found in both ${options.filePath} and ${options.companionFilePath}. ` +
|
|
213
|
+
`Keep "${key}" in only one file.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
...options.routeModule,
|
|
220
|
+
...options.companionExports,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stripRouteActionStub(routeModule: RouteModule): RouteModule {
|
|
225
|
+
if (!isRouteActionStub(routeModule.action)) {
|
|
226
|
+
return routeModule;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { action: _stubAction, ...rest } = routeModule;
|
|
230
|
+
return rest as RouteModule;
|
|
231
|
+
}
|
|
232
|
+
|
|
111
233
|
function unwrapModuleNamespace(moduleValue: Record<string, unknown>): Record<string, unknown> {
|
|
112
234
|
if (
|
|
113
|
-
moduleValue
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
235
|
+
!moduleValue
|
|
236
|
+
|| typeof moduleValue.default !== "object"
|
|
237
|
+
|| moduleValue.default === null
|
|
238
|
+
) {
|
|
239
|
+
return moduleValue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const defaultNamespace = moduleValue.default as Record<string, unknown>;
|
|
243
|
+
|
|
244
|
+
if ("default" in defaultNamespace) {
|
|
245
|
+
return defaultNamespace;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const namedExportKeys = Object.keys(moduleValue).filter(key => {
|
|
249
|
+
return key !== "default" && key !== "__esModule";
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
namedExportKeys.length > 0
|
|
254
|
+
&& namedExportKeys.every(key => {
|
|
255
|
+
return Object.prototype.hasOwnProperty.call(defaultNamespace, key)
|
|
256
|
+
&& defaultNamespace[key] === moduleValue[key];
|
|
257
|
+
})
|
|
117
258
|
) {
|
|
118
|
-
return
|
|
259
|
+
return defaultNamespace;
|
|
119
260
|
}
|
|
120
261
|
|
|
121
262
|
return moduleValue;
|
|
@@ -243,20 +384,29 @@ export async function loadRouteModule(
|
|
|
243
384
|
options: RouteModuleLoadOptions = {},
|
|
244
385
|
): Promise<RouteModule> {
|
|
245
386
|
const normalizedOptions = normalizeLoadOptions(options);
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
387
|
+
const baseModuleValue = await loadRawModuleRecord(filePath, normalizedOptions);
|
|
388
|
+
const routeModule = toRouteModule(filePath, baseModuleValue);
|
|
389
|
+
const companionFilePath = await resolveServerCompanionFilePath(filePath, normalizedOptions);
|
|
390
|
+
if (!companionFilePath) {
|
|
391
|
+
return stripRouteActionStub(routeModule);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const companionModuleValue = await loadRawModuleRecord(companionFilePath, normalizedOptions);
|
|
395
|
+
const companionExports = toRouteServerCompanionExports(filePath, companionFilePath, companionModuleValue);
|
|
396
|
+
const mergedRouteModule = mergeRouteModuleWithCompanion({
|
|
397
|
+
filePath,
|
|
398
|
+
companionFilePath,
|
|
399
|
+
routeModule,
|
|
400
|
+
companionExports,
|
|
401
|
+
});
|
|
402
|
+
return stripRouteActionStub(mergedRouteModule);
|
|
254
403
|
}
|
|
255
404
|
|
|
256
405
|
export async function loadRouteModules(options: {
|
|
257
406
|
rootFilePath: string;
|
|
258
407
|
layoutFiles: string[];
|
|
259
408
|
routeFilePath: string;
|
|
409
|
+
routeServerFilePath?: string;
|
|
260
410
|
cacheBustKey?: string;
|
|
261
411
|
serverBytecode?: boolean;
|
|
262
412
|
devSourceImports?: boolean;
|
|
@@ -273,7 +423,10 @@ export async function loadRouteModules(options: {
|
|
|
273
423
|
loadRouteModule(layoutFilePath, moduleOptions),
|
|
274
424
|
),
|
|
275
425
|
),
|
|
276
|
-
loadRouteModule(options.routeFilePath,
|
|
426
|
+
loadRouteModule(options.routeFilePath, {
|
|
427
|
+
...moduleOptions,
|
|
428
|
+
companionFilePath: options.routeServerFilePath,
|
|
429
|
+
}),
|
|
277
430
|
]);
|
|
278
431
|
|
|
279
432
|
return {
|
|
@@ -301,22 +454,42 @@ function normalizeMiddlewareExport(value: unknown): Middleware[] {
|
|
|
301
454
|
return [];
|
|
302
455
|
}
|
|
303
456
|
|
|
457
|
+
async function resolveGlobalMiddlewarePath(middlewareFilePath: string): Promise<string | null> {
|
|
458
|
+
const basePath = path.resolve(middlewareFilePath);
|
|
459
|
+
const serverPath = toServerCompanionPath(basePath);
|
|
460
|
+
const [baseExists, serverExists] = await Promise.all([
|
|
461
|
+
existsPath(basePath),
|
|
462
|
+
existsPath(serverPath),
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
if (baseExists && serverExists) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Global middleware file collision: both ${basePath} and ${serverPath} exist. ` +
|
|
468
|
+
"Use only one of these files.",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (serverExists) {
|
|
473
|
+
return serverPath;
|
|
474
|
+
}
|
|
475
|
+
if (baseExists) {
|
|
476
|
+
return basePath;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
304
482
|
export async function loadGlobalMiddleware(
|
|
305
483
|
middlewareFilePath: string,
|
|
306
484
|
options: string | RouteModuleLoadOptions = {},
|
|
307
485
|
): Promise<Middleware[]> {
|
|
308
|
-
|
|
486
|
+
const resolvedMiddlewarePath = await resolveGlobalMiddlewarePath(middlewareFilePath);
|
|
487
|
+
if (!resolvedMiddlewarePath) {
|
|
309
488
|
return [];
|
|
310
489
|
}
|
|
311
490
|
|
|
312
491
|
const normalizedOptions = normalizeLoadOptions(options);
|
|
313
|
-
const
|
|
314
|
-
? path.resolve(middlewareFilePath)
|
|
315
|
-
: await buildServerModule(middlewareFilePath, normalizedOptions);
|
|
316
|
-
const raw = unwrapModuleNamespace(await importModule<Record<string, unknown>>(
|
|
317
|
-
modulePath,
|
|
318
|
-
normalizedOptions.cacheBustKey,
|
|
319
|
-
));
|
|
492
|
+
const raw = await loadRawModuleRecord(resolvedMiddlewarePath, normalizedOptions);
|
|
320
493
|
|
|
321
494
|
return [
|
|
322
495
|
...normalizeMiddlewareExport(raw.default),
|
|
@@ -331,13 +504,7 @@ export async function loadNestedMiddleware(
|
|
|
331
504
|
const normalizedOptions = normalizeLoadOptions(options);
|
|
332
505
|
const rawModules = await Promise.all(
|
|
333
506
|
middlewareFilePaths.map(async (middlewareFilePath) => {
|
|
334
|
-
|
|
335
|
-
? path.resolve(middlewareFilePath)
|
|
336
|
-
: await buildServerModule(middlewareFilePath, normalizedOptions);
|
|
337
|
-
return unwrapModuleNamespace(await importModule<Record<string, unknown>>(
|
|
338
|
-
modulePath,
|
|
339
|
-
normalizedOptions.cacheBustKey,
|
|
340
|
-
));
|
|
507
|
+
return loadRawModuleRecord(middlewareFilePath, normalizedOptions);
|
|
341
508
|
}),
|
|
342
509
|
);
|
|
343
510
|
|
|
@@ -358,10 +525,5 @@ export async function loadApiRouteModule(
|
|
|
358
525
|
options: string | RouteModuleLoadOptions = {},
|
|
359
526
|
): Promise<ApiRouteModule> {
|
|
360
527
|
const normalizedOptions = normalizeLoadOptions(options);
|
|
361
|
-
|
|
362
|
-
? path.resolve(filePath)
|
|
363
|
-
: await buildServerModule(filePath, normalizedOptions);
|
|
364
|
-
return unwrapModuleNamespace(
|
|
365
|
-
await importModule<Record<string, unknown>>(modulePath, normalizedOptions.cacheBustKey),
|
|
366
|
-
) as ApiRouteModule;
|
|
528
|
+
return await loadRawModuleRecord(filePath, normalizedOptions) as ApiRouteModule;
|
|
367
529
|
}
|
|
@@ -155,7 +155,7 @@ function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload,
|
|
|
155
155
|
const tags: ReactNode[] = [];
|
|
156
156
|
|
|
157
157
|
const context = {
|
|
158
|
-
data: payload.
|
|
158
|
+
data: payload.loaderData,
|
|
159
159
|
params: payload.params,
|
|
160
160
|
url: new URL(payload.url),
|
|
161
161
|
error: payload.error,
|