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.
@@ -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,