react-bun-ssr 0.1.0 → 0.2.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
+ }
@@ -101,6 +101,8 @@ export function resolveConfig(config: FrameworkConfig = {}, cwd = process.cwd())
101
101
  const rootModule = path.resolve(appDir, config.rootModule ?? "root.tsx");
102
102
  const middlewareFile = path.resolve(appDir, config.middlewareFile ?? "middleware.ts");
103
103
  const distDir = path.resolve(cwd, config.distDir ?? "dist");
104
+ const mode = config.mode ?? (process.env.NODE_ENV === "production" ? "production" : "development");
105
+ const serverBytecode = config.serverBytecode ?? mode === "production";
104
106
  const headerRules = toHeaderRules(config);
105
107
 
106
108
  return {
@@ -113,7 +115,8 @@ export function resolveConfig(config: FrameworkConfig = {}, cwd = process.cwd())
113
115
  distDir,
114
116
  host: config.host ?? "0.0.0.0",
115
117
  port: config.port ?? 3000,
116
- mode: config.mode ?? (process.env.NODE_ENV === "production" ? "production" : "development"),
118
+ mode,
119
+ serverBytecode,
117
120
  headerRules,
118
121
  };
119
122
  }
@@ -16,9 +16,15 @@ export type {
16
16
  RedirectResult,
17
17
  ResponseHeaderRule,
18
18
  RequestContext,
19
+ RouteCatchContext,
20
+ RouteErrorContext,
21
+ RouteErrorResponse,
19
22
  RouteModule,
20
23
  } from "./types";
21
24
 
22
25
  export { createServer, startHttpServer } from "./server";
23
26
  export { defer, json, redirect, defineConfig } from "./helpers";
27
+ export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
28
+ export { Link, type LinkProps } from "./link";
29
+ export { useRouter, type Router, type RouterNavigateOptions } from "./router";
24
30
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -122,7 +122,7 @@ export async function listEntries(dirPath: string): Promise<FileEntry[]> {
122
122
  }
123
123
 
124
124
  export async function makeTempDir(prefix: string): Promise<string> {
125
- const dirPath = path.join("/tmp", `${prefix}-${crypto.randomUUID()}`);
125
+ const dirPath = path.join("/tmp", `${prefix}-${Bun.randomUUIDv7()}`);
126
126
  await ensureDir(dirPath);
127
127
  return dirPath;
128
128
  }
