react-bun-ssr 0.2.0 → 0.3.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
@@ -2,9 +2,9 @@
2
2
 
3
3
  `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
4
 
5
- - Documentation: https://react-bun-ssr.fly.dev/docs
6
- - API reference: https://react-bun-ssr.fly.dev/docs/api/overview
7
- - Blog: https://react-bun-ssr.fly.dev/blog
5
+ - Documentation: https://react-bun-ssr.dev/docs
6
+ - API reference: https://react-bun-ssr.dev/docs/api/overview
7
+ - Blog: https://react-bun-ssr.dev/blog
8
8
  - Repository: https://github.com/react-formation/react-bun-ssr
9
9
 
10
10
  ## Why react-bun-ssr?
@@ -50,7 +50,7 @@ http://127.0.0.1:3000
50
50
 
51
51
  For the full setup walkthrough, read the installation guide:
52
52
 
53
- - https://react-bun-ssr.fly.dev/docs/start/installation
53
+ - https://react-bun-ssr.dev/docs/start/installation
54
54
 
55
55
  ## What `rbssr init` gives you
56
56
 
@@ -75,7 +75,7 @@ rbssr.config.ts
75
75
 
76
76
  The quickest follow-up is:
77
77
 
78
- - https://react-bun-ssr.fly.dev/docs/start/quick-start
78
+ - https://react-bun-ssr.dev/docs/start/quick-start
79
79
 
80
80
  ## How it works
81
81
 
@@ -85,7 +85,7 @@ Routes live under `app/routes`. Page routes, API routes, dynamic params, and mar
85
85
 
86
86
  Read more:
87
87
 
88
- - https://react-bun-ssr.fly.dev/docs/routing/file-based-routing
88
+ - https://react-bun-ssr.dev/docs/routing/file-based-routing
89
89
 
90
90
  ### Request pipeline
91
91
 
@@ -93,8 +93,8 @@ For a page request, the framework resolves the matching route, runs global and n
93
93
 
94
94
  Read more:
95
95
 
96
- - https://react-bun-ssr.fly.dev/docs/routing/middleware
97
- - https://react-bun-ssr.fly.dev/docs/data/loaders
96
+ - https://react-bun-ssr.dev/docs/routing/middleware
97
+ - https://react-bun-ssr.dev/docs/data/loaders
98
98
 
99
99
  ### Rendering model
100
100
 
@@ -102,8 +102,8 @@ SSR is the default model. HTML responses stream, deferred loader data is support
102
102
 
103
103
  Read more:
104
104
 
105
- - https://react-bun-ssr.fly.dev/docs/rendering/streaming-deferred
106
- - https://react-bun-ssr.fly.dev/docs/routing/navigation
105
+ - https://react-bun-ssr.dev/docs/rendering/streaming-deferred
106
+ - https://react-bun-ssr.dev/docs/routing/navigation
107
107
 
108
108
  ### Bun-first runtime
109
109
 
@@ -111,7 +111,7 @@ Bun provides the runtime, server, bundler, markdown support, and file APIs that
111
111
 
112
112
  Read more:
113
113
 
114
- - https://react-bun-ssr.fly.dev/docs/api/bun-runtime-apis
114
+ - https://react-bun-ssr.dev/docs/api/bun-runtime-apis
115
115
 
116
116
  ## Core commands
117
117
 
@@ -132,7 +132,7 @@ Repository maintenance commands:
132
132
 
133
133
  CLI reference:
134
134
 
135
- - https://react-bun-ssr.fly.dev/docs/tooling/cli
135
+ - https://react-bun-ssr.dev/docs/tooling/cli
136
136
 
137
137
  ## Working on this repository
138
138
 
@@ -147,16 +147,24 @@ bun run docs:dev
147
147
 
148
148
  That starts the docs site locally using the framework itself.
149
149
 
150
+ Dependency ownership is split intentionally:
151
+
152
+ - the repo-root `package.json` is the published framework manifest
153
+ - [`app/package.json`](/Users/react-formation/code/my-app/app/package.json) owns docs-app runtime dependencies
154
+
155
+ Contributors should still use the repo-root commands; the workspace split is there to keep npm package metadata accurate, not to change the day-to-day workflow.
156
+
150
157
  ## Project layout
151
158
 
152
159
  - `framework/`: runtime, renderer, route handling, build tooling, and CLI internals
153
160
  - `bin/rbssr.ts`: CLI entrypoint
154
161
  - `app/`: docs site routes, layouts, middleware, blog, and styles
162
+ - `app/package.json`: private docs-app dependency manifest
155
163
  - `app/routes/docs/**/*.md`: authored documentation pages
156
164
  - `app/routes/blog/*.md`: authored blog posts
157
165
  - `scripts/`: generators and validation scripts
158
- - `tests/`: unit and integration tests
159
- - `e2e/`: Playwright end-to-end tests
166
+ - `tests/framework/`: framework runtime, CLI, build, unit/integration, and framework Playwright tests
167
+ - `tests/docs-app/`: docs site, blog, analytics, and docs-app Playwright tests
160
168
 
161
169
  ## Contributing
162
170
 
@@ -184,6 +192,6 @@ fly deploy
184
192
 
185
193
  Full deployment docs:
186
194
 
187
- - https://react-bun-ssr.fly.dev/docs/deployment/bun-deployment
188
- - https://react-bun-ssr.fly.dev/docs/deployment/configuration
189
- - https://react-bun-ssr.fly.dev/docs/deployment/troubleshooting
195
+ - https://react-bun-ssr.dev/docs/deployment/bun-deployment
196
+ - https://react-bun-ssr.dev/docs/deployment/configuration
197
+ - https://react-bun-ssr.dev/docs/deployment/troubleshooting
@@ -25,6 +25,21 @@ function log(message: string): void {
25
25
  console.log(`[rbssr] ${message}`);
26
26
  }
27
27
 
28
+ async function withNodeEnv<T>(nodeEnv: "development" | "production", run: () => Promise<T>): Promise<T> {
29
+ const previousNodeEnv = process.env.NODE_ENV;
30
+ process.env.NODE_ENV = nodeEnv;
31
+
32
+ try {
33
+ return await run();
34
+ } finally {
35
+ if (previousNodeEnv === undefined) {
36
+ delete process.env.NODE_ENV;
37
+ } else {
38
+ process.env.NODE_ENV = previousNodeEnv;
39
+ }
40
+ }
41
+ }
42
+
28
43
  async function getConfig(cwd: string): Promise<{ userConfig: FrameworkConfig; resolved: ResolvedConfig }> {
29
44
  const userConfig = await loadUserConfig(cwd);
30
45
  const resolved = resolveConfig(userConfig, cwd);
@@ -48,41 +63,47 @@ export async function runInit(args: string[], cwd = process.cwd()): Promise<void
48
63
  }
49
64
 
50
65
  export async function runBuild(cwd = process.cwd()): Promise<void> {
51
- const { resolved } = await getConfig(cwd);
52
-
53
- const distClientDir = path.join(resolved.distDir, "client");
54
- const generatedDir = path.resolve(cwd, ".rbssr/generated/client-entries");
55
-
56
- await Promise.all([
57
- ensureCleanDirectory(resolved.distDir),
58
- ensureCleanDirectory(generatedDir),
59
- ]);
60
-
61
- const routeManifest = await buildRouteManifest(resolved);
62
- const entries = await generateClientEntries({
63
- config: resolved,
64
- manifest: routeManifest,
65
- generatedDir,
66
- });
66
+ await withNodeEnv("production", async () => {
67
+ const userConfig = await loadUserConfig(cwd);
68
+ const resolved = resolveConfig({
69
+ ...userConfig,
70
+ mode: "production",
71
+ }, cwd);
72
+
73
+ const distClientDir = path.join(resolved.distDir, "client");
74
+ const generatedDir = path.resolve(cwd, ".rbssr/generated/client-entries");
75
+
76
+ await Promise.all([
77
+ ensureCleanDirectory(resolved.distDir),
78
+ ensureCleanDirectory(generatedDir),
79
+ ]);
80
+
81
+ const routeManifest = await buildRouteManifest(resolved);
82
+ const entries = await generateClientEntries({
83
+ config: resolved,
84
+ manifest: routeManifest,
85
+ generatedDir,
86
+ });
67
87
 
68
- const routeAssets = await bundleClientEntries({
69
- entries,
70
- outDir: distClientDir,
71
- dev: false,
72
- publicPrefix: "/client/",
73
- });
88
+ const routeAssets = await bundleClientEntries({
89
+ entries,
90
+ outDir: distClientDir,
91
+ dev: false,
92
+ publicPrefix: "/client/",
93
+ });
74
94
 
75
- await copyDirRecursive(resolved.publicDir, distClientDir);
95
+ await copyDirRecursive(resolved.publicDir, distClientDir);
76
96
 
77
- const buildManifest = createBuildManifest(routeAssets);
78
- await writeText(
79
- path.join(resolved.distDir, "manifest.json"),
80
- JSON.stringify(buildManifest, null, 2),
81
- );
97
+ const buildManifest = createBuildManifest(routeAssets);
98
+ await writeText(
99
+ path.join(resolved.distDir, "manifest.json"),
100
+ JSON.stringify(buildManifest, null, 2),
101
+ );
82
102
 
83
- await writeProductionServerEntrypoint({ distDir: resolved.distDir });
103
+ await writeProductionServerEntrypoint({ distDir: resolved.distDir });
84
104
 
85
- log(`build complete: ${resolved.distDir}`);
105
+ log(`build complete: ${resolved.distDir}`);
106
+ });
86
107
  }
87
108
 
88
109
  export async function runDev(cwd = process.cwd()): Promise<void> {
@@ -121,6 +142,7 @@ export async function runDev(cwd = process.cwd()): Promise<void> {
121
142
  stderr: "inherit",
122
143
  env: {
123
144
  ...process.env,
145
+ NODE_ENV: "development",
124
146
  RBSSR_DEV_LAUNCHER: "1",
125
147
  RBSSR_DEV_CHILD: "1",
126
148
  },
@@ -89,6 +89,13 @@ export function createDevClientWatch(options: {
89
89
  let metafilePoller: ReturnType<typeof setInterval> | undefined;
90
90
  let lastMetafileMtime = "";
91
91
 
92
+ const resetReadyState = (): void => {
93
+ const deferred = createDeferred();
94
+ state.readyPromise = deferred.promise;
95
+ state.resolveReady = deferred.resolve;
96
+ state.rejectReady = deferred.reject;
97
+ };
98
+
92
99
  const stopPolling = (): void => {
93
100
  if (metafilePoller) {
94
101
  clearInterval(metafilePoller);
@@ -152,10 +159,6 @@ export function createDevClientWatch(options: {
152
159
  await removePath(options.metafilePath);
153
160
 
154
161
  const previousOutputFiles = [...state.outputFiles];
155
- const deferred = createDeferred();
156
- state.readyPromise = deferred.promise;
157
- state.resolveReady = deferred.resolve;
158
- state.rejectReady = deferred.reject;
159
162
  state.buildCount = 0;
160
163
  state.outputFiles = new Set<string>();
161
164
  lastMetafileMtime = "";
@@ -195,7 +198,10 @@ export function createDevClientWatch(options: {
195
198
  stdin: "ignore",
196
199
  stdout: "inherit",
197
200
  stderr: "inherit",
198
- env: process.env,
201
+ env: {
202
+ ...process.env,
203
+ NODE_ENV: "development",
204
+ },
199
205
  });
200
206
 
201
207
  void state.process.exited.then((exitCode) => {
@@ -244,6 +250,7 @@ export function createDevClientWatch(options: {
244
250
  }
245
251
 
246
252
  state.entrySetSignature = nextEntrySetSignature;
253
+ resetReadyState();
247
254
  await stopProcess();
248
255
  await startProcess();
249
256
  options.onLog?.("restarted Bun client watch after entry set change");
@@ -22,11 +22,14 @@ export function parseFlags(args: string[]): CliFlags {
22
22
 
23
23
  export function createProductionServerEntrypointSource(): string {
24
24
  return `import path from "node:path";
25
- import config from "../../rbssr.config.ts";
26
25
  import { startHttpServer } from "react-bun-ssr";
27
26
 
27
+ process.env.NODE_ENV = "production";
28
+
28
29
  const rootDir = path.resolve(path.dirname(Bun.fileURLToPath(import.meta.url)), "../..");
29
30
  process.chdir(rootDir);
31
+ const configModule = await import("../../rbssr.config.ts");
32
+ const config = configModule.default;
30
33
 
31
34
  const manifestPath = path.resolve(rootDir, "dist/manifest.json");
32
35
  const manifest = await Bun.file(manifestPath).json();
@@ -53,6 +56,7 @@ export function createDevHotEntrypointSource(options: {
53
56
 
54
57
  return `import { runHotDevChild } from ${runtimeModulePath};
55
58
 
59
+ process.env.NODE_ENV = "development";
56
60
  process.chdir(${cwd});
57
61
  await runHotDevChild({
58
62
  cwd: ${cwd},
@@ -93,8 +97,8 @@ export function createTestCommands(extraArgs: string[]): string[][] {
93
97
  }
94
98
 
95
99
  return [
96
- ["bun", "test", "./tests/unit"],
97
- ["bun", "test", "./tests/integration"],
100
+ ["bun", "test", "./tests/framework/unit", "./tests/docs-app/unit"],
101
+ ["bun", "test", "./tests/framework/integration", "./tests/docs-app/integration"],
98
102
  ["bun", "x", "playwright", "test"],
99
103
  ];
100
104
  }
@@ -23,7 +23,6 @@ const BUILD_OPTIMIZE_IMPORTS = [
23
23
  'react-bun-ssr/route',
24
24
  'react',
25
25
  'react-dom',
26
- '@datadog/browser-rum-react',
27
26
  ];
28
27
 
29
28
  export interface ClientEntryFile {
@@ -338,6 +337,9 @@ export async function bundleClientEntries(options: {
338
337
  sourcemap: dev ? 'inline' : 'external',
339
338
  minify: !dev,
340
339
  naming: dev ? '[name].[ext]' : '[name]-[hash].[ext]',
340
+ define: {
341
+ "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"),
342
+ },
341
343
  });
342
344
 
343
345
  if (!result.success) {
@@ -60,6 +60,7 @@ interface NavigateOptions {
60
60
  interface NavigateResult {
61
61
  from: string;
62
62
  to: string;
63
+ nextUrl: URL;
63
64
  status: number;
64
65
  kind: "page" | "not_found" | "catch" | "error";
65
66
  redirected: boolean;
@@ -128,6 +129,16 @@ interface RuntimeState {
128
129
  transitionAbortController: AbortController | null;
129
130
  }
130
131
 
132
+ interface ClientRuntimeSingleton {
133
+ moduleRegistry: Map<string, RouteModuleBundle>;
134
+ pendingNavigationTransitions: Map<string, PendingNavigationTransition>;
135
+ navigationListeners: Set<(info: NavigateResult) => void>;
136
+ runtimeState: RuntimeState | null;
137
+ popstateBound: boolean;
138
+ navigationApiListenerBound: boolean;
139
+ navigationApiTransitionCounter: number;
140
+ }
141
+
131
142
  declare global {
132
143
  interface Window {
133
144
  __RBSSR_DEFERRED__?: DeferredClientRuntime;
@@ -137,12 +148,42 @@ declare global {
137
148
  const NAVIGATION_API_PENDING_TIMEOUT_MS = 1_500;
138
149
  const NAVIGATION_API_PENDING_MATCH_WINDOW_MS = 10_000;
139
150
  const ROUTE_ANNOUNCER_ID = "__rbssr-route-announcer";
140
- const moduleRegistry = new Map<string, RouteModuleBundle>();
141
- const pendingNavigationTransitions = new Map<string, PendingNavigationTransition>();
142
- let runtimeState: RuntimeState | null = null;
143
- let popstateBound = false;
144
- let navigationApiListenerBound = false;
145
- let navigationApiTransitionCounter = 0;
151
+ const CLIENT_RUNTIME_SINGLETON_KEY = Symbol.for("react-bun-ssr.client-runtime");
152
+
153
+ function getClientRuntimeSingleton(): ClientRuntimeSingleton {
154
+ const globalRegistry = globalThis as typeof globalThis & {
155
+ [CLIENT_RUNTIME_SINGLETON_KEY]?: ClientRuntimeSingleton;
156
+ };
157
+ const existing = globalRegistry[CLIENT_RUNTIME_SINGLETON_KEY];
158
+ if (existing) {
159
+ return existing;
160
+ }
161
+
162
+ const singleton: ClientRuntimeSingleton = {
163
+ moduleRegistry: new Map(),
164
+ pendingNavigationTransitions: new Map(),
165
+ navigationListeners: new Set(),
166
+ runtimeState: null,
167
+ popstateBound: false,
168
+ navigationApiListenerBound: false,
169
+ navigationApiTransitionCounter: 0,
170
+ };
171
+ globalRegistry[CLIENT_RUNTIME_SINGLETON_KEY] = singleton;
172
+ return singleton;
173
+ }
174
+
175
+ const clientRuntimeSingleton = getClientRuntimeSingleton();
176
+
177
+ function emitNavigation(info: NavigateResult): void {
178
+ for (const listener of clientRuntimeSingleton.navigationListeners) {
179
+ try {
180
+ listener(info);
181
+ } catch (error) {
182
+ // eslint-disable-next-line no-console
183
+ console.warn("[rbssr] router navigation listener failed", error);
184
+ }
185
+ }
186
+ }
146
187
 
147
188
  function pickOptionalClientModuleExport<T>(
148
189
  moduleValue: Record<string, unknown>,
@@ -291,18 +332,18 @@ function readNavigationDestinationHref(event: NavigateEventLike): string | null
291
332
  }
292
333
 
293
334
  function clearPendingNavigationTransition(id: string): void {
294
- const entry = pendingNavigationTransitions.get(id);
335
+ const entry = clientRuntimeSingleton.pendingNavigationTransitions.get(id);
295
336
  if (!entry) {
296
337
  return;
297
338
  }
298
339
 
299
340
  clearTimeout(entry.timeoutId);
300
- pendingNavigationTransitions.delete(id);
341
+ clientRuntimeSingleton.pendingNavigationTransitions.delete(id);
301
342
  }
302
343
 
303
344
  function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigationTransition | null {
304
345
  if (isFrameworkNavigationInfo(event.info)) {
305
- return pendingNavigationTransitions.get(event.info.id) ?? null;
346
+ return clientRuntimeSingleton.pendingNavigationTransitions.get(event.info.id) ?? null;
306
347
  }
307
348
 
308
349
  if (event.userInitiated) {
@@ -316,7 +357,7 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
316
357
 
317
358
  const now = Date.now();
318
359
  let bestMatch: PendingNavigationTransition | null = null;
319
- for (const candidate of pendingNavigationTransitions.values()) {
360
+ for (const candidate of clientRuntimeSingleton.pendingNavigationTransitions.values()) {
320
361
  if (candidate.destinationHref !== destinationHref) {
321
362
  continue;
322
363
  }
@@ -359,11 +400,11 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
359
400
  }
360
401
 
361
402
  function ensureRuntimeState(): RuntimeState {
362
- if (!runtimeState) {
403
+ if (!clientRuntimeSingleton.runtimeState) {
363
404
  throw new Error("Client runtime is not initialized. Ensure hydrateInitialRoute() ran first.");
364
405
  }
365
406
 
366
- return runtimeState;
407
+ return clientRuntimeSingleton.runtimeState;
367
408
  }
368
409
 
369
410
  function createTransitionUrl(toUrl: URL): URL {
@@ -467,7 +508,7 @@ function applyDeferredChunk(chunk: TransitionDeferredChunk): void {
467
508
  }
468
509
 
469
510
  async function ensureRouteModuleLoaded(routeId: string, snapshot: ClientRouterSnapshot): Promise<void> {
470
- if (moduleRegistry.has(routeId)) {
511
+ if (clientRuntimeSingleton.moduleRegistry.has(routeId)) {
471
512
  return;
472
513
  }
473
514
 
@@ -810,6 +851,7 @@ async function renderTransitionInitial(
810
851
  return {
811
852
  from: options.fromPath,
812
853
  to: toUrl.pathname + toUrl.search + toUrl.hash,
854
+ nextUrl: new URL(toUrl.toString()),
813
855
  status: chunk.status,
814
856
  kind: chunk.kind,
815
857
  redirected: options.redirected ?? false,
@@ -941,6 +983,7 @@ async function navigateToInternal(
941
983
  fromPath: currentPath,
942
984
  });
943
985
  options.onNavigate?.(result);
986
+ emitNavigation(result);
944
987
  return result;
945
988
  } catch {
946
989
  hardNavigate(toUrl);
@@ -953,8 +996,8 @@ async function navigateToInternal(
953
996
  }
954
997
 
955
998
  function nextNavigationTransitionId(): string {
956
- navigationApiTransitionCounter += 1;
957
- return `rbssr-nav-${Date.now()}-${navigationApiTransitionCounter}`;
999
+ clientRuntimeSingleton.navigationApiTransitionCounter += 1;
1000
+ return `rbssr-nav-${Date.now()}-${clientRuntimeSingleton.navigationApiTransitionCounter}`;
958
1001
  }
959
1002
 
960
1003
  function settlePendingNavigationTransition(
@@ -971,7 +1014,7 @@ function settlePendingNavigationTransition(
971
1014
  }
972
1015
 
973
1016
  function cancelPendingNavigationTransition(id: string): void {
974
- const pending = pendingNavigationTransitions.get(id);
1017
+ const pending = clientRuntimeSingleton.pendingNavigationTransitions.get(id);
975
1018
  if (!pending || pending.settled) {
976
1019
  return;
977
1020
  }
@@ -1007,7 +1050,7 @@ function createPendingNavigationTransition(options: {
1007
1050
  }): Promise<NavigateResult | null> {
1008
1051
  return new Promise(resolve => {
1009
1052
  const timeoutId = window.setTimeout(() => {
1010
- const pending = pendingNavigationTransitions.get(options.id);
1053
+ const pending = clientRuntimeSingleton.pendingNavigationTransitions.get(options.id);
1011
1054
  if (!pending || pending.settled) {
1012
1055
  return;
1013
1056
  }
@@ -1023,7 +1066,7 @@ function createPendingNavigationTransition(options: {
1023
1066
  });
1024
1067
  }, NAVIGATION_API_PENDING_TIMEOUT_MS);
1025
1068
 
1026
- pendingNavigationTransitions.set(options.id, {
1069
+ clientRuntimeSingleton.pendingNavigationTransitions.set(options.id, {
1027
1070
  id: options.id,
1028
1071
  destinationHref: options.toUrl.toString(),
1029
1072
  replace: options.replace,
@@ -1038,7 +1081,7 @@ function createPendingNavigationTransition(options: {
1038
1081
  }
1039
1082
 
1040
1083
  function bindNavigationApiNavigateListener(): void {
1041
- if (navigationApiListenerBound || typeof window === "undefined") {
1084
+ if (clientRuntimeSingleton.navigationApiListenerBound || typeof window === "undefined") {
1042
1085
  return;
1043
1086
  }
1044
1087
 
@@ -1098,15 +1141,15 @@ function bindNavigationApiNavigateListener(): void {
1098
1141
  return;
1099
1142
  }
1100
1143
 
1101
- navigationApiListenerBound = true;
1144
+ clientRuntimeSingleton.navigationApiListenerBound = true;
1102
1145
  }
1103
1146
 
1104
1147
  function bindPopstate(): void {
1105
- if (popstateBound || typeof window === "undefined") {
1148
+ if (clientRuntimeSingleton.popstateBound || typeof window === "undefined") {
1106
1149
  return;
1107
1150
  }
1108
1151
 
1109
- popstateBound = true;
1152
+ clientRuntimeSingleton.popstateBound = true;
1110
1153
  window.addEventListener("popstate", () => {
1111
1154
  const targetUrl = new URL(window.location.href);
1112
1155
  void navigateToInternal(targetUrl, {
@@ -1122,10 +1165,10 @@ export async function prefetchTo(to: string): Promise<void> {
1122
1165
  return;
1123
1166
  }
1124
1167
 
1125
- if (!runtimeState) {
1168
+ if (!clientRuntimeSingleton.runtimeState) {
1126
1169
  return;
1127
1170
  }
1128
- const state = runtimeState;
1171
+ const state = clientRuntimeSingleton.runtimeState;
1129
1172
  const toUrl = new URL(to, window.location.href);
1130
1173
  if (!isInternalUrl(toUrl)) {
1131
1174
  return;
@@ -1150,7 +1193,7 @@ export async function navigateWithNavigationApiOrFallback(
1150
1193
  return null;
1151
1194
  }
1152
1195
 
1153
- if (!runtimeState) {
1196
+ if (!clientRuntimeSingleton.runtimeState) {
1154
1197
  hardNavigate(toUrl);
1155
1198
  return null;
1156
1199
  }
@@ -1205,7 +1248,7 @@ export async function navigateTo(to: string, options: NavigateOptions = {}): Pro
1205
1248
  return null;
1206
1249
  }
1207
1250
 
1208
- if (!runtimeState) {
1251
+ if (!clientRuntimeSingleton.runtimeState) {
1209
1252
  hardNavigate(toUrl);
1210
1253
  return null;
1211
1254
  }
@@ -1213,21 +1256,28 @@ export async function navigateTo(to: string, options: NavigateOptions = {}): Pro
1213
1256
  return navigateToInternal(toUrl, options);
1214
1257
  }
1215
1258
 
1259
+ export function subscribeToNavigation(listener: (info: NavigateResult) => void): () => void {
1260
+ clientRuntimeSingleton.navigationListeners.add(listener);
1261
+ return () => {
1262
+ clientRuntimeSingleton.navigationListeners.delete(listener);
1263
+ };
1264
+ }
1265
+
1216
1266
  export function registerRouteModules(routeId: string, modules: RouteModuleBundle): void {
1217
- moduleRegistry.set(routeId, modules);
1218
- if (runtimeState) {
1219
- runtimeState.moduleRegistry.set(routeId, modules);
1267
+ clientRuntimeSingleton.moduleRegistry.set(routeId, modules);
1268
+ if (clientRuntimeSingleton.runtimeState) {
1269
+ clientRuntimeSingleton.runtimeState.moduleRegistry.set(routeId, modules);
1220
1270
  }
1221
1271
  }
1222
1272
 
1223
1273
  export function hydrateInitialRoute(routeId: string): void {
1224
- if (typeof document === "undefined" || runtimeState) {
1274
+ if (typeof document === "undefined" || clientRuntimeSingleton.runtimeState) {
1225
1275
  return;
1226
1276
  }
1227
1277
 
1228
1278
  const payload = reviveDeferredPayload(getScriptJson<RenderPayload>(RBSSR_PAYLOAD_SCRIPT_ID));
1229
1279
  const routerSnapshot = getScriptJson<ClientRouterSnapshot>(RBSSR_ROUTER_SCRIPT_ID);
1230
- const modules = moduleRegistry.get(routeId);
1280
+ const modules = clientRuntimeSingleton.moduleRegistry.get(routeId);
1231
1281
  if (!modules) {
1232
1282
  throw new Error(`Missing module registry for initial route "${routeId}"`);
1233
1283
  }
@@ -1247,13 +1297,13 @@ export function hydrateInitialRoute(routeId: string): void {
1247
1297
  }
1248
1298
 
1249
1299
  const root = hydrateRoot(container, appTree);
1250
- runtimeState = {
1300
+ clientRuntimeSingleton.runtimeState = {
1251
1301
  root,
1252
1302
  currentPayload: payload,
1253
1303
  currentRouteId: routeId,
1254
1304
  currentModules: modules,
1255
1305
  routerSnapshot,
1256
- moduleRegistry,
1306
+ moduleRegistry: clientRuntimeSingleton.moduleRegistry,
1257
1307
  prefetchCache: new Map(),
1258
1308
  navigationToken: 0,
1259
1309
  transitionAbortController: null,
@@ -71,15 +71,20 @@ function toHeaderRules(config: FrameworkConfig): ResolvedResponseHeaderRule[] {
71
71
  throw new Error(`[rbssr config] \`headers[${index}].headers\` must include at least one header.`);
72
72
  }
73
73
 
74
- const headers: Record<string, string> = {};
74
+ const headers: Record<string, string | null> = {};
75
75
  for (const [key, value] of entries) {
76
76
  if (typeof key !== "string" || key.trim().length === 0) {
77
77
  throw new Error(`[rbssr config] \`headers[${index}].headers\` contains an empty header name.`);
78
78
  }
79
79
 
80
+ if (value === null) {
81
+ headers[key] = null;
82
+ continue;
83
+ }
84
+
80
85
  if (typeof value !== "string" || value.trim().length === 0) {
81
86
  throw new Error(
82
- `[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value.`,
87
+ `[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value or null.`,
83
88
  );
84
89
  }
85
90
 
@@ -26,5 +26,5 @@ export { createServer, startHttpServer } from "./server";
26
26
  export { defer, json, redirect, defineConfig } from "./helpers";
27
27
  export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
28
28
  export { Link, type LinkProps } from "./link";
29
- export { useRouter, type Router, type RouterNavigateOptions } from "./router";
29
+ export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
30
30
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -4,15 +4,7 @@ import type {
4
4
  MouseEvent,
5
5
  TouchEvent,
6
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
- }
7
+ import type { RouterNavigateInfo } from "./router";
16
8
 
17
9
  export interface LinkProps
18
10
  extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
@@ -20,7 +12,7 @@ export interface LinkProps
20
12
  replace?: boolean;
21
13
  scroll?: boolean;
22
14
  prefetch?: "intent" | "none";
23
- onNavigate?: (info: NavigateInfo) => void;
15
+ onNavigate?: (info: RouterNavigateInfo) => void;
24
16
  }
25
17
 
26
18
  function shouldHandleNavigation(event: MouseEvent<HTMLAnchorElement>): boolean {
@@ -89,7 +81,7 @@ async function prefetch(href: string): Promise<void> {
89
81
  async function navigate(href: string, options: {
90
82
  replace?: boolean;
91
83
  scroll?: boolean;
92
- onNavigate?: (info: NavigateInfo) => void;
84
+ onNavigate?: (info: RouterNavigateInfo) => void;
93
85
  }): Promise<void> {
94
86
  if (typeof window === "undefined") {
95
87
  return;
@@ -29,21 +29,26 @@ export interface RouteModuleLoadOptions {
29
29
  cacheBustKey?: string;
30
30
  serverBytecode?: boolean;
31
31
  devSourceImports?: boolean;
32
+ nodeEnv?: "development" | "production";
32
33
  }
33
34
 
34
35
  export function createServerModuleCacheKey(options: {
35
36
  absoluteFilePath: string;
36
37
  cacheBustKey?: string;
37
38
  serverBytecode: boolean;
39
+ nodeEnv?: "development" | "production";
38
40
  }): string {
39
- return `${options.absoluteFilePath}|${options.cacheBustKey ?? 'prod'}|bytecode:${options.serverBytecode ? '1' : '0'}|bun:${Bun.version}`;
41
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
42
+ return `${options.absoluteFilePath}|${options.cacheBustKey ?? 'prod'}|bytecode:${options.serverBytecode ? '1' : '0'}|env:${nodeEnv}|bun:${Bun.version}`;
40
43
  }
41
44
 
42
45
  export function createServerBuildConfig(options: {
43
46
  absoluteFilePath: string;
44
47
  outDir: string;
45
48
  serverBytecode: boolean;
49
+ nodeEnv?: "development" | "production";
46
50
  }): Bun.BuildConfig {
51
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
47
52
  return {
48
53
  entrypoints: [options.absoluteFilePath],
49
54
  outdir: options.outDir,
@@ -56,6 +61,9 @@ export function createServerBuildConfig(options: {
56
61
  minify: false,
57
62
  naming: 'entry-[hash].[ext]',
58
63
  external: SERVER_BUILD_EXTERNAL,
64
+ define: {
65
+ "process.env.NODE_ENV": JSON.stringify(nodeEnv),
66
+ },
59
67
  };
60
68
  }
61
69
 
@@ -128,10 +136,12 @@ async function buildServerModule(
128
136
 
129
137
  const cacheBustKey = options.cacheBustKey;
130
138
  const serverBytecode = options.serverBytecode ?? true;
139
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
131
140
  const cacheKey = createServerModuleCacheKey({
132
141
  absoluteFilePath,
133
142
  cacheBustKey,
134
143
  serverBytecode,
144
+ nodeEnv,
135
145
  });
136
146
  const existing = serverBundlePathCache.get(cacheKey);
137
147
  if (existing) {
@@ -153,6 +163,7 @@ async function buildServerModule(
153
163
  absoluteFilePath,
154
164
  outDir,
155
165
  serverBytecode,
166
+ nodeEnv,
156
167
  }),
157
168
  );
158
169
 
@@ -169,6 +180,7 @@ async function buildServerModule(
169
180
  absoluteFilePath,
170
181
  outDir,
171
182
  serverBytecode: false,
183
+ nodeEnv,
172
184
  }),
173
185
  );
174
186
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Children,
3
3
  cloneElement,
4
+ Fragment,
4
5
  isValidElement,
5
6
  Suspense,
6
7
  use,
@@ -29,6 +30,50 @@ import {
29
30
  createPageAppTree,
30
31
  } from "./tree";
31
32
 
33
+ function isTitleElement(node: ReactNode): node is ReactElement {
34
+ return isValidElement(node) && node.type === "title";
35
+ }
36
+
37
+ function isMetaElement(node: ReactNode): node is ReactElement {
38
+ return isValidElement(node) && node.type === "meta";
39
+ }
40
+
41
+ function getMetaDedupeKey(node: ReactNode): string | null {
42
+ if (!isMetaElement(node)) {
43
+ return null;
44
+ }
45
+
46
+ const props = node.props as {
47
+ name?: string;
48
+ property?: string;
49
+ httpEquiv?: string;
50
+ charSet?: string;
51
+ itemProp?: string;
52
+ };
53
+
54
+ if (typeof props.name === "string" && props.name.length > 0) {
55
+ return `name:${props.name}`;
56
+ }
57
+
58
+ if (typeof props.property === "string" && props.property.length > 0) {
59
+ return `property:${props.property}`;
60
+ }
61
+
62
+ if (typeof props.httpEquiv === "string" && props.httpEquiv.length > 0) {
63
+ return `httpEquiv:${props.httpEquiv}`;
64
+ }
65
+
66
+ if (typeof props.itemProp === "string" && props.itemProp.length > 0) {
67
+ return `itemProp:${props.itemProp}`;
68
+ }
69
+
70
+ if (props.charSet !== undefined) {
71
+ return "charSet";
72
+ }
73
+
74
+ return null;
75
+ }
76
+
32
77
  export function renderPageApp(modules: RouteModuleBundle, payload: RenderPayload): string {
33
78
  return renderToString(createPageAppTree(modules, payload));
34
79
  }
@@ -84,6 +129,27 @@ function normalizeTitleChildren(node: ReactNode): ReactNode {
84
129
  return cloneElement(node, undefined, nextChildren);
85
130
  }
86
131
 
132
+ function expandHeadNodes(node: ReactNode): ReactNode[] {
133
+ if (Array.isArray(node)) {
134
+ return node.flatMap(value => expandHeadNodes(value));
135
+ }
136
+
137
+ if (node === null || node === undefined || typeof node === "boolean") {
138
+ return [];
139
+ }
140
+
141
+ if (!isValidElement(node)) {
142
+ return [node];
143
+ }
144
+
145
+ if (node.type === Fragment) {
146
+ const props = node.props as { children?: ReactNode };
147
+ return Children.toArray(props.children).flatMap(child => expandHeadNodes(child));
148
+ }
149
+
150
+ return [node];
151
+ }
152
+
87
153
  function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload, keyPrefix: string): ReactNode[] {
88
154
  const tags: ReactNode[] = [];
89
155
 
@@ -99,7 +165,7 @@ function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload,
99
165
  if (typeof headResult === "string") {
100
166
  tags.push(<title key={`${keyPrefix}:title`}>{headResult}</title>);
101
167
  } else if (headResult !== null && headResult !== undefined) {
102
- const nodes = Children.toArray(normalizeTitleChildren(headResult));
168
+ const nodes = expandHeadNodes(normalizeTitleChildren(headResult));
103
169
  for (let index = 0; index < nodes.length; index += 1) {
104
170
  const node = nodes[index]!;
105
171
  if (isValidElement(node)) {
@@ -122,11 +188,37 @@ function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload,
122
188
  }
123
189
 
124
190
  export function collectHeadElements(modules: RouteModuleBundle, payload: RenderPayload): ReactNode[] {
125
- return [
191
+ const elements = [
126
192
  ...moduleHeadToElements(modules.root, payload, "root"),
127
193
  ...modules.layouts.flatMap((layout, index) => moduleHeadToElements(layout, payload, `layout:${index}`)),
128
194
  ...moduleHeadToElements(modules.route, payload, "route"),
129
195
  ];
196
+
197
+ let lastTitleIndex = -1;
198
+ const lastMetaIndexes = new Map<string, number>();
199
+ for (let index = 0; index < elements.length; index += 1) {
200
+ if (isTitleElement(elements[index])) {
201
+ lastTitleIndex = index;
202
+ }
203
+
204
+ const metaKey = getMetaDedupeKey(elements[index]);
205
+ if (metaKey) {
206
+ lastMetaIndexes.set(metaKey, index);
207
+ }
208
+ }
209
+
210
+ return elements.filter((element, index) => {
211
+ if (isTitleElement(element)) {
212
+ return lastTitleIndex === -1 || index === lastTitleIndex;
213
+ }
214
+
215
+ const metaKey = getMetaDedupeKey(element);
216
+ if (metaKey) {
217
+ return lastMetaIndexes.get(metaKey) === index;
218
+ }
219
+
220
+ return true;
221
+ });
130
222
  }
131
223
 
132
224
  export function collectHeadMarkup(modules: RouteModuleBundle, payload: RenderPayload): string {
@@ -19,5 +19,5 @@ export type {
19
19
  export { defer, json, redirect } from "./helpers";
20
20
  export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
21
21
  export { Link, type LinkProps } from "./link";
22
- export { useRouter, type Router, type RouterNavigateOptions } from "./router";
22
+ export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
23
23
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -1,10 +1,36 @@
1
- import { useMemo } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { goBack, goForward, reloadPage } from "./navigation-api";
3
3
 
4
4
  export interface RouterNavigateOptions {
5
5
  scroll?: boolean;
6
6
  }
7
7
 
8
+ export interface RouterNavigateInfo {
9
+ from: string;
10
+ to: string;
11
+ nextUrl: URL;
12
+ status: number;
13
+ kind: "page" | "not_found" | "catch" | "error";
14
+ redirected: boolean;
15
+ prefetched: boolean;
16
+ }
17
+
18
+ export type RouterNavigateListener = (nextUrl: URL) => void;
19
+
20
+ export function notifyRouterNavigateListeners(
21
+ listeners: readonly RouterNavigateListener[],
22
+ nextUrl: URL,
23
+ ): void {
24
+ for (const listener of listeners) {
25
+ try {
26
+ listener(nextUrl);
27
+ } catch (error) {
28
+ // eslint-disable-next-line no-console
29
+ console.warn("[rbssr] router onNavigate listener failed", error);
30
+ }
31
+ }
32
+ }
33
+
8
34
  export interface Router {
9
35
  push(href: string, options?: RouterNavigateOptions): void;
10
36
  replace(href: string, options?: RouterNavigateOptions): void;
@@ -12,6 +38,7 @@ export interface Router {
12
38
  back(): void;
13
39
  forward(): void;
14
40
  refresh(): void;
41
+ onNavigate(listener: RouterNavigateListener): void;
15
42
  }
16
43
 
17
44
  function toAbsoluteHref(href: string): string {
@@ -28,9 +55,10 @@ const SERVER_ROUTER: Router = {
28
55
  back: () => undefined,
29
56
  forward: () => undefined,
30
57
  refresh: () => undefined,
58
+ onNavigate: () => undefined,
31
59
  };
32
60
 
33
- function createClientRouter(): Router {
61
+ function createClientRouter(onNavigate: Router["onNavigate"]): Router {
34
62
  return {
35
63
  push: (href, options) => {
36
64
  const absoluteHref = toAbsoluteHref(href);
@@ -69,12 +97,55 @@ function createClientRouter(): Router {
69
97
  refresh: () => {
70
98
  reloadPage();
71
99
  },
100
+ onNavigate,
72
101
  };
73
102
  }
74
103
 
75
104
  export function useRouter(): Router {
105
+ const navigateListenersRef = useRef<RouterNavigateListener[]>([]);
106
+ const didEmitInitialNavigationRef = useRef(false);
107
+ navigateListenersRef.current = [];
108
+
109
+ const onNavigate = useCallback<Router["onNavigate"]>((listener) => {
110
+ navigateListenersRef.current.push(listener);
111
+ }, []);
112
+
113
+ useEffect(() => {
114
+ if (typeof window === "undefined") {
115
+ return;
116
+ }
117
+
118
+ if (!didEmitInitialNavigationRef.current) {
119
+ didEmitInitialNavigationRef.current = true;
120
+ notifyRouterNavigateListeners(
121
+ navigateListenersRef.current,
122
+ new URL(window.location.href),
123
+ );
124
+ }
125
+
126
+ let unsubscribe: () => void = () => undefined;
127
+ let active = true;
128
+
129
+ void import("./client-runtime")
130
+ .then(runtime => {
131
+ if (!active) {
132
+ return;
133
+ }
134
+
135
+ unsubscribe = runtime.subscribeToNavigation((info) => {
136
+ notifyRouterNavigateListeners(navigateListenersRef.current, info.nextUrl);
137
+ });
138
+ })
139
+ .catch(() => undefined);
140
+
141
+ return () => {
142
+ active = false;
143
+ unsubscribe();
144
+ };
145
+ }, []);
146
+
76
147
  return useMemo(
77
- () => (typeof window === "undefined" ? SERVER_ROUTER : createClientRouter()),
78
- [],
148
+ () => (typeof window === "undefined" ? SERVER_ROUTER : createClientRouter(onNavigate)),
149
+ [onNavigate],
79
150
  );
80
151
  }
@@ -227,6 +227,11 @@ function applyConfiguredHeaders(options: {
227
227
  }
228
228
 
229
229
  for (const [name, value] of Object.entries(rule.headers)) {
230
+ if (value === null) {
231
+ headers.delete(name);
232
+ continue;
233
+ }
234
+
230
235
  headers.set(name, value);
231
236
  }
232
237
  }
@@ -630,15 +635,18 @@ export function createServer(
630
635
 
631
636
  const routeAdapter = await getRouteAdapter(activeConfig);
632
637
  const devCacheBustKey = dev ? String(runtimeOptions.reloadVersion?.() ?? 0) : undefined;
638
+ const nodeEnv: "development" | "production" = dev ? "development" : "production";
633
639
  const routeModuleLoadOptions = {
634
640
  cacheBustKey: devCacheBustKey,
635
641
  serverBytecode: activeConfig.serverBytecode,
636
642
  devSourceImports: false,
643
+ nodeEnv,
637
644
  };
638
645
  const requestModuleLoadOptions = {
639
646
  cacheBustKey: undefined,
640
647
  serverBytecode: activeConfig.serverBytecode,
641
648
  devSourceImports: dev,
649
+ nodeEnv,
642
650
  };
643
651
  const routeAssetsById = resolveAllRouteAssets({
644
652
  dev,
@@ -2,6 +2,7 @@ import {
2
2
  createContext,
3
3
  useContext,
4
4
  type ComponentType,
5
+ type Context,
5
6
  type ReactElement,
6
7
  type ReactNode,
7
8
  } from "react";
@@ -15,8 +16,29 @@ interface RuntimeState {
15
16
  reset: () => void;
16
17
  }
17
18
 
18
- const RuntimeContext = createContext<RuntimeState | null>(null);
19
- const OutletContext = createContext<ReactNode>(null);
19
+ const RUNTIME_CONTEXT_KEY = Symbol.for("react-bun-ssr.runtime-context");
20
+ const OUTLET_CONTEXT_KEY = Symbol.for("react-bun-ssr.outlet-context");
21
+
22
+ function getGlobalContext<T>(key: symbol, createValue: () => Context<T>): Context<T> {
23
+ const globalRegistry = globalThis as typeof globalThis & { [contextKey: symbol]: Context<T> | undefined };
24
+ const existing = globalRegistry[key];
25
+ if (existing) {
26
+ return existing;
27
+ }
28
+
29
+ const context = createValue();
30
+ globalRegistry[key] = context;
31
+ return context;
32
+ }
33
+
34
+ const RuntimeContext = getGlobalContext<RuntimeState | null>(
35
+ RUNTIME_CONTEXT_KEY,
36
+ () => createContext<RuntimeState | null>(null),
37
+ );
38
+ const OutletContext = getGlobalContext<ReactNode>(
39
+ OUTLET_CONTEXT_KEY,
40
+ () => createContext<ReactNode>(null),
41
+ );
20
42
  const NOOP_RESET = () => undefined;
21
43
 
22
44
  function useRuntimeState(): RuntimeState {
@@ -123,7 +123,7 @@ export interface ApiRouteModule {
123
123
 
124
124
  export interface ResponseHeaderRule {
125
125
  source: string;
126
- headers: Record<string, string>;
126
+ headers: Record<string, string | null>;
127
127
  }
128
128
 
129
129
  export interface ResolvedResponseHeaderRule extends ResponseHeaderRule {
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "react-bun-ssr",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
+ "workspaces": [
8
+ "app"
9
+ ],
7
10
  "types": "./framework/runtime/index.ts",
8
11
  "repository": {
9
12
  "type": "git",
@@ -44,10 +47,16 @@
44
47
  "start": "bun bin/rbssr.ts start",
45
48
  "typecheck": "bun bin/rbssr.ts typecheck",
46
49
  "test": "bun bin/rbssr.ts test",
47
- "test:unit": "bun test ./tests/unit",
48
- "test:integration": "bun test ./tests/integration",
49
- "test:consumer": "RBSSR_RUN_CONSUMER_SMOKE=1 bun test ./tests/integration/package-smoke.test.ts",
50
+ "test:unit": "bun test ./tests/framework/unit ./tests/docs-app/unit",
51
+ "test:integration": "bun test ./tests/framework/integration ./tests/docs-app/integration",
52
+ "test:consumer": "RBSSR_RUN_CONSUMER_SMOKE=1 bun test ./tests/framework/integration/package-smoke.test.ts",
53
+ "test:watch": "bunx chokidar-cli \"framework/**/*\" \"app/**/*\" \"scripts/**/*\" \"tests/**/*\" -c \"bun run test:unit && bun run test:integration\"",
54
+ "test:watch:framework:unit": "bunx chokidar-cli \"framework/**/*\" \"scripts/**/*\" \"tests/framework/unit/**/*\" \"tests/framework/helpers/**/*\" -c \"bun test ./tests/framework/unit\"",
55
+ "test:watch:framework:integration": "bunx chokidar-cli \"framework/**/*\" \"scripts/**/*\" \"tests/framework/integration/**/*\" \"tests/framework/helpers/**/*\" \"app/**/*\" -c \"bun test ./tests/framework/integration\"",
56
+ "test:watch:docs-app:unit": "bunx chokidar-cli \"app/**/*\" \"tests/docs-app/unit/**/*\" -c \"bun test ./tests/docs-app/unit\"",
57
+ "test:watch:docs-app:integration": "bunx chokidar-cli \"app/**/*\" \"scripts/**/*\" \"tests/docs-app/integration/**/*\" -c \"bun test ./tests/docs-app/integration\"",
50
58
  "test:e2e": "bunx playwright test",
59
+ "test:e2e:ui": "bunx playwright test --ui",
51
60
  "docs:dev": "bun run scripts/generate-api-docs.ts && bun run scripts/build-docs-manifest.ts && bun run scripts/build-search-index.ts && bun run scripts/build-blog-manifest.ts && bun run scripts/build-sitemap.ts && bun bin/rbssr.ts dev",
52
61
  "docs:build": "bun run scripts/docs-build.ts",
53
62
  "docs:check": "bun run scripts/check-docs.ts",
@@ -65,9 +74,5 @@
65
74
  "react": "^19",
66
75
  "react-dom": "^19",
67
76
  "typescript": "^5.9.2"
68
- },
69
- "dependencies": {
70
- "@datadog/browser-rum": "^6.28.1",
71
- "@datadog/browser-rum-react": "^6.28.1"
72
77
  }
73
78
  }