react-bun-ssr 0.3.1 → 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.
@@ -0,0 +1,270 @@
1
+ import {
2
+ RBSSR_HEAD_MARKER_END_ATTR,
3
+ RBSSR_HEAD_MARKER_START_ATTR,
4
+ } from "./runtime-constants";
5
+
6
+ const ELEMENT_NODE = 1;
7
+ const TEXT_NODE = 3;
8
+ const COMMENT_NODE = 8;
9
+
10
+ export function isContentInsensitiveHeadTag(tagName: string): boolean {
11
+ const normalized = tagName.toLowerCase();
12
+ return normalized === "script" || normalized === "style" || normalized === "noscript";
13
+ }
14
+
15
+ function normalizeNodeText(value: string): string {
16
+ return value.replace(/\s+/g, " ").trim();
17
+ }
18
+
19
+ export function nodeSignature(node: Node): string {
20
+ if (node.nodeType === TEXT_NODE) {
21
+ return `text:${node.textContent ?? ""}`;
22
+ }
23
+
24
+ if (node.nodeType === COMMENT_NODE) {
25
+ return `comment:${node.textContent ?? ""}`;
26
+ }
27
+
28
+ if (node.nodeType !== ELEMENT_NODE) {
29
+ return `node:${node.nodeType}`;
30
+ }
31
+
32
+ const element = node as Element;
33
+ const tagName = element.tagName.toLowerCase();
34
+ const attrs = Array.from(element.attributes)
35
+ .map(attribute => `${attribute.name}=${attribute.value}`)
36
+ .sort((a, b) => a.localeCompare(b))
37
+ .join("|");
38
+
39
+ if (isContentInsensitiveHeadTag(tagName)) {
40
+ return `element:${tagName}:${attrs}`;
41
+ }
42
+
43
+ if (tagName === "title") {
44
+ return `element:${tagName}:${attrs}:${normalizeNodeText(element.textContent ?? "")}`;
45
+ }
46
+
47
+ // Keep generic element identity structural and cheap: no innerHTML serialization.
48
+ return `element:${tagName}:${attrs}:${normalizeNodeText(element.textContent ?? "")}:${element.childElementCount}`;
49
+ }
50
+
51
+ function isIgnorableTextNode(node: Node): boolean {
52
+ return node.nodeType === TEXT_NODE && (node.textContent ?? "").trim().length === 0;
53
+ }
54
+
55
+ function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
56
+ const nodes: Node[] = [];
57
+ let cursor = startMarker.nextSibling;
58
+ while (cursor && cursor !== endMarker) {
59
+ nodes.push(cursor);
60
+ cursor = cursor.nextSibling;
61
+ }
62
+ return nodes;
63
+ }
64
+
65
+ function removeNode(node: Node): void {
66
+ if (node.parentNode) {
67
+ node.parentNode.removeChild(node);
68
+ }
69
+ }
70
+
71
+ function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
72
+ if (node.nodeType !== ELEMENT_NODE) {
73
+ return false;
74
+ }
75
+
76
+ const element = node as Element;
77
+ return (
78
+ element.tagName.toLowerCase() === "link"
79
+ && (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
80
+ && Boolean(element.getAttribute("href"))
81
+ );
82
+ }
83
+
84
+ function toAbsoluteHref(href: string, baseUri: string): string {
85
+ return new URL(href, baseUri).toString();
86
+ }
87
+
88
+ function resolveBaseUri(documentRef: Document): string {
89
+ const candidate = documentRef.baseURI;
90
+ if (typeof candidate === "string" && candidate.length > 0) {
91
+ try {
92
+ const parsed = new URL(candidate);
93
+ if (parsed.protocol !== "about:") {
94
+ return candidate;
95
+ }
96
+ } catch {
97
+ // fall back below
98
+ }
99
+ }
100
+
101
+ return "http://localhost/";
102
+ }
103
+
104
+ function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
105
+ if (!link.ownerDocument.defaultView || link.sheet) {
106
+ return Promise.resolve();
107
+ }
108
+
109
+ return new Promise(resolve => {
110
+ const finish = () => {
111
+ link.removeEventListener("load", finish);
112
+ link.removeEventListener("error", finish);
113
+ resolve();
114
+ };
115
+
116
+ link.addEventListener("load", finish, { once: true });
117
+ link.addEventListener("error", finish, { once: true });
118
+ });
119
+ }
120
+
121
+ async function reconcileStylesheetLinks(options: {
122
+ head: HTMLHeadElement;
123
+ desiredStylesheetHrefs: string[];
124
+ baseUri: string;
125
+ }): Promise<void> {
126
+ const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(href => toAbsoluteHref(href, options.baseUri));
127
+ const existingLinks = Array.from(
128
+ options.head.querySelectorAll('link[rel="stylesheet"][href]'),
129
+ ) as HTMLLinkElement[];
130
+
131
+ const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
132
+ for (const link of existingLinks) {
133
+ const href = link.getAttribute("href");
134
+ if (!href) {
135
+ continue;
136
+ }
137
+ const absoluteHref = toAbsoluteHref(href, options.baseUri);
138
+ const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
139
+ list.push(link);
140
+ existingByAbsoluteHref.set(absoluteHref, list);
141
+ }
142
+
143
+ const waitForLoads: Promise<void>[] = [];
144
+ for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
145
+ const href = options.desiredStylesheetHrefs[index]!;
146
+ const absoluteHref = desiredAbsoluteHrefs[index]!;
147
+ const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
148
+ if (existing) {
149
+ waitForLoads.push(waitForStylesheetLoad(existing));
150
+ continue;
151
+ }
152
+
153
+ const link = options.head.ownerDocument.createElement("link");
154
+ link.setAttribute("rel", "stylesheet");
155
+ link.setAttribute("href", href);
156
+ options.head.appendChild(link);
157
+ waitForLoads.push(waitForStylesheetLoad(link));
158
+ }
159
+
160
+ const seen = new Set<string>();
161
+ for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
162
+ const href = link.getAttribute("href");
163
+ if (!href) {
164
+ continue;
165
+ }
166
+
167
+ const absoluteHref = toAbsoluteHref(href, options.baseUri);
168
+ if (seen.has(absoluteHref)) {
169
+ removeNode(link);
170
+ continue;
171
+ }
172
+
173
+ seen.add(absoluteHref);
174
+ }
175
+
176
+ await Promise.all(waitForLoads);
177
+ }
178
+
179
+ export async function replaceManagedHead(
180
+ headHtml: string,
181
+ options: {
182
+ documentRef?: Document;
183
+ startMarkerAttr?: string;
184
+ endMarkerAttr?: string;
185
+ } = {},
186
+ ): Promise<void> {
187
+ const documentRef = options.documentRef ?? document;
188
+ const startMarkerAttr = options.startMarkerAttr ?? RBSSR_HEAD_MARKER_START_ATTR;
189
+ const endMarkerAttr = options.endMarkerAttr ?? RBSSR_HEAD_MARKER_END_ATTR;
190
+
191
+ const head = documentRef.head;
192
+ const startMarker = head.querySelector(`meta[${startMarkerAttr}]`);
193
+ const endMarker = head.querySelector(`meta[${endMarkerAttr}]`);
194
+
195
+ if (!startMarker || !endMarker || startMarker === endMarker) {
196
+ return;
197
+ }
198
+
199
+ const template = documentRef.createElement("template");
200
+ template.innerHTML = headHtml;
201
+
202
+ const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
203
+ .map(link => link.getAttribute("href"))
204
+ .filter((value): value is string => Boolean(value));
205
+ for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
206
+ removeNode(styleNode);
207
+ }
208
+
209
+ const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
210
+ const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
211
+ if (isIgnorableTextNode(node)) {
212
+ return false;
213
+ }
214
+
215
+ if (isStylesheetLinkNode(node)) {
216
+ return false;
217
+ }
218
+
219
+ return true;
220
+ });
221
+ const unusedCurrentNodes = new Set(currentNodes);
222
+
223
+ let cursor = startMarker.nextSibling;
224
+
225
+ // Structural identity only: avoid subtree HTML serialization in signature checks.
226
+ for (const desiredNode of desiredNodes) {
227
+ while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
228
+ const next = cursor.nextSibling;
229
+ removeNode(cursor);
230
+ cursor = next;
231
+ }
232
+
233
+ const desiredSignature = nodeSignature(desiredNode);
234
+
235
+ if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
236
+ unusedCurrentNodes.delete(cursor);
237
+ cursor = cursor.nextSibling;
238
+ continue;
239
+ }
240
+
241
+ let matchedNode: Node | null = null;
242
+ for (const currentNode of currentNodes) {
243
+ if (!unusedCurrentNodes.has(currentNode)) {
244
+ continue;
245
+ }
246
+ if (nodeSignature(currentNode) === desiredSignature) {
247
+ matchedNode = currentNode;
248
+ break;
249
+ }
250
+ }
251
+
252
+ if (matchedNode) {
253
+ unusedCurrentNodes.delete(matchedNode);
254
+ head.insertBefore(matchedNode, cursor ?? endMarker);
255
+ continue;
256
+ }
257
+
258
+ head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
259
+ }
260
+
261
+ for (const leftover of unusedCurrentNodes) {
262
+ removeNode(leftover);
263
+ }
264
+
265
+ await reconcileStylesheetLinks({
266
+ head,
267
+ desiredStylesheetHrefs,
268
+ baseUri: resolveBaseUri(documentRef),
269
+ });
270
+ }
@@ -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";
@@ -80,7 +80,7 @@ export function sha256Short(input: HashInput): string {
80
80
  }
