react-bun-ssr 0.1.1 → 0.3.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.
@@ -0,0 +1,159 @@
1
+ import { matchRouteBySegments } from "./matcher";
2
+ import type {
3
+ ClientRouteSnapshot,
4
+ Params,
5
+ TransitionChunk,
6
+ TransitionDeferredChunk,
7
+ TransitionDocumentChunk,
8
+ TransitionInitialChunk,
9
+ TransitionRedirectChunk,
10
+ } from "./types";
11
+
12
+ export const PREFETCH_TTL_MS = 30_000;
13
+ export const MAX_REDIRECT_DEPTH = 8;
14
+
15
+ export interface TransitionChunkParserState {
16
+ buffer: string;
17
+ initialChunk: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null;
18
+ deferredChunks: TransitionDeferredChunk[];
19
+ }
20
+
21
+ export interface TransitionNavigationOptions {
22
+ historyManagedByNavigationApi?: boolean;
23
+ isPopState?: boolean;
24
+ }
25
+
26
+ export function createTransitionChunkParserState(): TransitionChunkParserState {
27
+ return {
28
+ buffer: "",
29
+ initialChunk: null,
30
+ deferredChunks: [],
31
+ };
32
+ }
33
+
34
+ export function matchClientPageRoute(
35
+ routes: ClientRouteSnapshot[],
36
+ pathname: string,
37
+ ): { route: ClientRouteSnapshot; params: Params } | null {
38
+ return matchRouteBySegments(routes, pathname);
39
+ }
40
+
41
+ function applyParsedTransitionChunk(
42
+ state: TransitionChunkParserState,
43
+ chunk: TransitionChunk,
44
+ ): TransitionChunkParserState {
45
+ if (chunk.type === "initial" || chunk.type === "redirect" || chunk.type === "document") {
46
+ if (state.initialChunk) {
47
+ return state;
48
+ }
49
+
50
+ return {
51
+ ...state,
52
+ initialChunk: chunk,
53
+ };
54
+ }
55
+
56
+ return {
57
+ ...state,
58
+ deferredChunks: [...state.deferredChunks, chunk],
59
+ };
60
+ }
61
+
62
+ export function consumeTransitionChunkText(
63
+ state: TransitionChunkParserState,
64
+ text: string,
65
+ ): TransitionChunkParserState {
66
+ let buffer = state.buffer + text;
67
+ let nextState = {
68
+ ...state,
69
+ buffer: "",
70
+ };
71
+
72
+ let start = 0;
73
+ for (let index = 0; index < buffer.length; index += 1) {
74
+ if (buffer[index] !== "\n") {
75
+ continue;
76
+ }
77
+
78
+ const line = buffer.slice(start, index).trim();
79
+ if (line.length > 0) {
80
+ nextState = applyParsedTransitionChunk(
81
+ nextState,
82
+ JSON.parse(line) as TransitionChunk,
83
+ );
84
+ }
85
+ start = index + 1;
86
+ }
87
+
88
+ buffer = buffer.slice(start);
89
+ return {
90
+ ...nextState,
91
+ buffer,
92
+ };
93
+ }
94
+
95
+ export function flushTransitionChunkText(
96
+ state: TransitionChunkParserState,
97
+ ): TransitionChunkParserState {
98
+ const trailing = state.buffer.trim();
99
+ if (trailing.length === 0) {
100
+ return {
101
+ ...state,
102
+ buffer: "",
103
+ };
104
+ }
105
+
106
+ return {
107
+ ...applyParsedTransitionChunk(
108
+ {
109
+ ...state,
110
+ buffer: "",
111
+ },
112
+ JSON.parse(trailing) as TransitionChunk,
113
+ ),
114
+ buffer: "",
115
+ };
116
+ }
117
+
118
+ export function sanitizePrefetchCache<T extends { createdAt: number }>(
119
+ cache: Map<string, T>,
120
+ options: {
121
+ now?: number;
122
+ ttlMs?: number;
123
+ } = {},
124
+ ): void {
125
+ const now = options.now ?? Date.now();
126
+ const ttlMs = options.ttlMs ?? PREFETCH_TTL_MS;
127
+
128
+ for (const [key, entry] of cache.entries()) {
129
+ if (now - entry.createdAt > ttlMs) {
130
+ cache.delete(key);
131
+ }
132
+ }
133
+ }
134
+
135
+ export function shouldSkipSoftNavigation(
136
+ currentPath: string,
137
+ targetPath: string,
138
+ options: TransitionNavigationOptions,
139
+ ): boolean {
140
+ return (
141
+ currentPath === targetPath
142
+ && !options.isPopState
143
+ && !options.historyManagedByNavigationApi
144
+ );
145
+ }
146
+
147
+ export function shouldHardNavigateForRedirectDepth(
148
+ depth: number,
149
+ maxDepth = MAX_REDIRECT_DEPTH,
150
+ ): boolean {
151
+ return depth > maxDepth;
152
+ }
153
+
154
+ export function isStaleNavigationToken(
155
+ activeToken: number,
156
+ candidateToken: number,
157
+ ): boolean {
158
+ return activeToken !== candidateToken;
159
+ }
@@ -71,15 +71,20 @@ function toHeaderRules(config: FrameworkConfig): ResolvedResponseHeaderRule[] {
71
71
  throw new Error(`[rbssr config] \`headers[${index}].headers\` must include at least one header.`);
72
72
  }
