react-bun-ssr 0.1.0 → 0.1.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.
@@ -4,54 +4,33 @@ import {
4
4
  isValidElement,
5
5
  Suspense,
6
6
  use,
7
- type ComponentType,
8
7
  type ReactElement,
9
8
  type ReactNode,
10
9
  } from "react";
11
10
  import { renderToReadableStream, renderToStaticMarkup, renderToString } from "react-dom/server";
12
11
  import type { DeferredSettleEntry } from "./deferred";
13
12
  import type {
13
+ ClientRouterSnapshot,
14
14
  HydrationDocumentAssets,
15
15
  RenderPayload,
16
16
  RouteModule,
17
17
  RouteModuleBundle,
18
18
  } from "./types";
19
19
  import { safeJsonSerialize } from "./utils";
20
- import { createRouteTree } from "./tree";
21
-
22
- function resolveErrorBoundary(modules: RouteModuleBundle): ComponentType<{ error: unknown }> | null {
23
- if (modules.route.ErrorBoundary) {
24
- return modules.route.ErrorBoundary;
25
- }
26
-
27
- for (let index = modules.layouts.length - 1; index >= 0; index -= 1) {
28
- const candidate = modules.layouts[index]!.ErrorBoundary;
29
- if (candidate) {
30
- return candidate;
31
- }
32
- }
33
-
34
- return modules.root.ErrorBoundary ?? null;
35
- }
36
-
37
- function resolveNotFoundBoundary(modules: RouteModuleBundle): ComponentType | null {
38
- if (modules.route.NotFound) {
39
- return modules.route.NotFound;
40
- }
41
-
42
- for (let index = modules.layouts.length - 1; index >= 0; index -= 1) {
43
- const candidate = modules.layouts[index]!.NotFound;
44
- if (candidate) {
45
- return candidate;
46
- }
47
- }
48
-
49
- return modules.root.NotFound ?? null;
50
- }
20
+ import {
21
+ RBSSR_HEAD_MARKER_END_ATTR,
22
+ RBSSR_HEAD_MARKER_START_ATTR,
23
+ RBSSR_PAYLOAD_SCRIPT_ID,
24
+ RBSSR_ROUTER_SCRIPT_ID,
25
+ } from "./runtime-constants";
26
+ import {
27
+ createErrorAppTree,
28
+ createNotFoundAppTree,
29
+ createPageAppTree,
30
+ } from "./tree";
51
31
 
52
32
  export function renderPageApp(modules: RouteModuleBundle, payload: RenderPayload): string {
53
- const Leaf = modules.route.default;
54
- return renderToString(createRouteTree(modules, <Leaf />, payload));
33
+ return renderToString(createPageAppTree(modules, payload));
55
34
  }
56
35
 
