react-bun-ssr 0.3.0 → 0.3.2
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 +24 -17
- package/framework/cli/dev-runtime.ts +59 -30
- package/framework/cli/scaffold.ts +183 -5
- package/framework/runtime/client-runtime.tsx +1 -216
- package/framework/runtime/doctype-stream.ts +38 -0
- package/framework/runtime/head-reconcile.ts +270 -0
- package/framework/runtime/io.ts +12 -9
- package/framework/runtime/matcher.ts +4 -2
- package/framework/runtime/render.tsx +2 -31
- package/framework/runtime/route-order.ts +23 -0
- package/framework/runtime/route-scanner.ts +3 -18
- package/framework/runtime/router.ts +43 -5
- package/framework/runtime/server.ts +3 -2
- package/package.json +20 -2
package/README.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# react-bun-ssr
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/react-bun-ssr)
|
|
4
|
+
[](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?
|
|
@@ -31,6 +38,11 @@ Prerequisites:
|
|
|
31
38
|
- Bun `>= 1.3.10`
|
|
32
39
|
- `rbssr` available on PATH in the workflow you use to start a new app
|
|
33
40
|
|
|
41
|
+
Try in browser:
|
|
42
|
+
|
|
43
|
+
- StackBlitz (primary): https://stackblitz.com/github/react-formation/react-bun-ssr/tree/main/examples/sandbox-starter
|
|
44
|
+
- CodeSandbox (fallback): https://codesandbox.io/s/github/react-formation/react-bun-ssr/tree/main/examples/sandbox-starter
|
|
45
|
+
|
|
34
46
|
Minimal setup:
|
|
35
47
|
|
|
36
48
|
```bash
|
|
@@ -57,9 +69,15 @@ For the full setup walkthrough, read the installation guide:
|
|
|
57
69
|
`rbssr init` scaffolds a small Bun-first SSR app:
|
|
58
70
|
|
|
59
71
|
```text
|
|
72
|
+
package.json
|
|
73
|
+
tsconfig.json
|
|
74
|
+
.gitignore
|
|
60
75
|
app/
|
|
61
76
|
root.tsx
|
|
77
|
+
root.module.css
|
|
62
78
|
middleware.ts
|
|
79
|
+
public/
|
|
80
|
+
favicon.svg
|
|
63
81
|
routes/
|
|
64
82
|
index.tsx
|
|
65
83
|
api/
|
|
@@ -67,8 +85,13 @@ app/
|
|
|
67
85
|
rbssr.config.ts
|
|
68
86
|
```
|
|
69
87
|
|
|
88
|
+
- `package.json`: Bun scripts and framework/runtime dependencies
|
|
89
|
+
- `tsconfig.json`: starter TypeScript config for Bun + JSX
|
|
90
|
+
- `.gitignore`: minimal app-level ignore rules
|
|
70
91
|
- `app/root.tsx`: document shell and top-level layout
|
|
92
|
+
- `app/root.module.css`: starter CSS Module for layout and base presentation
|
|
71
93
|
- `app/middleware.ts`: global request pipeline hook
|
|
94
|
+
- `app/public/favicon.svg`: starter public asset
|
|
72
95
|
- `app/routes/index.tsx`: first SSR page route
|
|
73
96
|
- `app/routes/api/health.ts`: first API route
|
|
74
97
|
- `rbssr.config.ts`: runtime configuration entrypoint
|
|
@@ -141,6 +164,7 @@ This repository contains both the framework and the official docs site built wit
|
|
|
141
164
|
```bash
|
|
142
165
|
git clone git@github.com:react-formation/react-bun-ssr.git
|
|
143
166
|
cd react-bun-ssr
|
|
167
|
+
bun link
|
|
144
168
|
bun install
|
|
145
169
|
bun run docs:dev
|
|
146
170
|
```
|
|
@@ -178,20 +202,3 @@ Contributions should keep framework behavior, docs, tests, and generated artifac
|
|
|
178
202
|
- The release workflow derives the published package version from the Git tag and rewrites `package.json` in the release job before publishing.
|
|
179
203
|
- npm publishing uses trusted publishing with GitHub OIDC instead of an `NPM_TOKEN`.
|
|
180
204
|
- npm package settings must have a trusted publisher configured for `react-formation / react-bun-ssr / release.yml`.
|
|
181
|
-
|
|
182
|
-
## Deploying
|
|
183
|
-
|
|
184
|
-
Fly.io deployment support is already documented and used by this project.
|
|
185
|
-
|
|
186
|
-
Happy path:
|
|
187
|
-
|
|
188
|
-
```bash
|
|
189
|
-
fly auth login
|
|
190
|
-
fly deploy
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
Full deployment docs:
|
|
194
|
-
|
|
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
|
|
@@ -43,6 +43,10 @@ function toAbsoluteAppPath(appDir: string, relativePath: string): string {
|
|
|
43
43
|
return path.join(appDir, relativePath);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function toNormalizedWatchPath(fileName?: string | Buffer | null): string {
|
|
47
|
+
return typeof fileName === "string" ? normalizeSlashes(fileName) : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
interface DevHotData {
|
|
47
51
|
bunServer?: Bun.Server<undefined>;
|
|
48
52
|
reloadToken?: number;
|
|
@@ -88,9 +92,11 @@ export async function runHotDevChild(options: {
|
|
|
88
92
|
let nextClientBuildReason: DevReloadReason | null = null;
|
|
89
93
|
let stopping = false;
|
|
90
94
|
|
|
91
|
-
const watchers: FSWatcher[] = [];
|
|
92
95
|
let structuralSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
|
93
96
|
let structuralSyncQueue: Promise<void> = Promise.resolve();
|
|
97
|
+
let routesWatcher: FSWatcher | null = null;
|
|
98
|
+
let appWatcher: FSWatcher | null = null;
|
|
99
|
+
let configWatcher: FSWatcher | null = null;
|
|
94
100
|
|
|
95
101
|
const publishReload = (reason: DevReloadReason): void => {
|
|
96
102
|
reloadToken += 1;
|
|
@@ -253,11 +259,7 @@ export async function runHotDevChild(options: {
|
|
|
253
259
|
process.exit(RBSSR_DEV_RESTART_EXIT_CODE);
|
|
254
260
|
};
|
|
255
261
|
|
|
256
|
-
const handleAppEvent = (eventType: string,
|
|
257
|
-
const relativePath = typeof fileName === "string"
|
|
258
|
-
? normalizeSlashes(fileName)
|
|
259
|
-
: "";
|
|
260
|
-
|
|
262
|
+
const handleAppEvent = (eventType: string, relativePath: string): void => {
|
|
261
263
|
if (!relativePath) {
|
|
262
264
|
scheduleStructuralSync();
|
|
263
265
|
return;
|
|
@@ -289,12 +291,31 @@ export async function runHotDevChild(options: {
|
|
|
289
291
|
});
|
|
290
292
|
};
|
|
291
293
|
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
294
|
-
|
|
294
|
+
const ensureRoutesWatcher = async (): Promise<void> => {
|
|
295
|
+
if (routesWatcher || !(await existsPath(resolved.routesDir))) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
routesWatcher = watch(resolved.routesDir, { recursive: true }, (eventType, fileName) => {
|
|
301
|
+
const nestedPath = toNormalizedWatchPath(fileName);
|
|
302
|
+
const relativePath = nestedPath ? `routes/${nestedPath}` : "routes";
|
|
303
|
+
handleAppEvent(eventType, relativePath);
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
log(`recursive route watching unavailable for ${resolved.routesDir}; route topology updates may require a restart`);
|
|
295
307
|
}
|
|
296
308
|
};
|
|
297
309
|
|
|
310
|
+
const refreshRoutesWatcher = async (): Promise<void> => {
|
|
311
|
+
if (routesWatcher) {
|
|
312
|
+
routesWatcher.close();
|
|
313
|
+
routesWatcher = null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await ensureRoutesWatcher();
|
|
317
|
+
};
|
|
318
|
+
|
|
298
319
|
const cleanup = async (options: {
|
|
299
320
|
preserveServer: boolean;
|
|
300
321
|
}): Promise<void> => {
|
|
@@ -303,9 +324,12 @@ export async function runHotDevChild(options: {
|
|
|
303
324
|
structuralSyncTimer = undefined;
|
|
304
325
|
}
|
|
305
326
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
327
|
+
routesWatcher?.close();
|
|
328
|
+
routesWatcher = null;
|
|
329
|
+
appWatcher?.close();
|
|
330
|
+
appWatcher = null;
|
|
331
|
+
configWatcher?.close();
|
|
332
|
+
configWatcher = null;
|
|
309
333
|
|
|
310
334
|
if (clientWatch) {
|
|
311
335
|
await clientWatch.stop();
|
|
@@ -318,31 +342,38 @@ export async function runHotDevChild(options: {
|
|
|
318
342
|
}
|
|
319
343
|
};
|
|
320
344
|
|
|
345
|
+
await refreshRoutesWatcher();
|
|
346
|
+
|
|
321
347
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
348
|
+
appWatcher = watch(resolved.appDir, (eventType, fileName) => {
|
|
349
|
+
const relativePath = toNormalizedWatchPath(fileName);
|
|
350
|
+
if (relativePath === "routes" && eventType === "rename") {
|
|
351
|
+
void refreshRoutesWatcher();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
handleAppEvent(eventType, relativePath);
|
|
355
|
+
});
|
|
327
356
|
} catch {
|
|
328
|
-
log(`
|
|
357
|
+
log(`top-level app watching unavailable for ${resolved.appDir}; route topology updates may require a restart`);
|
|
329
358
|
}
|
|
330
359
|
|
|
331
360
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
361
|
+
configWatcher = watch(options.cwd, (eventType, fileName) => {
|
|
362
|
+
const configFileName = toNormalizedWatchPath(fileName);
|
|
363
|
+
if (!configFileName || !isConfigFileName(configFileName)) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (eventType === "rename" || eventType === "change") {
|
|
367
|
+
void restartForConfigChange();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
342
370
|
} catch {
|
|
343
371
|
log(`config file watching unavailable for ${options.cwd}; config changes may require a manual restart`);
|
|
344
372
|
}
|
|
345
373
|
|
|
374
|
+
await enqueueStructuralSync("bootstrap");
|
|
375
|
+
await refreshRoutesWatcher();
|
|
376
|
+
|
|
346
377
|
if (import.meta.hot) {
|
|
347
378
|
import.meta.hot.dispose(async (data: DevHotData) => {
|
|
348
379
|
data.bunServer = bunServer;
|
|
@@ -352,8 +383,6 @@ export async function runHotDevChild(options: {
|
|
|
352
383
|
});
|
|
353
384
|
}
|
|
354
385
|
|
|
355
|
-
await enqueueStructuralSync("bootstrap");
|
|
356
|
-
|
|
357
386
|
if (hotData.bunServer && bunServer) {
|
|
358
387
|
publishReload("server-runtime");
|
|
359
388
|
}
|
|
@@ -6,6 +6,23 @@ interface ScaffoldFile {
|
|
|
6
6
|
content: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
interface FrameworkPackageManifest {
|
|
10
|
+
version?: string;
|
|
11
|
+
devDependencies?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FRAMEWORK_VERSION = "0.0.0";
|
|
15
|
+
const DEFAULT_TYPESCRIPT_VERSION = "^5";
|
|
16
|
+
const DEFAULT_BUN_TYPES_VERSION = "latest";
|
|
17
|
+
const DEFAULT_REACT_TYPES_VERSION = "^19";
|
|
18
|
+
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
|
19
|
+
<rect width="96" height="96" rx="20" fill="#111827"/>
|
|
20
|
+
<path d="M25 28h20c13.807 0 25 11.193 25 25S58.807 78 45 78H25V28Zm18 40c8.837 0 16-7.163 16-16s-7.163-16-16-16h-8v32h8Z" fill="#f9fafb"/>
|
|
21
|
+
</svg>
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
let frameworkPackageManifestPromise: Promise<FrameworkPackageManifest> | null = null;
|
|
25
|
+
|
|
9
26
|
async function writeIfMissing(filePath: string, content: string, force: boolean): Promise<void> {
|
|
10
27
|
if (!force && await existsPath(filePath)) {
|
|
11
28
|
return;
|
|
@@ -14,8 +31,115 @@ async function writeIfMissing(filePath: string, content: string, force: boolean)
|
|
|
14
31
|
await writeText(filePath, content);
|
|
15
32
|
}
|
|
16
33
|
|
|
17
|
-
function
|
|
34
|
+
function getFrameworkPackageManifest(): Promise<FrameworkPackageManifest> {
|
|
35
|
+
if (!frameworkPackageManifestPromise) {
|
|
36
|
+
const packageJsonPath = path.resolve(import.meta.dir, "../../package.json");
|
|
37
|
+
frameworkPackageManifestPromise = Bun.file(packageJsonPath).json() as Promise<FrameworkPackageManifest>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return frameworkPackageManifestPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizePackageName(cwd: string): string {
|
|
44
|
+
const baseName = path.basename(path.resolve(cwd));
|
|
45
|
+
const normalized = baseName
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
48
|
+
.replace(/[._-]{2,}/g, "-")
|
|
49
|
+
.replace(/^[._-]+|[._-]+$/g, "");
|
|
50
|
+
|
|
51
|
+
return normalized || "rbssr-app";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createPackageJsonContent(options: {
|
|
55
|
+
cwd: string;
|
|
56
|
+
frameworkVersion: string;
|
|
57
|
+
typescriptVersion: string;
|
|
58
|
+
bunTypesVersion: string;
|
|
59
|
+
reactTypesVersion: string;
|
|
60
|
+
reactDomTypesVersion: string;
|
|
61
|
+
}): string {
|
|
62
|
+
const packageJson = {
|
|
63
|
+
name: normalizePackageName(options.cwd),
|
|
64
|
+
version: "0.0.0",
|
|
65
|
+
private: true,
|
|
66
|
+
type: "module",
|
|
67
|
+
scripts: {
|
|
68
|
+
dev: "rbssr dev",
|
|
69
|
+
build: "rbssr build",
|
|
70
|
+
start: "rbssr start",
|
|
71
|
+
typecheck: "bunx tsc --noEmit",
|
|
72
|
+
},
|
|
73
|
+
dependencies: {
|
|
74
|
+
"react-bun-ssr": options.frameworkVersion,
|
|
75
|
+
react: "^19",
|
|
76
|
+
"react-dom": "^19",
|
|
77
|
+
},
|
|
78
|
+
devDependencies: {
|
|
79
|
+
"@types/react": options.reactTypesVersion,
|
|
80
|
+
"@types/react-dom": options.reactDomTypesVersion,
|
|
81
|
+
"bun-types": options.bunTypesVersion,
|
|
82
|
+
typescript: options.typescriptVersion,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return `${JSON.stringify(packageJson, null, 2)}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createTsconfigContent(): string {
|
|
90
|
+
const tsconfig = {
|
|
91
|
+
compilerOptions: {
|
|
92
|
+
target: "ESNext",
|
|
93
|
+
module: "Preserve",
|
|
94
|
+
moduleResolution: "Bundler",
|
|
95
|
+
jsx: "react-jsx",
|
|
96
|
+
strict: true,
|
|
97
|
+
types: ["bun-types"],
|
|
98
|
+
},
|
|
99
|
+
include: ["app", "rbssr.config.ts"],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return `${JSON.stringify(tsconfig, null, 2)}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createGitignoreContent(): string {
|
|
106
|
+
return `node_modules
|
|
107
|
+
dist
|
|
108
|
+
.rbssr
|
|
109
|
+
.env
|
|
110
|
+
.env.local
|
|
111
|
+
.DS_Store
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function templateFiles(cwd: string): Promise<ScaffoldFile[]> {
|
|
116
|
+
const frameworkPackage = await getFrameworkPackageManifest();
|
|
117
|
+
const frameworkVersion = frameworkPackage.version ?? DEFAULT_FRAMEWORK_VERSION;
|
|
118
|
+
const typescriptVersion = frameworkPackage.devDependencies?.typescript ?? DEFAULT_TYPESCRIPT_VERSION;
|
|
119
|
+
const bunTypesVersion = frameworkPackage.devDependencies?.["bun-types"] ?? DEFAULT_BUN_TYPES_VERSION;
|
|
120
|
+
const reactTypesVersion = frameworkPackage.devDependencies?.["@types/react"] ?? DEFAULT_REACT_TYPES_VERSION;
|
|
121
|
+
const reactDomTypesVersion = frameworkPackage.devDependencies?.["@types/react-dom"] ?? DEFAULT_REACT_TYPES_VERSION;
|
|
122
|
+
|
|
18
123
|
return [
|
|
124
|
+
{
|
|
125
|
+
filePath: path.join(cwd, "package.json"),
|
|
126
|
+
content: createPackageJsonContent({
|
|
127
|
+
cwd,
|
|
128
|
+
frameworkVersion,
|
|
129
|
+
typescriptVersion,
|
|
130
|
+
bunTypesVersion,
|
|
131
|
+
reactTypesVersion,
|
|
132
|
+
reactDomTypesVersion,
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
filePath: path.join(cwd, "tsconfig.json"),
|
|
137
|
+
content: createTsconfigContent(),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
filePath: path.join(cwd, ".gitignore"),
|
|
141
|
+
content: createGitignoreContent(),
|
|
142
|
+
},
|
|
19
143
|
{
|
|
20
144
|
filePath: path.join(cwd, "rbssr.config.ts"),
|
|
21
145
|
content: `import { defineConfig } from "react-bun-ssr";
|
|
@@ -29,14 +153,17 @@ export default defineConfig({
|
|
|
29
153
|
{
|
|
30
154
|
filePath: path.join(cwd, "app/root.tsx"),
|
|
31
155
|
content: `import { Outlet } from "react-bun-ssr/route";
|
|
156
|
+
import styles from "./root.module.css";
|
|
32
157
|
|
|
33
158
|
export default function RootLayout() {
|
|
34
159
|
return (
|
|
35
|
-
<main className=
|
|
36
|
-
<header className=
|
|
160
|
+
<main className={styles.shell}>
|
|
161
|
+
<header className={styles.top}>
|
|
37
162
|
<h1>react-bun-ssr</h1>
|
|
38
163
|
</header>
|
|
39
|
-
<
|
|
164
|
+
<section className={styles.content}>
|
|
165
|
+
<Outlet />
|
|
166
|
+
</section>
|
|
40
167
|
</main>
|
|
41
168
|
);
|
|
42
169
|
}
|
|
@@ -44,6 +171,53 @@ export default function RootLayout() {
|
|
|
44
171
|
export function head() {
|
|
45
172
|
return <title>react-bun-ssr app</title>;
|
|
46
173
|
}
|
|
174
|
+
`,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
filePath: path.join(cwd, "app/root.module.css"),
|
|
178
|
+
content: `:global(*) {
|
|
179
|
+
box-sizing: border-box;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
:global(html) {
|
|
183
|
+
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
184
|
+
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
|
|
185
|
+
color: #0f172a;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
:global(body) {
|
|
189
|
+
margin: 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.shell {
|
|
193
|
+
min-height: 100vh;
|
|
194
|
+
width: min(100%, 72rem);
|
|
195
|
+
margin: 0 auto;
|
|
196
|
+
padding: 3rem 1.5rem 4rem;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.top {
|
|
200
|
+
display: flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
justify-content: space-between;
|
|
203
|
+
margin-bottom: 2rem;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.top h1 {
|
|
207
|
+
margin: 0;
|
|
208
|
+
font-size: clamp(2rem, 5vw, 3.5rem);
|
|
209
|
+
line-height: 1;
|
|
210
|
+
letter-spacing: -0.04em;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.content {
|
|
214
|
+
padding: 1.5rem;
|
|
215
|
+
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
216
|
+
border-radius: 1.5rem;
|
|
217
|
+
background: rgba(255, 255, 255, 0.84);
|
|
218
|
+
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
|
219
|
+
backdrop-filter: blur(18px);
|
|
220
|
+
}
|
|
47
221
|
`,
|
|
48
222
|
},
|
|
49
223
|
{
|
|
@@ -87,11 +261,15 @@ export const middleware: Middleware = async (ctx, next) => {
|
|
|
87
261
|
};
|
|
88
262
|
`,
|
|
89
263
|
},
|
|
264
|
+
{
|
|
265
|
+
filePath: path.join(cwd, "app/public/favicon.svg"),
|
|
266
|
+
content: FAVICON_SVG,
|
|
267
|
+
},
|
|
90
268
|
];
|
|
91
269
|
}
|
|
92
270
|
|
|
93
271
|
export async function scaffoldApp(cwd: string, options: { force: boolean }): Promise<void> {
|
|
94
|
-
for (const file of templateFiles(cwd)) {
|
|
272
|
+
for (const file of await templateFiles(cwd)) {
|
|
95
273
|
await writeIfMissing(file.filePath, file.content, options.force);
|
|
96
274
|
}
|
|
97
275
|
}
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RBSSR_HEAD_MARKER_END_ATTR,
|
|
3
|
+
RBSSR_HEAD_MARKER_START_ATTR,
|
|
4
|
+
} from "./runtime-constants";
|
|
5
|
+
|
|
6
|
+
const ELEMENT_NODE = 1;
|
|
7
|
+
const TEXT_NODE = 3;
|
|
8
|
+
const COMMENT_NODE = 8;
|
|
9
|
+
|
|
10
|
+
export function isContentInsensitiveHeadTag(tagName: string): boolean {
|
|
11
|
+
const normalized = tagName.toLowerCase();
|
|
12
|
+
return normalized === "script" || normalized === "style" || normalized === "noscript";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeNodeText(value: string): string {
|
|
16
|
+
return value.replace(/\s+/g, " ").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function nodeSignature(node: Node): string {
|
|
20
|
+
if (node.nodeType === TEXT_NODE) {
|
|
21
|
+
return `text:${node.textContent ?? ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (node.nodeType === COMMENT_NODE) {
|
|
25
|
+
return `comment:${node.textContent ?? ""}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (node.nodeType !== ELEMENT_NODE) {
|
|
29
|
+
return `node:${node.nodeType}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const element = node as Element;
|
|
33
|
+
const tagName = element.tagName.toLowerCase();
|
|
34
|
+
const attrs = Array.from(element.attributes)
|
|
35
|
+
.map(attribute => `${attribute.name}=${attribute.value}`)
|
|
36
|
+
.sort((a, b) => a.localeCompare(b))
|
|
37
|
+
.join("|");
|
|
38
|
+
|
|
39
|
+
if (isContentInsensitiveHeadTag(tagName)) {
|
|
40
|
+
return `element:${tagName}:${attrs}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (tagName === "title") {
|
|
44
|
+
return `element:${tagName}:${attrs}:${normalizeNodeText(element.textContent ?? "")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Keep generic element identity structural and cheap: no innerHTML serialization.
|
|
48
|
+
return `element:${tagName}:${attrs}:${normalizeNodeText(element.textContent ?? "")}:${element.childElementCount}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isIgnorableTextNode(node: Node): boolean {
|
|
52
|
+
return node.nodeType === TEXT_NODE && (node.textContent ?? "").trim().length === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
|
|
56
|
+
const nodes: Node[] = [];
|
|
57
|
+
let cursor = startMarker.nextSibling;
|
|
58
|
+
while (cursor && cursor !== endMarker) {
|
|
59
|
+
nodes.push(cursor);
|
|
60
|
+
cursor = cursor.nextSibling;
|
|
61
|
+
}
|
|
62
|
+
return nodes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function removeNode(node: Node): void {
|
|
66
|
+
if (node.parentNode) {
|
|
67
|
+
node.parentNode.removeChild(node);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
|
|
72
|
+
if (node.nodeType !== ELEMENT_NODE) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const element = node as Element;
|
|
77
|
+
return (
|
|
78
|
+
element.tagName.toLowerCase() === "link"
|
|
79
|
+
&& (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
|
|
80
|
+
&& Boolean(element.getAttribute("href"))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toAbsoluteHref(href: string, baseUri: string): string {
|
|
85
|
+
return new URL(href, baseUri).toString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveBaseUri(documentRef: Document): string {
|
|
89
|
+
const candidate = documentRef.baseURI;
|
|
90
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = new URL(candidate);
|
|
93
|
+
if (parsed.protocol !== "about:") {
|
|
94
|
+
return candidate;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// fall back below
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return "http://localhost/";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
|
|
105
|
+
if (!link.ownerDocument.defaultView || link.sheet) {
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Promise(resolve => {
|
|
110
|
+
const finish = () => {
|
|
111
|
+
link.removeEventListener("load", finish);
|
|
112
|
+
link.removeEventListener("error", finish);
|
|
113
|
+
resolve();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
link.addEventListener("load", finish, { once: true });
|
|
117
|
+
link.addEventListener("error", finish, { once: true });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function reconcileStylesheetLinks(options: {
|
|
122
|
+
head: HTMLHeadElement;
|
|
123
|
+
desiredStylesheetHrefs: string[];
|
|
124
|
+
baseUri: string;
|
|
125
|
+
}): Promise<void> {
|
|
126
|
+
const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(href => toAbsoluteHref(href, options.baseUri));
|
|
127
|
+
const existingLinks = Array.from(
|
|
128
|
+
options.head.querySelectorAll('link[rel="stylesheet"][href]'),
|
|
129
|
+
) as HTMLLinkElement[];
|
|
130
|
+
|
|
131
|
+
const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
|
|
132
|
+
for (const link of existingLinks) {
|
|
133
|
+
const href = link.getAttribute("href");
|
|
134
|
+
if (!href) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const absoluteHref = toAbsoluteHref(href, options.baseUri);
|
|
138
|
+
const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
|
|
139
|
+
list.push(link);
|
|
140
|
+
existingByAbsoluteHref.set(absoluteHref, list);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const waitForLoads: Promise<void>[] = [];
|
|
144
|
+
for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
|
|
145
|
+
const href = options.desiredStylesheetHrefs[index]!;
|
|
146
|
+
const absoluteHref = desiredAbsoluteHrefs[index]!;
|
|
147
|
+
const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
|
|
148
|
+
if (existing) {
|
|
149
|
+
waitForLoads.push(waitForStylesheetLoad(existing));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const link = options.head.ownerDocument.createElement("link");
|
|
154
|
+
link.setAttribute("rel", "stylesheet");
|
|
155
|
+
link.setAttribute("href", href);
|
|
156
|
+
options.head.appendChild(link);
|
|
157
|
+
waitForLoads.push(waitForStylesheetLoad(link));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const seen = new Set<string>();
|
|
161
|
+
for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
|
|
162
|
+
const href = link.getAttribute("href");
|
|
163
|
+
if (!href) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const absoluteHref = toAbsoluteHref(href, options.baseUri);
|
|
168
|
+
if (seen.has(absoluteHref)) {
|
|
169
|
+
removeNode(link);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
seen.add(absoluteHref);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await Promise.all(waitForLoads);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function replaceManagedHead(
|
|
180
|
+
headHtml: string,
|
|
181
|
+
options: {
|
|
182
|
+
documentRef?: Document;
|
|
183
|
+
startMarkerAttr?: string;
|
|
184
|
+
endMarkerAttr?: string;
|
|
185
|
+
} = {},
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const documentRef = options.documentRef ?? document;
|
|
188
|
+
const startMarkerAttr = options.startMarkerAttr ?? RBSSR_HEAD_MARKER_START_ATTR;
|
|
189
|
+
const endMarkerAttr = options.endMarkerAttr ?? RBSSR_HEAD_MARKER_END_ATTR;
|
|
190
|
+
|
|
191
|
+
const head = documentRef.head;
|
|
192
|
+
const startMarker = head.querySelector(`meta[${startMarkerAttr}]`);
|
|
193
|
+
const endMarker = head.querySelector(`meta[${endMarkerAttr}]`);
|
|
194
|
+
|
|
195
|
+
if (!startMarker || !endMarker || startMarker === endMarker) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const template = documentRef.createElement("template");
|
|
200
|
+
template.innerHTML = headHtml;
|
|
201
|
+
|
|
202
|
+
const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
|
|
203
|
+
.map(link => link.getAttribute("href"))
|
|
204
|
+
.filter((value): value is string => Boolean(value));
|
|
205
|
+
for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
|
|
206
|
+
removeNode(styleNode);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
|
|
210
|
+
const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
|
|
211
|
+
if (isIgnorableTextNode(node)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isStylesheetLinkNode(node)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
const unusedCurrentNodes = new Set(currentNodes);
|
|
222
|
+
|
|
223
|
+
let cursor = startMarker.nextSibling;
|
|
224
|
+
|
|
225
|
+
// Structural identity only: avoid subtree HTML serialization in signature checks.
|
|
226
|
+
for (const desiredNode of desiredNodes) {
|
|
227
|
+
while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
|
|
228
|
+
const next = cursor.nextSibling;
|
|
229
|
+
removeNode(cursor);
|
|
230
|
+
cursor = next;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const desiredSignature = nodeSignature(desiredNode);
|
|
234
|
+
|
|
235
|
+
if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
|
|
236
|
+
unusedCurrentNodes.delete(cursor);
|
|
237
|
+
cursor = cursor.nextSibling;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let matchedNode: Node | null = null;
|
|
242
|
+
for (const currentNode of currentNodes) {
|
|
243
|
+
if (!unusedCurrentNodes.has(currentNode)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (nodeSignature(currentNode) === desiredSignature) {
|
|
247
|
+
matchedNode = currentNode;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (matchedNode) {
|
|
253
|
+
unusedCurrentNodes.delete(matchedNode);
|
|
254
|
+
head.insertBefore(matchedNode, cursor ?? endMarker);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const leftover of unusedCurrentNodes) {
|
|
262
|
+
removeNode(leftover);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await reconcileStylesheetLinks({
|
|
266
|
+
head,
|
|
267
|
+
desiredStylesheetHrefs,
|
|
268
|
+
baseUri: resolveBaseUri(documentRef),
|
|
269
|
+
});
|
|
270
|
+
}
|
package/framework/runtime/io.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function sha256Short(input: HashInput): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export async function ensureDir(dirPath: string): Promise<void> {
|
|
83
|
-
runPosix(["mkdir", "-p", dirPath], `Failed to create directory: ${dirPath}`);
|
|
83
|
+
await runPosix(["mkdir", "-p", dirPath], `Failed to create directory: ${dirPath}`);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export async function ensureCleanDir(dirPath: string): Promise<void> {
|
|
@@ -89,7 +89,7 @@ export async function ensureCleanDir(dirPath: string): Promise<void> {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export async function removePath(targetPath: string): Promise<void> {
|
|
92
|
-
runPosix(["rm", "-rf", targetPath], `Failed to remove path: ${targetPath}`);
|
|
92
|
+
await runPosix(["rm", "-rf", targetPath], `Failed to remove path: ${targetPath}`);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export async function listEntries(dirPath: string): Promise<FileEntry[]> {
|
|
@@ -127,20 +127,23 @@ export async function makeTempDir(prefix: string): Promise<string> {
|
|
|
127
127
|
return dirPath;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
function runPosix(cmd: string[], context: string): void {
|
|
131
|
-
const result = Bun.
|
|
130
|
+
async function runPosix(cmd: string[], context: string): Promise<void> {
|
|
131
|
+
const result = Bun.spawn({
|
|
132
132
|
cmd,
|
|
133
133
|
stdout: "pipe",
|
|
134
134
|
stderr: "pipe",
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
const exitCode = await result.exited;
|
|
138
|
+
if (exitCode === 0) {
|
|
138
139
|
return;
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
const [stderr, stdout] = await Promise.all([
|
|
143
|
+
result.stderr ? new Response(result.stderr).text() : Promise.resolve(""),
|
|
144
|
+
result.stdout ? new Response(result.stdout).text() : Promise.resolve(""),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const details = stderr.trim() || stdout.trim() || `exit code ${exitCode}`;
|
|
145
148
|
throw new Error(`[io] ${context} (${cmd.join(" ")}): ${details}`);
|
|
146
149
|
}
|
|
@@ -6,8 +6,10 @@ import type {
|
|
|
6
6
|
RouteSegment,
|
|
7
7
|
} from "./types";
|
|
8
8
|
|
|
9
|
-
// Bun FileSystemRouter is the runtime matcher used by the server.
|
|
10
|
-
// This matcher is
|
|
9
|
+
// Bun FileSystemRouter is the runtime matcher used by the server for projected routes.
|
|
10
|
+
// This matcher is used by server fallbacks and client transition matching.
|
|
11
|
+
// It intentionally does first-match linear scanning and expects routes to be pre-ordered
|
|
12
|
+
// by specificity (higher score first, then longer segment length, then routePath).
|
|
11
13
|
function normalizePathname(pathname: string): string[] {
|
|
12
14
|
if (!pathname || pathname === "/") {
|
|
13
15
|
return [];
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
import { renderToReadableStream, renderToStaticMarkup, renderToString } from "react-dom/server";
|
|
12
12
|
import type { DeferredSettleEntry } from "./deferred";
|
|
13
|
+
import { prependDoctypeStream } from "./doctype-stream";
|
|
13
14
|
import type {
|
|
14
15
|
ClientRouterSnapshot,
|
|
15
16
|
HydrationDocumentAssets,
|
|
@@ -361,36 +362,6 @@ function HtmlDocument(options: {
|
|
|
361
362
|
);
|
|
362
363
|
}
|
|
363
364
|
|
|
364
|
-
function prependDoctype(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
365
|
-
const encoder = new TextEncoder();
|
|
366
|
-
const doctype = encoder.encode("<!doctype html>");
|
|
367
|
-
|
|
368
|
-
return new ReadableStream<Uint8Array>({
|
|
369
|
-
async start(controller) {
|
|
370
|
-
controller.enqueue(doctype);
|
|
371
|
-
const reader = stream.getReader();
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
while (true) {
|
|
375
|
-
const result = await reader.read();
|
|
376
|
-
if (result.done) {
|
|
377
|
-
break;
|
|
378
|
-
}
|
|
379
|
-
controller.enqueue(result.value);
|
|
380
|
-
}
|
|
381
|
-
controller.close();
|
|
382
|
-
} catch (error) {
|
|
383
|
-
controller.error(error);
|
|
384
|
-
} finally {
|
|
385
|
-
reader.releaseLock();
|
|
386
|
-
}
|
|
387
|
-
},
|
|
388
|
-
cancel(reason) {
|
|
389
|
-
void stream.cancel(reason);
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
365
|
export async function renderDocumentStream(options: {
|
|
395
366
|
appTree: ReactElement;
|
|
396
367
|
payload: RenderPayload;
|
|
@@ -410,7 +381,7 @@ export async function renderDocumentStream(options: {
|
|
|
410
381
|
/>,
|
|
411
382
|
);
|
|
412
383
|
|
|
413
|
-
return
|
|
384
|
+
return prependDoctypeStream(stream);
|
|
414
385
|
}
|
|
415
386
|
|
|
416
387
|
export function renderDocument(options: {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RouteSegment } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface RouteSpecificityComparable {
|
|
4
|
+
score: number;
|
|
5
|
+
segments: RouteSegment[];
|
|
6
|
+
routePath: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sortRoutesBySpecificity<T extends RouteSpecificityComparable>(
|
|
10
|
+
routes: T[],
|
|
11
|
+
): T[] {
|
|
12
|
+
return routes.sort((a, b) => {
|
|
13
|
+
if (a.score !== b.score) {
|
|
14
|
+
return b.score - a.score;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (a.segments.length !== b.segments.length) {
|
|
18
|
+
return b.segments.length - a.segments.length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return a.routePath.localeCompare(b.routePath);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
toRouteId,
|
|
15
15
|
trimFileExtension,
|
|
16
16
|
} from "./utils";
|
|
17
|
+
import { sortRoutesBySpecificity } from "./route-order";
|
|
17
18
|
|
|
18
19
|
const PAGE_FILE_RE = /\.(tsx|jsx|ts|js|md|mdx)$/;
|
|
19
20
|
const LAYOUT_FILE_RE = /\.(tsx|jsx|ts|js)$/;
|
|
@@ -103,22 +104,6 @@ function getAncestorDirs(relativeDir: string): string[] {
|
|
|
103
104
|
return result;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function sortRoutes<T extends { score: number; segments: RouteSegment[]; routePath: string }>(
|
|
107
|
-
routes: T[],
|
|
108
|
-
): T[] {
|
|
109
|
-
return routes.sort((a, b) => {
|
|
110
|
-
if (a.score !== b.score) {
|
|
111
|
-
return b.score - a.score;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (a.segments.length !== b.segments.length) {
|
|
115
|
-
return b.segments.length - a.segments.length;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return a.routePath.localeCompare(b.routePath);
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
107
|
export async function scanRoutes(
|
|
123
108
|
routesDir: string,
|
|
124
109
|
options: {
|
|
@@ -236,7 +221,7 @@ export async function scanRoutes(
|
|
|
236
221
|
const pageRoutes = await Promise.all(pageRouteTasks);
|
|
237
222
|
|
|
238
223
|
return {
|
|
239
|
-
pages:
|
|
240
|
-
api:
|
|
224
|
+
pages: sortRoutesBySpecificity(pageRoutes),
|
|
225
|
+
api: sortRoutesBySpecificity(apiRoutes),
|
|
241
226
|
};
|
|
242
227
|
}
|
|
@@ -17,6 +17,33 @@ export interface RouterNavigateInfo {
|
|
|
17
17
|
|
|
18
18
|
export type RouterNavigateListener = (nextUrl: URL) => void;
|
|
19
19
|
|
|
20
|
+
export interface RouterNavigateListenerStore {
|
|
21
|
+
clearRenderListeners(): void;
|
|
22
|
+
registerRenderListener(listener: RouterNavigateListener): void;
|
|
23
|
+
promoteRenderListenersToActive(): void;
|
|
24
|
+
getActiveListeners(): readonly RouterNavigateListener[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createRouterNavigateListenerStore(): RouterNavigateListenerStore {
|
|
28
|
+
let renderListeners: RouterNavigateListener[] = [];
|
|
29
|
+
let activeListeners: RouterNavigateListener[] = [];
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
clearRenderListeners() {
|
|
33
|
+
renderListeners = [];
|
|
34
|
+
},
|
|
35
|
+
registerRenderListener(listener) {
|
|
36
|
+
renderListeners.push(listener);
|
|
37
|
+
},
|
|
38
|
+
promoteRenderListenersToActive() {
|
|
39
|
+
activeListeners = renderListeners;
|
|
40
|
+
},
|
|
41
|
+
getActiveListeners() {
|
|
42
|
+
return activeListeners;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
export function notifyRouterNavigateListeners(
|
|
21
48
|
listeners: readonly RouterNavigateListener[],
|
|
22
49
|
nextUrl: URL,
|
|
@@ -102,14 +129,22 @@ function createClientRouter(onNavigate: Router["onNavigate"]): Router {
|
|
|
102
129
|
}
|
|
103
130
|
|
|
104
131
|
export function useRouter(): Router {
|
|
105
|
-
const
|
|
132
|
+
const navigateListenerStoreRef = useRef<RouterNavigateListenerStore | null>(null);
|
|
133
|
+
if (navigateListenerStoreRef.current === null) {
|
|
134
|
+
navigateListenerStoreRef.current = createRouterNavigateListenerStore();
|
|
135
|
+
}
|
|
136
|
+
const navigateListenerStore = navigateListenerStoreRef.current;
|
|
106
137
|
const didEmitInitialNavigationRef = useRef(false);
|
|
107
|
-
|
|
138
|
+
navigateListenerStore.clearRenderListeners();
|
|
108
139
|
|
|
109
140
|
const onNavigate = useCallback<Router["onNavigate"]>((listener) => {
|
|
110
|
-
|
|
141
|
+
navigateListenerStoreRef.current?.registerRenderListener(listener);
|
|
111
142
|
}, []);
|
|
112
143
|
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
navigateListenerStoreRef.current?.promoteRenderListenersToActive();
|
|
146
|
+
});
|
|
147
|
+
|
|
113
148
|
useEffect(() => {
|
|
114
149
|
if (typeof window === "undefined") {
|
|
115
150
|
return;
|
|
@@ -118,7 +153,7 @@ export function useRouter(): Router {
|
|
|
118
153
|
if (!didEmitInitialNavigationRef.current) {
|
|
119
154
|
didEmitInitialNavigationRef.current = true;
|
|
120
155
|
notifyRouterNavigateListeners(
|
|
121
|
-
|
|
156
|
+
navigateListenerStore.getActiveListeners(),
|
|
122
157
|
new URL(window.location.href),
|
|
123
158
|
);
|
|
124
159
|
}
|
|
@@ -133,7 +168,10 @@ export function useRouter(): Router {
|
|
|
133
168
|
}
|
|
134
169
|
|
|
135
170
|
unsubscribe = runtime.subscribeToNavigation((info) => {
|
|
136
|
-
notifyRouterNavigateListeners(
|
|
171
|
+
notifyRouterNavigateListeners(
|
|
172
|
+
navigateListenerStoreRef.current?.getActiveListeners() ?? [],
|
|
173
|
+
info.nextUrl,
|
|
174
|
+
);
|
|
137
175
|
});
|
|
138
176
|
})
|
|
139
177
|
.catch(() => undefined);
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
sanitizeErrorMessage,
|
|
69
69
|
stableHash,
|
|
70
70
|
} from "./utils";
|
|
71
|
+
import { sortRoutesBySpecificity } from "./route-order";
|
|
71
72
|
|
|
72
73
|
type ResponseKind = "static" | "html" | "api" | "internal-dev" | "internal-transition";
|
|
73
74
|
|
|
@@ -374,8 +375,8 @@ function resolveAllRouteAssets(options: {
|
|
|
374
375
|
return options.runtimeOptions.buildManifest?.routes ?? {};
|
|
375
376
|
}
|
|
376
377
|
|
|
377
|
-
function toClientRouteSnapshots(routes: PageRouteDefinition[]): ClientRouteSnapshot[] {
|
|
378
|
-
return routes.map(route => ({
|
|
378
|
+
export function toClientRouteSnapshots(routes: PageRouteDefinition[]): ClientRouteSnapshot[] {
|
|
379
|
+
return sortRoutesBySpecificity([...routes]).map(route => ({
|
|
379
380
|
id: route.id,
|
|
380
381
|
routePath: route.routePath,
|
|
381
382
|
segments: route.segments,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-bun-ssr",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -12,7 +12,23 @@
|
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "git+https://github.com/react-formation/react-bun-ssr.git"
|
|
14
14
|
},
|
|
15
|
-
"
|
|
15
|
+
"description": "Bun-native SSR React framework with file-based routing, loaders, actions, streaming SSR. No Node adapter layer.",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"bun",
|
|
18
|
+
"ssr",
|
|
19
|
+
"react",
|
|
20
|
+
"framework",
|
|
21
|
+
"file-based-routing",
|
|
22
|
+
"streaming",
|
|
23
|
+
"hydration",
|
|
24
|
+
"loaders",
|
|
25
|
+
"actions",
|
|
26
|
+
"markdown",
|
|
27
|
+
"rbssr",
|
|
28
|
+
"server-rendering",
|
|
29
|
+
"bun-native"
|
|
30
|
+
],
|
|
31
|
+
"homepage": "https://react-bun-ssr.dev",
|
|
16
32
|
"bugs": {
|
|
17
33
|
"url": "https://github.com/react-formation/react-bun-ssr/issues"
|
|
18
34
|
},
|
|
@@ -27,6 +43,7 @@
|
|
|
27
43
|
"framework",
|
|
28
44
|
"README.md"
|
|
29
45
|
],
|
|
46
|
+
"license": "MIT",
|
|
30
47
|
"bin": {
|
|
31
48
|
"rbssr": "bin/rbssr.ts"
|
|
32
49
|
},
|
|
@@ -67,6 +84,7 @@
|
|
|
67
84
|
"react-dom": "^19"
|
|
68
85
|
},
|
|
69
86
|
"devDependencies": {
|
|
87
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
70
88
|
"@playwright/test": "^1.54.2",
|
|
71
89
|
"@types/react": "^19",
|
|
72
90
|
"@types/react-dom": "^19",
|