73
73
 
74
- const headers: Record<string, string> = {};
74
+ const headers: Record<string, string | null> = {};
75
75
  for (const [key, value] of entries) {
76
76
  if (typeof key !== "string" || key.trim().length === 0) {
77
77
  throw new Error(`[rbssr config] \`headers[${index}].headers\` contains an empty header name.`);
78
78
  }
79
79
 
80
+ if (value === null) {
81
+ headers[key] = null;
82
+ continue;
83
+ }
84
+
80
85
  if (typeof value !== "string" || value.trim().length === 0) {
81
86
  throw new Error(
82
- `[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value.`,
87
+ `[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value or null.`,
83
88
  );
84
89
  }
85
90
 
@@ -26,5 +26,5 @@ export { createServer, startHttpServer } from "./server";
26
26
  export { defer, json, redirect, defineConfig } from "./helpers";
27
27
  export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
28
28
  export { Link, type LinkProps } from "./link";
29
- export { useRouter, type Router, type RouterNavigateOptions } from "./router";
29
+ export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
30
30
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -4,15 +4,7 @@ import type {
4
4
  MouseEvent,
5
5
  TouchEvent,
6
6
  } from "react";
7
-
8
- interface NavigateInfo {
9
- from: string;
10
- to: string;
11
- status: number;
12
- kind: "page" | "not_found" | "catch" | "error";
13
- redirected: boolean;
14
- prefetched: boolean;
15
- }
7
+ import type { RouterNavigateInfo } from "./router";
16
8
 
17
9
  export interface LinkProps
18
10
  extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
@@ -20,7 +12,7 @@ export interface LinkProps
20
12
  replace?: boolean;
21
13
  scroll?: boolean;
22
14
  prefetch?: "intent" | "none";
23
- onNavigate?: (info: NavigateInfo) => void;
15
+ onNavigate?: (info: RouterNavigateInfo) => void;
24
16
  }
25
17
 
