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 +25 -17
- package/framework/cli/commands.ts +52 -30
- package/framework/cli/dev-client-watch.ts +12 -5
- package/framework/cli/internal.ts +7 -3
- package/framework/runtime/build-tools.ts +3 -1
- package/framework/runtime/client-runtime.tsx +83 -33
- package/framework/runtime/config.ts +7 -2
- package/framework/runtime/index.ts +1 -1
- package/framework/runtime/link.tsx +3 -11
- package/framework/runtime/module-loader.ts +13 -1
- package/framework/runtime/render.tsx +94 -2
- package/framework/runtime/route-api.ts +1 -1
- package/framework/runtime/router.ts +75 -4
- package/framework/runtime/server.ts +8 -0
- package/framework/runtime/tree.tsx +24 -2
- package/framework/runtime/types.ts +1 -1
- package/package.json +13 -8
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.
|
|
6
|
-
- API reference: https://react-bun-ssr.
|
|
7
|
-
- Blog: https://react-bun-ssr.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
97
|
-
- https://react-bun-ssr.
|
|
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.
|
|
106
|
-
- https://react-bun-ssr.
|
|
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.
|
|
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.
|
|
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
|
|
159
|
-
- `
|
|
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.
|
|
188
|
-
- https://react-bun-ssr.
|
|
189
|
-
- https://react-bun-ssr.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
const routeAssets = await bundleClientEntries({
|
|
89
|
+
entries,
|
|
90
|
+
outDir: distClientDir,
|
|
91
|
+
dev: false,
|
|
92
|
+
publicPrefix: "/client/",
|
|
93
|
+
});
|
|
74
94
|
|
|
75
|
-
|
|
95
|
+
await copyDirRecursive(resolved.publicDir, distClientDir);
|
|
76
96
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
103
|
+
await writeProductionServerEntrypoint({ distDir: resolved.distDir });
|
|
84
104
|
|
|
85
|
-
|
|
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:
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
19
|
-
const
|
|
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.
|
|
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
|
}
|