@@ -0,0 +1,205 @@
1
+ import type {
2
+ AnchorHTMLAttributes,
3
+ FocusEvent,
4
+ MouseEvent,
5
+ TouchEvent,
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
+ }
16
+
17
+ export interface LinkProps
18
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
19
+ to: string;
20
+ replace?: boolean;
21
+ scroll?: boolean;
22
+ prefetch?: "intent" | "none";
23
+ onNavigate?: (info: NavigateInfo) => void;
24
+ }
25
+
26
+ function shouldHandleNavigation(event: MouseEvent<HTMLAnchorElement>): boolean {
27
+ if (event.defaultPrevented) {
28
+ return false;
29
+ }
30
+
31
+ if (event.button !== 0) {
32
+ return false;
33
+ }
34
+
35
+ if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
36
+ return false;
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ function toAbsoluteHref(to: string): string {
43
+ if (typeof window === "undefined") {
44
+ return to;
45
+ }
46
+ return new URL(to, window.location.href).toString();
47
+ }
48
+
49
+ function isInternalHref(href: string): boolean {
50
+ if (typeof window === "undefined") {
51
+ return false;
52
+ }
53
+
54
+ try {
55
+ const url = new URL(href, window.location.href);
56
+ return url.origin === window.location.origin;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function isSameDocumentHashNavigation(href: string): boolean {
63
+ if (typeof window === "undefined") {
64
+ return false;
65
+ }
66
+
67
+ try {
68
+ const target = new URL(href, window.location.href);
69
+ return (
70
+ target.origin === window.location.origin
71
+ && target.pathname === window.location.pathname
72
+ && target.search === window.location.search
73
+ && target.hash.length > 0
74
+ );
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ async function prefetch(href: string): Promise<void> {
81
+ if (typeof window === "undefined") {
82
+ return;
83
+ }
84
+
85
+ const runtime = await import("./client-runtime");
86
+ await runtime.prefetchTo(href);
87
+ }
88
+
89
+ async function navigate(href: string, options: {
90
+ replace?: boolean;
91
+ scroll?: boolean;
92
+ onNavigate?: (info: NavigateInfo) => void;
93
+ }): Promise<void> {
94
+ if (typeof window === "undefined") {
95
+ return;
96
+ }
97
+
98
+ const runtime = await import("./client-runtime");
99
+ await runtime.navigateWithNavigationApiOrFallback(href, {
100
+ replace: options.replace,
101
+ scroll: options.scroll,
102
+ onNavigate: options.onNavigate,
103
+ });
104
+ }
105
+
106
+ export function Link(props: LinkProps) {
107
+ const {
108
+ to,
109
+ replace = false,
110
+ scroll = true,
111
+ prefetch: prefetchMode = "intent",
112
+ onNavigate,
113
+ onMouseEnter,
114
+ onTouchStart,
115
+ onFocus,
116
+ onClick,
117
+ target,
118
+ rel,
119
+ download,
120
+ ...rest
121
+ } = props;
122
+
123
+ const href = to;
124
+ const resolvedHref = toAbsoluteHref(to);
125
+
126
+ const maybePrefetch = (): void => {
127
+ if (prefetchMode !== "intent") {
128
+ return;
129
+ }
130
+
131
+ if (!isInternalHref(resolvedHref)) {
132
+ return;
133
+ }
134
+
135
+ void prefetch(resolvedHref);
136
+ };
137
+
138
+ const handleMouseEnter = (event: MouseEvent<HTMLAnchorElement>): void => {
139
+ onMouseEnter?.(event);
140
+ if (event.defaultPrevented) {
141
+ return;
142
+ }
143
+ maybePrefetch();
144
+ };
145
+
146
+ const handleTouchStart = (event: TouchEvent<HTMLAnchorElement>): void => {
147
+ onTouchStart?.(event);
148
+ if (event.defaultPrevented) {
149
+ return;
150
+ }
151
+ maybePrefetch();
152
+ };
153
+
154
+ const handleFocus = (event: FocusEvent<HTMLAnchorElement>): void => {
155
+ onFocus?.(event);
156
+ if (event.defaultPrevented) {
157
+ return;
158
+ }
159
+ maybePrefetch();
160
+ };
161
+
162
+ const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
163
+ onClick?.(event);
164
+ if (!shouldHandleNavigation(event)) {
165
+ return;
166
+ }
167
+
168
+ if (download !== undefined) {
169
+ return;
170
+ }
171
+
172
+ if (target && target !== "_self") {
173
+ return;
174
+ }
175
+
176
+ if (!isInternalHref(resolvedHref)) {
177
+ return;
178
+ }
179
+
180
+ if (isSameDocumentHashNavigation(resolvedHref)) {
181
+ return;
182
+ }
183
+
184
+ event.preventDefault();
185
+ void navigate(resolvedHref, {
186
+ replace,
187
+ scroll,
188
+ onNavigate,
189
+ });
190
+ };
191
+
192
+ return (
193
+ <a
194
+ {...rest}
195
+ href={href}
196
+ target={target}
197
+ rel={rel}
198
+ download={download}
199
+ onClick={handleClick}
200
+ onMouseEnter={handleMouseEnter}
201
+ onTouchStart={handleTouchStart}
202
+ onFocus={handleFocus}
203
+ />
204
+ );
205
+ }
@@ -0,0 +1,54 @@
1
+ export interface MarkdownHeadingEntry {
2
+ text: string;
3
+ id: string;
4
+ depth: number;
5
+ }
6
+
7
+ function decodeHtml(value: string): string {
8
+ return value
9
+ .replace(/&lt;/g, '<')
10
+ .replace(/&gt;/g, '>')
11
+ .replace(/&quot;/g, '"')
12
+ .replace(/&#39;/g, "'")
13
+ .replace(/&amp;/g, '&');
14
+ }
15
+
16
+ function plainTextFromHtml(value: string): string {
17
+ return decodeHtml(value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
18
+ }
19
+
20
+ function slugifyHeading(value: string): string {
21
+ const slug = value
22
+ .toLowerCase()
23
+ .normalize('NFKD')
24
+ .replace(/[\u0300-\u036f]/g, '')
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '');
27
+
28
+ return slug || 'section';
29
+ }
30
+
31
+ export function addHeadingIds(html: string): string {
32
+ const seen = new Map<string, number>();
33
+
34
+ return html.replace(/<h([1-6])(\b[^>]*)>([\s\S]*?)<\/h\1>/g, (match, depth, attrs, inner) => {
35
+ const existingId = /\sid="([^"]+)"/.exec(attrs)?.[1];
36
+ if (existingId) {
37
+ return match;
38
+ }
39
+
40
+ const base = slugifyHeading(plainTextFromHtml(inner));
41
+ const count = seen.get(base) ?? 0;
42
+ seen.set(base, count + 1);
43
+ const id = count === 0 ? base : `${base}-${count + 1}`;
44
+ return `<h${depth}${attrs} id="${Bun.escapeHTML(id)}">${inner}</h${depth}>`;
45
+ });
46
+ }
47
+
48
+ export function extractHeadingEntriesFromHtml(html: string): MarkdownHeadingEntry[] {
49
+ return Array.from(html.matchAll(/<h([1-6])\b[^>]*id="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g)).map(match => ({
50
+ depth: Number(match[1]),
51
+ id: match[2] ?? '',
52
+ text: plainTextFromHtml(match[3] ?? ''),
53
+ }));
54
+ }
@@ -1,10 +1,10 @@
1
1
  import path from "node:path";
2
+ import { addHeadingIds } from "./markdown-headings";
2
3
  import { existsPath, readText, writeTextIfChanged } from "./io";
3
4
  import { normalizeSlashes, stableHash, trimFileExtension } from "./utils";
4
5
 
5
6
  const compiledMarkdownCache = new Map<string, { sourceHash: string; outputPath: string }>();
6
- const REQUIRED_FRONTMATTER_FIELDS = ["title", "description", "section", "order"] as const;
7
- const MARKDOWN_WRAPPER_VERSION = "2";
7
+ const MARKDOWN_WRAPPER_VERSION = "3";
8
8
 
9
9
  interface ParsedFrontmatter {
10
10
  title?: string;
@@ -22,15 +22,6 @@ function decodeHtml(value: string): string {
22
22
  .replace(/&amp;/g, "&");
23
23
  }
24
24
 
25
- function escapeHtml(value: string): string {
26
- return value
27
- .replace(/&/g, "&amp;")
28
- .replace(/</g, "&lt;")
29
- .replace(/>/g, "&gt;")
30
- .replace(/\"/g, "&quot;")
31
- .replace(/'/g, "&#39;");
32
- }
33
-
34
25
  function highlightWithRegex(
35
26
  source: string,
36
27
  regex: RegExp,
@@ -45,16 +36,16 @@ function highlightWithRegex(
45
36
  const index = match.index;
46
37
 
47
38
  if (index > cursor) {
48
- html += escapeHtml(source.slice(cursor, index));
39
+ html += Bun.escapeHTML(source.slice(cursor, index));
49
40
  }
50
41
 
51
- html += `<span class="token ${classify(value)}">${escapeHtml(value)}</span>`;
42
+ html += `<span class="token ${classify(value)}">${Bun.escapeHTML(value)}</span>`;
52
43
  cursor = index + value.length;
53
44
  match = regex.exec(source);
54
45
  }
55
46
 
56
47
  if (cursor < source.length) {
57
- html += escapeHtml(source.slice(cursor));
48
+ html += Bun.escapeHTML(source.slice(cursor));
58
49
  }
59
50
 
60
51
  return html;
@@ -96,7 +87,7 @@ function highlightCode(source: string, language: string): string {
96
87
  });
97
88
  }
98
89
 
99
- return escapeHtml(source);
90
+ return Bun.escapeHTML(source);
100
91
  }
101
92
 
102
93
  function applySyntaxHighlight(html: string): string {
@@ -105,7 +96,7 @@ function applySyntaxHighlight(html: string): string {
105
96
  (_match, language: string, rawCode: string) => {
106
97
  const code = decodeHtml(rawCode);
107
98
  const highlighted = highlightCode(code, language);
108
- return `<pre><code class="language-${escapeHtml(language)}">${highlighted}</code></pre>`;
99
+ return `<pre><code class="language-${Bun.escapeHTML(language)}">${highlighted}</code></pre>`;
109
100
  },
110
101
  );
111
102
  }
@@ -121,24 +112,11 @@ function resolveGeneratedRoot(routesDir: string, generatedMarkdownRootDir?: stri
121
112
  return path.resolve(appRoutesMatch[1]!, ".rbssr", "generated", "markdown-routes");
122
113
  }
123
114
 
124
- const snapshotRoutesMatch = normalizedRoutesDir.match(
125
- /^(.*)\/\.rbssr\/dev\/server-snapshots\/v\d+\/routes$/,
126
- );
127
- if (snapshotRoutesMatch) {
128
- return path.resolve(snapshotRoutesMatch[1]!, ".rbssr", "generated", "markdown-routes");
129
- }
130
-
131
115
  return path.resolve(routesDir, "..", ".rbssr", "generated", "markdown-routes");
132
116
  }
133
117
 
134
118
  function toRouteGroupKey(routesDir: string): string {
135
- const normalized = normalizeSlashes(path.resolve(routesDir));
136
- const canonical = normalized.replace(
137
- /\/\.rbssr\/dev\/server-snapshots\/v\d+\/routes$/,
138
- "/.rbssr/dev/server-snapshots/routes",
139
- );
140
-
141
- return stableHash(`${MARKDOWN_WRAPPER_VERSION}\0${canonical}`);
119
+ return stableHash(`${MARKDOWN_WRAPPER_VERSION}\0${normalizeSlashes(path.resolve(routesDir))}`);
142
120
  }
143
121
 
144
122
  function parseFrontmatter(raw: string): {
@@ -172,15 +150,6 @@ function parseFrontmatter(raw: string): {
172
150
  values.set(key, value);
173
151
  }
174
152
 
175
- for (const key of REQUIRED_FRONTMATTER_FIELDS) {
176
- if (!values.has(key)) {
177
- return {
178
- frontmatter: { tags: [] },
179
- markdown: raw,
180
- };
181
- }
182
- }
183
-
184
153
  const tags = (values.get("tags") ?? "")
185
154
  .split(",")
186
155
  .map(value => value.trim())
@@ -304,7 +273,7 @@ export async function compileMarkdownRouteModule(options: {
304
273
  }
305
274
 
306
275
  const parsed = parseFrontmatter(markdownSource);
307
- const highlightedHtml = applySyntaxHighlight(Bun.markdown.html(parsed.markdown));
276
+ const highlightedHtml = applySyntaxHighlight(addHeadingIds(Bun.markdown.html(parsed.markdown)));
308
277
  const html = parsed.frontmatter.title ? stripLeadingH1(highlightedHtml) : highlightedHtml;
309
278
  await writeFileIfChanged(
310
279
  outputPath,
@@ -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
  }