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.
package/README.md CHANGED
@@ -1,10 +1,17 @@
1
1
  # react-bun-ssr
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/react-bun-ssr)](https://www.npmjs.com/package/react-bun-ssr)
4
+ [![CI](https://github.com/react-formation/react-bun-ssr/actions/workflows/ci.yml/badge.svg)](https://github.com/react-formation/react-bun-ssr/actions/workflows/ci.yml)
5
+
3
6
  `react-bun-ssr` is a Bun-native SSR React framework with file-based routing, loaders, actions, middleware, streaming, soft navigation, and first-class markdown routes.
4
7
 
8
+ > **Stability: Experimental (early alpha).**
9
+ > Expect breaking changes across minor releases while core APIs and ergonomics are still being shaped.
10
+
5
11
  - Documentation: https://react-bun-ssr.dev/docs
6
12
  - API reference: https://react-bun-ssr.dev/docs/api/overview
7
13
  - Blog: https://react-bun-ssr.dev/blog
14
+ - Changelog: [CHANGELOG.md](./CHANGELOG.md)
8
15
  - Repository: https://github.com/react-formation/react-bun-ssr
9
16
 
10
17
  ## Why react-bun-ssr?
@@ -107,6 +114,43 @@ Read more:
107
114
  - https://react-bun-ssr.dev/docs/routing/middleware
108
115
  - https://react-bun-ssr.dev/docs/data/loaders
109
116
 
117
+ ### Actions with React `useActionState`
118
+
119
+ Page mutations use React 19 form actions (`useActionState`) with an explicit route stub:
120
+
121
+ ```tsx
122
+ // app/routes/login.tsx
123
+ import { useActionState } from "react";
124
+ import { createRouteAction } from "react-bun-ssr/route";
125
+
126
+ type LoginState = { error?: string };
127
+ export const action = createRouteAction<LoginState>();
128
+
129
+ export default function LoginPage() {
130
+ const [state, formAction, pending] = useActionState(action, {});
131
+ return (
132
+ <form action={formAction}>
133
+ {state.error ? <p>{state.error}</p> : null}
134
+ <button disabled={pending}>Sign in</button>
135
+ </form>
136
+ );
137
+ }
138
+ ```
139
+
140
+ ```tsx
141
+ // app/routes/login.server.tsx
142
+ import { redirect } from "react-bun-ssr";
143
+ import type { Action } from "react-bun-ssr/route";
144
+
145
+ export const action: Action = async (ctx) => {
146
+ const email = String(ctx.formData?.get("email") ?? "").trim();
147
+ if (!email) return { error: "Email is required" };
148
+ return redirect("/dashboard");
149
+ };
150
+ ```
151
+
152
+ `createRouteAction` is the preferred pattern. `useRouteAction` remains available for backward compatibility.
153
+
110
154
  ### Rendering model
111
155
 
112
156
  SSR is the default model. HTML responses stream, deferred loader data is supported, and soft client transitions are handled through `Link` and `useRouter`. The docs site in this repository uses the same routing, rendering, markdown, and transition model that framework users get.
@@ -152,6 +196,7 @@ This repository contains both the framework and the official docs site built wit
152
196
  ```bash
153
197
  git clone git@github.com:react-formation/react-bun-ssr.git
154
198
  cd react-bun-ssr
199
+ bun link
155
200
  bun install
156
201
  bun run docs:dev
157
202
  ```
@@ -189,20 +234,3 @@ Contributions should keep framework behavior, docs, tests, and generated artifac
189
234
  - The release workflow derives the published package version from the Git tag and rewrites `package.json` in the release job before publishing.
190
235
  - npm publishing uses trusted publishing with GitHub OIDC instead of an `NPM_TOKEN`.
191
236
  - npm package settings must have a trusted publisher configured for `react-formation / react-bun-ssr / release.yml`.
192
-
193
- ## Deploying
194
-
195
- Fly.io deployment support is already documented and used by this project.
196
-
197
- Happy path:
198
-
199
- ```bash
200
- fly auth login
201
- fly deploy
202
- ```
203
-
204
- Full deployment docs:
205
-
206
- - https://react-bun-ssr.dev/docs/deployment/bun-deployment
207
- - https://react-bun-ssr.dev/docs/deployment/configuration
208
- - https://react-bun-ssr.dev/docs/deployment/troubleshooting
@@ -26,13 +26,20 @@ function isConfigFileName(fileName: string): boolean {
26
26
  }
27
27
 
28
28
  function isTopLevelAppRuntimeFile(relativePath: string): boolean {
29
- return /^root\.(tsx|jsx|ts|js)$/.test(relativePath) || /^middleware\.(tsx|jsx|ts|js)$/.test(relativePath);
29
+ return /^root(?:\.server)?\.(tsx|jsx|ts|js)$/.test(relativePath)
30
+ || /^middleware(?:\.server)?\.(tsx|jsx|ts|js)$/.test(relativePath);
30
31
  }
31
32
 
32
33
  function isMarkdownRouteFile(relativePath: string): boolean {
33
34
  return /^routes\/.+\.md$/.test(relativePath);
34
35
  }
35
36
 
37
+ function isServerOnlyRuntimeFile(relativePath: string): boolean {
38
+ return /^root\.server\.(tsx|jsx|ts|js)$/.test(relativePath)
39
+ || /^middleware\.server\.(tsx|jsx|ts|js)$/.test(relativePath)
40
+ || /^routes\/.+\.server\.(tsx|jsx|ts|js)$/.test(relativePath);
41
+ }
42
+
36
43
  function isStructuralAppPath(relativePath: string): boolean {
37
44
  return relativePath === "routes"
38
45
  || relativePath.startsWith("routes/")
@@ -43,6 +50,10 @@ function toAbsoluteAppPath(appDir: string, relativePath: string): string {
43
50
  return path.join(appDir, relativePath);
44
51
  }
45
52
 
53
+ function toNormalizedWatchPath(fileName?: string | Buffer | null): string {
54
+ return typeof fileName === "string" ? normalizeSlashes(fileName) : "";
55
+ }
56
+
46
57
  interface DevHotData {
47
58
  bunServer?: Bun.Server<undefined>;
48
59
  reloadToken?: number;
@@ -88,9 +99,11 @@ export async function runHotDevChild(options: {
88
99
  let nextClientBuildReason: DevReloadReason | null = null;
89
100
  let stopping = false;
90
101
 
91
- const watchers: FSWatcher[] = [];
92
102
  let structuralSyncTimer: ReturnType<typeof setTimeout> | undefined;
93
103
  let structuralSyncQueue: Promise<void> = Promise.resolve();
104
+ let routesWatcher: FSWatcher | null = null;
105
+ let appWatcher: FSWatcher | null = null;
106
+ let configWatcher: FSWatcher | null = null;
94
107
 
95
108
  const publishReload = (reason: DevReloadReason): void => {
96
109
  reloadToken += 1;
@@ -253,21 +266,27 @@ export async function runHotDevChild(options: {
253
266
  process.exit(RBSSR_DEV_RESTART_EXIT_CODE);
254
267
  };
255
268
 
256
- const handleAppEvent = (eventType: string, fileName?: string | Buffer | null): void => {
257
- const relativePath = typeof fileName === "string"
258
- ? normalizeSlashes(fileName)
259
- : "";
260
-
269
+ const handleAppEvent = (eventType: string, relativePath: string): void => {
261
270
  if (!relativePath) {
262
271
  scheduleStructuralSync();
263
272
  return;
264
273
  }
265
274
 
275
+ if (eventType === "rename" && isServerOnlyRuntimeFile(relativePath)) {
276
+ publishReload("server-runtime");
277
+ return;
278
+ }
279
+
266
280
  if (eventType === "rename" && isStructuralAppPath(relativePath)) {
267
281
  scheduleStructuralSync();
268
282
  return;
269
283
  }
270
284
 
285
+ if (eventType === "change" && isServerOnlyRuntimeFile(relativePath)) {
286
+ publishReload("server-runtime");
287
+ return;
288
+ }
289
+
271
290
  if (eventType !== "change" || !isMarkdownRouteFile(relativePath)) {
272
291
  return;
273
292
  }
@@ -289,10 +308,29 @@ export async function runHotDevChild(options: {
289
308
  });
290
309
  };
291
310
 
292
- const addWatcher = (watcher: FSWatcher | null): void => {
293
- if (watcher) {
294
- watchers.push(watcher);
311
+ const ensureRoutesWatcher = async (): Promise<void> => {
312
+ if (routesWatcher || !(await existsPath(resolved.routesDir))) {
313
+ return;
314
+ }
315
+
316
+ try {
317
+ routesWatcher = watch(resolved.routesDir, { recursive: true }, (eventType, fileName) => {
318
+ const nestedPath = toNormalizedWatchPath(fileName);
319
+ const relativePath = nestedPath ? `routes/${nestedPath}` : "routes";
320
+ handleAppEvent(eventType, relativePath);
321
+ });
322
+ } catch {
323
+ log(`recursive route watching unavailable for ${resolved.routesDir}; route topology updates may require a restart`);
324
+ }
325
+ };
326
+
327
+ const refreshRoutesWatcher = async (): Promise<void> => {
328
+ if (routesWatcher) {
329
+ routesWatcher.close();
330
+ routesWatcher = null;
295
331
  }
332
+
333
+ await ensureRoutesWatcher();
296
334
  };
297
335
 
298
336
  const cleanup = async (options: {
@@ -303,9 +341,12 @@ export async function runHotDevChild(options: {
303
341
  structuralSyncTimer = undefined;
304
342
  }
305
343
 
306
- for (const watcher of watchers.splice(0, watchers.length)) {
307
- watcher.close();
308
- }
344
+ routesWatcher?.close();
345
+ routesWatcher = null;
346
+ appWatcher?.close();
347
+ appWatcher = null;
348
+ configWatcher?.close();
349
+ configWatcher = null;
309
350
 
310
351
  if (clientWatch) {
311
352
  await clientWatch.stop();
@@ -318,31 +359,38 @@ export async function runHotDevChild(options: {
318
359
  }
319
360
  };
320
361
 
362
+ await refreshRoutesWatcher();
363
+
321
364
  try {
322
- addWatcher(
323
- watch(resolved.appDir, { recursive: true }, (eventType, fileName) => {
324
- handleAppEvent(eventType, fileName);
325
- }),
326
- );
365
+ appWatcher = watch(resolved.appDir, (eventType, fileName) => {
366
+ const relativePath = toNormalizedWatchPath(fileName);
367
+ if (relativePath === "routes" && eventType === "rename") {
368
+ void refreshRoutesWatcher();
369
+ }
370
+
371
+ handleAppEvent(eventType, relativePath);
372
+ });
327
373
  } catch {
328
- log(`recursive file watching unavailable for ${resolved.appDir}; dev route topology updates may require a restart`);
374
+ log(`top-level app watching unavailable for ${resolved.appDir}; route topology updates may require a restart`);
329
375
  }
330
376
 
331
377
  try {
332
- addWatcher(
333
- watch(options.cwd, (eventType, fileName) => {
334
- if (typeof fileName !== "string" || !isConfigFileName(fileName)) {
335
- return;
336
- }
337
- if (eventType === "rename" || eventType === "change") {
338
- void restartForConfigChange();
339
- }
340
- }),
341
- );
378
+ configWatcher = watch(options.cwd, (eventType, fileName) => {
379
+ const configFileName = toNormalizedWatchPath(fileName);
380
+ if (!configFileName || !isConfigFileName(configFileName)) {
381
+ return;
382
+ }
383
+ if (eventType === "rename" || eventType === "change") {
384
+ void restartForConfigChange();
385
+ }
386
+ });
342
387
  } catch {
343
388
  log(`config file watching unavailable for ${options.cwd}; config changes may require a manual restart`);
344
389
  }
345
390
 
391
+ await enqueueStructuralSync("bootstrap");
392
+ await refreshRoutesWatcher();
393
+
346
394
  if (import.meta.hot) {
347
395
  import.meta.hot.dispose(async (data: DevHotData) => {
348
396
  data.bunServer = bunServer;
@@ -352,8 +400,6 @@ export async function runHotDevChild(options: {
352
400
  });
353
401
  }
354
402
 
355
- await enqueueStructuralSync("bootstrap");
356
-
357
403
  if (hotData.bunServer && bunServer) {
358
404
  publishReload("server-runtime");
359
405
  }
@@ -0,0 +1,26 @@
1
+ export type RouteActionStateHandler<TState = unknown> = (
2
+ previousState: TState,
3
+ formData: FormData,
4
+ ) => Promise<TState>;
5
+
6
+ const ROUTE_ACTION_STUB_MARKER = Symbol.for("react-bun-ssr.route-action-stub");
7
+
8
+ export function markRouteActionStub<TState>(
9
+ handler: RouteActionStateHandler<TState>,
10
+ ): RouteActionStateHandler<TState> {
11
+ Object.defineProperty(handler, ROUTE_ACTION_STUB_MARKER, {
12
+ value: true,
13
+ enumerable: false,
14
+ configurable: false,
15
+ writable: false,
16
+ });
17
+ return handler;
18
+ }
19
+
20
+ export function isRouteActionStub(value: unknown): value is RouteActionStateHandler<unknown> {
21
+ if (typeof value !== "function") {
22
+ return false;
23
+ }
24
+
25
+ return (value as unknown as Record<PropertyKey, unknown>)[ROUTE_ACTION_STUB_MARKER] === true;
26
+ }
@@ -17,11 +17,10 @@ import {
17
17
  type NavigationHistoryMode,
18
18
  } from "./navigation-api";
19
19
  import {
20
- RBSSR_HEAD_MARKER_END_ATTR,
21
- RBSSR_HEAD_MARKER_START_ATTR,
22
20
  RBSSR_PAYLOAD_SCRIPT_ID,
23
21
  RBSSR_ROUTER_SCRIPT_ID,
24
22
  } from "./runtime-constants";
23
+ import { replaceManagedHead } from "./head-reconcile";
25
24
  import {
26
25
  createCatchAppTree,
27
26
  createErrorAppTree,
@@ -375,7 +374,7 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
375
374
  }
376
375
 
377
376
  function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
378
- const sourceData = payload.data;
377
+ const sourceData = payload.loaderData;
379
378
  if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
380
379
  return payload;
381
380
  }
@@ -395,7 +394,7 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
395
394
 
396
395
  return {
397
396
  ...payload,
398
- data: revivedData,
397
+ loaderData: revivedData,
399
398
  };
400
399
  }
401
400
 
@@ -569,220 +568,6 @@ function createFallbackNotFoundRoute(rootModule: RouteModule): RouteModule {
569
568
  };
570
569
  }
571
570
 
572
- function nodeSignature(node: Node): string {
573
- if (node.nodeType === Node.TEXT_NODE) {
574
- return `text:${node.textContent ?? ""}`;
575
- }
576
-
577
- if (node.nodeType === Node.COMMENT_NODE) {
578
- return `comment:${node.textContent ?? ""}`;
579
- }
580
-
581
- if (node.nodeType !== Node.ELEMENT_NODE) {
582
- return `node:${node.nodeType}`;
583
- }
584
-
585
- const element = node as Element;
586
- const attrs = Array.from(element.attributes)
587
- .map(attribute => `${attribute.name}=${attribute.value}`)
588
- .sort((a, b) => a.localeCompare(b))
589
- .join("|");
590
-
591
- return `element:${element.tagName.toLowerCase()}:${attrs}:${element.innerHTML}`;
592
- }
593
-
594
- function isIgnorableTextNode(node: Node): boolean {
595
- return node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim().length === 0;
596
- }
597
-
598
- function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
599
- const nodes: Node[] = [];
600
- let cursor = startMarker.nextSibling;
601
- while (cursor && cursor !== endMarker) {
602
- nodes.push(cursor);
603
- cursor = cursor.nextSibling;
604
- }
605
- return nodes;
606
- }
607
-
608
- function removeNode(node: Node): void {
609
- if (node.parentNode) {
610
- node.parentNode.removeChild(node);
611
- }
612
- }
613
-
614
- function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
615
- if (node.nodeType !== Node.ELEMENT_NODE) {
616
- return false;
617
- }
618
-
619
- const element = node as Element;
620
- return (
621
- element.tagName.toLowerCase() === "link"
622
- && (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
623
- && Boolean(element.getAttribute("href"))
624
- );
625
- }
626
-
627
- function toAbsoluteHref(href: string): string {
628
- return new URL(href, document.baseURI).toString();
629
- }
630
-
631
- function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
632
- const sheet = link.sheet;
633
- if (sheet) {
634
- return Promise.resolve();
635
- }
636
-
637
- return new Promise(resolve => {
638
- const finish = () => {
639
- link.removeEventListener("load", finish);
640
- link.removeEventListener("error", finish);
641
- resolve();
642
- };
643
-
644
- link.addEventListener("load", finish, { once: true });
645
- link.addEventListener("error", finish, { once: true });
646
- });
647
- }
648
-
649
- async function reconcileStylesheetLinks(options: {
650
- head: HTMLHeadElement;
651
- desiredStylesheetHrefs: string[];
652
- }): Promise<void> {
653
- const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(toAbsoluteHref);
654
- const existingLinks = Array.from(
655
- options.head.querySelectorAll('link[rel="stylesheet"][href]'),
656
- ) as HTMLLinkElement[];
657
-
658
- const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
659
- for (const link of existingLinks) {
660
- const href = link.getAttribute("href");
661
- if (!href) {
662
- continue;
663
- }
664
- const absoluteHref = toAbsoluteHref(href);
665
- const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
666
- list.push(link);
667
- existingByAbsoluteHref.set(absoluteHref, list);
668
- }
669
-
670
- const waitForLoads: Promise<void>[] = [];
671
- for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
672
- const href = options.desiredStylesheetHrefs[index]!;
673
- const absoluteHref = desiredAbsoluteHrefs[index]!;
674
- const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
675
- if (existing) {
676
- waitForLoads.push(waitForStylesheetLoad(existing));
677
- continue;
678
- }
679
-
680
- const link = document.createElement("link");
681
- link.setAttribute("rel", "stylesheet");
682
- link.setAttribute("href", href);
683
- options.head.appendChild(link);
684
- waitForLoads.push(waitForStylesheetLoad(link));
685
- }
686
-
687
- const seen = new Set<string>();
688
- for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
689
- const href = link.getAttribute("href");
690
- if (!href) {
691
- continue;
692
- }
693
-
694
- const absoluteHref = toAbsoluteHref(href);
695
- if (seen.has(absoluteHref)) {
696
- removeNode(link);
697
- continue;
698
- }
699
-
700
- seen.add(absoluteHref);
701
- }
702
-
703
- await Promise.all(waitForLoads);
704
- }
705
-
706
- async function replaceManagedHead(headHtml: string): Promise<void> {
707
- const head = document.head;
708
- const startMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_START_ATTR}]`);
709
- const endMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_END_ATTR}]`);
710
-
711
- if (!startMarker || !endMarker || startMarker === endMarker) {
712
- return;
713
- }
714
-
715
- const template = document.createElement("template");
716
- template.innerHTML = headHtml;
717
-
718
- const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
719
- .map(link => link.getAttribute("href"))
720
- .filter((value): value is string => Boolean(value));
721
- for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
722
- removeNode(styleNode);
723
- }
724
-
725
- const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
726
- const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
727
- if (isIgnorableTextNode(node)) {
728
- return false;
729
- }
730
-
731
- if (isStylesheetLinkNode(node)) {
732
- return false;
733
- }
734
-
735
- return true;
736
- });
737
- const unusedCurrentNodes = new Set(currentNodes);
738
-
739
- let cursor = startMarker.nextSibling;
740
-
741
- for (const desiredNode of desiredNodes) {
742
- while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
743
- const next = cursor.nextSibling;
744
- removeNode(cursor);
745
- cursor = next;
746
- }
747
-
748
- const desiredSignature = nodeSignature(desiredNode);
749
-
750
- if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
751
- unusedCurrentNodes.delete(cursor);
752
- cursor = cursor.nextSibling;
753
- continue;
754
- }
755
-
756
- let matchedNode: Node | null = null;
757
- for (const currentNode of currentNodes) {
758
- if (!unusedCurrentNodes.has(currentNode)) {
759
- continue;
760
- }
761
- if (nodeSignature(currentNode) === desiredSignature) {
762
- matchedNode = currentNode;
763
- break;
764
- }
765
- }
766
-
767
- if (matchedNode) {
768
- unusedCurrentNodes.delete(matchedNode);
769
- head.insertBefore(matchedNode, cursor ?? endMarker);
770
- continue;
771
- }
772
-
773
- head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
774
- }
775
-
776
- for (const leftover of unusedCurrentNodes) {
777
- removeNode(leftover);
778
- }
779
-
780
- await reconcileStylesheetLinks({
781
- head,
782
- desiredStylesheetHrefs,
783
- });
784
- }
785
-
786
571
  async function renderTransitionInitial(
787
572
  chunk: TransitionInitialChunk,
788
573
  toUrl: URL,
@@ -928,7 +713,7 @@ async function navigateToInternal(
928
713
  matchedModules,
929
714
  {
930
715
  routeId: matched.route.id,
931
- data: null,
716
+ loaderData: null,
932
717
  params: matched.params,
933
718
  url: toUrl.toString(),
934
719
  },
@@ -0,0 +1,38 @@
1
+ const DOCTYPE = new TextEncoder().encode("<!doctype html>");
2
+
3
+ export function prependDoctypeStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
4
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
5
+
6
+ return new ReadableStream<Uint8Array>({
7
+ async start(controller) {
8
+ controller.enqueue(DOCTYPE);
9
+ reader = stream.getReader();
10
+
11
+ try {
12
+ while (true) {
13
+ const result = await reader.read();
14
+ if (result.done) {
15
+ break;
16
+ }
17
+ controller.enqueue(result.value);
18
+ }
19
+ controller.close();
20
+ } catch (error) {
21
+ controller.error(error);
22
+ } finally {
23
+ const activeReader = reader;
24
+ reader = null;
25
+ activeReader?.releaseLock();
26
+ }
27
+ },
28
+ async cancel(reason) {
29
+ const activeReader = reader;
30
+ if (activeReader) {
31
+ await activeReader.cancel(reason);
32
+ return;
33
+ }
34
+
35
+ await stream.cancel(reason);
36
+ },
37
+ });
38
+ }