81
81
 
82
82
  export async function ensureDir(dirPath: string): Promise<void> {
83
- runPosix(["mkdir", "-p", dirPath], `Failed to create directory: ${dirPath}`);
83
+ await runPosix(["mkdir", "-p", dirPath], `Failed to create directory: ${dirPath}`);
84
84
  }
85
85
 
86
86
  export async function ensureCleanDir(dirPath: string): Promise<void> {
@@ -89,7 +89,7 @@ export async function ensureCleanDir(dirPath: string): Promise<void> {
89
89
  }
90
90
 
91
91
  export async function removePath(targetPath: string): Promise<void> {
92
- runPosix(["rm", "-rf", targetPath], `Failed to remove path: ${targetPath}`);
92
+ await runPosix(["rm", "-rf", targetPath], `Failed to remove path: ${targetPath}`);
93
93
  }
94
94
 
95
95
  export async function listEntries(dirPath: string): Promise<FileEntry[]> {
@@ -127,20 +127,23 @@ export async function makeTempDir(prefix: string): Promise<string> {
127
127
  return dirPath;
128
128
  }
129
129
 
130
- function runPosix(cmd: string[], context: string): void {
131
- const result = Bun.spawnSync({
130
+ async function runPosix(cmd: string[], context: string): Promise<void> {
131
+ const result = Bun.spawn({
132
132
  cmd,
133
133
  stdout: "pipe",
134
134
  stderr: "pipe",
135
135
  });
136
136
 
137
- if (result.exitCode === 0) {
137
+ const exitCode = await result.exited;
138
+ if (exitCode === 0) {
138
139
  return;
139
140
  }
140
141
 
141
- const decoder = new TextDecoder();
142
- const stderr = result.stderr.length > 0 ? decoder.decode(result.stderr).trim() : "";
143
- const stdout = result.stdout.length > 0 ? decoder.decode(result.stdout).trim() : "";
144
- const details = stderr || stdout || `exit code ${result.exitCode}`;
142
+ const [stderr, stdout] = await Promise.all([
143
+ result.stderr ? new Response(result.stderr).text() : Promise.resolve(""),
144
+ result.stdout ? new Response(result.stdout).text() : Promise.resolve(""),
145
+ ]);
146
+
147
+ const details = stderr.trim() || stdout.trim() || `exit code ${exitCode}`;
145
148
  throw new Error(`[io] ${context} (${cmd.join(" ")}): ${details}`);
146
149
  }
@@ -6,8 +6,10 @@ import type {
6
6
  RouteSegment,
7
7
  } from "./types";
8
8
 
9
- // Bun FileSystemRouter is the runtime matcher used by the server.
10
- // This matcher is retained for lightweight unit coverage and internal utilities.
9
+ // Bun FileSystemRouter is the runtime matcher used by the server for projected routes.
10
+ // This matcher is used by server fallbacks and client transition matching.
11
+ // It intentionally does first-match linear scanning and expects routes to be pre-ordered
12
+ // by specificity (higher score first, then longer segment length, then routePath).
11
13
  function normalizePathname(pathname: string): string[] {
12
14
  if (!pathname || pathname === "/") {
13
15
  return [];