@bractjs/bractjs 0.1.6 → 0.1.8
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/package.json +1 -1
- package/src/__tests__/loader.test.ts +5 -2
- package/src/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +5 -3
- package/src/build/env-plugin.ts +7 -0
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +113 -9
- package/src/client/cache.ts +69 -0
- package/src/client/components/Link.tsx +16 -2
- package/src/client/components/LiveReload.tsx +4 -0
- package/src/client/hooks/useBlocker.ts +44 -0
- package/src/client/hooks/useFetcher.ts +66 -6
- package/src/client/hooks/useLocale.ts +12 -0
- package/src/client/hooks/useLocalizedLink.ts +18 -0
- package/src/client/hooks/useSearchParams.ts +74 -0
- package/src/client/rpc.ts +70 -0
- package/src/codegen/route-codegen.ts +63 -1
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +17 -1
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/handler.ts +5 -2
- package/src/image/optimizer.ts +6 -1
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +4 -0
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +8 -4
- package/src/server/adapter.ts +57 -0
- package/src/server/api-route.ts +127 -0
- package/src/server/context.ts +22 -0
- package/src/server/csrf.ts +1 -0
- package/src/server/env.ts +16 -0
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/render.ts +7 -0
- package/src/server/request-handler.ts +66 -8
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +1 -0
- package/src/server/static.ts +8 -1
- package/src/server/stream-handler.ts +111 -0
- package/src/server/validate.ts +89 -0
- package/src/shared/route-types.ts +11 -0
- package/types/index.d.ts +94 -1
- package/types/route.d.ts +11 -0
package/src/server/serve.ts
CHANGED
|
@@ -2,19 +2,29 @@ import { scanRoutes } from "./scanner.ts";
|
|
|
2
2
|
import { buildTrie } from "./matcher.ts";
|
|
3
3
|
import { handleRequest, type HandlerConfig } from "./request-handler.ts";
|
|
4
4
|
import { type ServerManifest } from "./render.ts";
|
|
5
|
-
import { isDev } from "./env.ts";
|
|
5
|
+
import { isDev, isExplicitDev } from "./env.ts";
|
|
6
6
|
import { loadManifest } from "../build/manifest.ts";
|
|
7
7
|
import { serveStatic } from "./static.ts";
|
|
8
8
|
import { handleImageRequest } from "../image/handler.ts";
|
|
9
9
|
import { loadServerActions } from "./action-registry.ts";
|
|
10
10
|
import { handleActionRequest } from "./action-handler.ts";
|
|
11
|
+
import { BunAdapter, type BractAdapter } from "./adapter.ts";
|
|
11
12
|
import { resolve, join } from "node:path";
|
|
12
13
|
|
|
14
|
+
export interface I18nConfig {
|
|
15
|
+
locales: string[];
|
|
16
|
+
defaultLocale: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
export interface BractJSConfig {
|
|
14
20
|
port: number;
|
|
15
21
|
appDir: string;
|
|
16
22
|
publicDir: string;
|
|
17
23
|
manifest: ServerManifest;
|
|
24
|
+
/** Optional custom adapter (Cloudflare Workers, Deno, Node, etc.). Defaults to Bun.serve(). */
|
|
25
|
+
adapter?: BractAdapter;
|
|
26
|
+
/** i18n locale prefix routing (E2). */
|
|
27
|
+
i18n?: I18nConfig;
|
|
18
28
|
// Build options (used by src/build/bundler.ts)
|
|
19
29
|
sourcemap?: "none" | "linked" | "inline" | "external";
|
|
20
30
|
minify?: boolean;
|
|
@@ -47,18 +57,18 @@ async function readDevManifest(buildDir: string): Promise<ServerManifest> {
|
|
|
47
57
|
};
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
60
|
+
/**
|
|
61
|
+
* Build the core application fetch handler.
|
|
62
|
+
* This is adapter-agnostic: it returns a (request) => Promise<Response> function
|
|
63
|
+
* that any adapter can call.
|
|
64
|
+
*/
|
|
65
|
+
export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
66
|
+
const appDir = resolve(config.appDir ?? "./app");
|
|
67
|
+
const publicDir = resolve(config.publicDir ?? "./public");
|
|
68
|
+
const buildDir = resolve(config.buildDir ?? "./build");
|
|
69
|
+
const imageCacheDir = resolve(config.imageCacheDir ?? ".bract-image-cache");
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
const manifestReady: Promise<ServerManifest> = !isDev() && !config?.manifest
|
|
71
|
+
const manifestReady: Promise<ServerManifest> = !isDev() && !config.manifest
|
|
62
72
|
? loadManifest(buildDir).then((m) => ({
|
|
63
73
|
clientEntry: m.clientEntry,
|
|
64
74
|
rootChunk: m.rootChunk,
|
|
@@ -66,62 +76,99 @@ export function createServer(config?: Partial<BractJSConfig>): {
|
|
|
66
76
|
Object.entries(m.routes).map(([pat, e]) => [pat, { file: e.chunk, chunk: e.chunk }]),
|
|
67
77
|
),
|
|
68
78
|
}))
|
|
69
|
-
: Promise.resolve(config
|
|
79
|
+
: Promise.resolve(config.manifest ?? DEFAULT_MANIFEST);
|
|
70
80
|
|
|
71
|
-
// Build route trie and register server actions concurrently at startup.
|
|
72
81
|
const trieReady = scanRoutes(appDir).then(buildTrie);
|
|
73
82
|
const actionsReady = loadServerActions(appDir);
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const url = new URL(request.url);
|
|
79
|
-
const { pathname } = url;
|
|
84
|
+
return async function fetch(request: Request): Promise<Response> {
|
|
85
|
+
const url = new URL(request.url);
|
|
86
|
+
const { pathname } = url;
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
// Dev-only: on-demand module compilation for HMR module swap.
|
|
89
|
+
// SECURITY(high): use isExplicitDev() (NODE_ENV === "development") rather
|
|
90
|
+
// than isDev() (NODE_ENV !== "production"). An operator who forgets to set
|
|
91
|
+
// NODE_ENV would otherwise expose /_hmr/module in production, letting
|
|
92
|
+
// anyone compile and download arbitrary appDir .ts/.tsx files as JS.
|
|
93
|
+
if (isExplicitDev() && pathname === "/_hmr/module") {
|
|
94
|
+
const { handleHmrModuleRequest } = await import("../dev/hmr-module-handler.ts");
|
|
95
|
+
return handleHmrModuleRequest(url, appDir);
|
|
96
|
+
}
|
|
86
97
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
// Typed API routes (registered via bract.route())
|
|
99
|
+
if (pathname.startsWith("/api")) {
|
|
100
|
+
const { handleApiRequest } = await import("./api-route.ts");
|
|
101
|
+
const apiRes = await handleApiRequest(request);
|
|
102
|
+
if (apiRes) return apiRes;
|
|
103
|
+
}
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
// Server actions endpoint (exact path; handler also validates).
|
|
106
|
+
if (pathname === "/_action") {
|
|
107
|
+
await actionsReady;
|
|
108
|
+
const actionRes = await handleActionRequest(request);
|
|
109
|
+
if (actionRes) return actionRes;
|
|
110
|
+
}
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
// SSE streaming endpoint for async-generator server actions.
|
|
113
|
+
if (pathname === "/_stream") {
|
|
114
|
+
await actionsReady;
|
|
115
|
+
const { handleStreamRequest } = await import("./stream-handler.ts");
|
|
116
|
+
const streamRes = await handleStreamRequest(request);
|
|
117
|
+
if (streamRes) return streamRes;
|
|
118
|
+
}
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
120
|
+
// Image optimization endpoint
|
|
121
|
+
if (pathname === "/_image") {
|
|
122
|
+
const imgRes = await handleImageRequest(request, publicDir, imageCacheDir);
|
|
123
|
+
if (imgRes) return imgRes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Serve hashed client assets + public/ with correct cache headers
|
|
127
|
+
const staticRes = await serveStatic(pathname, buildDir, publicDir);
|
|
128
|
+
if (staticRes) return staticRes;
|
|
129
|
+
|
|
130
|
+
const trie = await trieReady;
|
|
131
|
+
const manifest = isDev() ? await readDevManifest(buildDir) : await manifestReady;
|
|
132
|
+
const handlerConfig: HandlerConfig = { appDir, publicDir, manifest };
|
|
133
|
+
return handleRequest(request, trie, handlerConfig);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createServer(config?: Partial<BractJSConfig>): {
|
|
138
|
+
stop(): void;
|
|
139
|
+
} {
|
|
140
|
+
const port = config?.port ?? 3000;
|
|
141
|
+
|
|
142
|
+
const fetchHandler = buildFetchHandler(config ?? {});
|
|
143
|
+
|
|
144
|
+
// Use provided adapter or fall back to the default Bun adapter.
|
|
145
|
+
const adapter = config?.adapter ?? new BunAdapter();
|
|
146
|
+
|
|
147
|
+
if (adapter instanceof BunAdapter) {
|
|
148
|
+
adapter.setHandler(fetchHandler);
|
|
149
|
+
adapter.listen(port);
|
|
150
|
+
|
|
151
|
+
console.log(`[bract] Server running at http://localhost:${port}`);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
stop() { adapter.stop(); },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Custom adapter: wire fetch handler in and call listen if available.
|
|
159
|
+
if ("setHandler" in adapter && typeof (adapter as unknown as { setHandler: unknown }).setHandler === "function") {
|
|
160
|
+
(adapter as unknown as { setHandler: (h: (r: Request) => Promise<Response>) => void }).setHandler(fetchHandler);
|
|
161
|
+
}
|
|
162
|
+
adapter.listen?.(port);
|
|
119
163
|
|
|
120
164
|
console.log(`[bract] Server running at http://localhost:${port}`);
|
|
121
165
|
|
|
122
166
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
stop() {
|
|
168
|
+
if ("stop" in adapter && typeof (adapter as unknown as { stop: unknown }).stop === "function") {
|
|
169
|
+
(adapter as unknown as { stop: () => void }).stop();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
125
172
|
};
|
|
126
173
|
}
|
|
127
174
|
|
package/src/server/session.ts
CHANGED
|
@@ -80,6 +80,7 @@ function makeSession(data: SessionData): InternalSession {
|
|
|
80
80
|
|
|
81
81
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
82
82
|
|
|
83
|
+
// SECURITY(medium): caller can opt out of the Secure flag by passing secure:false; this is safe only on HTTP-only local dev — never use in production without HTTPS.
|
|
83
84
|
export function createCookieSession(options: CookieSessionOptions): SessionStorage {
|
|
84
85
|
const { name, secrets, maxAge, secure = true, sameSite = "Lax" } = options;
|
|
85
86
|
if (!Array.isArray(secrets) || secrets.length === 0) {
|
package/src/server/static.ts
CHANGED
|
@@ -25,12 +25,19 @@ async function safeRealpath(root: string, requested: string): Promise<string | n
|
|
|
25
25
|
* Returns null if the path doesn't match or the file isn't found.
|
|
26
26
|
* Guards against path traversal AND symlink escape.
|
|
27
27
|
*/
|
|
28
|
+
// Reject only `..` as a full path segment (e.g. "/a/../b"), not legitimate
|
|
29
|
+
// filenames that happen to contain ".." as a substring like "file..backup.txt".
|
|
30
|
+
// safeRealpath() is the authoritative escape check; this is defense-in-depth.
|
|
31
|
+
function hasDotDotSegment(pathname: string): boolean {
|
|
32
|
+
return pathname.split("/").includes("..");
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export async function serveStatic(
|
|
29
36
|
pathname: string,
|
|
30
37
|
buildDir: string,
|
|
31
38
|
publicDir: string,
|
|
32
39
|
): Promise<Response | null> {
|
|
33
|
-
if (pathname
|
|
40
|
+
if (hasDotDotSegment(pathname)) return null;
|
|
34
41
|
|
|
35
42
|
if (pathname.startsWith("/build/client/")) {
|
|
36
43
|
const rel = pathname.slice("/build/client/".length);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { resolveAction } from "./action-registry.ts";
|
|
2
|
+
import { isExplicitDev } from "./env.ts";
|
|
3
|
+
import { isAllowedMutation } from "./csrf.ts";
|
|
4
|
+
|
|
5
|
+
// ── SSE helpers ────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function sseChunk(event: string, data: unknown): string {
|
|
8
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── Handler ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Handles `GET /_stream?id=<actionId>` requests.
|
|
15
|
+
*
|
|
16
|
+
* The action identified by `id` must be an async generator function registered
|
|
17
|
+
* in the action registry. Each yielded value is sent as an SSE `data` event.
|
|
18
|
+
* The stream closes when the generator returns.
|
|
19
|
+
*
|
|
20
|
+
* Security: only IDs present in the registry are resolved — no path traversal.
|
|
21
|
+
*/
|
|
22
|
+
export async function handleStreamRequest(request: Request): Promise<Response | null> {
|
|
23
|
+
const url = new URL(request.url);
|
|
24
|
+
// SECURITY(medium): exact-match prevents URL confusion.
|
|
25
|
+
if (url.pathname !== "/_stream") return null;
|
|
26
|
+
|
|
27
|
+
// SECURITY(high): server actions can have side effects. A cross-origin
|
|
28
|
+
// <script>/<img>/<link rel=prefetch> pointing at /_stream?id=… would
|
|
29
|
+
// otherwise invoke any registered action with the user's cookies. Require
|
|
30
|
+
// the same gate as /_action: either a same-origin Origin header, or the
|
|
31
|
+
// client-issued X-BractJS-Action header (blocked cross-origin by CORS).
|
|
32
|
+
if (!isAllowedMutation(request)) {
|
|
33
|
+
return new Response(sseChunk("error", { message: "Forbidden" }), {
|
|
34
|
+
status: 403,
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": "text/event-stream",
|
|
37
|
+
"Cache-Control": "no-cache",
|
|
38
|
+
Connection: "keep-alive",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const actionId = url.searchParams.get("id");
|
|
44
|
+
// Guard: reject missing or clearly invalid IDs before registry lookup.
|
|
45
|
+
if (!actionId || !/^[0-9a-f]{16}$/.test(actionId)) {
|
|
46
|
+
return new Response(sseChunk("error", { message: "Invalid action ID" }), {
|
|
47
|
+
status: 400,
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "text/event-stream",
|
|
50
|
+
"Cache-Control": "no-cache",
|
|
51
|
+
Connection: "keep-alive",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const action = resolveAction(actionId);
|
|
57
|
+
if (!action) {
|
|
58
|
+
return new Response(sseChunk("error", { message: "Action not found" }), {
|
|
59
|
+
status: 404,
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "text/event-stream",
|
|
62
|
+
"Cache-Control": "no-cache",
|
|
63
|
+
Connection: "keep-alive",
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const stream = new ReadableStream({
|
|
69
|
+
async start(controller) {
|
|
70
|
+
const encoder = new TextEncoder();
|
|
71
|
+
try {
|
|
72
|
+
const result = await action();
|
|
73
|
+
// If the action is an async generator, stream each value.
|
|
74
|
+
if (result && typeof (result as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function") {
|
|
75
|
+
// SECURITY(medium): no per-stream yield cap. A malicious or buggy
|
|
76
|
+
// generator that yields forever holds a connection open and pegs
|
|
77
|
+
// serialization CPU. The Bun.serve runtime aborts when the client
|
|
78
|
+
// disconnects, so the worst case is a slow attacker keeping their
|
|
79
|
+
// own connection open — bounded by OS fd limits, not memory.
|
|
80
|
+
// Apps wanting hard bounds should wrap their generator with a
|
|
81
|
+
// count/time limit before exporting it as an action.
|
|
82
|
+
for await (const value of result as AsyncIterable<unknown>) {
|
|
83
|
+
controller.enqueue(encoder.encode(sseChunk("data", value)));
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
// Plain return value: emit once then close.
|
|
87
|
+
controller.enqueue(encoder.encode(sseChunk("data", result)));
|
|
88
|
+
}
|
|
89
|
+
controller.enqueue(encoder.encode("event: done\ndata: {}\n\n"));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Never expose internal error details to clients in production.
|
|
92
|
+
const message = isExplicitDev()
|
|
93
|
+
? (err instanceof Error ? err.message : String(err))
|
|
94
|
+
: "Internal server error";
|
|
95
|
+
console.error("[bractjs] stream action error:", err);
|
|
96
|
+
controller.enqueue(encoder.encode(sseChunk("error", { message })));
|
|
97
|
+
} finally {
|
|
98
|
+
controller.close();
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return new Response(stream, {
|
|
104
|
+
headers: {
|
|
105
|
+
"Content-Type": "text/event-stream",
|
|
106
|
+
"Cache-Control": "no-cache",
|
|
107
|
+
Connection: "keep-alive",
|
|
108
|
+
"X-Accel-Buffering": "no",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// ── Duck-typed schema interface ────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
interface SchemaWithParse<T> {
|
|
4
|
+
parse(input: unknown): T;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface SafeParseResult<T> {
|
|
8
|
+
success: boolean;
|
|
9
|
+
data?: T;
|
|
10
|
+
error?: { issues?: Array<{ path: (string | number)[]; message: string }> };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SchemaWithSafeParse<T> {
|
|
14
|
+
safeParse(input: unknown): SafeParseResult<T> | Promise<SafeParseResult<T>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Schema<T> = SchemaWithParse<T> | SchemaWithSafeParse<T>;
|
|
18
|
+
|
|
19
|
+
// ── Field error shape ─────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface FieldErrors {
|
|
22
|
+
[field: string]: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ValidationError extends Error {
|
|
26
|
+
readonly status = 400;
|
|
27
|
+
constructor(public readonly fieldErrors: FieldErrors) {
|
|
28
|
+
super("Validation failed");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── validate() ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function toPlainObject(input: FormData | Record<string, unknown>): Record<string, unknown> {
|
|
35
|
+
if (input instanceof FormData) {
|
|
36
|
+
const out: Record<string, unknown> = {};
|
|
37
|
+
for (const [key, value] of input.entries()) {
|
|
38
|
+
if (key in out) {
|
|
39
|
+
const existing = out[key];
|
|
40
|
+
out[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
|
41
|
+
} else {
|
|
42
|
+
out[key] = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate `input` against a Zod-compatible or Valibot-compatible schema.
|
|
52
|
+
*
|
|
53
|
+
* - If the schema has `.safeParse()`: uses it to collect field errors and throws
|
|
54
|
+
* a typed `ValidationError` on failure (which the framework converts to a 400).
|
|
55
|
+
* - If the schema only has `.parse()`: wraps it and re-throws the error as a
|
|
56
|
+
* `ValidationError` with a single `_` field containing the error message.
|
|
57
|
+
*
|
|
58
|
+
* Returns the parsed (coerced) data on success.
|
|
59
|
+
*/
|
|
60
|
+
export async function validate<T>(
|
|
61
|
+
schema: Schema<T>,
|
|
62
|
+
input: FormData | Record<string, unknown>,
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
const plain = toPlainObject(input);
|
|
65
|
+
|
|
66
|
+
if ("safeParse" in schema && typeof schema.safeParse === "function") {
|
|
67
|
+
const result = await schema.safeParse(plain);
|
|
68
|
+
if ((result as SafeParseResult<T>).success) {
|
|
69
|
+
return (result as SafeParseResult<T>).data as T;
|
|
70
|
+
}
|
|
71
|
+
const issues = (result as SafeParseResult<T>).error?.issues ?? [];
|
|
72
|
+
const fieldErrors: FieldErrors = {};
|
|
73
|
+
for (const issue of issues) {
|
|
74
|
+
const key = issue.path.join(".") || "_";
|
|
75
|
+
(fieldErrors[key] ??= []).push(issue.message);
|
|
76
|
+
}
|
|
77
|
+
const err = new ValidationError(fieldErrors);
|
|
78
|
+
throw Response.json({ errors: fieldErrors }, { status: 400, statusText: err.message });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback: plain .parse() — wrap any thrown error.
|
|
82
|
+
try {
|
|
83
|
+
return (schema as SchemaWithParse<T>).parse(plain);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
const fieldErrors: FieldErrors = { _: [message] };
|
|
87
|
+
throw Response.json({ errors: fieldErrors }, { status: 400, statusText: "Validation failed" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -33,10 +33,21 @@ export type MetaFunction<T = unknown> = (
|
|
|
33
33
|
args: MetaArgs<T>
|
|
34
34
|
) => MetaDescriptor[];
|
|
35
35
|
|
|
36
|
+
export interface BeforeLoadArgs {
|
|
37
|
+
params: Record<string, string>;
|
|
38
|
+
context: Record<string, unknown>;
|
|
39
|
+
location: { pathname: string; search: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type BeforeLoadFunction = (
|
|
43
|
+
args: BeforeLoadArgs,
|
|
44
|
+
) => void | Response | Promise<void | Response>;
|
|
45
|
+
|
|
36
46
|
export interface RouteModule<TLoader = unknown, TAction = unknown> {
|
|
37
47
|
loader?: LoaderFunction<TLoader>;
|
|
38
48
|
action?: ActionFunction<TAction>;
|
|
39
49
|
meta?: MetaFunction<TLoader>;
|
|
50
|
+
beforeLoad?: BeforeLoadFunction;
|
|
40
51
|
handle?: Record<string, unknown>;
|
|
41
52
|
ErrorBoundary?: React.ComponentType<{ error: unknown }>;
|
|
42
53
|
default?: React.ComponentType;
|
package/types/index.d.ts
CHANGED
|
@@ -80,12 +80,50 @@ export declare function requestLogger(): MiddlewareFn;
|
|
|
80
80
|
export declare function cors(options: CorsOptions): MiddlewareFn;
|
|
81
81
|
export declare function authGuard(options: AuthGuardOptions): MiddlewareFn;
|
|
82
82
|
|
|
83
|
+
// ── API routes (C1) ───────────────────────────────────────────────────────
|
|
84
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
85
|
+
export interface ApiRouteDefinition<TMethod extends HttpMethod, TPath extends string, TInput, TOutput> {
|
|
86
|
+
method: TMethod;
|
|
87
|
+
path: TPath;
|
|
88
|
+
handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>;
|
|
89
|
+
}
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
export declare function route<TMethod extends HttpMethod, TPath extends string, TInput, TOutput>(
|
|
92
|
+
method: TMethod,
|
|
93
|
+
path: TPath,
|
|
94
|
+
handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>,
|
|
95
|
+
): ApiRouteDefinition<TMethod, TPath, TInput, TOutput>;
|
|
96
|
+
export type AppApiRoutes = never; // users extend this via codegen
|
|
97
|
+
|
|
98
|
+
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
|
99
|
+
type ApiClient<TRoutes extends { method: string; path: string; input: unknown; output: unknown }> = {
|
|
100
|
+
[TPath in TRoutes["path"]]: {
|
|
101
|
+
[TMethod in Extract<TRoutes, { path: TPath }>["method"]]: (
|
|
102
|
+
input?: Extract<TRoutes, { path: TPath; method: TMethod }>["input"],
|
|
103
|
+
) => Promise<UnwrapPromise<Extract<TRoutes, { path: TPath; method: TMethod }>["output"]>>;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
export declare function createClient<
|
|
107
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
108
|
+
>(baseUrl?: string): ApiClient<TRoutes>;
|
|
109
|
+
|
|
110
|
+
// ── Validate (C2) ────────────────────────────────────────────────────────
|
|
111
|
+
export interface FieldErrors { [field: string]: string[] }
|
|
112
|
+
export declare class ValidationError extends Error {
|
|
113
|
+
readonly status: 400;
|
|
114
|
+
readonly fieldErrors: FieldErrors;
|
|
115
|
+
}
|
|
116
|
+
export declare function validate<T>(
|
|
117
|
+
schema: { safeParse?(i: unknown): unknown } | { parse(i: unknown): T },
|
|
118
|
+
input: FormData | Record<string, unknown>,
|
|
119
|
+
): Promise<T>;
|
|
120
|
+
|
|
83
121
|
// ── Client components ─────────────────────────────────────────────────────
|
|
84
122
|
export declare function Scripts(): null;
|
|
85
123
|
export declare function LiveReload(): ReactNode;
|
|
86
124
|
export declare function Outlet(): ReactNode;
|
|
87
125
|
|
|
88
|
-
export interface LinkProps { to: string; prefetch?: "hover" | "none"; children?: ReactNode; className?: string; [key: string]: unknown; }
|
|
126
|
+
export interface LinkProps { to: string; prefetch?: "hover" | "none"; viewTransition?: boolean; children?: ReactNode; className?: string; [key: string]: unknown; }
|
|
89
127
|
export declare function Link(props: LinkProps): ReactNode;
|
|
90
128
|
|
|
91
129
|
export interface FormProps { method?: "post" | "put" | "delete"; action?: string; children?: ReactNode; [key: string]: unknown; }
|
|
@@ -106,4 +144,59 @@ export interface FetcherResult {
|
|
|
106
144
|
load(path: string): Promise<void>;
|
|
107
145
|
submit(path: string, opts: { method: string; body: FormData | Record<string, string> }): Promise<void>;
|
|
108
146
|
}
|
|
147
|
+
export interface StreamFetcherResult<T = unknown> {
|
|
148
|
+
events: AsyncGenerator<T>;
|
|
149
|
+
connect(actionId: string): AsyncGenerator<T>;
|
|
150
|
+
}
|
|
109
151
|
export declare function useFetcher(): FetcherResult;
|
|
152
|
+
export declare function useFetcher<T>(opts: { stream: true }): StreamFetcherResult<T>;
|
|
153
|
+
|
|
154
|
+
export interface SearchParamsResult<T extends Record<string, string> = Record<string, string>> {
|
|
155
|
+
searchParams: URLSearchParams;
|
|
156
|
+
getParam<K extends keyof T & string>(key: K): T[K] | null;
|
|
157
|
+
setSearchParams(updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams)): void;
|
|
158
|
+
}
|
|
159
|
+
export declare function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
|
|
160
|
+
|
|
161
|
+
// ── Typed route context ───────────────────────────────────────────────────
|
|
162
|
+
export declare function defineContext<T>(
|
|
163
|
+
factory: (args: { request: Request; params: Record<string, string> }) => T | Promise<T>
|
|
164
|
+
): ContextFactory<T>;
|
|
165
|
+
export interface ContextFactory<T> {
|
|
166
|
+
_factory: (args: { request: Request; params: Record<string, string> }) => T | Promise<T>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── beforeLoad / useBlocker ───────────────────────────────────────────────
|
|
170
|
+
export declare function useBlocker(shouldBlock: () => boolean): void;
|
|
171
|
+
|
|
172
|
+
// ── i18n routing (E2) ────────────────────────────────────────────────────
|
|
173
|
+
export declare function useLocale(defaultLocale?: string): string;
|
|
174
|
+
export declare function useLocalizedLink(defaultLocale?: string): (path: string) => string;
|
|
175
|
+
export interface I18nConfig { locales: string[]; defaultLocale: string; }
|
|
176
|
+
|
|
177
|
+
// ── Adapter (D1) ──────────────────────────────────────────────────────────
|
|
178
|
+
export interface BractAdapter {
|
|
179
|
+
fetch(request: Request): Promise<Response>;
|
|
180
|
+
listen?(port: number): void;
|
|
181
|
+
}
|
|
182
|
+
export declare class BunAdapter implements BractAdapter {
|
|
183
|
+
setHandler(handler: (request: Request) => Promise<Response>): void;
|
|
184
|
+
fetch(request: Request): Promise<Response>;
|
|
185
|
+
listen(port: number): void;
|
|
186
|
+
stop(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Cloudflare adapter (D2) ───────────────────────────────────────────────
|
|
190
|
+
export declare function createCloudflareAdapter(
|
|
191
|
+
handler: (request: Request) => Promise<Response>,
|
|
192
|
+
): BractAdapter & { fetch(request: Request, env: Record<string, unknown>, ctx: unknown): Promise<Response> };
|
|
193
|
+
export declare function makeCloudflareHandler(
|
|
194
|
+
handler: (request: Request) => Promise<Response>,
|
|
195
|
+
): { fetch(request: Request, env: Record<string, unknown>, ctx: unknown): Promise<Response> };
|
|
196
|
+
|
|
197
|
+
// ── CSS Modules (D3) ─────────────────────────────────────────────────────
|
|
198
|
+
export declare const cssModulesPlugin: unknown; // BunPlugin
|
|
199
|
+
export declare function transformCssModule(filePath: string): Promise<{ map: Record<string, string>; css: string }>;
|
|
200
|
+
|
|
201
|
+
// ── buildFetchHandler (D1) ───────────────────────────────────────────────
|
|
202
|
+
export declare function buildFetchHandler(config: Partial<import("./config.d.ts").BractJSConfig>): (request: Request) => Promise<Response>;
|
package/types/route.d.ts
CHANGED
|
@@ -31,10 +31,21 @@ export type ActionFunction<T = unknown> = (
|
|
|
31
31
|
|
|
32
32
|
export type MetaFunction<T = unknown> = (args: MetaArgs<T>) => MetaDescriptor[];
|
|
33
33
|
|
|
34
|
+
export interface BeforeLoadArgs {
|
|
35
|
+
params: Record<string, string>;
|
|
36
|
+
context: Record<string, unknown>;
|
|
37
|
+
location: { pathname: string; search: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type BeforeLoadFunction = (
|
|
41
|
+
args: BeforeLoadArgs,
|
|
42
|
+
) => void | Response | Promise<void | Response>;
|
|
43
|
+
|
|
34
44
|
export interface RouteModule<TLoader = unknown, TAction = unknown> {
|
|
35
45
|
loader?: LoaderFunction<TLoader>;
|
|
36
46
|
action?: ActionFunction<TAction>;
|
|
37
47
|
meta?: MetaFunction<TLoader>;
|
|
48
|
+
beforeLoad?: BeforeLoadFunction;
|
|
38
49
|
handle?: Record<string, unknown>;
|
|
39
50
|
ErrorBoundary?: ComponentType<{ error: unknown }>;
|
|
40
51
|
default?: ComponentType;
|