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 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\.(tsx|jsx|ts|js)$/.test(relativePath) || /^middleware\.(tsx|jsx|ts|js)$/.test(relativePath);
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.data;
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
- data: revivedData,
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
- data: null,
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
- export type {
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
- ResponseHeaderRule,
18
- RequestContext,
19
- RouteCatchContext,
20
- RouteErrorContext,
21
- RouteErrorResponse,
22
- RouteModule,
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, defineConfig } from "./helpers";
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
- && typeof moduleValue.default === "object"
115
- && moduleValue.default !== null
116
- && "default" in (moduleValue.default as Record<string, unknown>)
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 moduleValue.default as Record<string, unknown>;
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 modulePath = normalizedOptions.devSourceImports
247
- ? path.resolve(filePath)
248
- : await buildServerModule(filePath, normalizedOptions);
249
- const moduleValue = await importModule<unknown>(
250
- modulePath,
251
- normalizedOptions.cacheBustKey,
252
- );
253
- return toRouteModule(filePath, moduleValue);
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, moduleOptions),
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
- if (!(await existsPath(middlewareFilePath))) {
486
+ const resolvedMiddlewarePath = await resolveGlobalMiddlewarePath(middlewareFilePath);
487
+ if (!resolvedMiddlewarePath) {
309
488
  return [];
310
489
  }
311
490
 
312
491
  const normalizedOptions = normalizeLoadOptions(options);
313
- const modulePath = normalizedOptions.devSourceImports
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
- const modulePath = normalizedOptions.devSourceImports
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
- const modulePath = normalizedOptions.devSourceImports
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.data,
158
+ data: payload.loaderData,
159
159
  params: payload.params,
160
160
  url: new URL(payload.url),
161
161
  error: payload.error,