react-bun-ssr 0.1.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 +205 -0
- package/bin/rbssr.ts +2 -0
- package/framework/cli/commands.ts +343 -0
- package/framework/cli/main.ts +63 -0
- package/framework/cli/scaffold.ts +97 -0
- package/framework/index.ts +1 -0
- package/framework/runtime/build-tools.ts +234 -0
- package/framework/runtime/bun-route-adapter.ts +200 -0
- package/framework/runtime/client-runtime.tsx +62 -0
- package/framework/runtime/config.ts +142 -0
- package/framework/runtime/deferred.ts +115 -0
- package/framework/runtime/helpers.ts +40 -0
- package/framework/runtime/index.ts +24 -0
- package/framework/runtime/io.ts +146 -0
- package/framework/runtime/markdown-routes.ts +319 -0
- package/framework/runtime/matcher.ts +89 -0
- package/framework/runtime/middleware.ts +26 -0
- package/framework/runtime/module-loader.ts +192 -0
- package/framework/runtime/render.tsx +370 -0
- package/framework/runtime/route-api.ts +17 -0
- package/framework/runtime/route-scanner.ts +242 -0
- package/framework/runtime/server.ts +744 -0
- package/framework/runtime/tree.tsx +77 -0
- package/framework/runtime/types.ts +213 -0
- package/framework/runtime/utils.ts +115 -0
- package/package.json +59 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { Params, RenderPayload, RouteModuleBundle } from "./types";
|
|
3
|
+
|
|
4
|
+
interface RuntimeState {
|
|
5
|
+
data: unknown;
|
|
6
|
+
params: Params;
|
|
7
|
+
url: URL;
|
|
8
|
+
error?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const RuntimeContext = createContext<RuntimeState | null>(null);
|
|
12
|
+
const OutletContext = createContext<ReactNode>(null);
|
|
13
|
+
|
|
14
|
+
function useRuntimeState(): RuntimeState {
|
|
15
|
+
const state = useContext(RuntimeContext);
|
|
16
|
+
if (!state) {
|
|
17
|
+
throw new Error("react-bun-ssr hooks must be used inside a framework route tree");
|
|
18
|
+
}
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useLoaderData<T = unknown>(): T {
|
|
23
|
+
return useRuntimeState().data as T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useParams<T extends Params = Params>(): T {
|
|
27
|
+
return useRuntimeState().params as T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useRequestUrl(): URL {
|
|
31
|
+
return useRuntimeState().url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useRouteError(): unknown {
|
|
35
|
+
return useRuntimeState().error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Outlet(): ReactElement | null {
|
|
39
|
+
const outlet = useContext(OutletContext);
|
|
40
|
+
if (outlet === undefined || outlet === null) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return <>{outlet}</>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createRouteTree(
|
|
47
|
+
modules: RouteModuleBundle,
|
|
48
|
+
leafElement: ReactElement,
|
|
49
|
+
payload: RenderPayload,
|
|
50
|
+
): ReactElement {
|
|
51
|
+
const runtimeState: RuntimeState = {
|
|
52
|
+
data: payload.data,
|
|
53
|
+
params: payload.params,
|
|
54
|
+
url: new URL(payload.url),
|
|
55
|
+
error: payload.error,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let current: ReactNode = leafElement;
|
|
59
|
+
|
|
60
|
+
for (let index = modules.layouts.length - 1; index >= 0; index -= 1) {
|
|
61
|
+
const Layout = modules.layouts[index]!.default;
|
|
62
|
+
current = (
|
|
63
|
+
<OutletContext.Provider value={current}>
|
|
64
|
+
<Layout />
|
|
65
|
+
</OutletContext.Provider>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const Root = modules.root.default;
|
|
70
|
+
const tree = (
|
|
71
|
+
<OutletContext.Provider value={current}>
|
|
72
|
+
<Root />
|
|
73
|
+
</OutletContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return <RuntimeContext.Provider value={runtimeState}>{tree}</RuntimeContext.Provider>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type Params = Record<string, string>;
|
|
4
|
+
|
|
5
|
+
export interface RequestContext {
|
|
6
|
+
request: Request;
|
|
7
|
+
url: URL;
|
|
8
|
+
params: Params;
|
|
9
|
+
cookies: Map<string, string>;
|
|
10
|
+
locals: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LoaderContext extends RequestContext {}
|
|
14
|
+
|
|
15
|
+
export interface ActionContext extends RequestContext {
|
|
16
|
+
formData?: FormData;
|
|
17
|
+
json?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DeferredToken {
|
|
21
|
+
__rbssrDeferred: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeferredLoaderResult<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
25
|
+
__rbssrType: "defer";
|
|
26
|
+
data: T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type LoaderResult =
|
|
30
|
+
| Response
|
|
31
|
+
| RedirectResult
|
|
32
|
+
| DeferredLoaderResult<Record<string, unknown>>
|
|
33
|
+
| Record<string, unknown>
|
|
34
|
+
| string
|
|
35
|
+
| number
|
|
36
|
+
| boolean
|
|
37
|
+
| null;
|
|
38
|
+
export type ActionResult = LoaderResult | RedirectResult;
|
|
39
|
+
|
|
40
|
+
export type Loader = (ctx: LoaderContext) => Promise<LoaderResult> | LoaderResult;
|
|
41
|
+
export type Action = (ctx: ActionContext) => Promise<ActionResult> | ActionResult;
|
|
42
|
+
|
|
43
|
+
export interface RedirectResult {
|
|
44
|
+
type: "redirect";
|
|
45
|
+
location: string;
|
|
46
|
+
status?: 301 | 302 | 303 | 307 | 308;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Middleware = (
|
|
50
|
+
ctx: RequestContext,
|
|
51
|
+
next: () => Promise<Response>,
|
|
52
|
+
) => Promise<Response> | Response;
|
|
53
|
+
|
|
54
|
+
export type HeadFn = (ctx: {
|
|
55
|
+
data: unknown;
|
|
56
|
+
params: Params;
|
|
57
|
+
url: URL;
|
|
58
|
+
error?: unknown;
|
|
59
|
+
}) => ReactNode;
|
|
60
|
+
|
|
61
|
+
export type MetaFn = (ctx: {
|
|
62
|
+
data: unknown;
|
|
63
|
+
params: Params;
|
|
64
|
+
url: URL;
|
|
65
|
+
error?: unknown;
|
|
66
|
+
}) => Record<string, string>;
|
|
67
|
+
|
|
68
|
+
export type MetaValue = Record<string, string> | MetaFn;
|
|
69
|
+
|
|
70
|
+
export interface RouteModule {
|
|
71
|
+
default: ComponentType;
|
|
72
|
+
loader?: Loader;
|
|
73
|
+
action?: Action;
|
|
74
|
+
middleware?: Middleware | Middleware[];
|
|
75
|
+
head?: HeadFn;
|
|
76
|
+
meta?: MetaValue;
|
|
77
|
+
ErrorBoundary?: ComponentType<{ error: unknown }>;
|
|
78
|
+
NotFound?: ComponentType;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type LayoutModule = RouteModule;
|
|
82
|
+
|
|
83
|
+
export type ApiHandler = (ctx: RequestContext) => Promise<Response | unknown> | Response | unknown;
|
|
84
|
+
|
|
85
|
+
export interface ApiRouteModule {
|
|
86
|
+
GET?: ApiHandler;
|
|
87
|
+
POST?: ApiHandler;
|
|
88
|
+
PUT?: ApiHandler;
|
|
89
|
+
PATCH?: ApiHandler;
|
|
90
|
+
DELETE?: ApiHandler;
|
|
91
|
+
HEAD?: ApiHandler;
|
|
92
|
+
OPTIONS?: ApiHandler;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ResponseHeaderRule {
|
|
96
|
+
source: string;
|
|
97
|
+
headers: Record<string, string>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ResolvedResponseHeaderRule extends ResponseHeaderRule {
|
|
101
|
+
matcher: RegExp;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface FrameworkConfig {
|
|
105
|
+
appDir?: string;
|
|
106
|
+
routesDir?: string;
|
|
107
|
+
publicDir?: string;
|
|
108
|
+
rootModule?: string;
|
|
109
|
+
middlewareFile?: string;
|
|
110
|
+
distDir?: string;
|
|
111
|
+
host?: string;
|
|
112
|
+
port?: number;
|
|
113
|
+
mode?: "development" | "production";
|
|
114
|
+
headers?: ResponseHeaderRule[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ResolvedConfig {
|
|
118
|
+
cwd: string;
|
|
119
|
+
appDir: string;
|
|
120
|
+
routesDir: string;
|
|
121
|
+
publicDir: string;
|
|
122
|
+
rootModule: string;
|
|
123
|
+
middlewareFile: string;
|
|
124
|
+
distDir: string;
|
|
125
|
+
host: string;
|
|
126
|
+
port: number;
|
|
127
|
+
mode: "development" | "production";
|
|
128
|
+
headerRules: ResolvedResponseHeaderRule[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type SegmentKind = "static" | "dynamic" | "catchall";
|
|
132
|
+
|
|
133
|
+
export interface RouteSegment {
|
|
134
|
+
kind: SegmentKind;
|
|
135
|
+
value: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PageRouteDefinition {
|
|
139
|
+
type: "page";
|
|
140
|
+
id: string;
|
|
141
|
+
filePath: string;
|
|
142
|
+
routePath: string;
|
|
143
|
+
segments: RouteSegment[];
|
|
144
|
+
score: number;
|
|
145
|
+
layoutFiles: string[];
|
|
146
|
+
middlewareFiles: string[];
|
|
147
|
+
directory: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ApiRouteDefinition {
|
|
151
|
+
type: "api";
|
|
152
|
+
id: string;
|
|
153
|
+
filePath: string;
|
|
154
|
+
routePath: string;
|
|
155
|
+
segments: RouteSegment[];
|
|
156
|
+
score: number;
|
|
157
|
+
middlewareFiles: string[];
|
|
158
|
+
directory: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface RouteManifest {
|
|
162
|
+
pages: PageRouteDefinition[];
|
|
163
|
+
api: ApiRouteDefinition[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface RouteMatch<T extends PageRouteDefinition | ApiRouteDefinition> {
|
|
167
|
+
route: T;
|
|
168
|
+
params: Params;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface BuildRouteAsset {
|
|
172
|
+
script: string;
|
|
173
|
+
css: string[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface BuildManifest {
|
|
177
|
+
version: string;
|
|
178
|
+
generatedAt: string;
|
|
179
|
+
routes: Record<string, BuildRouteAsset>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface RenderPayload {
|
|
183
|
+
routeId: string;
|
|
184
|
+
data: unknown;
|
|
185
|
+
params: Params;
|
|
186
|
+
url: string;
|
|
187
|
+
error?: {
|
|
188
|
+
message: string;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface RouteModuleBundle {
|
|
193
|
+
root: RouteModule;
|
|
194
|
+
layouts: LayoutModule[];
|
|
195
|
+
route: RouteModule;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface HydrationDocumentAssets {
|
|
199
|
+
script?: string;
|
|
200
|
+
css: string[];
|
|
201
|
+
devVersion?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ServerRuntimeOptions {
|
|
205
|
+
dev?: boolean;
|
|
206
|
+
buildManifest?: BuildManifest;
|
|
207
|
+
devAssets?: Record<string, BuildRouteAsset>;
|
|
208
|
+
getDevAssets?: () => Record<string, BuildRouteAsset>;
|
|
209
|
+
reloadVersion?: () => number;
|
|
210
|
+
subscribeReload?: (listener: (version: number) => void) => (() => void) | void;
|
|
211
|
+
resolvePaths?: () => Partial<ResolvedConfig>;
|
|
212
|
+
onBeforeRequest?: () => Promise<void>;
|
|
213
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { sha256Short, type HashInput } from "./io";
|
|
3
|
+
|
|
4
|
+
export function normalizeSlashes(value: string): string {
|
|
5
|
+
return value.replace(/\\+/g, "/");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function trimFileExtension(value: string): string {
|
|
9
|
+
return value.replace(/\.(tsx?|jsx?|mdx?)$/, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ensureLeadingSlash(value: string): string {
|
|
13
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function withoutTrailingSlash(value: string): string {
|
|
17
|
+
if (value === "/") {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toRouteId(relativeFilePath: string): string {
|
|
24
|
+
return normalizeSlashes(trimFileExtension(relativeFilePath))
|
|
25
|
+
.replace(/\//g, "__")
|
|
26
|
+
.replace(/\[\.\.\./g, "catchall_")
|
|
27
|
+
.replace(/\[/g, "param_")
|
|
28
|
+
.replace(/\]/g, "")
|
|
29
|
+
.replace(/\(|\)/g, "")
|
|
30
|
+
.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isRouteGroup(segment: string): boolean {
|
|
34
|
+
return segment.startsWith("(") && segment.endsWith(")");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function toImportPath(fromDir: string, absoluteTargetPath: string): string {
|
|
38
|
+
const relativePath = normalizeSlashes(path.relative(fromDir, absoluteTargetPath));
|
|
39
|
+
if (relativePath.startsWith(".")) {
|
|
40
|
+
return relativePath;
|
|
41
|
+
}
|
|
42
|
+
return `./${relativePath}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function routePathFromSegments(segments: string[]): string {
|
|
46
|
+
const pathValue = segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
47
|
+
return withoutTrailingSlash(pathValue === "" ? "/" : pathValue);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCookieHeader(headerValue: string | null): Map<string, string> {
|
|
51
|
+
const cookies = new Map<string, string>();
|
|
52
|
+
if (!headerValue) {
|
|
53
|
+
return cookies;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const part of headerValue.split(";")) {
|
|
57
|
+
const [rawName, ...rest] = part.trim().split("=");
|
|
58
|
+
if (!rawName) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const rawValue = rest.join("=");
|
|
62
|
+
try {
|
|
63
|
+
cookies.set(rawName, decodeURIComponent(rawValue));
|
|
64
|
+
} catch {
|
|
65
|
+
cookies.set(rawName, rawValue);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return cookies;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isMutatingMethod(method: string): boolean {
|
|
73
|
+
return ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function safeJsonSerialize(value: unknown): string {
|
|
77
|
+
return JSON.stringify(value)
|
|
78
|
+
.replace(/</g, "\\u003c")
|
|
79
|
+
.replace(/\u2028/g, "\\u2028")
|
|
80
|
+
.replace(/\u2029/g, "\\u2029");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sanitizeErrorMessage(error: unknown, production: boolean): string {
|
|
84
|
+
if (!production) {
|
|
85
|
+
if (error instanceof Error) {
|
|
86
|
+
return error.message;
|
|
87
|
+
}
|
|
88
|
+
return String(error);
|
|
89
|
+
}
|
|
90
|
+
return "Internal Server Error";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function stableHash(input: HashInput): string {
|
|
94
|
+
return sha256Short(input);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ensureWithin(baseDir: string, target: string): string | null {
|
|
98
|
+
const resolved = path.resolve(baseDir, target);
|
|
99
|
+
const relative = path.relative(baseDir, resolved);
|
|
100
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return resolved;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function toPosixPath(value: string): string {
|
|
107
|
+
return normalizeSlashes(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function toFileImportUrl(filePath: string): string {
|
|
111
|
+
const resolved = path.resolve(filePath).replace(/\\/g, "/");
|
|
112
|
+
const withLeadingSlash = resolved.startsWith("/") ? resolved : `/${resolved}`;
|
|
113
|
+
const encoded = encodeURI(withLeadingSlash).replace(/#/g, "%23").replace(/\?/g, "%3F");
|
|
114
|
+
return `file://${encoded}`;
|
|
115
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-bun-ssr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./framework/runtime/index.ts",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"bun": ">=1.3.9"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"framework",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"rbssr": "./bin/rbssr.ts"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./framework/runtime/index.ts",
|
|
24
|
+
"default": "./framework/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"./route": {
|
|
27
|
+
"types": "./framework/runtime/route-api.ts",
|
|
28
|
+
"default": "./framework/runtime/route-api.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"init": "bun bin/rbssr.ts init",
|
|
33
|
+
"dev": "bun bin/rbssr.ts dev",
|
|
34
|
+
"build": "bun bin/rbssr.ts build",
|
|
35
|
+
"start": "bun bin/rbssr.ts start",
|
|
36
|
+
"typecheck": "bun bin/rbssr.ts typecheck",
|
|
37
|
+
"test": "bun bin/rbssr.ts test",
|
|
38
|
+
"test:unit": "bun test ./tests/unit",
|
|
39
|
+
"test:integration": "bun test ./tests/integration",
|
|
40
|
+
"test:e2e": "bunx playwright test",
|
|
41
|
+
"docs:dev": "bun run scripts/generate-api-docs.ts && bun run scripts/build-search-index.ts && bun bin/rbssr.ts dev",
|
|
42
|
+
"docs:build": "bun run scripts/docs-build.ts",
|
|
43
|
+
"docs:check": "bun run scripts/check-docs.ts",
|
|
44
|
+
"docs:preview": "bun run start"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": "^19",
|
|
48
|
+
"react-dom": "^19"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@playwright/test": "^1.54.2",
|
|
52
|
+
"@types/bun": "latest",
|
|
53
|
+
"@types/react": "^19",
|
|
54
|
+
"@types/react-dom": "^19",
|
|
55
|
+
"react": "^19",
|
|
56
|
+
"react-dom": "^19",
|
|
57
|
+
"typescript": "^5.9.2"
|
|
58
|
+
}
|
|
59
|
+
}
|