@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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/loader.test.ts +5 -2
  3. package/src/adapters/cloudflare.ts +65 -0
  4. package/src/build/bundler.ts +5 -3
  5. package/src/build/env-plugin.ts +7 -0
  6. package/src/build/plugins/css-modules.ts +110 -0
  7. package/src/client/ClientRouter.tsx +113 -9
  8. package/src/client/cache.ts +69 -0
  9. package/src/client/components/Link.tsx +16 -2
  10. package/src/client/components/LiveReload.tsx +4 -0
  11. package/src/client/hooks/useBlocker.ts +44 -0
  12. package/src/client/hooks/useFetcher.ts +66 -6
  13. package/src/client/hooks/useLocale.ts +12 -0
  14. package/src/client/hooks/useLocalizedLink.ts +18 -0
  15. package/src/client/hooks/useSearchParams.ts +74 -0
  16. package/src/client/rpc.ts +70 -0
  17. package/src/codegen/route-codegen.ts +63 -1
  18. package/src/dev/devtools.ts +144 -0
  19. package/src/dev/hmr-client.ts +14 -0
  20. package/src/dev/hmr-module-handler.ts +17 -1
  21. package/src/dev/hmr-server.ts +16 -0
  22. package/src/image/handler.ts +5 -2
  23. package/src/image/optimizer.ts +6 -1
  24. package/src/index.ts +27 -0
  25. package/src/middleware/cors.ts +4 -0
  26. package/src/middleware/requestLogger.ts +4 -0
  27. package/src/server/action-handler.ts +8 -4
  28. package/src/server/adapter.ts +57 -0
  29. package/src/server/api-route.ts +127 -0
  30. package/src/server/context.ts +22 -0
  31. package/src/server/csrf.ts +1 -0
  32. package/src/server/env.ts +16 -0
  33. package/src/server/i18n.ts +63 -0
  34. package/src/server/loader.ts +61 -1
  35. package/src/server/render.ts +7 -0
  36. package/src/server/request-handler.ts +66 -8
  37. package/src/server/serve.ts +102 -55
  38. package/src/server/session.ts +1 -0
  39. package/src/server/static.ts +8 -1
  40. package/src/server/stream-handler.ts +111 -0
  41. package/src/server/validate.ts +89 -0
  42. package/src/shared/route-types.ts +11 -0
  43. package/types/index.d.ts +94 -1
  44. package/types/route.d.ts +11 -0
@@ -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
- export function createServer(config?: Partial<BractJSConfig>): {
51
- server: ReturnType<typeof Bun.serve>;
52
- stop(): void;
53
- } {
54
- const port = config?.port ?? 3000;
55
- const appDir = resolve(config?.appDir ?? "./app");
56
- const publicDir = resolve(config?.publicDir ?? "./public");
57
- const buildDir = resolve(config?.buildDir ?? "./build");
58
- const imageCacheDir = resolve(config?.imageCacheDir ?? ".bract-image-cache");
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
- // In production, load the pre-built manifest; otherwise use provided or default
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?.manifest ?? DEFAULT_MANIFEST);
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
- const server = Bun.serve({
76
- port,
77
- async fetch(request) {
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
- // Dev-only: on-demand module compilation for HMR module swap
82
- if (isDev() && pathname === "/_hmr/module") {
83
- const { handleHmrModuleRequest } = await import("../dev/hmr-module-handler.ts");
84
- return handleHmrModuleRequest(url, appDir);
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
- // Server actions endpoint
88
- if (pathname.startsWith("/_action")) {
89
- await actionsReady;
90
- const actionRes = await handleActionRequest(request);
91
- if (actionRes) return actionRes;
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
- // Image optimization endpoint
95
- if (pathname === "/_image") {
96
- const imgRes = await handleImageRequest(request, publicDir, imageCacheDir);
97
- if (imgRes) return imgRes;
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
- // Serve hashed client assets + public/ with correct cache headers
101
- const staticRes = await serveStatic(pathname, buildDir, publicDir);
102
- if (staticRes) return staticRes;
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
- const trie = await trieReady;
105
- const manifest = isDev() ? await readDevManifest(buildDir) : await manifestReady;
106
- const handlerConfig: HandlerConfig = { appDir, publicDir, manifest };
107
- return handleRequest(request, trie, handlerConfig);
108
- },
109
- // Return JSON for any uncaught exception so the client's r.json() never sees
110
- // plain-text "Internal Server Error" bodies prevents JSON.parse errors.
111
- error(err: Error) {
112
- console.error("[bractjs] unhandled server error:", err);
113
- return new Response(JSON.stringify({ error: err.message }), {
114
- status: 500,
115
- headers: { "Content-Type": "application/json; charset=utf-8" },
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
- server,
124
- stop() { server.stop(); },
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
 
@@ -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) {
@@ -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.includes("..")) return null;
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;