@bractjs/bractjs 0.1.25 → 0.1.27

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 (56) hide show
  1. package/README.md +773 -465
  2. package/bin/cli.ts +23 -3
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen.test.ts +36 -0
  6. package/src/__tests__/compile-safety.test.ts +163 -0
  7. package/src/__tests__/compile-smoke.test.ts +276 -0
  8. package/src/__tests__/csp.test.ts +80 -0
  9. package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
  10. package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
  11. package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
  12. package/src/__tests__/integration.test.ts +62 -0
  13. package/src/__tests__/layout-registry.test.ts +23 -0
  14. package/src/__tests__/loader.test.ts +23 -0
  15. package/src/__tests__/middleware.test.ts +22 -0
  16. package/src/__tests__/programmatic-api.test.ts +41 -2
  17. package/src/__tests__/response.test.ts +54 -1
  18. package/src/__tests__/security.test.ts +35 -0
  19. package/src/__tests__/server-module-stub.test.ts +145 -0
  20. package/src/__tests__/stream-handler.test.ts +36 -0
  21. package/src/__tests__/typed-routing.test.ts +189 -0
  22. package/src/build/bundler.ts +46 -20
  23. package/src/build/directives.ts +2 -2
  24. package/src/build/env-plugin.ts +63 -0
  25. package/src/build/react-dedupe.ts +41 -0
  26. package/src/client/ClientRouter.tsx +22 -8
  27. package/src/client/build-path.ts +24 -0
  28. package/src/client/components/Form.tsx +10 -1
  29. package/src/client/components/Link.tsx +31 -8
  30. package/src/client/hooks/useFetcher.ts +17 -1
  31. package/src/client/hooks/useNavigate.ts +46 -0
  32. package/src/client/hooks/useParams.ts +15 -4
  33. package/src/client/hooks/useSearchParams.ts +16 -6
  34. package/src/client/nav-utils.ts +54 -3
  35. package/src/client/registry.ts +107 -0
  36. package/src/client/types.ts +3 -0
  37. package/src/codegen/route-codegen.ts +62 -23
  38. package/src/config/load.ts +50 -2
  39. package/src/dev/devtools.ts +72 -39
  40. package/src/dev/hmr-module-handler.ts +6 -4
  41. package/src/dev/rebuilder.ts +16 -1
  42. package/src/dev/server.ts +3 -0
  43. package/src/index.ts +30 -3
  44. package/src/server/csp.ts +92 -0
  45. package/src/server/csrf.ts +44 -6
  46. package/src/server/layout.ts +12 -2
  47. package/src/server/loader.ts +5 -7
  48. package/src/server/render.ts +29 -10
  49. package/src/server/request-handler.ts +15 -4
  50. package/src/server/response.ts +58 -5
  51. package/src/server/serve.ts +10 -0
  52. package/src/server/static.ts +11 -1
  53. package/src/server/stream-handler.ts +8 -7
  54. package/src/server/use-client-runtime.ts +62 -0
  55. package/src/shared/meta-tags.tsx +46 -0
  56. package/types/index.d.ts +67 -5
@@ -3,12 +3,32 @@ export interface RedirectOptions {
3
3
  allowExternal?: boolean;
4
4
  }
5
5
 
