@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
@@ -1,4 +1,5 @@
1
1
  import { useState } from "react";
2
+ import { toSamePath } from "../nav-utils.ts";
2
3
 
3
4
  // ── Types ──────────────────────────────────────────────────────────────────
4
5
 
@@ -100,7 +101,22 @@ export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | Stre
100
101
  submitOpts.body instanceof FormData
101
102
  ? submitOpts.body
102
103
  : new URLSearchParams(submitOpts.body as Record<string, string>);
103
- const res = await fetch(path, { method: submitOpts.method, body });
104
+ // Send the custom header so the server's CSRF gate accepts this
105
+ // same-origin mutation (browsers block it cross-origin without a CORS
106
+ // preflight). Without it every fetcher submit 403s.
107
+ const res = await fetch(path, {
108
+ method: submitOpts.method,
109
+ body,
110
+ headers: { "X-BractJS-Action": "1" },
111
+ });
112
+ // If the action redirected, do a real navigation rather than parsing the
113
+ // redirect target as JSON. Off-origin targets get a full-page nav so we
114
+ // never follow an attacker-controlled Location inside the SPA.
115
+ if (res.redirected) {
116
+ const to = toSamePath(res.url);
117
+ window.location.assign(to ?? res.url);
118
+ return;
119
+ }
104
120
  setData(await res.json());
105
121
  } finally {
106
122
  setState("idle");
@@ -0,0 +1,46 @@
1
+ import { useContext, useCallback } from "react";
2
+ import { NavigationContext } from "../router.tsx";
3
+ import { buildPath } from "../build-path.ts";
4
+ import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
5
+
6
+ // ── Types ──────────────────────────────────────────────────────────────────
7
+
8
+ export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> {
9
+ /** Path params for a dynamic `to` (e.g. `{ params: { id } }` for `/blog/:id`). */
10
+ params?: ParamsFor<TTo>;
11
+ }
12
+
13
+ export interface NavigateFn {
14
+ <TTo extends RegisteredRoutes>(
15
+ to: TTo | (string & {}),
16
+ options?: NavigateOptions<TTo>,
17
+ ): Promise<void>;
18
+ }
19
+
20
+ // ── Hook ───────────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Returns a typed `navigate(to, { params })` for programmatic soft navigation —
24
+ * the imperative counterpart to `<Link>`. Mirrors `<Link>`'s `to`/`params` API:
25
+ * `to` autocompletes registered routes (after `bractjs codegen`) while still
26
+ * accepting any string, and `params` is typed per route.
27
+ *
28
+ * SSR-safe and safe outside a `ClientRouter`: with no NavigationContext it
29
+ * resolves to a no-op (same guard as `<Link>`), so it never throws during render.
30
+ *
31
+ * Note: navigation always pushes a history entry today; a `replace` option will
32
+ * follow once the underlying navigate contract supports it.
33
+ */
34
+ export function useNavigate(): NavigateFn {
35
+ const navCtx = useContext(NavigationContext);
36
+ return useCallback<NavigateFn>(
37
+ (to, options) => {
38
+ const href = options?.params
39
+ ? buildPath(to as string, options.params as Record<string, string>)
40
+ : (to as string);
41
+ if (!navCtx) return Promise.resolve();
42
+ return navCtx.navigate(href);
43
+ },
44
+ [navCtx],
45
+ );
46
+ }
@@ -1,14 +1,25 @@
1
1
  import { useContext } from "react";
2
2
  import { RouterContext } from "../router.tsx";
3
3
  import { BractJSContext } from "../../shared/context.ts";
4
+ import type { ParamsFor } from "../registry.ts";
4
5
 
5
6
  /**
6
- * Returns the current route's URL params (e.g. { id: "42" }).
7
- * Pass a RouteParams<T> generic for typed params: useParams<RouteParams<"/blog/:id">>()
7
+ * Returns the current route's URL params (e.g. `{ id: "42" }`).
8
+ *
9
+ * Pass the route pattern as a generic to type the result against your codegen'd
10
+ * routes: `useParams<"/blog/:id">()` → `{ id: string }`. The pattern is supplied
11
+ * by the caller because the framework can't infer the active route at the type
12
+ * level (React Router's `useParams` has the same limitation). An object generic
13
+ * — `useParams<{ id: string }>()` — also works for hand-typed shapes.
14
+ *
8
15
  * Works in both SSR and client contexts.
9
16
  */
10
- export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
17
+ // Overload 1: a route literal params resolved from the registry.
18
+ export function useParams<TTo extends string>(): ParamsFor<TTo>;
19
+ // Overload 2: an explicit object shape (back-compat with the old generic form).
20
+ export function useParams<T extends Record<string, string> = Record<string, string>>(): T;
21
+ export function useParams(): Record<string, string> {
11
22
  const router = useContext(RouterContext);
12
23
  const bract = useContext(BractJSContext);
13
- return (router?.params ?? bract?.params ?? {}) as T;
24
+ return router?.params ?? bract?.params ?? {};
14
25
  }
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useEffect, useRef, startTransition } from "react";
2
2
  import { NavigationContext } from "../router.tsx";
