@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.
- package/README.md +773 -465
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen.test.ts +36 -0
- package/src/__tests__/compile-safety.test.ts +163 -0
- package/src/__tests__/compile-smoke.test.ts +276 -0
- package/src/__tests__/csp.test.ts +80 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
- package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
- package/src/__tests__/integration.test.ts +62 -0
- package/src/__tests__/layout-registry.test.ts +23 -0
- package/src/__tests__/loader.test.ts +23 -0
- package/src/__tests__/middleware.test.ts +22 -0
- package/src/__tests__/programmatic-api.test.ts +41 -2
- package/src/__tests__/response.test.ts +54 -1
- package/src/__tests__/security.test.ts +35 -0
- package/src/__tests__/server-module-stub.test.ts +145 -0
- package/src/__tests__/stream-handler.test.ts +36 -0
- package/src/__tests__/typed-routing.test.ts +189 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +63 -0
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/build-path.ts +24 -0
- package/src/client/components/Form.tsx +10 -1
- package/src/client/components/Link.tsx +31 -8
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/hooks/useNavigate.ts +46 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useSearchParams.ts +16 -6
- package/src/client/nav-utils.ts +54 -3
- package/src/client/registry.ts +107 -0
- package/src/client/types.ts +3 -0
- package/src/codegen/route-codegen.ts +62 -23
- package/src/config/load.ts +50 -2
- package/src/dev/devtools.ts +72 -39
- package/src/dev/hmr-module-handler.ts +6 -4
- package/src/dev/rebuilder.ts +16 -1
- package/src/dev/server.ts +3 -0
- package/src/index.ts +30 -3
- package/src/server/csp.ts +92 -0
- package/src/server/csrf.ts +44 -6
- package/src/server/layout.ts +12 -2
- package/src/server/loader.ts +5 -7
- package/src/server/render.ts +29 -10
- package/src/server/request-handler.ts +15 -4
- package/src/server/response.ts +58 -5
- package/src/server/serve.ts +10 -0
- package/src/server/static.ts +11 -1
- package/src/server/stream-handler.ts +8 -7
- package/src/server/use-client-runtime.ts +62 -0
- package/src/shared/meta-tags.tsx +46 -0
- 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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
*
|
|
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
|
-
|
|
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(
|
|
70
|
-
return
|
|
79
|
+
const getParam = useCallback((key: string): string | null => {
|
|
80
|
+
return searchParams.get(key);
|
|
71
81
|
}, [searchParams]);
|
|
72
82
|
|
|
73
83
|
return { searchParams, getParam, setSearchParams };
|
package/src/client/nav-utils.ts
CHANGED
|
@@ -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
|
|
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))
|
|
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
|
|
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;
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
99
|
-
"//
|
|
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
|
-
|
|
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
|
|
125
|
-
"//
|
|
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
|
|
package/src/config/load.ts
CHANGED
|
@@ -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 =
|
|
14
|
-
return cfg ?? {};
|
|
61
|
+
const cfg = mod.default ?? mod;
|
|
62
|
+
return validateUserConfig(cfg ?? {});
|
|
15
63
|
}
|
|
16
64
|
return {};
|
|
17
65
|
}
|