@bractjs/bractjs 0.1.28 → 0.1.29
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 +3 -2
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +34 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/route-lint.test.ts +5 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/route-lint.ts +3 -3
- package/src/client/ClientRouter.tsx +118 -18
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/router.tsx +7 -1
- package/src/client/rpc.ts +11 -1
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +8 -3
- package/src/config/load.ts +1 -0
- package/src/index.ts +11 -3
- package/src/server/action-handler.ts +1 -20
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +9 -3
- package/src/server/csrf.ts +10 -3
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +12 -19
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +34 -16
- package/src/server/request-handler.ts +67 -27
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +5 -1
- package/src/server/serve.ts +28 -3
- package/src/server/session.ts +12 -1
- package/src/server/validate.ts +4 -1
- package/src/shared/context.ts +3 -1
- package/src/shared/route-types.ts +108 -0
- package/types/config.d.ts +3 -0
- package/types/index.d.ts +17 -0
- package/types/route.d.ts +76 -1
- package/LICENSE +0 -21
- package/README.md +0 -1331
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { buildMatches } from "../server/matches.ts";
|
|
3
|
+
import type { LayoutChain } from "../server/layout.ts";
|
|
4
|
+
import type { LoaderResults } from "../server/loader.ts";
|
|
5
|
+
|
|
6
|
+
describe("buildMatches", () => {
|
|
7
|
+
test("returns root → layouts → route in order with ids and data", () => {
|
|
8
|
+
const chain: LayoutChain = {
|
|
9
|
+
root: { handle: { breadcrumb: "Home" } },
|
|
10
|
+
layouts: [{ handle: { breadcrumb: "Blog" } }],
|
|
11
|
+
route: { handle: { breadcrumb: "Post" } },
|
|
12
|
+
files: { root: "root.tsx", layouts: ["routes/blog/layout.tsx"], route: "routes/blog/[id].tsx" },
|
|
13
|
+
};
|
|
14
|
+
const data: LoaderResults = { root: { user: "a" }, layouts: [{ posts: 2 }], route: { id: "7" } };
|
|
15
|
+
|
|
16
|
+
const matches = buildMatches(chain, data, { id: "7" }, "/blog/7");
|
|
17
|
+
|
|
18
|
+
expect(matches.map((m) => m.id)).toEqual([
|
|
19
|
+
"root.tsx",
|
|
20
|
+
"routes/blog/layout.tsx",
|
|
21
|
+
"routes/blog/[id].tsx",
|
|
22
|
+
]);
|
|
23
|
+
expect(matches.map((m) => m.handle?.breadcrumb)).toEqual(["Home", "Blog", "Post"]);
|
|
24
|
+
expect(matches[2].data).toEqual({ id: "7" });
|
|
25
|
+
expect(matches[1].data).toEqual({ posts: 2 });
|
|
26
|
+
// params + pathname shared across the chain.
|
|
27
|
+
expect(matches.every((m) => m.pathname === "/blog/7")).toBe(true);
|
|
28
|
+
expect(matches.every((m) => m.params.id === "7")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("handle is undefined when a module does not export it", () => {
|
|
32
|
+
const chain: LayoutChain = {
|
|
33
|
+
root: {},
|
|
34
|
+
layouts: [],
|
|
35
|
+
route: { handle: { title: "x" } },
|
|
36
|
+
files: { root: "root.tsx", layouts: [], route: "routes/_index.tsx" },
|
|
37
|
+
};
|
|
38
|
+
const data: LoaderResults = { root: null, layouts: [], route: null };
|
|
39
|
+
const matches = buildMatches(chain, data, {}, "/");
|
|
40
|
+
expect(matches[0].handle).toBeUndefined();
|
|
41
|
+
expect(matches[1].handle).toEqual({ title: "x" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("falls back to synthetic ids when files metadata is absent", () => {
|
|
45
|
+
const chain: LayoutChain = {
|
|
46
|
+
root: {},
|
|
47
|
+
layouts: [{}],
|
|
48
|
+
route: {},
|
|
49
|
+
};
|
|
50
|
+
const data: LoaderResults = { root: null, layouts: [null], route: null };
|
|
51
|
+
const matches = buildMatches(chain, data, {}, "/x");
|
|
52
|
+
expect(matches.map((m) => m.id)).toEqual(["root", "layout:0", "route"]);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/build/route-lint.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { extractExports } from "./directives.ts";
|
|
|
4
4
|
// (wrong case) of one of these is almost always a mistake — the framework's
|
|
5
5
|
// projection is case-sensitive, so `Loader` is silently ignored.
|
|
6
6
|
export const ROUTE_EXPORT_NAMES = [
|
|
7
|
-
"default", "loader", "action", "
|
|
8
|
-
"
|
|
9
|
-
"loaderDeps", "context",
|
|
7
|
+
"default", "loader", "action", "clientLoader", "clientAction", "meta", "headers",
|
|
8
|
+
"middleware", "beforeLoad", "shouldRevalidate", "searchSchema", "ssr", "Fallback",
|
|
9
|
+
"handle", "ErrorBoundary", "config", "loaderDeps", "context",
|
|
10
10
|
] as const;
|
|
11
11
|
|
|
12
12
|
const CANONICAL_LOWER = new Map(ROUTE_EXPORT_NAMES.map((n) => [n.toLowerCase(), n]));
|
|
@@ -16,7 +16,7 @@ import { matchPatternForPath, toSamePath, parseTo, createLocationKey } from "./n
|
|
|
16
16
|
import { loaderCache, cacheKey } from "./cache.ts";
|
|
17
17
|
import { registerRevalidator, type RevalidationInfo } from "./revalidation.ts";
|
|
18
18
|
import { MetaTags } from "../shared/meta-tags.tsx";
|
|
19
|
-
import type { MetaDescriptor, RouterLocation, ShouldRevalidateFunction } from "../shared/route-types.ts";
|
|
19
|
+
import type { MetaDescriptor, RouterLocation, RouteMatch, ShouldRevalidateFunction } from "../shared/route-types.ts";
|
|
20
20
|
|
|
21
21
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -47,6 +47,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
47
47
|
const [params, setParams] = useState(initialData.params);
|
|
48
48
|
const [location, setLocation] = useState<RouterLocation>(initialData.location);
|
|
49
49
|
const [search, setSearch] = useState<Record<string, unknown>>(initialData.search ?? {});
|
|
50
|
+
const [matches, setMatches] = useState<RouteMatch[]>(initialData.matches ?? []);
|
|
50
51
|
const [navState, setNavState] = useState<NavigationState>("idle");
|
|
51
52
|
const [revalidationState, setRevalidationState] = useState<"idle" | "loading">("idle");
|
|
52
53
|
const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
|
|
@@ -61,6 +62,8 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
61
62
|
// Refs mirroring state that the stable revalidate/submit callbacks need.
|
|
62
63
|
const locationRef = useRef(location);
|
|
63
64
|
useEffect(() => { locationRef.current = location; }, [location]);
|
|
65
|
+
const paramsRef = useRef(params);
|
|
66
|
+
useEffect(() => { paramsRef.current = params; }, [params]);
|
|
64
67
|
const currentModuleRef = useRef(currentModule);
|
|
65
68
|
useEffect(() => { currentModuleRef.current = currentModule; }, [currentModule]);
|
|
66
69
|
|
|
@@ -69,6 +72,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
69
72
|
if (state.actionData !== undefined) setActionData(state.actionData);
|
|
70
73
|
if (state.params !== undefined) setParams(state.params);
|
|
71
74
|
if (state.search !== undefined) setSearch(state.search);
|
|
75
|
+
if (state.matches !== undefined) setMatches(state.matches);
|
|
72
76
|
if (state.location !== undefined) setLocation(state.location);
|
|
73
77
|
else if (state.pathname !== undefined) {
|
|
74
78
|
// Legacy callers pass a (possibly query-carrying) pathname string.
|
|
@@ -148,6 +152,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
148
152
|
// React 19 hoists the <title>/<meta> elements rendered by <MetaTags>
|
|
149
153
|
// into <head>, so description/OG tags update on soft navigation.
|
|
150
154
|
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
155
|
+
setMatches((data.matches as RouteMatch[] | undefined) ?? []);
|
|
151
156
|
});
|
|
152
157
|
};
|
|
153
158
|
|
|
@@ -199,6 +204,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
199
204
|
setParams(((fresh as Record<string, unknown>).params as Record<string, string>) ?? {});
|
|
200
205
|
setSearch(((fresh as Record<string, unknown>).search as Record<string, unknown>) ?? {});
|
|
201
206
|
setMeta(((fresh as Record<string, unknown>).meta as MetaDescriptor[] | undefined) ?? []);
|
|
207
|
+
setMatches(((fresh as Record<string, unknown>).matches as RouteMatch[] | undefined) ?? []);
|
|
202
208
|
});
|
|
203
209
|
});
|
|
204
210
|
return;
|
|
@@ -215,6 +221,27 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
215
221
|
return;
|
|
216
222
|
}
|
|
217
223
|
const data = await res.json() as Record<string, unknown>;
|
|
224
|
+
|
|
225
|
+
// clientLoader (RR7-style): when the route exports one, it runs in the
|
|
226
|
+
// browser and its result replaces the route's loader slice. It receives a
|
|
227
|
+
// `serverLoader()` that resolves to the freshly-fetched server data, so a
|
|
228
|
+
// clientLoader can wrap/augment/cache it. Other slices (root/layouts) and
|
|
229
|
+
// the meta/matches payload are untouched.
|
|
230
|
+
const clientLoader = (routeModule as Record<string, unknown> | null)
|
|
231
|
+
?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
|
|
232
|
+
if (typeof clientLoader === "function") {
|
|
233
|
+
try {
|
|
234
|
+
data.route = await clientLoader({
|
|
235
|
+
request: new Request(new URL(dataPath, window.location.origin)),
|
|
236
|
+
params: (data.params as Record<string, string>) ?? {},
|
|
237
|
+
search: (data.search as Record<string, unknown>) ?? {},
|
|
238
|
+
serverLoader: () => Promise.resolve(data.route),
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error("[bractjs] clientLoader error:", err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
218
245
|
if (staleTime > 0) loaderCache.set(key, data, staleTime, gcTime);
|
|
219
246
|
|
|
220
247
|
// Update DevTools state (dev-only — no-op in prod since the import fails).
|
|
@@ -290,6 +317,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
290
317
|
setParams((data.params as Record<string, string>) ?? {});
|
|
291
318
|
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
292
319
|
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
320
|
+
setMatches((data.matches as RouteMatch[] | undefined) ?? []);
|
|
293
321
|
});
|
|
294
322
|
} catch (err) {
|
|
295
323
|
console.error("[bractjs] revalidate error:", err);
|
|
@@ -304,6 +332,39 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
304
332
|
return () => registerRevalidator(null);
|
|
305
333
|
}, [revalidate]);
|
|
306
334
|
|
|
335
|
+
// clientLoader.hydrate: for a fully-SSR'd route whose clientLoader opted into
|
|
336
|
+
// hydration, run it once after mount and replace the route's loader slice.
|
|
337
|
+
// Routes that didn't SSR (hydrationPending truthy) take the fetch path below,
|
|
338
|
+
// where clientLoader already applies via loadRoute on navigation; this effect
|
|
339
|
+
// is only for the first paint of an SSR document.
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (hydrationPending) return;
|
|
342
|
+
const cl = (initialModule as Record<string, unknown> | null)
|
|
343
|
+
?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
|
|
344
|
+
if (typeof cl !== "function" || cl.hydrate !== true) return;
|
|
345
|
+
let cancelled = false;
|
|
346
|
+
void (async () => {
|
|
347
|
+
const path = window.location.pathname + window.location.search;
|
|
348
|
+
const serverSlice = (initialData.loaderData as Record<string, unknown>)?.route;
|
|
349
|
+
try {
|
|
350
|
+
const next = await cl({
|
|
351
|
+
request: new Request(new URL(path, window.location.origin)),
|
|
352
|
+
params: initialData.params,
|
|
353
|
+
search: initialData.search ?? {},
|
|
354
|
+
serverLoader: async () => serverSlice,
|
|
355
|
+
});
|
|
356
|
+
if (cancelled) return;
|
|
357
|
+
startTransition(() => {
|
|
358
|
+
setLoaderData((prev) => ({ ...prev, route: next }));
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error("[bractjs] clientLoader (hydrate) error:", err);
|
|
362
|
+
}
|
|
363
|
+
})();
|
|
364
|
+
return () => { cancelled = true; };
|
|
365
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
307
368
|
// Selective-SSR / SPA hydration completion. The first client render matched
|
|
308
369
|
// the server (Fallback or empty shell); after mount, put loader data in
|
|
309
370
|
// place and swap in the real component via a transition.
|
|
@@ -334,6 +395,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
334
395
|
setParams((data.params as Record<string, string>) ?? {});
|
|
335
396
|
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
336
397
|
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
398
|
+
setMatches((data.matches as RouteMatch[] | undefined) ?? []);
|
|
337
399
|
setHydrationPending(false);
|
|
338
400
|
});
|
|
339
401
|
return;
|
|
@@ -402,34 +464,72 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
402
464
|
const body = opts.body instanceof FormData
|
|
403
465
|
? opts.body
|
|
404
466
|
: new URLSearchParams(opts.body);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
467
|
+
|
|
468
|
+
// The server submit — also the `serverAction()` a clientAction can call.
|
|
469
|
+
// A redirected response short-circuits to a real navigation (via
|
|
470
|
+
// toSamePath so an attacker Location can never soft-nav the SPA); it
|
|
471
|
+
// returns a sentinel so the caller stops.
|
|
472
|
+
const REDIRECTED = Symbol("redirected");
|
|
473
|
+
let lastStatus = 0;
|
|
474
|
+
const doServerPost = async (): Promise<unknown> => {
|
|
475
|
+
const res = await fetch(to, {
|
|
476
|
+
method: opts.method.toUpperCase(),
|
|
477
|
+
body,
|
|
478
|
+
headers: { "X-BractJS-Action": "1" },
|
|
479
|
+
});
|
|
480
|
+
lastStatus = res.status;
|
|
481
|
+
if (res.redirected) {
|
|
482
|
+
const safe = toSamePath(res.url);
|
|
483
|
+
if (safe) { await navigateRef.current(safe); return REDIRECTED; }
|
|
484
|
+
window.location.assign(res.url);
|
|
485
|
+
return REDIRECTED;
|
|
416
486
|
}
|
|
417
|
-
|
|
418
|
-
|
|
487
|
+
return res.json();
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// clientAction (RR7-style): if the target route exports one, it runs in
|
|
491
|
+
// the browser and decides whether/how to hit the server via serverAction().
|
|
492
|
+
const [toPath] = to.split("?");
|
|
493
|
+
const pattern = matchPatternForPath(toPath, manifest);
|
|
494
|
+
const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
|
|
495
|
+
let clientAction: import("../shared/route-types.ts").ClientActionFunction | undefined;
|
|
496
|
+
if (chunkUrl) {
|
|
497
|
+
try {
|
|
498
|
+
const mod = await import(/* @vite-ignore */ chunkUrl) as Record<string, unknown>;
|
|
499
|
+
if (typeof mod.clientAction === "function") {
|
|
500
|
+
clientAction = mod.clientAction as import("../shared/route-types.ts").ClientActionFunction;
|
|
501
|
+
}
|
|
502
|
+
} catch { /* fall back to a plain server submit */ }
|
|
419
503
|
}
|
|
420
|
-
|
|
504
|
+
|
|
505
|
+
let data: unknown;
|
|
506
|
+
if (clientAction) {
|
|
507
|
+
let calledServer = false;
|
|
508
|
+
data = await clientAction({
|
|
509
|
+
request: new Request(new URL(to, window.location.origin), { method: opts.method.toUpperCase() }),
|
|
510
|
+
params: paramsRef.current,
|
|
511
|
+
formData: body instanceof FormData ? body : new FormData(),
|
|
512
|
+
serverAction: () => { calledServer = true; return doServerPost(); },
|
|
513
|
+
});
|
|
514
|
+
// If the clientAction triggered a redirect via serverAction(), stop.
|
|
515
|
+
if (calledServer && data === REDIRECTED) return;
|
|
516
|
+
} else {
|
|
517
|
+
data = await doServerPost();
|
|
518
|
+
if (data === REDIRECTED) return;
|
|
519
|
+
}
|
|
520
|
+
|
|
421
521
|
setActionData(data);
|
|
422
522
|
setNavState("loading");
|
|
423
|
-
await revalidate({ formMethod: opts.method, actionStatus:
|
|
523
|
+
await revalidate({ formMethod: opts.method, actionStatus: lastStatus });
|
|
424
524
|
} finally {
|
|
425
525
|
setNavState("idle");
|
|
426
526
|
}
|
|
427
|
-
}, [revalidate]);
|
|
527
|
+
}, [revalidate, manifest]);
|
|
428
528
|
|
|
429
529
|
return (
|
|
430
530
|
<RouterContext.Provider
|
|
431
531
|
value={{
|
|
432
|
-
loaderData, actionData, params, pathname: location.pathname, location, search,
|
|
532
|
+
loaderData, actionData, params, pathname: location.pathname, location, search, matches,
|
|
433
533
|
manifest, currentModule, setRoute, revalidate, revalidationState, hydrationPending,
|
|
434
534
|
}}
|
|
435
535
|
>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { RouterContext } from "../router.tsx";
|
|
3
|
+
import { BractJSContext } from "../../shared/context.ts";
|
|
4
|
+
import type { RouteMatch } from "../../shared/route-types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the matched route chain, outermost → innermost: the root, then each
|
|
8
|
+
* layout, then the leaf route. Each entry exposes `{ id, pathname, params,
|
|
9
|
+
* data, handle }`, where `handle` is that module's static `handle` export.
|
|
10
|
+
*
|
|
11
|
+
* Use it to build breadcrumbs or conditional chrome from `handle` without
|
|
12
|
+
* threading props through every layout. Works in both SSR and client contexts;
|
|
13
|
+
* the chain updates on soft navigation and revalidation.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* // routes/blog/[id].tsx
|
|
17
|
+
* export const handle = { breadcrumb: "Post" };
|
|
18
|
+
*
|
|
19
|
+
* // some layout
|
|
20
|
+
* const crumbs = useMatches()
|
|
21
|
+
* .filter((m) => m.handle?.breadcrumb)
|
|
22
|
+
* .map((m) => m.handle!.breadcrumb as string);
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* `handle` must be JSON-serializable — it travels in the SSR bootstrap and the
|
|
26
|
+
* `/_data` soft-nav payload, the same as loader data.
|
|
27
|
+
*/
|
|
28
|
+
export function useMatches(): RouteMatch[] {
|
|
29
|
+
const router = useContext(RouterContext);
|
|
30
|
+
const bract = useContext(BractJSContext);
|
|
31
|
+
return router?.matches ?? bract?.matches ?? [];
|
|
32
|
+
}
|
package/src/client/router.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, type ComponentType } from "react";
|
|
2
2
|
import type { ServerManifest } from "../server/render.ts";
|
|
3
|
-
import type { RouterLocation } from "../shared/route-types.ts";
|
|
3
|
+
import type { RouterLocation, RouteMatch } from "../shared/route-types.ts";
|
|
4
4
|
|
|
5
5
|
// ── Route module shape visible on the client ───────────────────────────────
|
|
6
6
|
|
|
@@ -9,6 +9,10 @@ export interface RouteModuleClient {
|
|
|
9
9
|
ErrorBoundary?: ComponentType<{ error: Error }>;
|
|
10
10
|
/** SSR'd placeholder for selective-SSR routes (`ssr: false` / `"data-only"`). */
|
|
11
11
|
Fallback?: ComponentType;
|
|
12
|
+
/** Browser-side loader (RR7-style). Runs on navigation instead of just fetching /_data. */
|
|
13
|
+
clientLoader?: import("../shared/route-types.ts").ClientLoaderFunction;
|
|
14
|
+
/** Browser-side action (RR7-style). Runs on submit instead of POSTing directly. */
|
|
15
|
+
clientAction?: import("../shared/route-types.ts").ClientActionFunction;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/**
|
|
@@ -30,6 +34,8 @@ export interface RouteState {
|
|
|
30
34
|
location: RouterLocation;
|
|
31
35
|
/** Validated search params (route `searchSchema` output; raw string record otherwise). */
|
|
32
36
|
search: Record<string, unknown>;
|
|
37
|
+
/** The matched route chain (root → layouts → route) for `useMatches()`. */
|
|
38
|
+
matches: RouteMatch[];
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export interface RouterContextValue extends RouteState {
|
package/src/client/rpc.ts
CHANGED
|
@@ -49,9 +49,19 @@ export function createClient<
|
|
|
49
49
|
const httpMethod = method.toUpperCase();
|
|
50
50
|
const url = baseUrl + path;
|
|
51
51
|
const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
|
|
52
|
+
// Send the custom CSRF marker on every mutating call. The server's
|
|
53
|
+
// /api CSRF gate accepts Sec-Fetch-Site / Origin too, but this
|
|
54
|
+
// header keeps same-origin calls working even behind proxies that
|
|
55
|
+
// strip those — and it can't be set cross-origin without a CORS
|
|
56
|
+
// preflight the framework's CORS never grants. Mirrors the
|
|
57
|
+
// server-action proxy in src/build/directives.ts.
|
|
58
|
+
const isMutating = httpMethod !== "GET";
|
|
59
|
+
const headers: Record<string, string> = {};
|
|
60
|
+
if (hasBody) headers["Content-Type"] = "application/json";
|
|
61
|
+
if (isMutating) headers["X-BractJS-Action"] = "1";
|
|
52
62
|
const res = await fetch(url, {
|
|
53
63
|
method: httpMethod,
|
|
54
|
-
headers:
|
|
64
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
55
65
|
body: hasBody ? JSON.stringify(input) : undefined,
|
|
56
66
|
});
|
|
57
67
|
if (!res.ok) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
-
import { scanRoutes, type RouteFile } from "../server/scanner.ts";
|
|
2
|
+
import { scanRoutes, layoutDirsFromFilePath, type RouteFile } from "../server/scanner.ts";
|
|
3
3
|
|
|
4
4
|
// Codegen entry-points: `bun build --compile` can't statically trace
|
|
5
5
|
// `Bun.Glob` scans or `import(absPath)` calls, so we materialise the route /
|
|
@@ -9,13 +9,14 @@ import { scanRoutes, type RouteFile } from "../server/scanner.ts";
|
|
|
9
9
|
|
|
10
10
|
// ── Path safety ────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
// Allow ASCII filename characters, `/` for nested directories,
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
12
|
+
// Allow ASCII filename characters, `/` for nested directories, `[`/`]` for
|
|
13
|
+
// file-based dynamic route syntax (`[id]`, `[...slug]`, `[[id]]`), and `(`/`)`
|
|
14
|
+
// for route-group folders (`(marketing)`). All emit sites wrap the path in
|
|
15
|
+
// JSON.stringify, but we still allowlist the charset as defense-in-depth
|
|
16
|
+
// against a hostile filename containing a backtick, $, quote, backslash, or
|
|
17
|
+
// whitespace breaking out of the generated literal. `..` as a whole segment is
|
|
18
|
+
// rejected separately below (path-traversal guard).
|
|
19
|
+
const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]()]+$/;
|
|
19
20
|
|
|
20
21
|
function assertSafeFilePath(filePath: string): void {
|
|
21
22
|
if (!SAFE_FILEPATH_RE.test(filePath)) {
|
|
@@ -37,26 +38,17 @@ function pathToIdent(prefix: string, relPath: string): string {
|
|
|
37
38
|
|
|
38
39
|
// ── Layout discovery ───────────────────────────────────────────────────────
|
|
39
40
|
|
|
40
|
-
function layoutDirsForPattern(urlPattern: string): string[] {
|
|
41
|
-
if (urlPattern === "") return [];
|
|
42
|
-
const segments = urlPattern.split("/");
|
|
43
|
-
segments.pop();
|
|
44
|
-
const dirs: string[] = [];
|
|
45
|
-
for (let i = 1; i <= segments.length; i++) {
|
|
46
|
-
dirs.push(segments.slice(0, i).join("/"));
|
|
47
|
-
}
|
|
48
|
-
return dirs;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
41
|
/**
|
|
52
42
|
* Find every `routes/<dir>/layout.tsx` (or `.ts`) that exists on disk for the
|
|
53
43
|
* given set of routes. Mirrors the runtime probe in `resolveLayoutChain` but
|
|
54
|
-
* runs once at codegen time so the generated registry is exhaustive.
|
|
44
|
+
* runs once at codegen time so the generated registry is exhaustive. Layout
|
|
45
|
+
* dirs are derived from each route's FILE path (via `layoutDirsFromFilePath`)
|
|
46
|
+
* so route-group folders are covered identically to the runtime.
|
|
55
47
|
*/
|
|
56
48
|
async function collectLayouts(appDir: string, routes: RouteFile[]): Promise<string[]> {
|
|
57
49
|
const layoutPaths = new Set<string>();
|
|
58
50
|
for (const route of routes) {
|
|
59
|
-
for (const dir of
|
|
51
|
+
for (const dir of layoutDirsFromFilePath(route.filePath)) {
|
|
60
52
|
for (const ext of ["tsx", "ts"]) {
|
|
61
53
|
const rel = `routes/${dir}/layout.${ext}`;
|
|
62
54
|
const abs = resolve(join(appDir, rel));
|
|
@@ -3,11 +3,12 @@ import { scanRoutes } from "../server/scanner.ts";
|
|
|
3
3
|
import type { Segment } from "../server/scanner.ts";
|
|
4
4
|
import { hashString } from "../build/hash.ts";
|
|
5
5
|
|
|
6
|
-
// Convert [param] / [...catchAll] notation to :param colon-style
|
|
6
|
+
// Convert [param] / [[optional]] / [...catchAll] notation to :param colon-style.
|
|
7
7
|
function patternToColon(urlPattern: string): string {
|
|
8
8
|
if (urlPattern === "") return "/";
|
|
9
9
|
return "/" + urlPattern.split("/").map((seg) => {
|
|
10
10
|
if (seg.startsWith("[...") && seg.endsWith("]")) return ":" + seg.slice(4, -1);
|
|
11
|
+
if (seg.startsWith("[[") && seg.endsWith("]]")) return ":" + seg.slice(2, -2);
|
|
11
12
|
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
|
|
12
13
|
return seg;
|
|
13
14
|
}).join("/");
|
|
@@ -16,7 +17,9 @@ function patternToColon(urlPattern: string): string {
|
|
|
16
17
|
function paramsFromSegments(segments: Segment[]): string[] {
|
|
17
18
|
return segments.flatMap((seg) =>
|
|
18
19
|
typeof seg === "string" ? [] :
|
|
19
|
-
"param" in seg ? [seg.param] :
|
|
20
|
+
"param" in seg ? [seg.param] :
|
|
21
|
+
"optional" in seg ? [seg.optional] :
|
|
22
|
+
[seg.catchAll],
|
|
20
23
|
);
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -36,7 +39,9 @@ function substituteParams(pattern: string, params: string[]): string {
|
|
|
36
39
|
const SAFE_PATTERN_RE = /^\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*))*$|^\/$/;
|
|
37
40
|
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
38
41
|
// Same guard the module-registry codegen applies before emitting import paths.
|
|
39
|
-
|
|
42
|
+
// Parens are permitted for route-group folders like `(marketing)`; they are
|
|
43
|
+
// inert inside the double-quoted import string the codegen emits.
|
|
44
|
+
const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]()]+$/;
|
|
40
45
|
|
|
41
46
|
function assertSafePattern(pattern: string): void {
|
|
42
47
|
if (!SAFE_PATTERN_RE.test(pattern)) {
|
package/src/config/load.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
|
|
|
26
26
|
|
|
27
27
|
check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
|
|
28
28
|
check("hmrPort", typeof c.hmrPort === "number" && Number.isFinite(c.hmrPort), "a finite number");
|
|
29
|
+
check("maxRequestBodySize", typeof c.maxRequestBodySize === "number" && Number.isFinite(c.maxRequestBodySize) && c.maxRequestBodySize > 0, "a positive finite number");
|
|
29
30
|
check("appDir", typeof c.appDir === "string", "a string");
|
|
30
31
|
check("publicDir", typeof c.publicDir === "string", "a string");
|
|
31
32
|
check("buildDir", typeof c.buildDir === "string", "a string");
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,10 @@ export { buildFetchHandler } from "./server/serve.ts";
|
|
|
4
4
|
export { defineContext } from "./server/context.ts";
|
|
5
5
|
export type { ContextFactory } from "./server/context.ts";
|
|
6
6
|
export { route } from "./server/api-route.ts";
|
|
7
|
-
export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
|
|
7
|
+
export type { ApiRouteDefinition, ApiRouteOptions, AppApiRoutes } from "./server/api-route.ts";
|
|
8
8
|
export { validate, safeValidate, isValidationResponse, readValidationError } from "./server/validate.ts";
|
|
9
9
|
export type { FieldErrors, ValidationError, SafeValidateResult } from "./server/validate.ts";
|
|
10
|
+
export { hasForbiddenKey, nullProtoFromEntries } from "./server/proto-guard.ts";
|
|
10
11
|
export { formText, formValues } from "./shared/form-data.ts";
|
|
11
12
|
export { defineActions } from "./shared/define-actions.ts";
|
|
12
13
|
export { validateSearch, searchParamsToObject } from "./server/search.ts";
|
|
@@ -71,6 +72,12 @@ export type {
|
|
|
71
72
|
LoaderFunction,
|
|
72
73
|
ActionFunction,
|
|
73
74
|
MetaFunction,
|
|
75
|
+
HeadersFunction,
|
|
76
|
+
HeadersArgs,
|
|
77
|
+
RouteMiddlewareFunction,
|
|
78
|
+
ClientLoaderFunction,
|
|
79
|
+
ClientActionFunction,
|
|
80
|
+
RouteMatch,
|
|
74
81
|
RouteModule,
|
|
75
82
|
RouteDefinition,
|
|
76
83
|
RouterLocation,
|
|
@@ -87,8 +94,8 @@ export { BractJSContext, BractJSProvider, useBractJSContext } from "./shared/con
|
|
|
87
94
|
export type { BractJSContextValue, RouteManifest } from "./shared/context.ts";
|
|
88
95
|
|
|
89
96
|
// Middleware
|
|
90
|
-
export { pipeline, MiddlewarePipeline } from "./server/middleware.ts";
|
|
91
|
-
export type { MiddlewareFn, MiddlewareContext } from "./server/middleware.ts";
|
|
97
|
+
export { pipeline, MiddlewarePipeline, runRouteMiddleware, collectRouteMiddleware } from "./server/middleware.ts";
|
|
98
|
+
export type { MiddlewareFn, MiddlewareContext, RouteMiddleware } from "./server/middleware.ts";
|
|
92
99
|
export { requestLogger } from "./middleware/requestLogger.ts";
|
|
93
100
|
export { cors } from "./middleware/cors.ts";
|
|
94
101
|
export type { CorsOptions } from "./middleware/cors.ts";
|
|
@@ -118,6 +125,7 @@ export { useLoaderData } from "./client/hooks/useLoaderData.ts";
|
|
|
118
125
|
export { useActionData } from "./client/hooks/useActionData.ts";
|
|
119
126
|
export { useLocation } from "./client/hooks/useLocation.ts";
|
|
120
127
|
export { useParams } from "./client/hooks/useParams.ts";
|
|
128
|
+
export { useMatches } from "./client/hooks/useMatches.ts";
|
|
121
129
|
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
122
130
|
export { useNavigate } from "./client/hooks/useNavigate.ts";
|
|
123
131
|
export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
|
|
@@ -1,31 +1,12 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { json } from "./response.ts";
|
|
3
3
|
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
4
|
+
import { hasForbiddenKey } from "./proto-guard.ts";
|
|
4
5
|
|
|
5
|
-
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
6
6
|
// Cap action JSON bodies. Anything over this looks like an abuse attempt;
|
|
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
|
-
// Max nesting we will fully scan for forbidden keys. Legitimate action payloads
|
|
11
|
-
// are shallow; anything deeper is treated as hostile.
|
|
12
|
-
const MAX_SCAN_DEPTH = 200;
|
|
13
|
-
|
|
14
|
-
// Deep scan: nested objects can carry __proto__ pollution vectors too.
|
|
15
|
-
// SECURITY(high): this is a security filter, so it must FAIL CLOSED. A payload
|
|
16
|
-
// nested past MAX_SCAN_DEPTH is rejected (returns true) rather than silently
|
|
17
|
-
// passed — otherwise an attacker could bury `__proto__` below the cap to evade
|
|
18
|
-
// the check and reach a recursive-merge sink in action code.
|
|
19
|
-
function hasForbiddenKey(value: unknown, depth = 0): boolean {
|
|
20
|
-
if (!value || typeof value !== "object") return false;
|
|
21
|
-
if (depth > MAX_SCAN_DEPTH) return true;
|
|
22
|
-
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
23
|
-
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
24
|
-
if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
10
|
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
30
11
|
const url = new URL(request.url);
|
|
31
12
|
// SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
|
package/src/server/adapter.ts
CHANGED
|
@@ -22,9 +22,24 @@ export interface BractAdapter {
|
|
|
22
22
|
* Default adapter — wraps `Bun.serve()`.
|
|
23
23
|
* Created internally by `createServer()` when no adapter is provided.
|
|
24
24
|
*/
|
|
25
|
+
// SECURITY(medium): hard ceiling on request body size at the server boundary,
|
|
26
|
+
// independent of any Content-Length the client advertises. The per-route and
|
|
27
|
+
// /_action handlers apply their own (smaller) caps and double-check the decoded
|
|
28
|
+
// size, but this is the single backstop every code path inherits — it bounds
|
|
29
|
+
// memory even for paths that don't pre-check (e.g. an app's own /api handler
|
|
30
|
+
// that reads request.formData() directly). Sits above the 10 MiB route-form
|
|
31
|
+
// cap so legitimate uploads still pass; raise it via the `maxRequestBodySize`
|
|
32
|
+
// config for apps with a dedicated large-upload endpoint.
|
|
33
|
+
const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; // 16 MiB
|
|
34
|
+
|
|
25
35
|
export class BunAdapter implements BractAdapter {
|
|
26
36
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
27
37
|
private handler: ((request: Request) => Promise<Response>) | null = null;
|
|
38
|
+
private maxRequestBodySize: number;
|
|
39
|
+
|
|
40
|
+
constructor(maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
41
|
+
this.maxRequestBodySize = maxRequestBodySize;
|
|
42
|
+
}
|
|
28
43
|
|
|
29
44
|
setHandler(handler: (request: Request) => Promise<Response>): void {
|
|
30
45
|
this.handler = handler;
|
|
@@ -40,6 +55,7 @@ export class BunAdapter implements BractAdapter {
|
|
|
40
55
|
const handler = this.handler;
|
|
41
56
|
this.server = Bun.serve({
|
|
42
57
|
port,
|
|
58
|
+
maxRequestBodySize: this.maxRequestBodySize,
|
|
43
59
|
fetch: handler,
|
|
44
60
|
error(err: Error) {
|
|
45
61
|
console.error("[bractjs] unhandled server error:", err);
|