6
- function isSafeInternalRedirect(url: string): boolean {
7
- // Must be path-only: single leading "/" not followed by "/" or "\".
8
- // Rejects: "//evil.com", "/\\evil.com", "https://...", "javascript:...", "".
6
+ // Brand stamped onto redirect Responses the app explicitly opted into sending
7
+ // off-origin via `redirect(url, …, { allowExternal: true })`. sanitizeRedirect()
8
+ // (below) lets branded Responses through untouched but neutralizes any *other*
9
+ // 3xx whose Location escapes the request origin — e.g. a loader/action that
10
+ // throws a raw `new Response(null,{status:302,headers:{Location:"//evil"}})`,
11
+ // which would otherwise bypass this helper's guard entirely.
12
+ const ALLOW_EXTERNAL = Symbol.for("bractjs.redirect.allowExternal");
13
+
14
+ export function isSafeInternalRedirect(url: string): boolean {
15
+ // Must be path-only: a single leading "/" that does not begin an authority
16
+ // ("//host", "/\\host") or a scheme. Rejects, raw OR percent-encoded:
17
+ // "//evil.com", "/\\evil.com", "/%2f%2fevil.com", "/%5cevil.com",
18
+ // "https://…", "javascript:…", "" — plus any control/whitespace char that
19
+ // browsers strip or normalize ("/\t//evil", "/\n/evil") before resolving.
9
20
  if (url.length === 0) return false;
21
+ // Reject any C0 control char, space, or DEL — browsers strip/normalize these
22
+ // and can turn "/\t//evil" into a protocol-relative escape. Checked via char
23
+ // code (not a regex with control-char literals) to keep the source portable.
24
+ for (let i = 0; i < url.length; i++) {
25
+ const c = url.charCodeAt(i);
26
+ if (c <= 0x20 || c === 0x7f) return false;
27
+ }
10
28
  if (url[0] !== "/") return false;
11
- if (url[1] === "/" || url[1] === "\\") return false;
29
+ const rest = url.slice(1).toLowerCase();
30
+ if (rest.startsWith("/") || rest.startsWith("\\")) return false;
31
+ if (rest.startsWith("%2f") || rest.startsWith("%5c")) return false;
12
32
  return true;
13
33
  }
14
34
 
@@ -26,7 +46,40 @@ export function redirect(
26
46
  }
27
47
  const h = new Headers(headers);
28
48
  h.set("Location", url);
29
- return new Response(null, { status, headers: h });
49
+ const res = new Response(null, { status, headers: h });
50
+ // Brand opt-in external redirects so the global sanitizer trusts them.
51
+ if (options?.allowExternal) (res as { [ALLOW_EXTERNAL]?: true })[ALLOW_EXTERNAL] = true;
52
+ return res;
53
+ }
54
+
55
+ /**
56
+ * Last-line guard applied to every redirect Response the request handler is
57
+ * about to emit. Returns the Response untouched unless it is a 3xx whose
58
+ * `Location` escapes `requestUrl`'s origin AND it was not produced by
59
+ * `redirect(..., { allowExternal: true })`. In that case the off-origin
60
+ * Location is treated as an open-redirect attempt: it is logged and replaced
61
+ * with a 500 so the client never follows it.
62
+ */
63
+ export function sanitizeRedirect(res: Response, requestUrl: string): Response {
64
+ if (res.status < 300 || res.status >= 400) return res;
65
+ if ((res as { [ALLOW_EXTERNAL]?: true })[ALLOW_EXTERNAL]) return res;
66
+ const loc = res.headers.get("Location");
67
+ if (loc === null) return res;
68
+ // Same-origin absolute Locations are fine; reduce to a path and re-check.
69
+ let safe = isSafeInternalRedirect(loc);
70
+ if (!safe) {
71
+ try {
72
+ safe = new URL(loc, requestUrl).origin === new URL(requestUrl).origin;
73
+ } catch {
74
+ safe = false;
75
+ }
76
+ }
77
+ if (safe) return res;
78
+ console.error(
79
+ `[bractjs] blocked off-origin redirect Location "${loc}". ` +
80
+ `Use redirect(url, status, headers, { allowExternal: true }) to opt in.`,
81
+ );
82
+ return error("Internal Server Error", 500);
30
83
  }
31
84
 
