@bractjs/bractjs 0.1.6 → 0.1.7

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 +2 -1
  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
@@ -0,0 +1,12 @@
1
+ import { useParams } from "./useParams.ts";
2
+
3
+ /**
4
+ * Returns the current locale from URL params.
5
+ * Works when the router is configured with i18n prefix routes (`/:locale/...`).
6
+ *
7
+ * Falls back to `defaultLocale` when no locale param is present (e.g. SSR without locale prefix).
8
+ */
9
+ export function useLocale(defaultLocale = "en"): string {
10
+ const params = useParams<{ locale?: string }>();
11
+ return params.locale ?? defaultLocale;
12
+ }
@@ -0,0 +1,18 @@
1
+ import { useLocale } from "./useLocale.ts";
2
+
3
+ /**
4
+ * Returns a helper that prepends the current locale to a path.
5
+ *
6
+ * Usage:
7
+ * const localizedTo = useLocalizedLink();
8
+ * <Link to={localizedTo('/about')} /> // → /en/about
9
+ */
10
+ export function useLocalizedLink(defaultLocale = "en"): (path: string) => string {
11
+ const locale = useLocale(defaultLocale);
12
+ return (path: string) => {
13
+ // Don't double-prefix if the path already starts with /<locale>.
14
+ const alreadyPrefixed = path.startsWith(`/${locale}/`) || path === `/${locale}`;
15
+ if (alreadyPrefixed) return path;
16
+ return `/${locale}${path.startsWith("/") ? path : "/" + path}`;
17
+ };
18
+ }
@@ -0,0 +1,74 @@
1
+ import { useState, useCallback, useEffect, useRef, startTransition } from "react";
2
+ import { NavigationContext } from "../router.tsx";
3
+ import { useContext } from "react";
4
+
5
+ // ── Types ──────────────────────────────────────────────────────────────────
6
+
7
+ type SetSearchParams = (
8
+ updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams),
9
+ ) => void;
10
+
11
+ export interface SearchParamsResult<T extends Record<string, string>> {
12
+ searchParams: URLSearchParams;
13
+ getParam<K extends keyof T & string>(key: K): T[K] | null;
14
+ setSearchParams: SetSearchParams;
15
+ }
16
+
17
+ // ── Hook ───────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Reads and writes URL search params, typed per-route via generic T.
21
+ * Triggers a loader re-run (soft-nav fetch) when params change.
22
+ *
23
+ * T is the route's SearchParams shape (e.g. { page: string; sort: string }).
24
+ * This hook is SSR-safe: on the server window is absent, so it returns empty params.
25
+ */
26
+ export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T> {
27
+ const navCtx = useContext(NavigationContext);
28
+
29
+ function readCurrent(): URLSearchParams {
30
+ if (typeof window === "undefined") return new URLSearchParams();
31
+ return new URLSearchParams(window.location.search);
32
+ }
33
+
34
+ const [searchParams, setSearchParamsState] = useState<URLSearchParams>(readCurrent);
35
+
36
+ // Track whether we triggered the change ourselves to avoid double re-run.
37
+ const selfTriggerRef = useRef(false);
38
+
39
+ // Sync when the browser's history changes (back/forward, external pushState).
40
+ useEffect(() => {
41
+ function onPopState() {
42
+ setSearchParamsState(new URLSearchParams(window.location.search));
43
+ }
44
+ window.addEventListener("popstate", onPopState);
45
+ return () => window.removeEventListener("popstate", onPopState);
46
+ }, []);
47
+
48
+ const setSearchParams: SetSearchParams = useCallback((updater) => {
49
+ const next =
50
+ typeof updater === "function"
51
+ ? updater(new URLSearchParams(window.location.search))
52
+ : new URLSearchParams(updater);
53
+
54
+ const newSearch = next.toString();
55
+ const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
56
+
57
+ // Update browser URL without pushing a new history entry if only params changed.
58
+ history.pushState({}, "", newUrl);
59
+ selfTriggerRef.current = true;
60
+ startTransition(() => setSearchParamsState(next));
61
+
62
+ // Trigger a loader re-run via the NavigationContext navigate so the full
63
+ // soft-nav fetch path is exercised (meta update, module swap, etc.).
64
+ if (navCtx) {
65
+ void navCtx.navigate(window.location.pathname + (newSearch ? "?" + newSearch : ""));
66
+ }
67
+ }, [navCtx]);
68
+
69
+ const getParam = useCallback(<K extends keyof T & string>(key: K): T[K] | null => {
70
+ return (searchParams.get(key) as T[K] | null);
71
+ }, [searchParams]);
72
+
73
+ return { searchParams, getParam, setSearchParams };
74
+ }
@@ -0,0 +1,70 @@
1
+ // ── createClient ───────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Creates a fully-typed fetch client for BractJS API routes.
5
+ *
6
+ * Usage:
7
+ * import type { AppApiRoutes } from 'bractjs';
8
+ * const client = createClient<AppApiRoutes>();
9
+ * const users = await client['/api/users'].GET();
10
+ *
11
+ * The proxy builds the fetch URL from the property access chain and HTTP method,
12
+ * so `client['/api/users'].GET()` calls `GET /api/users`.
13
+ *
14
+ * This is intentionally minimal (no batching, no retries) — add wrapping as needed.
15
+ */
16
+
17
+ type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
18
+
19
+ // Given a union of route definitions (method/path/input/output), extract
20
+ // the output type for a specific method + path pair.
21
+ type RouteOutput<
22
+ TRoutes extends { method: string; path: string; input: unknown; output: unknown },
23
+ TMethod extends string,
24
+ TPath extends string,
25
+ > = Extract<TRoutes, { method: TMethod; path: TPath }>["output"];
26
+
27
+ type RouteInput<
28
+ TRoutes extends { method: string; path: string; input: unknown; output: unknown },
29
+ TMethod extends string,
30
+ TPath extends string,
31
+ > = Extract<TRoutes, { method: TMethod; path: TPath }>["input"];
32
+
33
+ type ApiClient<TRoutes extends { method: string; path: string; input: unknown; output: unknown }> = {
34
+ [TPath in TRoutes["path"]]: {
35
+ [TMethod in Extract<TRoutes, { path: TPath }>["method"]]: (
36
+ input?: RouteInput<TRoutes, TMethod, TPath>,
37
+ ) => Promise<UnwrapPromise<RouteOutput<TRoutes, TMethod, TPath>>>;
38
+ };
39
+ };
40
+
41
+ export function createClient<
42
+ TRoutes extends { method: string; path: string; input: unknown; output: unknown },
43
+ >(baseUrl = ""): ApiClient<TRoutes> {
44
+ return new Proxy({} as ApiClient<TRoutes>, {
45
+ get(_target, path: string) {
46
+ return new Proxy({} as Record<string, unknown>, {
47
+ get(_t, method: string) {
48
+ return async (input?: unknown) => {
49
+ const httpMethod = method.toUpperCase();
50
+ const url = baseUrl + path;
51
+ const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
52
+ const res = await fetch(url, {
53
+ method: httpMethod,
54
+ headers: hasBody ? { "Content-Type": "application/json" } : undefined,
55
+ body: hasBody ? JSON.stringify(input) : undefined,
56
+ });
57
+ if (!res.ok) {
58
+ const err = await res.json().catch(() => ({ error: res.statusText }));
59
+ throw Object.assign(new Error((err as { error?: string }).error ?? res.statusText), {
60
+ status: res.status,
61
+ response: res,
62
+ });
63
+ }
64
+ return res.json();
65
+ };
66
+ },
67
+ });
68
+ },
69
+ });
70
+ }
@@ -78,6 +78,61 @@ const HEADER =
78
78
  "\n" +
