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.
@@ -0,0 +1,206 @@
1
+ import type { ResponseContext, ResponseCookieOptions, ResponseCookies } from "./types";
2
+
3
+ type HeaderOperation =
4
+ | { type: "set"; name: string; value: string }
5
+ | { type: "append"; name: string; value: string }
6
+ | { type: "delete"; name: string };
7
+
8
+ interface ResponseContextState {
9
+ requestCookies: Map<string, string>;
10
+ pendingCookies: Map<string, string | undefined>;
11
+ headerOperations: HeaderOperation[];
12
+ headerPreview: Headers;
13
+ }
14
+
15
+ const responseContextState = new WeakMap<ResponseContext, ResponseContextState>();
16
+
17
+ function toHeaderName(name: string): string {
18
+ return String(name).trim();
19
+ }
20
+
21
+ function toCookieDate(value: Date | string): string {
22
+ if (value instanceof Date) {
23
+ return value.toUTCString();
24
+ }
25
+
26
+ const parsed = new Date(value);
27
+ if (!Number.isNaN(parsed.valueOf())) {
28
+ return parsed.toUTCString();
29
+ }
30
+
31
+ return value;
32
+ }
33
+
34
+ function normalizeSameSite(value: ResponseCookieOptions["sameSite"]): "Strict" | "Lax" | "None" | null {
35
+ if (!value) {
36
+ return null;
37
+ }
38
+
39
+ const lowered = value.toLowerCase();
40
+ if (lowered === "strict") {
41
+ return "Strict";
42
+ }
43
+ if (lowered === "lax") {
44
+ return "Lax";
45
+ }
46
+ if (lowered === "none") {
47
+ return "None";
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ function serializeCookie(name: string, value: string, options: ResponseCookieOptions = {}): string {
54
+ const parts = [`${name}=${encodeURIComponent(value)}`];
55
+
56
+ if (options.path) {
57
+ parts.push(`Path=${options.path}`);
58
+ }
59
+ if (options.domain) {
60
+ parts.push(`Domain=${options.domain}`);
61
+ }
62
+ if (typeof options.maxAge === "number" && Number.isFinite(options.maxAge)) {
63
+ parts.push(`Max-Age=${Math.trunc(options.maxAge)}`);
64
+ }
65
+ if (options.expires) {
66
+ parts.push(`Expires=${toCookieDate(options.expires)}`);
67
+ }
68
+ if (options.httpOnly) {
69
+ parts.push("HttpOnly");
70
+ }
71
+ if (options.secure) {
72
+ parts.push("Secure");
73
+ }
74
+ const sameSite = normalizeSameSite(options.sameSite);
75
+ if (sameSite) {
76
+ parts.push(`SameSite=${sameSite}`);
77
+ }
78
+
79
+ return parts.join("; ");
80
+ }
81
+
82
+ function serializeDeleteCookie(
83
+ name: string,
84
+ options: Omit<ResponseCookieOptions, "expires" | "maxAge"> = {},
85
+ ): string {
86
+ return serializeCookie(name, "", {
87
+ ...options,
88
+ maxAge: 0,
89
+ expires: new Date(0),
90
+ });
91
+ }
92
+
93
+ function createMutableHeaders(state: ResponseContextState): ResponseContext["headers"] {
94
+ return {
95
+ set(name, value) {
96
+ const normalizedName = toHeaderName(name);
97
+ state.headerOperations.push({
98
+ type: "set",
99
+ name: normalizedName,
100
+ value: String(value),
101
+ });
102
+ state.headerPreview.set(normalizedName, String(value));
103
+ },
104
+ append(name, value) {
105
+ const normalizedName = toHeaderName(name);
106
+ state.headerOperations.push({
107
+ type: "append",
108
+ name: normalizedName,
109
+ value: String(value),
110
+ });
111
+ state.headerPreview.append(normalizedName, String(value));
112
+ },
113
+ delete(name) {
114
+ const normalizedName = toHeaderName(name);
115
+ state.headerOperations.push({
116
+ type: "delete",
117
+ name: normalizedName,
118
+ });
119
+ state.headerPreview.delete(normalizedName);
120
+ },
121
+ get(name) {
122
+ return state.headerPreview.get(toHeaderName(name));
123
+ },
124
+ has(name) {
125
+ return state.headerPreview.has(toHeaderName(name));
126
+ },
127
+ };
128
+ }
129
+
130
+ class MutableResponseCookies implements ResponseCookies {
131
+ constructor(private readonly state: ResponseContextState) {}
132
+
133
+ get(name: string): string | undefined {
134
+ if (this.state.pendingCookies.has(name)) {
135
+ return this.state.pendingCookies.get(name);
136
+ }
137
+
138
+ return this.state.requestCookies.get(name);
139
+ }
140
+
141
+ set(name: string, value: string, options: ResponseCookieOptions = {}): void {
142
+ const serialized = serializeCookie(name, value, options);
143
+ this.state.pendingCookies.set(name, value);
144
+ this.state.headerOperations.push({
145
+ type: "append",
146
+ name: "set-cookie",
147
+ value: serialized,
148
+ });
149
+ this.state.headerPreview.append("set-cookie", serialized);
150
+ }
151
+
152
+ delete(name: string, options: Omit<ResponseCookieOptions, "expires" | "maxAge"> = {}): void {
153
+ const serialized = serializeDeleteCookie(name, options);
154
+ this.state.pendingCookies.set(name, undefined);
155
+ this.state.headerOperations.push({
156
+ type: "append",
157
+ name: "set-cookie",
158
+ value: serialized,
159
+ });
160
+ this.state.headerPreview.append("set-cookie", serialized);
161
+ }
162
+ }
163
+
164
+ export function createResponseContext(requestCookies: Map<string, string>): ResponseContext {
165
+ const state: ResponseContextState = {
166
+ requestCookies,
167
+ pendingCookies: new Map(),
168
+ headerOperations: [],
169
+ headerPreview: new Headers(),
170
+ };
171
+
172
+ const context: ResponseContext = {
173
+ headers: createMutableHeaders(state),
174
+ cookies: new MutableResponseCookies(state),
175
+ };
176
+
177
+ responseContextState.set(context, state);
178
+ return context;
179
+ }
180
+
181
+ export function applyResponseContext(response: Response, context: ResponseContext): Response {
182
+ const state = responseContextState.get(context);
183
+ if (!state || state.headerOperations.length === 0) {
184
+ return response;
185
+ }
186
+
187
+ const headers = new Headers(response.headers);
188
+ for (const operation of state.headerOperations) {
189
+ if (operation.type === "set") {
190
+ headers.set(operation.name, operation.value);
191
+ continue;
192
+ }
193
+ if (operation.type === "append") {
194
+ headers.append(operation.name, operation.value);
195
+ continue;
196
+ }
197
+
198
+ headers.delete(operation.name);
199
+ }
200
+
201
+ return new Response(response.body, {
202
+ status: response.status,
203
+ statusText: response.statusText,
204
+ headers,
205
+ });
206
+ }
@@ -1,23 +1,56 @@
1
- export type {
2
- Action,
3
- ActionContext,
4
- ActionResult,
5
- DeferredLoaderResult,
6
- DeferredToken,
7
- Loader,
8
- LoaderContext,
9
- LoaderResult,
10
- Middleware,
11
- Params,
12
- RedirectResult,
13
- RequestContext,
14
- RouteCatchContext,
15
- RouteErrorContext,
16
- RouteErrorResponse,
1
+ import type { AppRouteLocals as RootAppRouteLocals } from "react-bun-ssr";
2
+ import type {
3
+ Action as RuntimeAction,
4
+ ActionContext as RuntimeActionContext,
5
+ ActionResult as RuntimeActionResult,
6
+ DeferredLoaderResult as RuntimeDeferredLoaderResult,
7
+ DeferredToken as RuntimeDeferredToken,
8
+ Loader as RuntimeLoader,
9
+ LoaderContext as RuntimeLoaderContext,
10
+ LoaderResult as RuntimeLoaderResult,
11
+ Middleware as RuntimeMiddleware,
12
+ Params as RuntimeParams,
13
+ RedirectResult as RuntimeRedirectResult,
14
+ RequestContext as RuntimeRequestContext,
15
+ ResponseContext as RuntimeResponseContext,
16
+ ResponseCookies as RuntimeResponseCookies,
17
+ ResponseCookieOptions as RuntimeResponseCookieOptions,
18
+ RouteCatchContext as RuntimeRouteCatchContext,
19
+ RouteErrorContext as RuntimeRouteErrorContext,
20
+ RouteErrorResponse as RuntimeRouteErrorResponse,
17
21
  } from "./types";
18
22
 
19
- export { defer, json, redirect } from "./helpers";
23
+ export interface AppRouteLocals extends RootAppRouteLocals, Record<string, unknown> {}
24
+
25
+ export type Action = RuntimeAction<AppRouteLocals>;
26
+ export type ActionContext = RuntimeActionContext<AppRouteLocals>;
27
+ export type ActionResult = RuntimeActionResult;
28
+ export type DeferredLoaderResult = RuntimeDeferredLoaderResult;
29
+ export type DeferredToken = RuntimeDeferredToken;
30
+ export type Loader = RuntimeLoader<AppRouteLocals>;
31
+ export type LoaderContext = RuntimeLoaderContext<AppRouteLocals>;
32
+ export type LoaderResult = RuntimeLoaderResult;
33
+ export type Middleware = RuntimeMiddleware<AppRouteLocals>;
34
+ export type Params = RuntimeParams;
35
+ export type RedirectResult = RuntimeRedirectResult;
36
+ export type RequestContext = RuntimeRequestContext<AppRouteLocals>;
37
+ export type ResponseContext = RuntimeResponseContext;
38
+ export type ResponseCookies = RuntimeResponseCookies;
39
+ export type ResponseCookieOptions = RuntimeResponseCookieOptions;
40
+ export type RouteCatchContext = RuntimeRouteCatchContext;
41
+ export type RouteErrorContext = RuntimeRouteErrorContext;
42
+ export type RouteErrorResponse = RuntimeRouteErrorResponse;
43
+
44
+ export { assertSameOriginAction, defer, json, redirect, sanitizeRedirectTarget } from "./helpers";
20
45
  export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
21
46
  export { Link, type LinkProps } from "./link";
22
47
  export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
23
- export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
48
+ export {
49
+ Outlet,
50
+ createRouteAction,
51
+ useLoaderData,
52
+ useParams,
53
+ useRequestUrl,
54
+ useRouteAction,
55
+ useRouteError,
56
+ } from "./tree";
@@ -21,6 +21,7 @@ const LAYOUT_FILE_RE = /\.(tsx|jsx|ts|js)$/;
21
21
  const API_FILE_RE = /\.(ts|js|tsx|jsx)$/;
22
22
  const MD_FILE_RE = /\.md$/;
23
23
  const MDX_FILE_RE = /\.mdx$/;
24
+ const SERVER_SUFFIX_RE = /\.server$/;
24
25
 
25
26
  async function walkFiles(rootDir: string): Promise<string[]> {
26
27
  if (!(await existsPath(rootDir))) {
@@ -104,6 +105,23 @@ function getAncestorDirs(relativeDir: string): string[] {
104
105
  return result;
105
106
  }
106
107
 
108
+ function stripServerSuffix(value: string): string {
109
+ return value.replace(SERVER_SUFFIX_RE, "");
110
+ }
111
+
112
+ function hasServerSuffix(value: string): boolean {
113
+ return SERVER_SUFFIX_RE.test(value);
114
+ }
115
+
116
+ function removeServerSuffixFromRelativePath(relativeFilePath: string): string {
117
+ const extension = path.extname(relativeFilePath);
118
+ if (!extension) {
119
+ return relativeFilePath;
120
+ }
121
+ const withoutExt = relativeFilePath.slice(0, -extension.length);
122
+ return `${stripServerSuffix(withoutExt)}${extension}`;
123
+ }
124
+
107
125
  export async function scanRoutes(
108
126
  routesDir: string,
109
127
  options: {
@@ -113,16 +131,21 @@ export async function scanRoutes(
113
131
  const allFiles = (await walkFiles(routesDir)).sort((a, b) => a.localeCompare(b));
114
132
 
115
133
  const layoutByDir = new Map<string, string>();
134
+ const layoutServerByDir = new Map<string, string>();
116
135
  const middlewareByDir = new Map<string, string>();
136
+ const pageCompanionByKey = new Map<string, string>();
117
137
 
118
138
  const pageRouteTasks: Array<Promise<PageRouteDefinition>> = [];
119
- const apiRoutes: ApiRouteDefinition[] = [];
139
+ const pageRouteKeys = new Set<string>();
140
+ const apiRouteByKey = new Map<string, ApiRouteDefinition>();
120
141
 
121
142
  for (const absoluteFilePath of allFiles) {
122
143
  const relativeFilePath = normalizeSlashes(path.relative(routesDir, absoluteFilePath));
123
144
  const relativeDir = normalizeSlashes(path.dirname(relativeFilePath) === "." ? "" : path.dirname(relativeFilePath));
124
145
  const fileName = path.basename(relativeFilePath);
125
146
  const fileBaseName = trimFileExtension(fileName);
147
+ const logicalBaseName = stripServerSuffix(fileBaseName);
148
+ const isServerVariant = hasServerSuffix(fileBaseName);
126
149
 
127
150
  if (MDX_FILE_RE.test(fileName)) {
128
151
  throw new Error(
@@ -130,13 +153,56 @@ export async function scanRoutes(
130
153
  );
131
154
  }
132
155
 
133
- if (fileBaseName === "_layout" && LAYOUT_FILE_RE.test(fileName)) {
134
- layoutByDir.set(relativeDir, absoluteFilePath);
156
+ if (logicalBaseName === "_layout" && LAYOUT_FILE_RE.test(fileName)) {
157
+ if (isServerVariant) {
158
+ if (layoutServerByDir.has(relativeDir)) {
159
+ throw new Error(
160
+ `Duplicate layout companion route files in "${relativeDir || "/"}": ` +
161
+ `"${layoutServerByDir.get(relativeDir)!}" and "${absoluteFilePath}".`,
162
+ );
163
+ }
164
+ layoutServerByDir.set(relativeDir, absoluteFilePath);
165
+ } else {
166
+ layoutByDir.set(relativeDir, absoluteFilePath);
167
+ }
135
168
  continue;
136
169
  }
137
170
 
138
- if (fileBaseName === "_middleware" && API_FILE_RE.test(fileName)) {
171
+ if (logicalBaseName === "_middleware" && API_FILE_RE.test(fileName)) {
172
+ const existing = middlewareByDir.get(relativeDir);
173
+ if (existing && existing !== absoluteFilePath) {
174
+ throw new Error(
175
+ `Middleware file collision in "${relativeDir || "/"}": ` +
176
+ `"${existing}" and "${absoluteFilePath}" both resolve to "_middleware".`,
177
+ );
178
+ }
139
179
  middlewareByDir.set(relativeDir, absoluteFilePath);
180
+ continue;
181
+ }
182
+
183
+ if (
184
+ isServerVariant
185
+ && !relativeFilePath.startsWith("api/")
186
+ && PAGE_FILE_RE.test(fileName)
187
+ && !logicalBaseName.startsWith("_")
188
+ ) {
189
+ const canonicalRelativeFilePath = removeServerSuffixFromRelativePath(relativeFilePath);
190
+ const routeKey = trimFileExtension(canonicalRelativeFilePath);
191
+ const existing = pageCompanionByKey.get(routeKey);
192
+ if (existing && existing !== absoluteFilePath) {
193
+ throw new Error(
194
+ `Duplicate route companion files for "${routeKey}": "${existing}" and "${absoluteFilePath}".`,
195
+ );
196
+ }
197
+ pageCompanionByKey.set(routeKey, absoluteFilePath);
198
+ }
199
+ }
200
+
201
+ for (const [relativeDir, layoutServerFilePath] of layoutServerByDir.entries()) {
202
+ if (!layoutByDir.has(relativeDir)) {
203
+ throw new Error(
204
+ `Found layout companion "${layoutServerFilePath}" without base layout file "${relativeDir ? `${relativeDir}/` : ""}_layout.tsx".`,
205
+ );
140
206
  }
141
207
  }
142
208
 
@@ -145,10 +211,12 @@ export async function scanRoutes(
145
211
  const relativeDir = normalizeSlashes(path.dirname(relativeFilePath) === "." ? "" : path.dirname(relativeFilePath));
146
212
  const fileName = path.basename(relativeFilePath);
147
213
  const fileBaseName = trimFileExtension(fileName);
214
+ const logicalBaseName = stripServerSuffix(fileBaseName);
215
+ const isServerVariant = hasServerSuffix(fileBaseName);
148
216
 
149
217
  if (
150
- (fileBaseName === "_layout" && LAYOUT_FILE_RE.test(fileName))
151
- || (fileBaseName === "_middleware" && API_FILE_RE.test(fileName))
218
+ (logicalBaseName === "_layout" && LAYOUT_FILE_RE.test(fileName))
219
+ || (logicalBaseName === "_middleware" && API_FILE_RE.test(fileName))
152
220
  ) {
153
221
  continue;
154
222
  }
@@ -156,18 +224,31 @@ export async function scanRoutes(
156
224
  const isApiRoute = relativeFilePath.startsWith("api/");
157
225
 
158
226
  if (isApiRoute) {
159
- if (!API_FILE_RE.test(fileName) || fileBaseName.startsWith("_")) {
227
+ if (!API_FILE_RE.test(fileName) || logicalBaseName.startsWith("_")) {
160
228
  continue;
161
229
  }
162
230
 
163
- const withoutExt = trimFileExtension(relativeFilePath);
231
+ const canonicalRelativeFilePath = isServerVariant
232
+ ? removeServerSuffixFromRelativePath(relativeFilePath)
233
+ : relativeFilePath;
234
+ const withoutExt = trimFileExtension(canonicalRelativeFilePath);
235
+ if (apiRouteByKey.has(withoutExt)) {
236
+ const existing = apiRouteByKey.get(withoutExt)!;
237
+ throw new Error(
238
+ `API route collision for "${withoutExt}": "${existing.filePath}" and "${absoluteFilePath}". ` +
239
+ "Use only one of the plain route file or .server route file.",
240
+ );
241
+ }
164
242
  const shape = toUrlShape(withoutExt);
165
- const ancestors = getAncestorDirs(relativeDir);
243
+ const canonicalRelativeDir = normalizeSlashes(
244
+ path.dirname(canonicalRelativeFilePath) === "." ? "" : path.dirname(canonicalRelativeFilePath),
245
+ );
246
+ const ancestors = getAncestorDirs(canonicalRelativeDir);
166
247
  const middlewareFiles = ancestors
167
248
  .map(dir => middlewareByDir.get(dir))
168
249
  .filter((value): value is string => Boolean(value));
169
250
 
170
- apiRoutes.push({
251
+ apiRouteByKey.set(withoutExt, {
171
252
  type: "api",
172
253
  id: toRouteId(withoutExt),
173
254
  filePath: absoluteFilePath,
@@ -175,12 +256,12 @@ export async function scanRoutes(
175
256
  segments: shape.segments,
176
257
  score: getRouteScore(shape.segments),
177
258
  middlewareFiles,
178
- directory: relativeDir,
259
+ directory: canonicalRelativeDir,
179
260
  });
180
261
  continue;
181
262
  }
182
263
 
183
- if (!PAGE_FILE_RE.test(fileName) || fileBaseName.startsWith("_")) {
264
+ if (!PAGE_FILE_RE.test(fileName) || logicalBaseName.startsWith("_") || isServerVariant) {
184
265
  continue;
185
266
  }
186
267
 
@@ -203,11 +284,13 @@ export async function scanRoutes(
203
284
  .map(dir => middlewareByDir.get(dir))
204
285
  .filter((value): value is string => Boolean(value));
205
286
 
287
+ pageRouteKeys.add(withoutExt);
206
288
  pageRouteTasks.push(routeFilePath.then((resolvedRouteFilePath) => {
207
289
  return {
208
290
  type: "page",
209
291
  id: toRouteId(withoutExt),
210
292
  filePath: resolvedRouteFilePath,
293
+ serverFilePath: pageCompanionByKey.get(withoutExt),
211
294
  routePath: shape.routePath,
212
295
  segments: shape.segments,
213
296
  score: getRouteScore(shape.segments),
@@ -218,7 +301,16 @@ export async function scanRoutes(
218
301
  }));
219
302
  }
220
303
 
304
+ for (const [routeKey, companionPath] of pageCompanionByKey.entries()) {
305
+ if (!pageRouteKeys.has(routeKey)) {
306
+ throw new Error(
307
+ `Found route companion "${companionPath}" without base route module "${routeKey}.tsx".`,
308
+ );
309
+ }
310
+ }
311
+
221
312
  const pageRoutes = await Promise.all(pageRouteTasks);
313
+ const apiRoutes = [...apiRouteByKey.values()];
222
314
 
223
315
  return {
224
316
  pages: sortRoutesBySpecificity(pageRoutes),