32
85
  export function json<T>(data: T, init?: ResponseInit): Response {
@@ -12,6 +12,7 @@ import { BunAdapter, type BractAdapter } from "./adapter.ts";
12
12
  import type { ModuleRegistry } from "./layout.ts";
13
13
  import { resolve, join } from "node:path";
14
14
  import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
15
+ import { installUseClientServerStub } from "./use-client-runtime.ts";
15
16
 
16
17
  export interface I18nConfig {
17
18
  locales: string[];
@@ -107,6 +108,15 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
107
108
  }))
108
109
  : Promise.resolve(config.manifest ?? DEFAULT_MANIFEST);
109
110
 
111
+ // When routes are imported from SOURCE at runtime (dev server AND
112
+ // `bractjs start`, which fall back to scanRoutes + dynamic import rather than
113
+ // a pre-stubbed compiled bundle), a `"use client"` route component would
114
+ // execute during SSR and crash on browser-only hooks. Install the runtime
115
+ // stub that null-renders such modules on the server — parity with the
116
+ // compiled bundle's useClientStubPlugin. Skipped on the compiled path, which
117
+ // supplies a pre-built moduleRegistry.
118
+ if (!config.moduleRegistry) installUseClientServerStub(appDir);
119
+
110
120
  // Codegen / compiled-binary path: when the caller supplies pre-scanned
111
121
  // routes, skip the runtime `Bun.Glob` scan that `bun build --compile`
112
122
  // can't satisfy (the routes/ directory isn't on the filesystem in a
@@ -1,9 +1,19 @@
1
1
  import { join, resolve, sep } from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
+ import { isDevRuntime } from "./env.ts";
3
4
 
4
5
  const IMMUTABLE = "public, max-age=31536000, immutable";
5
6
  const NO_CACHE = "no-cache";
6
7
 