57
36
  export function renderErrorApp(
@@ -68,49 +47,6 @@ export function renderNotFoundApp(modules: RouteModuleBundle, payload: RenderPay
68
47
  return tree ? renderToString(tree) : null;
69
48
  }
70
49
 
71
- export function createPageAppTree(modules: RouteModuleBundle, payload: RenderPayload): ReactElement {
72
- const Leaf = modules.route.default;
73
- return createRouteTree(modules, <Leaf />, payload);
74
- }
75
-
76
- export function createErrorAppTree(
77
- modules: RouteModuleBundle,
78
- payload: RenderPayload,
79
- error: unknown,
80
- ): ReactElement | null {
81
- const Boundary = resolveErrorBoundary(modules);
82
- if (!Boundary) {
83
- return null;
84
- }
85
-
86
- const boundaryPayload: RenderPayload = {
87
- ...payload,
88
- error: {
89
- message: error instanceof Error ? error.message : String(error),
90
- },
91
- };
92
-
93
- return createRouteTree(modules, <Boundary error={error} />, boundaryPayload);
94
- }
95
-
96
- export function createNotFoundAppTree(modules: RouteModuleBundle, payload: RenderPayload): ReactElement | null {
97
- const Boundary = resolveNotFoundBoundary(modules);
98
- if (!Boundary) {
99
- return null;
100
- }
101
-
102
- return createRouteTree(modules, <Boundary />, payload);
103
- }
104
-
105
- function escapeHtml(value: string): string {
106
- return value
107
- .replace(/&/g, "&amp;")
108
- .replace(/</g, "&lt;")
109
- .replace(/>/g, "&gt;")
110
- .replace(/\"/g, "&quot;")
111
- .replace(/'/g, "&#39;");
112
- }
113
-
114
50
  function toTitleText(children: ReactNode): string {
115
51
  return Children.toArray(children)
116
52
  .map(child => {
@@ -214,6 +150,20 @@ function withVersionQuery(url: string, version?: number): string {
214
150
  return `${url}${separator}v=${version}`;
215
151
  }
216
152
 
153
+ function createVersionedCssHrefs(assets: HydrationDocumentAssets): string[] {
154
+ return assets.css.map(href => withVersionQuery(href, assets.devVersion));
155
+ }
156
+
157
+ export function createManagedHeadMarkup(options: {
158
+ headMarkup: string;
159
+ assets: HydrationDocumentAssets;
160
+ }): string {
161
+ const cssLinks = createVersionedCssHrefs(options.assets)
162
+ .map(href => `<link rel="stylesheet" href="${Bun.escapeHTML(href)}"/>`)
163
+ .join("");
164
+ return `${options.headMarkup}${cssLinks}`;
165
+ }
166
+
217
167
  function toDeferredScript(entry: DeferredSettleEntry): ReactElement {
218
168
  return (
219
169
  <Suspense fallback={null} key={entry.id}>
@@ -236,13 +186,13 @@ function HtmlDocument(options: {
236
186
  appTree: ReactElement;
237
187
  payload: RenderPayload;
238
188
  assets: HydrationDocumentAssets;
239
- headElements: ReactNode[];
189
+ managedHeadElements: ReactNode[];
190
+ routerSnapshot: ClientRouterSnapshot;
240
191
  deferredSettleEntries: DeferredSettleEntry[];
241
192
  }): ReactElement {
242
- const { appTree, payload, assets, headElements, deferredSettleEntries } = options;
193
+ const { appTree, payload, assets, managedHeadElements, deferredSettleEntries } = options;
243
194
  const versionedScript = assets.script ? withVersionQuery(assets.script, assets.devVersion) : undefined;
244
- const cssLinks = assets.css.map((href, index) => {
245
- const versionedHref = withVersionQuery(href, assets.devVersion);
195
+ const cssLinks = createVersionedCssHrefs(assets).map((versionedHref, index) => {
246
196
  return <link key={`css:${index}:${versionedHref}`} rel="stylesheet" href={versionedHref} />;
247
197
  });
248
198
 
@@ -251,8 +201,10 @@ function HtmlDocument(options: {
251
201
  <head>
252
202
  <meta charSet="utf-8" />
253
203
  <meta name="viewport" content="width=device-width, initial-scale=1" />
254
- {headElements}
204
+ <meta {...{ [RBSSR_HEAD_MARKER_START_ATTR]: "1" }} />
205
+ {managedHeadElements}
255
206
  {cssLinks}
207
+ <meta {...{ [RBSSR_HEAD_MARKER_END_ATTR]: "1" }} />
256
208
  </head>
257
209
  <body>
258
210
  <div id="rbssr-root">{appTree}</div>
@@ -262,10 +214,15 @@ function HtmlDocument(options: {
262
214
  }}
263
215
  />
264
216
  <script
265
- id="__RBSSR_PAYLOAD__"
217
+ id={RBSSR_PAYLOAD_SCRIPT_ID}
266
218
  type="application/json"
267
219
  dangerouslySetInnerHTML={{ __html: safeJsonSerialize(payload) }}
268
220
  />
221
+ <script
222
+ id={RBSSR_ROUTER_SCRIPT_ID}
223
+ type="application/json"
224
+ dangerouslySetInnerHTML={{ __html: safeJsonSerialize(options.routerSnapshot) }}
225
+ />
269
226
  {versionedScript ? <script type="module" src={versionedScript} /> : null}
270
227
  {typeof assets.devVersion === "number" ? (
271
228
  <script
@@ -315,6 +272,7 @@ export async function renderDocumentStream(options: {
315
272
  payload: RenderPayload;
316
273
  assets: HydrationDocumentAssets;
317
274
  headElements: ReactNode[];
275
+ routerSnapshot: ClientRouterSnapshot;
318
276
  deferredSettleEntries?: DeferredSettleEntry[];
319
277
  }): Promise<ReadableStream<Uint8Array>> {
320
278
  const stream = await renderToReadableStream(
@@ -322,7 +280,8 @@ export async function renderDocumentStream(options: {
322
280
  appTree={options.appTree}
323
281
  payload={options.payload}
324
282
  assets={options.assets}
325
- headElements={options.headElements}
283
+ managedHeadElements={options.headElements}
284
+ routerSnapshot={options.routerSnapshot}
326
285
  deferredSettleEntries={options.deferredSettleEntries ?? []}
327
286
  />,
328
287
  );
@@ -335,16 +294,19 @@ export function renderDocument(options: {
335
294
  payload: RenderPayload;
336
295
  assets: HydrationDocumentAssets;
337
296
  headMarkup: string;
297
+ routerSnapshot: ClientRouterSnapshot;
338
298
  }): string {
339
- const { appMarkup, payload, assets, headMarkup } = options;
299
+ const { appMarkup, payload, assets, headMarkup, routerSnapshot } = options;
340
300
  const versionedScript = assets.script ? withVersionQuery(assets.script, assets.devVersion) : undefined;
341
- const cssLinks = assets.css
342
- .map(href => `<link rel="stylesheet" href="${escapeHtml(withVersionQuery(href, assets.devVersion))}"/>`)
343
- .join("\n");
301
+ const managedHeadMarkup = createManagedHeadMarkup({
302
+ headMarkup,
303
+ assets,
304
+ });
344
305
 
345
- const payloadScript = `<script id="__RBSSR_PAYLOAD__" type="application/json">${safeJsonSerialize(payload)}</script>`;
306
+ const payloadScript = `<script id="${RBSSR_PAYLOAD_SCRIPT_ID}" type="application/json">${safeJsonSerialize(payload)}</script>`;
307
+ const routerScript = `<script id="${RBSSR_ROUTER_SCRIPT_ID}" type="application/json">${safeJsonSerialize(routerSnapshot)}</script>`;
346
308
  const entryScript = versionedScript
347
- ? `<script type="module" src="${escapeHtml(versionedScript)}"></script>`
309
+ ? `<script type="module" src="${Bun.escapeHTML(versionedScript)}"></script>`
348
310
  : "";
349
311
  const devScript = typeof assets.devVersion === "number"
350
312
  ? `<script>${buildDevReloadClientScript(assets.devVersion)}</script>`
@@ -356,13 +318,15 @@ export function renderDocument(options: {
356
318
  <head>
357
319
  <meta charset="utf-8" />
358
320
  <meta name="viewport" content="width=device-width, initial-scale=1" />
359
- ${headMarkup}
360
- ${cssLinks}
321
+ <meta ${RBSSR_HEAD_MARKER_START_ATTR}="1" />
322
+ ${managedHeadMarkup}
323
+ <meta ${RBSSR_HEAD_MARKER_END_ATTR}="1" />
361
324
  </head>
362
325
  <body>
363
326
  <div id="rbssr-root">${appMarkup}</div>
364
327
  ${deferredBootstrapScript}
365
328
  ${payloadScript}
329
+ ${routerScript}
366
330
  ${entryScript}
367
331
  ${devScript}
368
332
  </body>
@@ -11,7 +11,13 @@ export type {
11
11
  Params,
12
12
  RedirectResult,
13
13
  RequestContext,
14
+ RouteCatchContext,
15
+ RouteErrorContext,
16
+ RouteErrorResponse,
14
17
  } from "./types";
15
18
 
16
19
  export { defer, json, redirect } from "./helpers";
20
+ export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
21
+ export { Link, type LinkProps } from "./link";
22
+ export { useRouter, type Router, type RouterNavigateOptions } from "./router";
17
23
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -0,0 +1,166 @@
1
+ import type { RouteErrorResponse } from "./types";
2
+
3
+ const ROUTE_ERROR_SYMBOL = Symbol.for("rbssr.route_error");
4
+ const REDIRECT_MIN = 300;
5
+ const REDIRECT_MAX = 399;
6
+
7
+ interface InternalRouteError {
8
+ [ROUTE_ERROR_SYMBOL]: true;
9
+ response: RouteErrorResponse;
10
+ }
11
+
12
+ function isRedirectStatus(status: number): boolean {
13
+ return status >= REDIRECT_MIN && status <= REDIRECT_MAX;
14
+ }
15
+
16
+ function normalizeStatus(status: number): number {
17
+ if (!Number.isFinite(status) || status < 100 || status > 599) {
18
+ throw new Error("routeError(status, ...) requires an HTTP status between 100 and 599.");
19
+ }
20
+ return Math.trunc(status);
21
+ }
22
+
23
+ function defaultStatusText(status: number): string {
24
+ try {
25
+ return new Response(null, { status }).statusText || "Error";
26
+ } catch {
27
+ return "Error";
28
+ }
29
+ }
30
+
31
+ function headersToRecord(headersInit?: HeadersInit): Record<string, string> | undefined {
32
+ if (!headersInit) {
33
+ return undefined;
34
+ }
35
+ const headers = new Headers(headersInit);
36
+ const record: Record<string, string> = {};
37
+ let count = 0;
38
+ headers.forEach((value, key) => {
39
+ record[key] = value;
40
+ count += 1;
41
+ });
42
+ return count > 0 ? record : undefined;
43
+ }
44
+
45
+ export function createRouteErrorResponse(
46
+ status: number,
47
+ data?: unknown,
48
+ init: {
49
+ statusText?: string;
50
+ headers?: HeadersInit;
51
+ } = {},
52
+ ): RouteErrorResponse {
53
+ const normalizedStatus = normalizeStatus(status);
54
+ return {
55
+ type: "route_error",
56
+ status: normalizedStatus,
57
+ statusText: init.statusText ?? defaultStatusText(normalizedStatus),
58
+ data,
59
+ headers: headersToRecord(init.headers),
60
+ };
61
+ }
62
+
63
+ function isInternalRouteError(value: unknown): value is InternalRouteError {
64
+ return Boolean(
65
+ value
66
+ && typeof value === "object"
67
+ && (value as Partial<InternalRouteError>)[ROUTE_ERROR_SYMBOL] === true
68
+ && isRouteErrorResponse((value as Partial<InternalRouteError>).response),
69
+ );
70
+ }
71
+
72
+ export function routeError(
73
+ status: number,
74
+ data?: unknown,
75
+ init: {
76
+ statusText?: string;
77
+ headers?: HeadersInit;
78
+ } = {},
79
+ ): never {
80
+ const response = createRouteErrorResponse(status, data, init);
81
+ throw {
82
+ [ROUTE_ERROR_SYMBOL]: true,
83
+ response,
84
+ } satisfies InternalRouteError;
85
+ }
86
+
87
+ export function notFound(data?: unknown): never {
88
+ return routeError(404, data, { statusText: "Not Found" });
89
+ }
90
+
91
+ export function isRouteErrorResponse(value: unknown): value is RouteErrorResponse {
92
+ if (!value || typeof value !== "object") {
93
+ return false;
94
+ }
95
+
96
+ const candidate = value as Partial<RouteErrorResponse>;
97
+ return (
98
+ candidate.type === "route_error"
99
+ && typeof candidate.status === "number"
100
+ && Number.isFinite(candidate.status)
101
+ && typeof candidate.statusText === "string"
102
+ );
103
+ }
104
+
105
+ export function toRouteErrorResponse(value: unknown): RouteErrorResponse | null {
106
+ if (isInternalRouteError(value)) {
107
+ return value.response;
108
+ }
109
+
110
+ if (isRouteErrorResponse(value)) {
111
+ return value;
112
+ }
113
+
114
+ if (value instanceof Response) {
115
+ if (isRedirectStatus(value.status)) {
116
+ return null;
117
+ }
118
+
119
+ return createRouteErrorResponse(value.status, undefined, {
120
+ statusText: value.statusText,
121
+ headers: value.headers,
122
+ });
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ export function sanitizeRouteErrorResponse(
129
+ routeErrorResponse: RouteErrorResponse,
130
+ production: boolean,
131
+ ): RouteErrorResponse {
132
+ if (!production || routeErrorResponse.status < 500) {
133
+ return routeErrorResponse;
134
+ }
135
+
136
+ return {
137
+ ...routeErrorResponse,
138
+ statusText: "Internal Server Error",
139
+ data: undefined,
140
+ };
141
+ }
142
+
143
+ export function toRouteErrorHttpResponse(routeErrorResponse: RouteErrorResponse): Response {
144
+ const headers = new Headers(routeErrorResponse.headers);
145
+ const init = {
146
+ status: routeErrorResponse.status,
147
+ statusText: routeErrorResponse.statusText,
148
+ headers,
149
+ };
150
+
151
+ if (routeErrorResponse.data === undefined || routeErrorResponse.data === null) {
152
+ return new Response(null, init);
153
+ }
154
+
155
+ if (typeof routeErrorResponse.data === "string") {
156
+ if (!headers.has("content-type")) {
157
+ headers.set("content-type", "text/plain; charset=utf-8");
158
+ }
159
+ return new Response(routeErrorResponse.data, init);
160
+ }
161
+
162
+ if (!headers.has("content-type")) {
163
+ headers.set("content-type", "application/json; charset=utf-8");
164
+ }
165
+ return new Response(JSON.stringify(routeErrorResponse.data), init);
166
+ }
@@ -0,0 +1,80 @@
1
+ import { useMemo } from "react";
2
+ import { goBack, goForward, reloadPage } from "./navigation-api";
3
+
4
+ export interface RouterNavigateOptions {
5
+ scroll?: boolean;
6
+ }
7
+
8
+ export interface Router {
9
+ push(href: string, options?: RouterNavigateOptions): void;
10
+ replace(href: string, options?: RouterNavigateOptions): void;
11
+ prefetch(href: string): void;
12
+ back(): void;
13
+ forward(): void;
14
+ refresh(): void;
15
+ }
16
+
17
+ function toAbsoluteHref(href: string): string {
18
+ if (typeof window === "undefined") {
19
+ return href;
20
+ }
21
+ return new URL(href, window.location.href).toString();
22
+ }
23
+
24
+ const SERVER_ROUTER: Router = {
25
+ push: () => undefined,
26
+ replace: () => undefined,
27
+ prefetch: () => undefined,
28
+ back: () => undefined,
29
+ forward: () => undefined,
30
+ refresh: () => undefined,
31
+ };
32
+
33
+ function createClientRouter(): Router {
34
+ return {
35
+ push: (href, options) => {
36
+ const absoluteHref = toAbsoluteHref(href);
37
+ void import("./client-runtime")
38
+ .then(runtime => runtime.navigateWithNavigationApiOrFallback(absoluteHref, {
39
+ replace: false,
40
+ scroll: options?.scroll,
41
+ }))
42
+ .catch(() => {
43
+ window.location.assign(absoluteHref);
44
+ });
45
+ },
46
+ replace: (href, options) => {
47
+ const absoluteHref = toAbsoluteHref(href);
48
+ void import("./client-runtime")
49
+ .then(runtime => runtime.navigateWithNavigationApiOrFallback(absoluteHref, {
50
+ replace: true,
51
+ scroll: options?.scroll,
52
+ }))
53
+ .catch(() => {
54
+ window.location.replace(absoluteHref);
55
+ });
56
+ },
57
+ prefetch: href => {
58
+ const absoluteHref = toAbsoluteHref(href);
59
+ void import("./client-runtime")
60
+ .then(runtime => runtime.prefetchTo(absoluteHref))
61
+ .catch(() => undefined);
62
+ },
63
+ back: () => {
64
+ goBack();
65
+ },
66
+ forward: () => {
67
+ goForward();
68
+ },
69
+ refresh: () => {
70
+ reloadPage();
71
+ },
72
+ };
73
+ }
74
+
75
+ export function useRouter(): Router {
76
+ return useMemo(
77
+ () => (typeof window === "undefined" ? SERVER_ROUTER : createClientRouter()),
78
+ [],
79
+ );
80
+ }
@@ -0,0 +1,4 @@
1
+ export const RBSSR_PAYLOAD_SCRIPT_ID = "__RBSSR_PAYLOAD__";
2
+ export const RBSSR_ROUTER_SCRIPT_ID = "__RBSSR_ROUTER__";
3
+ export const RBSSR_HEAD_MARKER_START_ATTR = "data-rbssr-head-start";
4
+ export const RBSSR_HEAD_MARKER_END_ATTR = "data-rbssr-head-end";