3
3
  import { useContext } from "react";
4
+ import type { SearchFor } from "../registry.ts";
4
5
 
5
6
  // ── Types ──────────────────────────────────────────────────────────────────
6
7
 
@@ -17,13 +18,22 @@ export interface SearchParamsResult<T extends Record<string, string>> {
17
18
  // ── Hook ───────────────────────────────────────────────────────────────────
18
19
 
19
20
  /**
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.
21
+ * Reads and writes URL search params. Triggers a loader re-run (soft-nav fetch)
22
+ * when params change.
23
+ *
24
+ * Pass the route pattern as a generic to type the result against your codegen'd
25
+ * routes: `useSearchParams<"/posts">()`. Augment `RouteSearchParamsMap` to give a
26
+ * route a concrete shape (defaults to `Record<string, string>`). The pattern is
27
+ * supplied by the caller — the framework can't infer the active route at the type
28
+ * level. An object generic — `useSearchParams<{ page: string }>()` — also works.
22
29
  *
23
- * T is the route's SearchParams shape (e.g. { page: string; sort: string }).
24
30
  * This hook is SSR-safe: on the server window is absent, so it returns empty params.
25
31
  */
26
- export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T> {
32
+ // Overload 1: a route literal search shape resolved from the registry.
33
+ export function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
34
+ // Overload 2: an explicit object shape (back-compat with the old generic form).
35
+ export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
36
+ export function useSearchParams(): SearchParamsResult<Record<string, string>> {
27
37
  const navCtx = useContext(NavigationContext);
28
38
 
29
39
  function readCurrent(): URLSearchParams {
@@ -66,8 +76,8 @@ export function useSearchParams<T extends Record<string, string> = Record<string
66
76
  }
67
77
  }, [navCtx]);
68
78
 
69
- const getParam = useCallback(<K extends keyof T & string>(key: K): T[K] | null => {
70
- return (searchParams.get(key) as T[K] | null);
79
+ const getParam = useCallback((key: string): string | null => {
80
+ return searchParams.get(key);
71
81
  }, [searchParams]);
72
82
 
73
83
  return { searchParams, getParam, setSearchParams };
@@ -1,5 +1,27 @@
1
1
  import type { ServerManifest } from "../server/render.ts";
2
2
 
3
+ // ── Redirect normalization ─────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Normalize a Location/redirect target to a same-origin path the client router
7
+ * can match. Returns an internal "/path?query#hash" for same-origin targets, or
8
+ * `null` for off-origin, protocol-relative, or malformed values — the caller
9
+ * MUST NOT feed a null result to the SPA router (an off-origin Location should
10
+ * trigger a full-page navigation instead, so the browser applies its own
11
+ * cross-origin protections). This is the client-side complement to the server's
12
+ * `sanitizeRedirect()`: it stops a soft-nav from silently following an
13
+ * attacker-controlled `Location` header.
14
+ */
15
+ export function toSamePath(loc: string): string | null {
16
+ try {
17
+ const u = new URL(loc, window.location.href);
18
+ if (u.origin !== window.location.origin) return null;
19
+ return u.pathname + u.search + u.hash;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
3
25
  // ── Pattern Matching ───────────────────────────────────────────────────────
4
26
 
5
27
  /**
@@ -21,15 +43,44 @@ function patternMatches(pathname: string, pattern: string): boolean {
21
43
  return p === pathSegs.length;
22
44
  }
23
45
 
46
+ /**
47
+ * Specificity score for a matching pattern, used to pick the best match the
48
+ * same way the server's trie does: static > dynamic > catch-all. Higher wins.
49
+ * Object key order is not reliable for priority, so we must score, not
50
+ * first-match (otherwise `[...slug]` can shadow `_index` / static routes).
51
+ */
52
+ function patternScore(pattern: string): number {
53
+ if (pattern === "") return 1_000_000; // index route — most specific for "/"
54
+ let score = 0;
55
+ for (const seg of pattern.split("/")) {
56
+ score *= 10;
57
+ if (seg.startsWith("[...") && seg.endsWith("]")) score += 1; // catch-all
58
+ else if (seg.startsWith("[") && seg.endsWith("]")) score += 2; // dynamic
59
+ else score += 3; // static
60
+ }
61
+ return score;
62
+ }
63
+
24
64
  // ── Export ─────────────────────────────────────────────────────────────────
25
65
 
26
- /** Returns the manifest pattern key that matches pathname, or null. */
66
+ /** Returns the highest-priority manifest pattern that matches pathname, or null. */
27
67
  export function matchPatternForPath(
28
68
  pathname: string,
29
69
  manifest: ServerManifest,
30
70
  ): string | null {
71
+ // Exact static match wins outright (most specific) — also a fast path.
72
+ const normalized = pathname.replace(/^\//, "");
73
+ if (normalized in manifest.routes) return normalized;
74
+
75
+ let best: string | null = null;
76
+ let bestScore = -1;
31
77
  for (const pattern of Object.keys(manifest.routes)) {
32
- if (patternMatches(pathname, pattern)) return pattern;
78
+ if (!patternMatches(pathname, pattern)) continue;
79
+ const score = patternScore(pattern);
80
+ if (score > bestScore) {
81
+ best = pattern;
82
+ bestScore = score;
83
+ }
33
84
  }
34
- return null;
85
+ return best;
35
86
  }
@@ -0,0 +1,107 @@
1
+ // Type-registration seam for end-to-end typed routing (TanStack-Router style).
2
+ //
3
+ // This file is RUNTIME-FREE — it contributes only types. The runtime helpers
4
+ // (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`) resolve their route
5
+ // types from `Register` declared here.
6
+ //
7
+ // HOW IT WORKS
8
+ // ------------
9
+ // `Register` is an empty, augmentable interface. Running `bractjs codegen`
10
+ // writes `app/route-types.gen.ts`, which augments it:
11
+ //
12
+ // declare module "@bractjs/bractjs" {
13
+ // interface Register {
14
+ // routes: {
15
+ // routes: "/" | "/blog/:id";
16
+ // params: { "/blog/:id": { id: string } };
17
+ // search: RouteSearchParamsMap;
18
+ // };
19
+ // }
20
+ // }
21
+ //
22
+ // Once augmented, `<Link to="...">` autocompletes the app's routes and type-
23
+ // checks `params`; `useNavigate`, `useParams`, `useSearchParams` follow suit.
24
+ //
25
+ // GRACEFUL FALLBACK
26
+ // -----------------
27
+ // Un-augmented (no codegen, or codegen not yet run), `Register` is `{}`, so the
28
+ // `Register extends { routes: ... } ? ... : <loose>` conditionals below resolve
29
+ // to the loose `string` / `Record<string, string>` types BractJS used before.
30
+ // Existing apps therefore keep compiling unchanged — the typed surface only
31
+ // activates after the generated augmentation lands.
32
+ //
33
+ // NOTE: this seam is mirrored verbatim in `types/index.d.ts` (the published
34
+ // declaration surface). Keep the two in sync — a divergence silently disables
35
+ // typed routing for either monorepo or published consumers.
36
+
37
+ // ── The augmentable seam ─────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Augmentable registration interface. Empty by default; `route-types.gen.ts`
41
+ * augments it with this app's `RouteRegistry`. See file header.
42
+ */
43
+ export interface Register {}
44
+
45
+ /** The shape the generated file plugs into `Register["routes"]`. */
46
+ export interface RouteRegistry {
47
+ /** Union of all route patterns, colon-style — e.g. `"/" | "/blog/:id"`. */
48
+ routes: string;
49
+ /** Map of pattern → params object — e.g. `{ "/blog/:id": { id: string } }`. */
50
+ params: Record<string, Record<string, string>>;
51
+ /** Map of pattern → search-params object. */
52
+ search: Record<string, Record<string, string>>;
53
+ }
54
+
55
+ // ── Package-level customization maps (stable augmentation targets) ───────────
56
+ //
57
+ // Users type search params / context per route by augmenting THESE interfaces
58
+ // on the package, e.g.:
59
+ //
60
+ // declare module "@bractjs/bractjs" {
61
+ // interface RouteSearchParamsMap { "/posts": { page: string } }
62
+ // }
63
+ //
64
+ // The generated file seeds every route with a permissive default; user
65
+ // augmentations merge on top.
66
+
67
+ /** Per-route search-params shapes. Augment to type a route's search params. */
68
+ export interface RouteSearchParamsMap {}
69
+
70
+ /** Per-route context shapes. Augment to type a route's `context`. */
71
+ export interface RouteContextMap {}
72
+
73
+ // ── Resolution helpers (drive the runtime hooks/components) ──────────────────
74
+
75
+ // NOTE: these conditionals deliberately do NOT use `infer R extends RouteRegistry`.
76
+ // A constrained `infer` here silently fails to match the generated registry (its
77
+ // `search` member is an empty interface, which trips the constraint check) and
78
+ // falls back to the loose branch — defeating the whole feature. We instead infer
79
+ // each member's shape directly. `RouteRegistry` remains the documented contract
80
+ // the generated file targets.
81
+
82
+ /**
83
+ * The app's route union when registered, else `string`. The fallback is what
84
+ * keeps un-codegen'd apps compiling: every `to` still accepts any string.
85
+ */
86
+ export type RegisteredRoutes =
87
+ Register extends { routes: { routes: infer R } } ? R : string;
88
+
89
+ /** Pattern → params map when registered, else a permissive map. */
90
+ export type RegisteredParamsMap =
91
+ Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
92
+
93
+ /** Pattern → search map when registered, else a permissive map. */
94
+ export type RegisteredSearchMap =
95
+ Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
96
+
97
+ /** Params object for a specific route literal (`{}` for static routes). */
98
+ export type ParamsFor<TTo> =
99
+ TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
100
+
101
+ /** Search-params object for a specific route literal. */
102
+ export type SearchFor<TTo> =
103
+ TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
104
+
105
+ /** Whether a route literal carries any path params. Reserved for a future strict `<Link>` mode. */
106
+ export type HasParams<TTo> =
107
+ keyof ParamsFor<TTo> extends never ? false : true;
@@ -1,4 +1,5 @@
1
1
  import type { ServerManifest } from "../server/render.ts";
2
+ import type { MetaDescriptor } from "../shared/route-types.ts";
2
3
 
3
4
  // ── BractJSClientData ────────────────────────────────────────────────────
4
5
 
@@ -10,6 +11,8 @@ export interface BractJSClientData {
10
11
  manifest: ServerManifest;
11
12
  /** Path of the matched route file, used to pre-import the module before hydration. */
12
13
  routeFile?: string;
14
+ /** Merged meta descriptors for the current route — keeps <head> in sync. */
15
+ meta?: MetaDescriptor[];
13
16
  }
14
17
 
15
18
  // ── Window augmentation ────────────────────────────────────────────────────
@@ -78,29 +78,32 @@ const HEADER =
78
78
  "\n" +
79
79
  "/* eslint-disable */\n";
80
80
 
81
+ // `RouteSearchParamsMap` / `RouteContextMap` are owned by the package
82
+ // (`@bractjs/bractjs`) so users have a single stable module to augment. We do
83
+ // NOT seed per-route keys here: seeding `"/posts": Record<string,string>` would
84
+ // conflict with a user augmentation declaring `"/posts": { page: string }`
85
+ // (duplicate-property error). Instead `SearchParams<T>` / `Context<T>` fall back
86
+ // to the permissive default for any route the user hasn't augmented.
81
87
  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
88
  if (routes.length === 0) {
86
89
  return "export type SearchParams<_T extends AppRoutes> = Record<string, string>;";
87
90
  }
88
91
  const branches = routes
89
92
  .map((r) => {
90
93
  assertSafePattern(r.pattern);
91
- return " T extends " + JSON.stringify(r.pattern) + " ? RouteSearchParamsMap[" + JSON.stringify(r.pattern) + "] :";
94
+ const key = JSON.stringify(r.pattern);
95
+ // Use `extends Record<K, infer V>` rather than `keyof` + index access:
96
+ // RouteSearchParamsMap may be `{}` (no user augmentation), and indexing an
97
+ // empty interface — even inside a `K extends keyof M` guard — trips
98
+ // TS2538 "cannot be used as an index type". The Record-infer form resolves
99
+ // V only when the route is augmented, and falls back otherwise.
100
+ return " T extends " + key +
101
+ " ? (RouteSearchParamsMap extends Record<" + key + ", infer V> ? V : Record<string, string>) :";
92
102
  })
93
103
  .join("\n");
94
- const mapEntries = routes
95
- .map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, string>;")
96
- .join("\n");
97
104
  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
- "",
105
+ "// Augment RouteSearchParamsMap (on the package) to type a route's search params:",
106
+ "// declare module \"@bractjs/bractjs\" { interface RouteSearchParamsMap { \"/blog\": { page: string } } }",
104
107
  "export type SearchParams<T extends AppRoutes> =",
105
108
  branches,
106
109
  " Record<string, string>;",
@@ -114,25 +117,51 @@ function contextTypeLines(routes: Array<{ pattern: string }>): string {
114
117
  const branches = routes
115
118
  .map((r) => {
116
119
  assertSafePattern(r.pattern);
117
- return " T extends " + JSON.stringify(r.pattern) + " ? RouteContextMap[" + JSON.stringify(r.pattern) + "] :";
120
+ const key = JSON.stringify(r.pattern);
121
+ // See SearchParams above for why this uses `extends Record<K, infer V>`.
122
+ return " T extends " + key +
123
+ " ? (RouteContextMap extends Record<" + key + ", infer V> ? V : Record<string, unknown>) :";
118
124
  })
119
125
  .join("\n");
120
- const mapEntries = routes
121
- .map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, unknown>;")
122
- .join("\n");
123
126
  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
- "",
127
+ "// Augment RouteContextMap (on the package) to type a route's context:",
128
+ "// declare module \"@bractjs/bractjs\" { interface RouteContextMap { \"/blog\": { user: User } } }",
130
129
  "export type Context<T extends AppRoutes> =",
131
130
  branches,
132
131
  " Record<string, unknown>;",
133
132
  ].join("\n");
134
133
  }
135
134
 
135
+ // The `Register` augmentation: this is what wires the app's routes into the
136
+ // package's runtime helpers (<Link>, useNavigate, useParams, useSearchParams).
137
+ function registerAugmentationLines(routes: Array<{ pattern: string; params: string[] }>): string {
138
+ const paramEntries = routes
139
+ .map((r) => {
140
+ assertSafePattern(r.pattern);
141
+ r.params.forEach(assertSafeParam);
142
+ const shape = r.params.length === 0
143
+ ? "{}"
144
+ : "{ " + r.params.map((p) => p + ": string").join("; ") + " }";
145
+ return " " + JSON.stringify(r.pattern) + ": " + shape + ";";
146
+ })
147
+ .join("\n");
148
+ return [
149
+ "// Registers this app's routes with BractJS. After this augmentation, <Link>,",
150
+ "// useNavigate, useParams, and useSearchParams are type-safe against AppRoutes.",
151
+ "declare module \"@bractjs/bractjs\" {",
152
+ " interface Register {",
153
+ " routes: {",
154
+ " routes: AppRoutes;",
155
+ " params: {",
156
+ paramEntries,
157
+ " };",
158
+ " search: RouteSearchParamsMap;",
159
+ " };",
160
+ " }",
161
+ "}",
162
+ ].join("\n");
163
+ }
164
+
136
165
  export async function generateRouteTypes(appDir: string): Promise<string> {
137
166
  const routeFiles = await scanRoutes(appDir);
138
167
  const routes = routeFiles.map((r) => ({
@@ -149,8 +178,15 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
149
178
 
150
179
  const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
151
180
 
181
+ // `RouteSearchParamsMap` / `RouteContextMap` are imported from the package so
182
+ // the local `SearchParams<T>` / `Context<T>` reference the same interfaces the
183
+ // user augments via `declare module "@bractjs/bractjs"`.
184
+ const IMPORTS = 'import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs";';
185
+
152
186
  return [
153
187
  HEADER,
188
+ IMPORTS,
189
+ "",
154
190
  "export type AppRoutes =",
155
191
  union + ";",
156
192
  "",
@@ -175,6 +211,9 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
175
211
  builderEntries,
176
212
  "} as const;",
177
213
  "",
214
+ // No routes → AppRoutes is `never`; nothing to register.
215
+ routes.length > 0 ? registerAugmentationLines(routes) : "",
216
+ "",
178
217
  ].join("\n");
179
218
  }
180
219
 
@@ -1,6 +1,54 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { BractJSConfig } from "../server/serve.ts";
3
3
 
4
+ /**
5
+ * Shallow shape check for a user-supplied config object. We don't validate
6
+ * exhaustively (plugins/adapters/hooks are opaque), but we catch the common
7
+ * mistakes early — a string `port`, a non-array `clientEnv`, etc. — so the
8
+ * failure surfaces here with a clear message instead of deep inside the build
9
+ * or the request path.
10
+ */
11
+ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
12
+ if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) {
13
+ throw new Error(
14
+ `bractjs.config: default export must be a config object, got ${
15
+ Array.isArray(cfg) ? "array" : typeof cfg
16
+ }`,
17
+ );
18
+ }
19
+ const c = cfg as Record<string, unknown>;
20
+
21
+ const check = (key: string, ok: boolean, expected: string): void => {
22
+ if (key in c && c[key] !== undefined && !ok) {
23
+ throw new Error(`bractjs.config: "${key}" must be ${expected}`);
24
+ }
25
+ };
26
+
27
+ check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
28
+ check("appDir", typeof c.appDir === "string", "a string");
29
+ check("publicDir", typeof c.publicDir === "string", "a string");
30
+ check("buildDir", typeof c.buildDir === "string", "a string");
31
+ check("imageCacheDir", typeof c.imageCacheDir === "string", "a string");
32
+ check("minify", typeof c.minify === "boolean", "a boolean");
33
+ check(
34
+ "sourcemap",
35
+ typeof c.sourcemap === "string" &&
36
+ ["none", "linked", "inline", "external"].includes(c.sourcemap as string),
37
+ 'one of "none" | "linked" | "inline" | "external"',
38
+ );
39
+ check(
40
+ "clientEnv",
41
+ Array.isArray(c.clientEnv) && c.clientEnv.every((k) => typeof k === "string"),
42
+ "an array of strings",
43
+ );
44
+ check("plugins", Array.isArray(c.plugins), "an array of Bun plugins");
45
+ check("onStart", typeof c.onStart === "function", "a function");
46
+ check("onShutdown", typeof c.onShutdown === "function", "a function");
47
+ check("onError", typeof c.onError === "function", "a function");
48
+
49
+ return c as Partial<BractJSConfig>;
50
+ }
51
+
4
52
  /**
5
53
  * Load `bractjs.config.ts` (or `.js`) from the user's cwd if present.
6
54
  * Returns an empty object when no file exists — callers fall back to defaults.
@@ -10,8 +58,8 @@ export async function loadUserConfig(): Promise<Partial<BractJSConfig>> {
10
58
  const path = resolve(process.cwd(), name);
11
59
  if (!(await Bun.file(path).exists())) continue;
12
60
  const mod = await import(path);
13
- const cfg = (mod.default ?? mod) as Partial<BractJSConfig>;
14
- return cfg ?? {};
61
+ const cfg = mod.default ?? mod;
62
+ return validateUserConfig(cfg ?? {});
15
63
  }
16
64
  return {};
17
65
  }