79
79
  "/* eslint-disable */\n";
80
80
 
81
+ function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
82
+ // Route files may declare `export type SearchParams = { page: string }`.
83
+ // We emit a mapped type that falls back to Record<string,string> per route.
84
+ // Users augment via module augmentation or via their route file's export.
85
+ if (routes.length === 0) {
86
+ return "export type SearchParams<_T extends AppRoutes> = Record<string, string>;";
87
+ }
88
+ const branches = routes
89
+ .map((r) => {
90
+ assertSafePattern(r.pattern);
91
+ return " T extends " + JSON.stringify(r.pattern) + " ? RouteSearchParamsMap[" + JSON.stringify(r.pattern) + "] :";
92
+ })
93
+ .join("\n");
94
+ const mapEntries = routes
95
+ .map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, string>;")
96
+ .join("\n");
97
+ return [
98
+ "// Augment RouteSearchParamsMap to type search params per route:",
99
+ "// declare module 'bractjs' { interface RouteSearchParamsMap { '/blog': { page: string } } }",
100
+ "export interface RouteSearchParamsMap {",
101
+ mapEntries,
102
+ "}",
103
+ "",
104
+ "export type SearchParams<T extends AppRoutes> =",
105
+ branches,
106
+ " Record<string, string>;",
107
+ ].join("\n");
108
+ }
109
+
110
+ function contextTypeLines(routes: Array<{ pattern: string }>): string {
111
+ if (routes.length === 0) {
112
+ return "export type Context<_T extends AppRoutes> = Record<string, unknown>;";
113
+ }
114
+ const branches = routes
115
+ .map((r) => {
116
+ assertSafePattern(r.pattern);
117
+ return " T extends " + JSON.stringify(r.pattern) + " ? RouteContextMap[" + JSON.stringify(r.pattern) + "] :";
118
+ })
119
+ .join("\n");
120
+ const mapEntries = routes
121
+ .map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, unknown>;")
122
+ .join("\n");
123
+ return [
124
+ "// Augment RouteContextMap to type context per route:",
125
+ "// declare module 'bractjs' { interface RouteContextMap { '/blog': { user: User } } }",
126
+ "export interface RouteContextMap {",
127
+ mapEntries,
128
+ "}",
129
+ "",
130
+ "export type Context<T extends AppRoutes> =",
131
+ branches,
132
+ " Record<string, unknown>;",
133
+ ].join("\n");
134
+ }
135
+
81
136
  export async function generateRouteTypes(appDir: string): Promise<string> {
82
137
  const routeFiles = await scanRoutes(appDir);
83
138
  const routes = routeFiles.map((r) => ({
@@ -101,14 +156,21 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
101
156
  "",
102
157
  paramsTypeLines(routes),
103
158
  "",
159
+ searchParamsTypeLines(routes),
160
+ "",
161
+ contextTypeLines(routes),
162
+ "",
104
163
  "export type TypedLoaderArgs<T extends AppRoutes> = {",
105
164
  " request: Request;",
106
165
  " params: RouteParams<T>;",
107
- " context: Record<string, unknown>;",
166
+ " context: Context<T>;",
108
167
  "};",
109
168
  "export type TypedActionArgs<T extends AppRoutes> =",
110
169
  " TypedLoaderArgs<T> & { formData: FormData };",
111
170
  "",
171
+ "/** A locale-prefixed variant of a route (E2 i18n routing). */",
172
+ "export type LocalizedRoute<T extends AppRoutes> = `/${string}${T}`;",
173
+ "",
112
174
  "export const routes = {",
113
175
  builderEntries,
114
176
  "} as const;",
@@ -0,0 +1,144 @@
1
+ /**
2
+ * BractJS DevTools Panel (E3).
3
+ *
4
+ * In dev mode this module is imported by the HMR client and registers a
5
+ * `<bractjs-devtools>` custom element. The element reads shared state from
6
+ * `window.__BRACTJS_DEVTOOLS__` which is populated by ClientRouter.
7
+ *
8
+ * Ctrl+Shift+B toggles the panel.
9
+ * Zero production overhead — this file is never imported in production because
10
+ * it is only loaded via `if (__BRACT_DEV__)` in the HMR client.
11
+ */
12
+
13
+ export interface DevtoolsState {
14
+ route: string | null;
15
+ loaderData: Record<string, unknown>;
16
+ navState: string;
17
+ cacheEntries: Array<{ key: string; age: number; staleTime: number; gcTime: number }>;
18
+ beforeLoadTrace: string[];
19
+ }
20
+
21
+ declare global {
22
+ interface Window {
23
+ __BRACTJS_DEVTOOLS__?: DevtoolsState;
24
+ }
25
+ }
26
+
27
+ const PANEL_ID = "bractjs-devtools-panel";
28
+
29
+ class BractJSDevtools extends HTMLElement {
30
+ private open = false;
31
+ private panel: HTMLDivElement | null = null;
32
+
33
+ connectedCallback() {
34
+ this.style.cssText = "position:fixed;bottom:0;right:0;z-index:2147483647;font-family:monospace;";
35
+
36
+ const toggle = document.createElement("button");
37
+ toggle.textContent = "⚡ BractJS";
38
+ toggle.style.cssText =
39
+ "background:#1e1e1e;color:#61dafb;border:none;padding:4px 10px;cursor:pointer;font-size:12px;";
40
+ toggle.onclick = () => this.togglePanel();
41
+ this.appendChild(toggle);
42
+
43
+ // Keyboard shortcut
44
+ document.addEventListener("keydown", (e) => {
45
+ if (e.ctrlKey && e.shiftKey && e.key === "B") {
46
+ e.preventDefault();
47
+ this.togglePanel();
48
+ }
49
+ });
50
+ }
51
+
52
+ private togglePanel() {
53
+ if (this.panel) {
54
+ this.panel.remove();
55
+ this.panel = null;
56
+ this.open = false;
57
+ } else {
58
+ this.open = true;
59
+ this.renderPanel();
60
+ }
61
+ }
62
+
63
+ private renderPanel() {
64
+ const state = window.__BRACTJS_DEVTOOLS__ ?? {
65
+ route: null,
66
+ loaderData: {},
67
+ navState: "idle",
68
+ cacheEntries: [],
69
+ beforeLoadTrace: [],
70
+ };
71
+
72
+ const panel = document.createElement("div");
73
+ panel.id = PANEL_ID;
74
+ panel.style.cssText =
75
+ "background:#1e1e1e;color:#ccc;width:480px;max-height:60vh;overflow:auto;" +
76
+ "border-top:2px solid #61dafb;border-left:2px solid #61dafb;padding:12px;font-size:11px;";
77
+
78
+ const header = document.createElement("div");
79
+ header.style.cssText = "color:#61dafb;font-weight:bold;margin-bottom:8px;font-size:13px;";
80
+ header.textContent = "BractJS DevTools";
81
+ panel.appendChild(header);
82
+
83
+ this.section(panel, "Route", state.route ?? "(none)");
84
+ this.section(panel, "Navigation state", state.navState);
85
+ this.section(panel, "Loader data", JSON.stringify(state.loaderData, null, 2));
86
+
87
+ if (state.cacheEntries.length > 0) {
88
+ const cacheText = state.cacheEntries
89
+ .map((e) => `${e.key}\n age=${e.age}ms stale=${e.staleTime}ms gc=${e.gcTime}ms`)
90
+ .join("\n");
91
+ this.section(panel, `Cache (${state.cacheEntries.length})`, cacheText);
92
+ }
93
+
94
+ if (state.beforeLoadTrace.length > 0) {
95
+ this.section(panel, "beforeLoad trace", state.beforeLoadTrace.join("\n"));
96
+ }
97
+
98
+ this.panel = panel;
99
+ this.appendChild(panel);
100
+
101
+ // Auto-refresh every second while open.
102
+ const timer = setInterval(() => {
103
+ if (!this.open) { clearInterval(timer); return; }
104
+ panel.remove();
105
+ this.panel = null;
106
+ this.renderPanel();
107
+ }, 1000);
108
+ }
109
+
110
+ private section(parent: HTMLElement, title: string, content: string) {
111
+ const h = document.createElement("div");
112
+ h.style.cssText = "color:#61dafb;margin-top:8px;margin-bottom:2px;";
113
+ h.textContent = title;
114
+ parent.appendChild(h);
115
+
116
+ const pre = document.createElement("pre");
117
+ pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-all;color:#ccc;";
118
+ pre.textContent = content;
119
+ parent.appendChild(pre);
120
+ }
121
+ }
122
+
123
+ if (typeof customElements !== "undefined" && !customElements.get("bractjs-devtools")) {
124
+ customElements.define("bractjs-devtools", BractJSDevtools);
125
+ }
126
+
127
+ /**
128
+ * Inject the `<bractjs-devtools>` element into the document body.
129
+ * Called by the HMR client in dev mode.
130
+ */
131
+ export function injectDevtools(): void {
132
+ if (typeof document === "undefined") return;
133
+ if (document.querySelector("bractjs-devtools")) return;
134
+ const el = document.createElement("bractjs-devtools");
135
+ document.body.appendChild(el);
136
+ }
137
+
138
+ /**
139
+ * Update the shared devtools state object.
140
+ * Called by ClientRouter on every navigation.
141
+ */
142
+ export function updateDevtoolsState(state: Partial<DevtoolsState>): void {
143
+ window.__BRACTJS_DEVTOOLS__ = { ...window.__BRACTJS_DEVTOOLS__, ...state } as DevtoolsState;
144
+ }
@@ -13,6 +13,15 @@
13
13
  */
14
14
  export const hmrClientScript: string = `
15
15
  (function () {
16
+ // Inject DevTools panel in dev mode (E3).
17
+ if (typeof customElements !== 'undefined') {
18
+ import('/_bractjs/devtools.js').then(function(m) {
19
+ if (typeof m.injectDevtools === 'function') m.injectDevtools();
20
+ }).catch(function() {
21
+ // DevTools module not available — skip silently.
22
+ });
23
+ }
24
+
16
25
  function connect() {
17
26
  var ws = new WebSocket("ws://localhost:3001");
18
27
  ws.onmessage = function (event) {
@@ -21,6 +30,11 @@ export const hmrClientScript: string = `
21
30
  if (msg.type === "hmr:reload") {
22
31
  location.reload();
23
32
  } else if (msg.type === "hmr:route" && msg.pattern != null && msg.chunkUrl) {
33
+ // Validate chunk URL is a same-origin relative path before importing.
34
+ // Prevents a compromised/MITM'd dev WS from executing arbitrary URLs.
35
+ if (typeof msg.chunkUrl !== 'string' || !/^\/build\//.test(msg.chunkUrl)) {
36
+ return;
37
+ }
24
38
  // Cache-bust so the browser re-fetches the rebuilt chunk.
25
39
  // The chunk was built with splitting, so it shares the same React
26
40
  // instance as client.js — no dual-React issue.
@@ -1,5 +1,7 @@
1
1
  import { resolve, join, sep } from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
+ import { serverOnlyPlugin } from "../build/env-plugin.ts";
4
+ import { useServerProxyPlugin } from "../build/directives.ts";
3
5
 
4
6
  /**
5
7
  * Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
@@ -18,6 +20,14 @@ export async function handleHmrModuleRequest(
18
20
  return new Response("Missing file param", { status: 400 });
19
21
  }
20
22
 
23
+ // SECURITY(high): restrict to JS/TS source files. Without this, /_hmr/module
24
+ // would build and ship the contents of any file inside appDir (e.g. .env,
25
+ // .json, .md) as JavaScript to the browser — useful only for compiling
26
+ // route modules, so allowlist their extensions.
27
+ if (!/\.(tsx?|jsx?)$/.test(file)) {
28
+ return new Response("Forbidden", { status: 403 });
29
+ }
30
+
21
31
  // Resolve and guard against path traversal AND symlink escape.
22
32
  const rootDir = resolve(appDir);
23
33
  const candidate = resolve(join(rootDir, file));
@@ -34,12 +44,18 @@ export async function handleHmrModuleRequest(
34
44
  return new Response("Forbidden", { status: 403 });
35
45
  }
36
46
 
37
- // Build in-memory (no outdir outputs held in memory, no disk write)
47
+ // SECURITY(high): apply the same client-bundle guard plugins the production
48
+ // build uses. Without these, a route module that imports `*.server.ts` or
49
+ // contains "use server" exports would have that server source compiled and
50
+ // shipped to the browser as JavaScript over /_hmr/module — leaking
51
+ // credentials, DB code, etc. The serverOnlyPlugin hard-fails such imports
52
+ // and useServerProxyPlugin rewrites "use server" exports to fetch stubs.
38
53
  const result = await Bun.build({
39
54
  entrypoints: [fullPath],
40
55
  target: "browser",
41
56
  minify: false,
42
57
  sourcemap: "inline",
58
+ plugins: [serverOnlyPlugin, useServerProxyPlugin],
43
59
  });
44
60
 
45
61
  if (!result.success || result.outputs.length === 0) {
@@ -18,6 +18,22 @@ export function createHmrServer(port = 3001): {
18
18
  const server = Bun.serve({
19
19
  port,
20
20
  fetch(req, srv) {
21
+ // SECURITY(medium): reject WebSocket upgrades that don't come from a
22
+ // loopback Origin. Without this, any website the developer visits could
23
+ // open a WS to ws://localhost:<port> and receive file paths from HMR
24
+ // broadcasts (a passive leak of project structure). Same-origin /
25
+ // missing Origin (curl, native ws clients) are allowed for dev DX.
26
+ const origin = req.headers.get("Origin");
27
+ if (origin) {
28
+ try {
29
+ const host = new URL(origin).hostname;
30
+ if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]" && host !== "::1") {
31
+ return new Response("Forbidden", { status: 403 });
32
+ }
33
+ } catch {
34
+ return new Response("Forbidden", { status: 403 });
35
+ }
36
+ }
21
37
  if (srv.upgrade(req)) return undefined;
22
38
  return new Response("HMR WebSocket endpoint", { status: 426 });
23
39
  },
@@ -14,8 +14,11 @@ async function parseParams(
14
14
  publicDir: string,
15
15
  ): Promise<{ src: string; filePath: string; params: ImageTransformParams } | null> {
16
16
  const src = sp.get("src");
17
- // src must be a /public/ path with no traversal sequences
18
- if (!src || !src.startsWith("/public/") || src.includes("..")) return null;
17
+ // src must be a /public/ path with no ".." path segment. We check segments
18
+ // (not substring) so filenames like "foo..bar.jpg" are still allowed —
19
+ // realpath()/prefix check below is the authoritative escape guard.
20
+ if (!src || !src.startsWith("/public/")) return null;
21
+ if (src.split("/").includes("..")) return null;
19
22
 
20
23
  const rel = src.slice("/public/".length);
21
24
  const root = resolve(publicDir);
@@ -57,9 +57,14 @@ function resizeArgs(params: ImageTransformParams): string[] {
57
57
 
58
58
  function buildArgs(binary: string, input: string, params: ImageTransformParams): string[] {
59
59
  const base = binary === "magick" ? ["magick", "convert"] : ["convert"];
60
+ // SECURITY(low): prefix the input with `file:` so ImageMagick treats it as
61
+ // a filesystem path even if it contains a coder prefix like `mvg:` or
62
+ // `https:` (e.g. an attacker who plants a file named "https:evil.txt"
63
+ // inside publicDir). Defense in depth — realpath() already constrains the
64
+ // path to publicDir, so this prevents the "format coder hijack" class only.
60
65
  return [
61
66
  ...base,
62
- input,
67
+ `file:${input}`,
63
68
  ...resizeArgs(params),
64
69
  "-quality", String(params.q),
65
70
  "-strip",
package/src/index.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  // Server
2
2
  export { createServer, renderRoute, redirect, json, error } from "./server/index.ts";
3
+ export { buildFetchHandler } from "./server/serve.ts";
4
+ export { defineContext } from "./server/context.ts";
5
+ export type { ContextFactory } from "./server/context.ts";
6
+ export { route } from "./server/api-route.ts";
7
+ export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
8
+ export { validate } from "./server/validate.ts";
9
+ export type { FieldErrors, ValidationError } from "./server/validate.ts";
10
+ export type { BractAdapter } from "./server/adapter.ts";
11
+ export { BunAdapter } from "./server/adapter.ts";
12
+
13
+ // Adapters
14
+ export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloudflare.ts";
15
+
16
+ // Build plugins
17
+ export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
18
+
19
+ // Client RPC
20
+ export { createClient } from "./client/rpc.ts";
3
21
  export type { BractJSConfig, RenderOptions, ServerManifest } from "./server/index.ts";
4
22
 
5
23
  // Shared types
@@ -49,3 +67,12 @@ export { useActionData } from "./client/hooks/useActionData.ts";
49
67
  export { useParams } from "./client/hooks/useParams.ts";
50
68
  export { useNavigation } from "./client/hooks/useNavigation.ts";
51
69
  export { useFetcher } from "./client/hooks/useFetcher.ts";
70
+ export { useSearchParams } from "./client/hooks/useSearchParams.ts";
71
+ export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
72
+ export { useBlocker } from "./client/hooks/useBlocker.ts";
73
+ export { useLocale } from "./client/hooks/useLocale.ts";
74
+ export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
75
+
76
+ // i18n utilities (server-side)
77
+ export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
78
+ export type { I18nConfig } from "./server/serve.ts";
@@ -25,6 +25,10 @@ export function cors(options: CorsOptions): MiddlewareFn {
25
25
 
26
26
  return async (ctx, next) => {
27
27
  const origin = ctx.request.headers.get("Origin") ?? "";
28
+ // SECURITY(high): Access-Control-Allow-Headers MUST NOT list
29
+ // `X-BractJS-Action`. That header is the CSRF gate in csrf.ts — its
30
+ // protection relies on browsers blocking non-allowlisted custom headers
31
+ // cross-origin. Adding it here would let any origin forge mutations.
28
32
  const corsHeaders: Record<string, string> = {
29
33
  "Access-Control-Allow-Methods": allowedMethods,
30
34
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
@@ -3,6 +3,10 @@ import type { MiddlewareFn } from "../server/middleware.ts";
3
3
  /**
4
4
  * Logs "[METHOD] /path → status in Xms" for every request.
5
5
  */
6
+ // SECURITY(medium): only the pathname is logged — query string is intentionally
7
+ // omitted because it may carry tokens (e.g. password-reset links, OAuth codes,
8
+ // signed share URLs). Do not extend this to log searchParams without a redaction
9
+ // allowlist.
6
10
  export function requestLogger(): MiddlewareFn {
7
11
  return async (ctx, next) => {
8
12
  const start = Date.now();
@@ -7,17 +7,21 @@ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
7
7
  // FormData uploads (large files) take the multipart branch and bypass this.
8
8
  const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
9
9
 
10
- function hasForbiddenKey(value: unknown): boolean {
11
- if (!value || typeof value !== "object") return false;
10
+ // Deep scan: nested objects can carry __proto__ pollution vectors too.
11
+ function hasForbiddenKey(value: unknown, depth = 0): boolean {
12
+ if (depth > 20 || !value || typeof value !== "object") return false;
12
13
  for (const key of Object.keys(value as Record<string, unknown>)) {
13
14
  if (FORBIDDEN_KEYS.has(key)) return true;
15
+ if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
14
16
  }
15
17
  return false;
16
18
  }
17
19
 
18
20
  export async function handleActionRequest(request: Request): Promise<Response | null> {
19
21
  const url = new URL(request.url);
20
- if (!url.pathname.startsWith("/_action")) return null;
22
+ // SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
23
+ // would otherwise also reach this handler).
24
+ if (url.pathname !== "/_action") return null;
21
25
  if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
22
26
  if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
23
27
 
@@ -54,7 +58,7 @@ export async function handleActionRequest(request: Request): Promise<Response |
54
58
  if (!Array.isArray(parsed)) {
55
59
  return new Response("Bad Request: args must be array", { status: 400 });
56
60
  }
57
- if (parsed.some(hasForbiddenKey)) {
61
+ if (parsed.some((v) => hasForbiddenKey(v))) {
58
62
  return new Response("Bad Request: forbidden keys", { status: 400 });
59
63
  }
60
64
  args = parsed;