8
+ // Hashed client chunks are safe to cache forever in production (a content change
9
+ // yields a new filename). In DEV, however, the dev rebuilder can reuse a chunk
10
+ // filename across rebuilds while its contents change, so marking them immutable
11
+ // makes the browser pin stale JS (e.g. an old route matcher) for a year. Serve
12
+ // them no-cache in dev so each rebuild is picked up.
13
+ function clientAssetCacheControl(): string {
14
+ return isDevRuntime() ? NO_CACHE : IMMUTABLE;
15
+ }
16
+
7
17
  /**
8
18
  * Resolve to a canonical path that follows symlinks, with a fallback for
9
19
  * `bun build --compile` binaries.
@@ -66,7 +76,7 @@ export async function serveStatic(
66
76
  if (!full) return null;
67
77
  const file = Bun.file(full);
68
78
  if (!(await file.exists())) return null;
69
- return new Response(file, { headers: { "Cache-Control": IMMUTABLE } });
79
+ return new Response(file, { headers: { "Cache-Control": clientAssetCacheControl() } });
70
80
  }
71
81
 
72
82
  if (pathname.startsWith("/public/")) {
@@ -1,6 +1,5 @@
1
1
  import { resolveAction } from "./action-registry.ts";
2
2
  import { isExplicitDev } from "./env.ts";
3
- import { isAllowedMutation } from "./csrf.ts";
4
3
 
5
4
  // ── SSE helpers ────────────────────────────────────────────────────────────
6
5
 
@@ -24,12 +23,14 @@ export async function handleStreamRequest(request: Request): Promise<Response |
24
23
  // SECURITY(medium): exact-match prevents URL confusion.
25
24
  if (url.pathname !== "/_stream") return null;
26
25
 
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)) {
26
+ // SECURITY(high): /_stream invokes side-effecting server actions over GET.
27
+ // GET can't carry a body and browsers issue cross-origin GETs from
28
+ // <script>/<img>/<link rel=prefetch> *without* an Origin header, so a bare
29
+ // same-origin-Origin gate is not enough here. Require the client-issued
30
+ // X-BractJS-Action header outright: it's a custom header, so browsers block
31
+ // it cross-origin without a CORS preflight, and the real client (useFetcher)
32
+ // always sends it. This is strictly tighter than the /_action gate.
33
+ if (!request.headers.get("X-BractJS-Action")) {
33
34
  return new Response(sseChunk("error", { message: "Forbidden" }), {
34
35
  status: 403,
35
36
  headers: {
@@ -0,0 +1,62 @@
1
+ import { resolve } from "node:path";
2
+ import { hasClientDirective, extractExports } from "../build/directives.ts";
3
+
4
+ /**
5
+ * Runtime stubbing of `"use client"` modules during SSR, for the source-import
6
+ * code path.
7
+ *
8
+ * The compiled server bundle (`bun build --compile`) applies
9
+ * `useClientStubPlugin`, replacing every `"use client"` export with a
10
+ * `() => null` component so SSR never calls browser-only hooks/APIs. But both
11
+ * the dev server AND `bractjs start` render route modules via a raw `import()`
12
+ * of the SOURCE (they fall back to `scanRoutes` + dynamic import rather than the
13
+ * pre-stubbed bundle). Without this, a `"use client"` route component executes
14
+ * on the server and crashes on `useState`/`useRef` ("Invalid hook call").
15
+ *
16
+ * Registering this `Bun.plugin` makes the server process apply the same
17
+ * transform at module-load time. It only affects this process's `import()`
18
+ * (SSR) — the separately bundled client still ships the real component, so
19
+ * hydration restores interactivity in the browser.
20
+ *
21
+ * The filter is scoped to source files UNDER appDir (never node_modules): a
22
+ * runtime onLoad must always return an object, so any matched non-client file is
23
+ * passed through verbatim, and we must not re-transpile third-party packages
24
+ * (doing so breaks CJS interop shapes such as react/jsx-dev-runtime).
25
+ *
26
+ * Idempotent: safe to call more than once per process.
27
+ */
28
+ let installed = false;
29
+
30
+ function escapeRegExp(s: string): string {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+ }
33
+
34
+ export function installUseClientServerStub(appDir = "./app"): void {
35
+ if (installed) return;
36
+ installed = true;
37
+
38
+ const absAppDir = resolve(appDir);
39
+ const filter = new RegExp(`^${escapeRegExp(absAppDir)}.*\\.(tsx?|jsx?)$`);
40
+
41
+ Bun.plugin({
42
+ name: "bractjs:use-client-server-stub",
43
+ setup(build) {
44
+ build.onLoad({ filter }, async ({ path }) => {
45
+ const src = await Bun.file(path).text();
46
+ const loader = path.endsWith(".tsx") ? "tsx" : path.endsWith(".jsx") ? "jsx" : path.endsWith(".ts") ? "ts" : "js";
47
+ if (!hasClientDirective(src)) {
48
+ // Runtime onLoad must return an object; pass app source through. Bun
49
+ // transpiles app TS/TSX anyway, so this is a no-op in practice.
50
+ return { contents: src, loader };
51
+ }
52
+ const names = extractExports(src).filter((n) => n !== "default");
53
+ const stubs = names.map((n) => `export const ${n} = () => null;`);
54
+ // Always provide a null default — a "use client" module rendered on the
55
+ // server should yield nothing, regardless of how default is declared
56
+ // (which `extractExports` can't always detect, e.g. `export default X`).
57
+ stubs.push("export default () => null;");
58
+ return { contents: stubs.join("\n"), loader: "ts" };
59
+ });
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,46 @@
1
+ import { Fragment, createElement, type ReactElement } from "react";
2
+ import type { MetaDescriptor } from "./route-types.ts";
3
+
4
+ /**
5
+ * Renders route `meta()` descriptors as document-metadata elements.
6
+ *
7
+ * React 19 automatically hoists `<title>`, `<meta>`, and `<link>` rendered
8
+ * anywhere in the tree up into `<head>` — both during streaming SSR and on the
9
+ * client. We render the merged descriptors here (inside the SSR shell AND the
10
+ * ClientRouter tree) so:
11
+ * - crawlers and no-JS clients see real <title>/<meta> tags in the SSR HTML,
12
+ * - hydration matches the server tree (no mismatch warning),
13
+ * - soft navigation updates the document head by re-rendering this component.
14
+ *
15
+ * Keys are derived from the descriptor identity (title / name / property) so a
16
+ * later route can override an earlier one without React duplicating the node.
17
+ */
18
+ export function MetaTags({ meta }: { meta: MetaDescriptor[] }): ReactElement {
19
+ const children: ReactElement[] = [];
20
+
21
+ for (const d of meta) {
22
+ if ("title" in d && typeof (d as { title: unknown }).title === "string") {
23
+ const title = (d as { title: string }).title;
24
+ children.push(createElement("title", { key: "title" }, title));
25
+ } else if ("name" in d && "content" in d) {
26
+ const { name, content } = d as { name: string; content: string };
27
+ children.push(createElement("meta", { key: `name:${name}`, name, content }));
28
+ } else if ("property" in d && "content" in d) {
29
+ const { property, content } = d as { property: string; content: string };
30
+ children.push(createElement("meta", { key: `prop:${property}`, property, content }));
31
+ } else {
32
+ // Arbitrary descriptor: render each string field as a meta attribute set.
33
+ const entries = Object.entries(d).filter(
34
+ ([, v]) => typeof v === "string",
35
+ ) as Array<[string, string]>;
36
+ if (entries.length > 0) {
37
+ const props: Record<string, string> = {};
38
+ for (const [k, v] of entries) props[k] = v;
39
+ const key = entries.map(([k, v]) => `${k}=${v}`).join("&");
40
+ children.push(createElement("meta", { key: `raw:${key}`, ...props }));
41
+ }
42
+ }
43
+ }
44
+
45
+ return createElement(Fragment, null, ...children);
46
+ }
package/types/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode, Context } from "react";
1
+ import type { ReactNode, Context, CSSProperties } from "react";
2
2
 
3
3
  // ── Route types ───────────────────────────────────────────────────────────
4
4
  export type {
@@ -34,7 +34,8 @@ export interface LifecycleHooks {
34
34
  export declare function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks;
35
35
  export declare function createServer(config?: Partial<BractJSConfig>): { stop(): void };
36
36
  export declare function renderRoute(options: RenderOptions): Promise<Response>;
37
- export declare function redirect(url: string, status?: number): Response;
37
+ export interface RedirectOptions { allowExternal?: boolean; }
38
+ export declare function redirect(url: string, status?: number, headers?: HeadersInit, options?: RedirectOptions): Response;
38
39
  export declare function json<T>(data: T, init?: ResponseInit): Response;
39
40
  export declare function error(message: string, status?: number): Response;
40
41
 
@@ -128,13 +129,49 @@ export declare function validate<T>(
128
129
  input: FormData | Record<string, unknown>,
129
130
  ): Promise<T>;
130
131
 
132
+ // ── Typed-routing registration seam ───────────────────────────────────────
133
+ // Mirror of src/client/registry.ts. Augment `Register` (done by `bractjs codegen`
134
+ // in app/route-types.gen.ts) to make <Link>/useNavigate/useParams/useSearchParams
135
+ // type-safe. Un-augmented, everything falls back to loose `string` / Record so
136
+ // apps that never run codegen keep compiling. Keep in sync with registry.ts.
137
+ export interface Register {}
138
+ export interface RouteRegistry {
139
+ routes: string;
140
+ params: Record<string, Record<string, string>>;
141
+ search: Record<string, Record<string, string>>;
142
+ }
143
+ export interface RouteSearchParamsMap {}
144
+ export interface RouteContextMap {}
145
+ // Infer each member directly (NOT `infer R extends RouteRegistry` — a constrained
146
+ // infer fails to match the generated registry and falls back to loose). Keep in
147
+ // sync with src/client/registry.ts.
148
+ export type RegisteredRoutes =
149
+ Register extends { routes: { routes: infer R } } ? R : string;
150
+ export type RegisteredParamsMap =
151
+ Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
152
+ export type RegisteredSearchMap =
153
+ Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
154
+ export type ParamsFor<TTo> =
155
+ TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
156
+ export type SearchFor<TTo> =
157
+ TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
158
+ export declare function buildPath(pattern: string, params: Record<string, string | number>): string;
159
+
131
160
  // ── Client components ─────────────────────────────────────────────────────
132
161
  export declare function Scripts(): null;
133
162
  export declare function LiveReload(): ReactNode;
134
163
  export declare function Outlet(): ReactNode;
135
164
 
136
- export interface LinkProps { to: string; prefetch?: "hover" | "none"; viewTransition?: boolean; children?: ReactNode; className?: string; [key: string]: unknown; }
137
- export declare function Link(props: LinkProps): ReactNode;
165
+ export type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = {
166
+ to: TTo | (string & {});
167
+ params?: ParamsFor<TTo>;
168
+ prefetch?: "hover" | "none";
169
+ viewTransition?: boolean;
170
+ children?: ReactNode;
171
+ className?: string;
172
+ [key: string]: unknown;
173
+ };
174
+ export declare function Link<TTo extends RegisteredRoutes = RegisteredRoutes>(props: LinkProps<TTo>): ReactNode;
138
175
 
139
176
  export interface FormProps { method?: "post" | "put" | "delete"; action?: string; children?: ReactNode; [key: string]: unknown; }
140
177
  export declare function Form(props: FormProps): ReactNode;
@@ -142,12 +179,36 @@ export declare function Form(props: FormProps): ReactNode;
142
179
  export interface AwaitProps<T> { resolve: Promise<T>; fallback: ReactNode; children: (data: T) => ReactNode; }
143
180
  export declare function Await<T>(props: AwaitProps<T>): ReactNode;
144
181
 
182
+ export type ImageFormat = "webp" | "avif" | "jpeg" | "png";
183
+ export type ImageFit = "cover" | "contain" | "fill";
184
+ export interface ImageProps {
185
+ src: string;
186
+ alt: string;
187
+ width?: number;
188
+ height?: number;
189
+ quality?: number;
190
+ format?: ImageFormat;
191
+ fit?: ImageFit;
192
+ priority?: boolean;
193
+ sizes?: string;
194
+ className?: string;
195
+ style?: CSSProperties;
196
+ }
197
+ export declare function Image(props: ImageProps): ReactNode;
198
+
145
199
  // ── Client hooks ──────────────────────────────────────────────────────────
146
200
  export declare function useLoaderData<T = unknown>(): T;
147
201
  export declare function useActionData<T = unknown>(): T | null;
148
- export declare function useParams(): Record<string, string>;
202
+ export declare function useParams<TTo extends string>(): ParamsFor<TTo>;
203
+ export declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
149
204
  export type NavigationState = "idle" | "loading" | "submitting";
150
205
  export declare function useNavigation(): { state: NavigationState };
206
+
207
+ export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> { params?: ParamsFor<TTo>; }
208
+ export interface NavigateFn {
209
+ <TTo extends RegisteredRoutes>(to: TTo | (string & {}), options?: NavigateOptions<TTo>): Promise<void>;
210
+ }
211
+ export declare function useNavigate(): NavigateFn;
151
212
  export interface FetcherResult {
152
213
  data: unknown;
153
214
  state: NavigationState;
@@ -166,6 +227,7 @@ export interface SearchParamsResult<T extends Record<string, string> = Record<st
166
227
  getParam<K extends keyof T & string>(key: K): T[K] | null;
167
228
  setSearchParams(updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams)): void;
168
229
  }
230
+ export declare function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
169
231
  export declare function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
170
232
 
171
233
  // ── Typed route context ───────────────────────────────────────────────────