26
18
  function shouldHandleNavigation(event: MouseEvent<HTMLAnchorElement>): boolean {
@@ -89,7 +81,7 @@ async function prefetch(href: string): Promise<void> {
89
81
  async function navigate(href: string, options: {
90
82
  replace?: boolean;
91
83
  scroll?: boolean;
92
- onNavigate?: (info: NavigateInfo) => void;
84
+ onNavigate?: (info: RouterNavigateInfo) => void;
93
85
  }): Promise<void> {
94
86
  if (typeof window === "undefined") {
95
87
  return;
@@ -112,24 +112,11 @@ function resolveGeneratedRoot(routesDir: string, generatedMarkdownRootDir?: stri
112
112
  return path.resolve(appRoutesMatch[1]!, ".rbssr", "generated", "markdown-routes");
113
113
  }
114
114
 
115
- const snapshotRoutesMatch = normalizedRoutesDir.match(
116
- /^(.*)\/\.rbssr\/dev\/server-snapshots\/v\d+\/routes$/,
117
- );
118
- if (snapshotRoutesMatch) {
119
- return path.resolve(snapshotRoutesMatch[1]!, ".rbssr", "generated", "markdown-routes");
120
- }
121
-
122
115
  return path.resolve(routesDir, "..", ".rbssr", "generated", "markdown-routes");
123
116
  }
124
117
 
125
118
  function toRouteGroupKey(routesDir: string): string {
126
- const normalized = normalizeSlashes(path.resolve(routesDir));
127
- const canonical = normalized.replace(
128
- /\/\.rbssr\/dev\/server-snapshots\/v\d+\/routes$/,
129
- "/.rbssr/dev/server-snapshots/routes",
130
- );
131
-
132
- return stableHash(`${MARKDOWN_WRAPPER_VERSION}\0${canonical}`);
119
+ return stableHash(`${MARKDOWN_WRAPPER_VERSION}\0${normalizeSlashes(path.resolve(routesDir))}`);
133
120
  }
134
121
 
135
122
  function parseFrontmatter(raw: string): {
@@ -60,10 +60,10 @@ function matchSegments(segments: RouteSegment[], pathname: string): Params | nul
60
60
  return params;
61
61
  }
62
62
 
63
- export function matchPageRoute(
64
- routes: PageRouteDefinition[],
63
+ export function matchRouteBySegments<T extends { segments: RouteSegment[] }>(
64
+ routes: T[],
65
65
  pathname: string,
66
- ): RouteMatch<PageRouteDefinition> | null {
66
+ ): { route: T; params: Params } | null {
67
67
  for (const route of routes) {
68
68
  const params = matchSegments(route.segments, pathname);
69
69
  if (params) {
@@ -74,16 +74,16 @@ export function matchPageRoute(
74
74
  return null;
75
75
  }
76
76
 
77
+ export function matchPageRoute(
78
+ routes: PageRouteDefinition[],
79
+ pathname: string,
80
+ ): RouteMatch<PageRouteDefinition> | null {
81
+ return matchRouteBySegments(routes, pathname);
82
+ }
83
+
77
84
  export function matchApiRoute(
78
85
  routes: ApiRouteDefinition[],
79
86
  pathname: string,
80
87
  ): RouteMatch<ApiRouteDefinition> | null {
81
- for (const route of routes) {
82
- const params = matchSegments(route.segments, pathname);
83
- if (params) {
84
- return { route, params };
85
- }
86
- }
87
-
88
- return null;
88
+ return matchRouteBySegments(routes, pathname);
89
89
  }
@@ -28,21 +28,27 @@ const SERVER_BUILD_EXTERNAL = [
28
28
  export interface RouteModuleLoadOptions {
29
29
  cacheBustKey?: string;
30
30
  serverBytecode?: boolean;
31
+ devSourceImports?: boolean;
32
+ nodeEnv?: "development" | "production";
31
33
  }
32
34
 
33
35
  export function createServerModuleCacheKey(options: {
34
36
  absoluteFilePath: string;
35
37
  cacheBustKey?: string;
36
38
  serverBytecode: boolean;
39
+ nodeEnv?: "development" | "production";
37
40
  }): string {
38
- return `${options.absoluteFilePath}|${options.cacheBustKey ?? 'prod'}|bytecode:${options.serverBytecode ? '1' : '0'}|bun:${Bun.version}`;
41
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
42
+ return `${options.absoluteFilePath}|${options.cacheBustKey ?? 'prod'}|bytecode:${options.serverBytecode ? '1' : '0'}|env:${nodeEnv}|bun:${Bun.version}`;
39
43
  }
40
44
 
41
45
  export function createServerBuildConfig(options: {
42
46
  absoluteFilePath: string;
43
47
  outDir: string;
44
48
  serverBytecode: boolean;
49
+ nodeEnv?: "development" | "production";
45
50
  }): Bun.BuildConfig {
51
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
46
52
  return {
47
53
  entrypoints: [options.absoluteFilePath],
48
54
  outdir: options.outDir,
@@ -55,6 +61,9 @@ export function createServerBuildConfig(options: {
55
61
  minify: false,
56
62
  naming: 'entry-[hash].[ext]',
57
63
  external: SERVER_BUILD_EXTERNAL,
64
+ define: {
65
+ "process.env.NODE_ENV": JSON.stringify(nodeEnv),
66
+ },
58
67
  };
59
68
  }
60
69
 
@@ -67,15 +76,21 @@ export async function importModule<T>(
67
76
  return (await import(url)) as T;
68
77
  }
69
78
 
79
+ function normalizeLoadOptions(
80
+ options: string | RouteModuleLoadOptions | undefined,
81
+ ): RouteModuleLoadOptions {
82
+ if (typeof options === "string") {
83
+ return {
84
+ cacheBustKey: options,
85
+ };
86
+ }
87
+
88
+ return options ?? {};
89
+ }
90
+
70
91
  function toRouteModule(filePath: string, moduleValue: unknown): RouteModule {
71
92
  const rawValue = moduleValue as Record<string, unknown>;
72
- const value =
73
- rawValue &&
74
- typeof rawValue.default === 'object' &&
75
- rawValue.default !== null &&
76
- 'default' in (rawValue.default as Record<string, unknown>)
77
- ? (rawValue.default as Partial<RouteModule>)
78
- : (rawValue as Partial<RouteModule>);
93
+ const value = unwrapModuleNamespace(rawValue) as Partial<RouteModule>;
79
94
  const component = value.default;
80
95
 
81
96
  if (typeof component !== 'function') {
@@ -93,6 +108,19 @@ function toRouteModule(filePath: string, moduleValue: unknown): RouteModule {
93
108
  } as RouteModule;
94
109
  }
95
110
 
111
+ function unwrapModuleNamespace(moduleValue: Record<string, unknown>): Record<string, unknown> {
112
+ if (
113
+ moduleValue
114
+ && typeof moduleValue.default === "object"
115
+ && moduleValue.default !== null
116
+ && "default" in (moduleValue.default as Record<string, unknown>)
117
+ ) {
118
+ return moduleValue.default as Record<string, unknown>;
119
+ }
120
+
121
+ return moduleValue;
122
+ }
123
+
96
124
  function isCompilableRouteModule(filePath: string): boolean {
97
125
  return /\.(tsx|jsx|ts|js)$/.test(filePath);
98
126
  }
@@ -108,10 +136,12 @@ async function buildServerModule(
108
136
 
109
137
  const cacheBustKey = options.cacheBustKey;
110
138
  const serverBytecode = options.serverBytecode ?? true;
139
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
111
140
  const cacheKey = createServerModuleCacheKey({
112
141
  absoluteFilePath,
113
142
  cacheBustKey,
114
143
  serverBytecode,
144
+ nodeEnv,
115
145
  });
116
146
  const existing = serverBundlePathCache.get(cacheKey);
117
147
  if (existing) {
@@ -133,6 +163,7 @@ async function buildServerModule(
133
163
  absoluteFilePath,
134
164
  outDir,
135
165
  serverBytecode,
166
+ nodeEnv,
136
167
  }),
137
168
  );
138
169
 
@@ -149,6 +180,7 @@ async function buildServerModule(
149
180
  absoluteFilePath,
150
181
  outDir,
151
182
  serverBytecode: false,
183
+ nodeEnv,
152
184
  }),
153
185
  );
154
186
 
@@ -210,10 +242,13 @@ export async function loadRouteModule(
210
242
  filePath: string,
211
243
  options: RouteModuleLoadOptions = {},
212
244
  ): Promise<RouteModule> {
213
- const bundledModulePath = await buildServerModule(filePath, options);
245
+ const normalizedOptions = normalizeLoadOptions(options);
246
+ const modulePath = normalizedOptions.devSourceImports
247
+ ? path.resolve(filePath)
248
+ : await buildServerModule(filePath, normalizedOptions);
214
249
  const moduleValue = await importModule<unknown>(
215
- bundledModulePath,
216
- options.cacheBustKey,
250
+ modulePath,
251
+ normalizedOptions.cacheBustKey,
217
252
  );
218
253
  return toRouteModule(filePath, moduleValue);
219
254
  }
@@ -224,10 +259,12 @@ export async function loadRouteModules(options: {
224
259
  routeFilePath: string;
225
260
  cacheBustKey?: string;
226
261
  serverBytecode?: boolean;
262
+ devSourceImports?: boolean;
227
263
  }): Promise<RouteModuleBundle> {
228
264
  const moduleOptions: RouteModuleLoadOptions = {
229
265
  cacheBustKey: options.cacheBustKey,
230
266
  serverBytecode: options.serverBytecode,
267
+ devSourceImports: options.devSourceImports,
231
268
  };
232
269
  const [root, layouts, route] = await Promise.all([
233
270
  loadRouteModule(options.rootFilePath, moduleOptions),
@@ -266,16 +303,20 @@ function normalizeMiddlewareExport(value: unknown): Middleware[] {
266
303
 
267
304
  export async function loadGlobalMiddleware(
268
305
  middlewareFilePath: string,
269
- cacheBustKey?: string,
306
+ options: string | RouteModuleLoadOptions = {},
270
307
  ): Promise<Middleware[]> {
271
308
  if (!(await existsPath(middlewareFilePath))) {
272
309
  return [];
273
310
  }
274
311
 
275
- const raw = await importModule<Record<string, unknown>>(
276
- middlewareFilePath,
277
- cacheBustKey,
278
- );
312
+ 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
+ ));
279
320
 
280
321
  return [
281
322
  ...normalizeMiddlewareExport(raw.default),
@@ -285,14 +326,18 @@ export async function loadGlobalMiddleware(
285
326
 
286
327
  export async function loadNestedMiddleware(
287
328
  middlewareFilePaths: string[],
288
- cacheBustKey?: string,
329
+ options: string | RouteModuleLoadOptions = {},
289
330
  ): Promise<Middleware[]> {
331
+ const normalizedOptions = normalizeLoadOptions(options);
290
332
  const rawModules = await Promise.all(
291
- middlewareFilePaths.map((middlewareFilePath) => {
292
- return importModule<Record<string, unknown>>(
293
- middlewareFilePath,
294
- cacheBustKey,
295
- );
333
+ 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
+ ));
296
341
  }),
297
342
  );
298
343
 
@@ -310,8 +355,13 @@ export function extractRouteMiddleware(module: RouteModule): Middleware[] {
310
355
 
311
356
  export async function loadApiRouteModule(
312
357
  filePath: string,
313
- cacheBustKey?: string,
358
+ options: string | RouteModuleLoadOptions = {},
314
359
  ): Promise<ApiRouteModule> {
315
- const raw = await importModule<ApiRouteModule>(filePath, cacheBustKey);
316
- return raw;
360
+ 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;
317
367
  }