@bractjs/bractjs 0.1.27 → 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/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +339 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- 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 +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- package/README.md +0 -1125
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseTo } from "./nav-utils.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serialize a validated-search-shaped object back into a query string
|
|
5
|
+
* (including the leading `?`, or `""` when empty).
|
|
6
|
+
*
|
|
7
|
+
* - `undefined`/`null` values are dropped (the way to delete a param).
|
|
8
|
+
* - Arrays serialize as repeated keys (`{ tag: ["a","b"] }` → `?tag=a&tag=b`),
|
|
9
|
+
* the inverse of the server's `searchParamsToObject`.
|
|
10
|
+
* - Other objects are JSON-stringified; pair them with a schema field that
|
|
11
|
+
* `JSON.parse`s on the way in.
|
|
12
|
+
* - Everything else goes through `String()` — the server schema re-coerces on
|
|
13
|
+
* the next request, so numbers/booleans round-trip.
|
|
14
|
+
*/
|
|
15
|
+
export function serializeSearch(search: Record<string, unknown>): string {
|
|
16
|
+
const sp = new URLSearchParams();
|
|
17
|
+
for (const [key, value] of Object.entries(search)) {
|
|
18
|
+
if (value === undefined || value === null) continue;
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
for (const item of value) {
|
|
21
|
+
if (item === undefined || item === null) continue;
|
|
22
|
+
sp.append(key, typeof item === "object" ? JSON.stringify(item) : String(item));
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
sp.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const qs = sp.toString();
|
|
29
|
+
return qs ? "?" + qs : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace a path's query string with the serialized `search` object,
|
|
34
|
+
* preserving any hash. No-op when `search` is undefined.
|
|
35
|
+
*/
|
|
36
|
+
export function withSearch(path: string, search?: Record<string, unknown>): string {
|
|
37
|
+
if (!search) return path;
|
|
38
|
+
const { pathname, hash } = parseTo(path);
|
|
39
|
+
return pathname + serializeSearch(search) + hash;
|
|
40
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -8,11 +8,15 @@ export interface BractJSClientData {
|
|
|
8
8
|
actionData: unknown;
|
|
9
9
|
params: Record<string, string>;
|
|
10
10
|
pathname: string;
|
|
11
|
+
/** Validated search params for the initial request (route `searchSchema` output). */
|
|
12
|
+
search?: Record<string, unknown>;
|
|
11
13
|
manifest: ServerManifest;
|
|
12
14
|
/** Path of the matched route file, used to pre-import the module before hydration. */
|
|
13
15
|
routeFile?: string;
|
|
14
16
|
/** Merged meta descriptors for the current route — keeps <head> in sync. */
|
|
15
17
|
meta?: MetaDescriptor[];
|
|
18
|
+
/** Present when the document did not SSR the route component (selective SSR / SPA shell). */
|
|
19
|
+
ssrMode?: "client-only" | "data-only" | "spa";
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
// ── Window augmentation ────────────────────────────────────────────────────
|
|
@@ -22,5 +26,7 @@ declare global {
|
|
|
22
26
|
__BRACTJS_DATA__: BractJSClientData;
|
|
23
27
|
/** Dev-only: registered by ClientRouter for module-level HMR swaps. */
|
|
24
28
|
__BRACTJS_HMR_ACCEPT__?: (pattern: string, mod: Record<string, unknown>) => void;
|
|
29
|
+
/** Dev-only: HMR WebSocket port published by the server's dev bootstrap. */
|
|
30
|
+
__BRACTJS_HMR_PORT__?: number;
|
|
25
31
|
}
|
|
26
32
|
}
|
|
@@ -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));
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { scanRoutes } from "../server/scanner.ts";
|
|
3
3
|
import type { Segment } from "../server/scanner.ts";
|
|
4
|
+
import { hashString } from "../build/hash.ts";
|
|
4
5
|
|
|
5
|
-
// Convert [param] / [...catchAll] notation to :param colon-style
|
|
6
|
+
// Convert [param] / [[optional]] / [...catchAll] notation to :param colon-style.
|
|
6
7
|
function patternToColon(urlPattern: string): string {
|
|
7
8
|
if (urlPattern === "") return "/";
|
|
8
9
|
return "/" + urlPattern.split("/").map((seg) => {
|
|
9
10
|
if (seg.startsWith("[...") && seg.endsWith("]")) return ":" + seg.slice(4, -1);
|
|
11
|
+
if (seg.startsWith("[[") && seg.endsWith("]]")) return ":" + seg.slice(2, -2);
|
|
10
12
|
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
|
|
11
13
|
return seg;
|
|
12
14
|
}).join("/");
|
|
@@ -15,7 +17,9 @@ function patternToColon(urlPattern: string): string {
|
|
|
15
17
|
function paramsFromSegments(segments: Segment[]): string[] {
|
|
16
18
|
return segments.flatMap((seg) =>
|
|
17
19
|
typeof seg === "string" ? [] :
|
|
18
|
-
"param" in seg ? [seg.param] :
|
|
20
|
+
"param" in seg ? [seg.param] :
|
|
21
|
+
"optional" in seg ? [seg.optional] :
|
|
22
|
+
[seg.catchAll],
|
|
19
23
|
);
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -34,6 +38,10 @@ function substituteParams(pattern: string, params: string[]): string {
|
|
|
34
38
|
// backtick, ${ }, or quote into the generated TS source.
|
|
35
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_]*))*$|^\/$/;
|
|
36
40
|
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
41
|
+
// Same guard the module-registry codegen applies before emitting import paths.
|
|
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._\/\-\[\]()]+$/;
|
|
37
45
|
|
|
38
46
|
function assertSafePattern(pattern: string): void {
|
|
39
47
|
if (!SAFE_PATTERN_RE.test(pattern)) {
|
|
@@ -45,6 +53,11 @@ function assertSafeParam(name: string): void {
|
|
|
45
53
|
throw new Error(`[bractjs] codegen: refusing to emit unsafe param name: ${JSON.stringify(name)}`);
|
|
46
54
|
}
|
|
47
55
|
}
|
|
56
|
+
function assertSafeFilePath(filePath: string): void {
|
|
57
|
+
if (!SAFE_FILEPATH_RE.test(filePath) || filePath.split("/").includes("..")) {
|
|
58
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe file path: ${JSON.stringify(filePath)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
48
61
|
|
|
49
62
|
function builderEntry(pattern: string, params: string[]): string {
|
|
50
63
|
assertSafePattern(pattern);
|
|
@@ -110,6 +123,38 @@ function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
|
|
|
110
123
|
].join("\n");
|
|
111
124
|
}
|
|
112
125
|
|
|
126
|
+
// Per-route VALIDATED search shapes, inferred from each route's `searchSchema`
|
|
127
|
+
// export via type-only `typeof import(...)`. Routes without a schema fall back
|
|
128
|
+
// to the user's RouteSearchParamsMap augmentation, then a permissive record.
|
|
129
|
+
// This map feeds `Register.routes.searchOutput` → `useSearch`/`useSetSearch`/
|
|
130
|
+
// `<Link search>`; the legacy string-valued `search` member is untouched.
|
|
131
|
+
function searchOutputTypeLines(routes: Array<{ pattern: string; filePath: string }>): string {
|
|
132
|
+
if (routes.length === 0) {
|
|
133
|
+
return "export type GeneratedSearchOutput = Record<never, never>;";
|
|
134
|
+
}
|
|
135
|
+
const entries = routes
|
|
136
|
+
.map((r) => {
|
|
137
|
+
assertSafePattern(r.pattern);
|
|
138
|
+
assertSafeFilePath(r.filePath);
|
|
139
|
+
const key = JSON.stringify(r.pattern);
|
|
140
|
+
const spec = JSON.stringify("./" + r.filePath.split("\\").join("/"));
|
|
141
|
+
return " " + key + ": typeof import(" + spec + ") extends { searchSchema: infer S }\n" +
|
|
142
|
+
" ? InferSchemaOutput<S>\n" +
|
|
143
|
+
" : (RouteSearchParamsMap extends Record<" + key + ", infer V> ? V : Record<string, unknown>);";
|
|
144
|
+
})
|
|
145
|
+
.join("\n");
|
|
146
|
+
return [
|
|
147
|
+
"// Validated search shape per route, inferred from `searchSchema` exports.",
|
|
148
|
+
"export type GeneratedSearchOutput = {",
|
|
149
|
+
entries,
|
|
150
|
+
"};",
|
|
151
|
+
"",
|
|
152
|
+
"/** Validated search object for a route — what `useSearch<T>()` returns. */",
|
|
153
|
+
"export type SearchOutput<T extends AppRoutes> =",
|
|
154
|
+
" T extends keyof GeneratedSearchOutput ? GeneratedSearchOutput[T] : Record<string, unknown>;",
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
113
158
|
function contextTypeLines(routes: Array<{ pattern: string }>): string {
|
|
114
159
|
if (routes.length === 0) {
|
|
115
160
|
return "export type Context<_T extends AppRoutes> = Record<string, unknown>;";
|
|
@@ -156,18 +201,87 @@ function registerAugmentationLines(routes: Array<{ pattern: string; params: stri
|
|
|
156
201
|
paramEntries,
|
|
157
202
|
" };",
|
|
158
203
|
" search: RouteSearchParamsMap;",
|
|
204
|
+
" searchOutput: GeneratedSearchOutput;",
|
|
159
205
|
" };",
|
|
160
206
|
" }",
|
|
161
207
|
"}",
|
|
162
208
|
].join("\n");
|
|
163
209
|
}
|
|
164
210
|
|
|
211
|
+
// ── Freshness fingerprint ────────────────────────────────────────────────
|
|
212
|
+
// The generated file embeds a hash of its route patterns so the dev server can
|
|
213
|
+
// detect drift precisely (and skip rewrites when nothing changed, which would
|
|
214
|
+
// otherwise trigger an editor reload loop). Patterns are sorted everywhere so
|
|
215
|
+
// the output is identical across machines regardless of filesystem scan order.
|
|
216
|
+
|
|
217
|
+
const FINGERPRINT_RE = /^\/\/ bractjs:routes ([0-9a-f]+) \((\d+) routes?\)$/m;
|
|
218
|
+
|
|
219
|
+
/** Stable 8-hex fingerprint of a route-pattern set (order-independent). */
|
|
220
|
+
export function routesFingerprint(patterns: string[]): Promise<string> {
|
|
221
|
+
return hashString([...patterns].sort().join("\n"));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Extract the fingerprint hash previously written into a generated file, or null. */
|
|
225
|
+
export function readFingerprint(src: string | null): string | null {
|
|
226
|
+
if (!src) return null;
|
|
227
|
+
const m = src.match(FINGERPRINT_RE);
|
|
228
|
+
return m ? m[1] : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A precise human-readable reason the generated types are stale, or null when
|
|
233
|
+
* fresh. `patterns` must be colon-style (the form the generated file embeds);
|
|
234
|
+
* prefer {@link explainStalenessForApp} which derives them for you.
|
|
235
|
+
*/
|
|
236
|
+
export async function explainStaleness(
|
|
237
|
+
oldSrc: string | null,
|
|
238
|
+
patterns: string[],
|
|
239
|
+
): Promise<string | null> {
|
|
240
|
+
if (!oldSrc) return "route-types.gen.ts is missing — generating it";
|
|
241
|
+
const current = await routesFingerprint(patterns);
|
|
242
|
+
if (readFingerprint(oldSrc) === current) return null;
|
|
243
|
+
// Recover the old pattern set from the union members to report add/remove
|
|
244
|
+
// counts. The last member ends with `;`, so allow an optional trailing `;`.
|
|
245
|
+
const old = new Set(
|
|
246
|
+
[...oldSrc.matchAll(/^ {2}\| "([^"]+)";?$/gm)].map((m) => m[1]),
|
|
247
|
+
);
|
|
248
|
+
const now = new Set(patterns);
|
|
249
|
+
const added = patterns.filter((p) => !old.has(p)).length;
|
|
250
|
+
const removed = [...old].filter((p) => !now.has(p)).length;
|
|
251
|
+
const parts: string[] = [];
|
|
252
|
+
if (added) parts.push(`+${added} added`);
|
|
253
|
+
if (removed) parts.push(`-${removed} removed`);
|
|
254
|
+
const detail = parts.length ? ` (${parts.join(", ")})` : "";
|
|
255
|
+
return `routes changed since last codegen${detail} — regenerating`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Colon-style route patterns for an app dir (the form the generated file uses). */
|
|
259
|
+
export async function routePatternsForApp(appDir: string): Promise<string[]> {
|
|
260
|
+
const routeFiles = await scanRoutes(appDir);
|
|
261
|
+
return routeFiles.map((r) => patternToColon(r.urlPattern));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** {@link explainStaleness} against the current generated file + route set on disk. */
|
|
265
|
+
export async function explainStalenessForApp(
|
|
266
|
+
appDir: string,
|
|
267
|
+
outPath?: string,
|
|
268
|
+
): Promise<string | null> {
|
|
269
|
+
const dest = outPath ?? join(appDir, "route-types.gen.ts");
|
|
270
|
+
const existing = await Bun.file(dest).text().catch(() => null);
|
|
271
|
+
return explainStaleness(existing, await routePatternsForApp(appDir));
|
|
272
|
+
}
|
|
273
|
+
|
|
165
274
|
export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
166
275
|
const routeFiles = await scanRoutes(appDir);
|
|
167
|
-
const routes = routeFiles
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
276
|
+
const routes = routeFiles
|
|
277
|
+
.map((r) => ({
|
|
278
|
+
pattern: patternToColon(r.urlPattern),
|
|
279
|
+
params: paramsFromSegments(r.segments),
|
|
280
|
+
filePath: r.filePath,
|
|
281
|
+
}))
|
|
282
|
+
// Deterministic order independent of filesystem scan order, so the output
|
|
283
|
+
// is byte-identical across machines (and the idempotent write works).
|
|
284
|
+
.sort((a, b) => a.pattern.localeCompare(b.pattern));
|
|
171
285
|
|
|
172
286
|
const union = routes.length > 0
|
|
173
287
|
? routes.map((r) => {
|
|
@@ -180,11 +294,18 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
180
294
|
|
|
181
295
|
// `RouteSearchParamsMap` / `RouteContextMap` are imported from the package so
|
|
182
296
|
// the local `SearchParams<T>` / `Context<T>` reference the same interfaces the
|
|
183
|
-
// user augments via `declare module "@bractjs/bractjs"`.
|
|
184
|
-
|
|
297
|
+
// user augments via `declare module "@bractjs/bractjs"`. `InferSchemaOutput`
|
|
298
|
+
// derives each route's validated search shape from its `searchSchema` export.
|
|
299
|
+
const IMPORTS = 'import type { RouteSearchParamsMap, RouteContextMap, InferSchemaOutput } from "@bractjs/bractjs";';
|
|
300
|
+
|
|
301
|
+
// Freshness breadcrumb: lets the dev server detect drift without re-deriving
|
|
302
|
+
// the whole file, and lets writeRouteTypes skip identical writes.
|
|
303
|
+
const fingerprint = await routesFingerprint(routes.map((r) => r.pattern));
|
|
304
|
+
const FINGERPRINT = `// bractjs:routes ${fingerprint} (${routes.length} route${routes.length === 1 ? "" : "s"})`;
|
|
185
305
|
|
|
186
306
|
return [
|
|
187
307
|
HEADER,
|
|
308
|
+
FINGERPRINT,
|
|
188
309
|
IMPORTS,
|
|
189
310
|
"",
|
|
190
311
|
"export type AppRoutes =",
|
|
@@ -194,16 +315,24 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
194
315
|
"",
|
|
195
316
|
searchParamsTypeLines(routes),
|
|
196
317
|
"",
|
|
318
|
+
searchOutputTypeLines(routes),
|
|
319
|
+
"",
|
|
197
320
|
contextTypeLines(routes),
|
|
198
321
|
"",
|
|
199
322
|
"export type TypedLoaderArgs<T extends AppRoutes> = {",
|
|
200
323
|
" request: Request;",
|
|
201
324
|
" params: RouteParams<T>;",
|
|
202
325
|
" context: Context<T>;",
|
|
326
|
+
" search: T extends keyof GeneratedSearchOutput ? GeneratedSearchOutput[T] : Record<string, unknown>;",
|
|
203
327
|
"};",
|
|
204
328
|
"export type TypedActionArgs<T extends AppRoutes> =",
|
|
205
329
|
" TypedLoaderArgs<T> & { formData: FormData };",
|
|
206
330
|
"",
|
|
331
|
+
"/** Loader args fully typed for a route literal: `loader(args: LoaderArgsFor<\"/posts\">)`. */",
|
|
332
|
+
"export type LoaderArgsFor<T extends AppRoutes> = TypedLoaderArgs<T>;",
|
|
333
|
+
"/** Action args fully typed for a route literal: `action(args: ActionArgsFor<\"/posts\">)`. */",
|
|
334
|
+
"export type ActionArgsFor<T extends AppRoutes> = TypedActionArgs<T>;",
|
|
335
|
+
"",
|
|
207
336
|
"/** A locale-prefixed variant of a route (E2 i18n routing). */",
|
|
208
337
|
"export type LocalizedRoute<T extends AppRoutes> = `/${string}${T}`;",
|
|
209
338
|
"",
|
|
@@ -217,8 +346,17 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
217
346
|
].join("\n");
|
|
218
347
|
}
|
|
219
348
|
|
|
220
|
-
export async function writeRouteTypes(
|
|
349
|
+
export async function writeRouteTypes(
|
|
350
|
+
appDir: string,
|
|
351
|
+
outPath?: string,
|
|
352
|
+
): Promise<{ dest: string; written: boolean }> {
|
|
221
353
|
const dest = outPath ?? join(appDir, "route-types.gen.ts");
|
|
222
|
-
|
|
354
|
+
const next = await generateRouteTypes(appDir);
|
|
355
|
+
// Skip the write (and the log, and the resulting file-watcher event) when the
|
|
356
|
+
// content is unchanged — otherwise auto-codegen in dev would loop the editor.
|
|
357
|
+
const existing = await Bun.file(dest).text().catch(() => null);
|
|
358
|
+
if (existing === next) return { dest, written: false };
|
|
359
|
+
await Bun.write(dest, next);
|
|
223
360
|
console.log("[bract] codegen →", dest);
|
|
361
|
+
return { dest, written: true };
|
|
224
362
|
}
|
package/src/config/load.ts
CHANGED
|
@@ -25,6 +25,8 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
|
|
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");
|
|
28
30
|
check("appDir", typeof c.appDir === "string", "a string");
|
|
29
31
|
check("publicDir", typeof c.publicDir === "string", "a string");
|
|
30
32
|
check("buildDir", typeof c.buildDir === "string", "a string");
|
|
@@ -45,10 +47,30 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
|
|
|
45
47
|
check("onStart", typeof c.onStart === "function", "a function");
|
|
46
48
|
check("onShutdown", typeof c.onShutdown === "function", "a function");
|
|
47
49
|
check("onError", typeof c.onError === "function", "a function");
|
|
50
|
+
check("ssr", typeof c.ssr === "boolean", "a boolean");
|
|
51
|
+
check(
|
|
52
|
+
"prerender",
|
|
53
|
+
typeof c.prerender === "function" ||
|
|
54
|
+
(Array.isArray(c.prerender) && c.prerender.every((p) => typeof p === "string")),
|
|
55
|
+
"an array of paths or a function returning one",
|
|
56
|
+
);
|
|
48
57
|
|
|
49
58
|
return c as Partial<BractJSConfig>;
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Identity helper for `bractjs.config.ts` — wrap your default export to get
|
|
63
|
+
* autocomplete and type-checking on the config fields (no runtime effect):
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* import { defineConfig } from "@bractjs/bractjs";
|
|
67
|
+
* export default defineConfig({ port: 3000, clientEnv: ["PUBLIC_API_URL"] });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function defineConfig(config: Partial<BractJSConfig>): Partial<BractJSConfig> {
|
|
71
|
+
return config;
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
/**
|
|
53
75
|
* Load `bractjs.config.ts` (or `.js`) from the user's cwd if present.
|
|
54
76
|
* Returns an empty object when no file exists — callers fall back to defaults.
|
package/src/dev/hmr-client.ts
CHANGED
|
@@ -23,7 +23,9 @@ export const hmrClientScript: string = `
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function connect() {
|
|
26
|
-
|
|
26
|
+
// Port published by the server's dev bootstrap (config hmrPort), else 3001.
|
|
27
|
+
var port = window.__BRACTJS_HMR_PORT__ || 3001;
|
|
28
|
+
var ws = new WebSocket("ws://localhost:" + port);
|
|
27
29
|
ws.onmessage = function (event) {
|
|
28
30
|
try {
|
|
29
31
|
var msg = JSON.parse(event.data);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface RouteTableRow {
|
|
2
|
+
pattern: string;
|
|
3
|
+
file: string;
|
|
4
|
+
hasLoader: boolean;
|
|
5
|
+
hasAction: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render a compact route inventory for the dev server boot log, so a developer
|
|
10
|
+
* can see at a glance what routes the app matched (and which have data/mutation
|
|
11
|
+
* handlers). Pure string formatting — no I/O.
|
|
12
|
+
*/
|
|
13
|
+
export function formatRouteTable(rows: RouteTableRow[]): string {
|
|
14
|
+
if (rows.length === 0) return "[bractjs] no routes found under routes/";
|
|
15
|
+
const sorted = [...rows].sort((a, b) => a.pattern.localeCompare(b.pattern));
|
|
16
|
+
const patternWidth = Math.max(7, ...sorted.map((r) => r.pattern.length));
|
|
17
|
+
const lines = sorted.map((r) => {
|
|
18
|
+
const markers = [r.hasLoader ? "loader" : "", r.hasAction ? "action" : ""]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join(" ");
|
|
21
|
+
return ` ${r.pattern.padEnd(patternWidth)} ${markers.padEnd(13)} ${r.file}`;
|
|
22
|
+
});
|
|
23
|
+
return [
|
|
24
|
+
`[bractjs] ${rows.length} route${rows.length === 1 ? "" : "s"}:`,
|
|
25
|
+
...lines,
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
package/src/dev/server.ts
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
1
|
import { createServer } from "../server/serve.ts";
|
|
2
|
-
import { setRuntimeMode } from "../server/env.ts";
|
|
2
|
+
import { setRuntimeMode, setDevHmrPort } from "../server/env.ts";
|
|
3
3
|
import { createHmrServer } from "./hmr-server.ts";
|
|
4
4
|
import { watchApp } from "./watcher.ts";
|
|
5
5
|
import { rebuildClient } from "./rebuilder.ts";
|
|
6
|
-
import { filePathToPattern } from "../server/scanner.ts";
|
|
7
|
-
import { basename, extname } from "node:path";
|
|
6
|
+
import { filePathToPattern, scanRoutes } from "../server/scanner.ts";
|
|
7
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
8
8
|
import type { LifecycleHooks } from "../server/lifecycle.ts";
|
|
9
9
|
import { loadUserConfig } from "../config/load.ts";
|
|
10
10
|
import type { BractJSConfig } from "../server/serve.ts";
|
|
11
|
+
import { writeRouteTypes, explainStalenessForApp } from "../codegen/route-codegen.ts";
|
|
12
|
+
import { lintRouteModuleSource } from "../build/route-lint.ts";
|
|
13
|
+
import { formatRouteTable, type RouteTableRow } from "./route-table.ts";
|
|
14
|
+
|
|
15
|
+
// Warn-once across HMR rebuilds so the same lint message doesn't spam the log.
|
|
16
|
+
const warnedRouteIssues = new Set<string>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Statically lint route modules and print the route table. Reads each route
|
|
20
|
+
* file's source once (no module execution, no per-request cost). Returns the
|
|
21
|
+
* table rows so the boot path can print them alongside the HMR port.
|
|
22
|
+
*/
|
|
23
|
+
async function inspectRoutes(appDir: string): Promise<RouteTableRow[]> {
|
|
24
|
+
const routes = await scanRoutes(appDir);
|
|
25
|
+
const rows: RouteTableRow[] = [];
|
|
26
|
+
for (const r of routes) {
|
|
27
|
+
let src = "";
|
|
28
|
+
try {
|
|
29
|
+
src = await Bun.file(resolve(process.cwd(), appDir, r.filePath)).text();
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const warning of lintRouteModuleSource(src, r.filePath)) {
|
|
34
|
+
const key = r.filePath + "\0" + warning;
|
|
35
|
+
if (warnedRouteIssues.has(key)) continue;
|
|
36
|
+
warnedRouteIssues.add(key);
|
|
37
|
+
console.warn(`[bractjs] ${warning}`);
|
|
38
|
+
}
|
|
39
|
+
rows.push({
|
|
40
|
+
pattern: r.urlPattern === "" ? "/" : "/" + r.urlPattern,
|
|
41
|
+
file: r.filePath,
|
|
42
|
+
hasLoader: /^export\s+(?:async\s+)?function\s+loader\b|^export\s+const\s+loader\b/m.test(src),
|
|
43
|
+
hasAction: /^export\s+(?:async\s+)?function\s+action\b|^export\s+const\s+action\b/m.test(src),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Regenerate typed routes if the route set drifted from the last codegen.
|
|
51
|
+
* Idempotent: writeRouteTypes skips the write when content is unchanged, so it
|
|
52
|
+
* never triggers an editor reload loop. Runs at boot and on add/remove/rename.
|
|
53
|
+
*/
|
|
54
|
+
async function syncRouteTypes(appDir: string): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
const reason = await explainStalenessForApp(appDir);
|
|
57
|
+
if (reason) console.log(`[bractjs] ${reason}`);
|
|
58
|
+
await writeRouteTypes(appDir);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// Codegen is a DX aid, never fatal to the dev loop.
|
|
61
|
+
console.warn("[bractjs] route codegen skipped:", err instanceof Error ? err.message : err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
11
64
|
|
|
12
65
|
export interface DevServerOptions {
|
|
13
66
|
/** HTTP port for the app server. Default: config.port ?? 3000. */
|
|
@@ -38,15 +91,42 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
|
|
|
38
91
|
// for any source-import path, dev or `bractjs start`), so no separate dev hook
|
|
39
92
|
// is needed here.
|
|
40
93
|
|
|
41
|
-
const hmrPort = options?.hmrPort ?? 3001;
|
|
94
|
+
const hmrPort = options?.hmrPort ?? merged.hmrPort ?? 3001;
|
|
42
95
|
const appPort = options?.port ?? merged.port ?? 3000;
|
|
96
|
+
// Publish the port so the SSR dev bootstrap tells the HMR client where to connect.
|
|
97
|
+
setDevHmrPort(hmrPort);
|
|
98
|
+
|
|
99
|
+
const appDir = merged.appDir ?? "./app";
|
|
43
100
|
|
|
44
|
-
|
|
101
|
+
// Keep typed routes fresh on boot (covers "added a route while the server was
|
|
102
|
+
// down"). Idempotent — no-op write when nothing changed.
|
|
103
|
+
await syncRouteTypes(appDir);
|
|
104
|
+
|
|
105
|
+
// Friendly port-conflict message instead of a raw Bun EADDRINUSE stack.
|
|
106
|
+
const onPortInUse = (which: "app server" | "HMR socket", port: number): never => {
|
|
107
|
+
console.error(
|
|
108
|
+
`[bractjs] Port ${port} is already in use (${which}). ` +
|
|
109
|
+
`Set \`port\` (and \`hmrPort\` for the HMR socket) in bractjs.config.ts, ` +
|
|
110
|
+
`or stop the process using it.`,
|
|
111
|
+
);
|
|
112
|
+
return process.exit(1);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let hmr: ReturnType<typeof createHmrServer>;
|
|
116
|
+
try {
|
|
117
|
+
hmr = createHmrServer(hmrPort);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if ((err as { code?: string }).code === "EADDRINUSE") onPortInUse("HMR socket", hmrPort);
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
45
122
|
|
|
46
123
|
// Build client bundle before the HTTP server starts accepting requests
|
|
47
124
|
const { duration: initialMs } = await rebuildClient(merged);
|
|
48
125
|
console.log(`[bractjs] initial client build in ${initialMs}ms`);
|
|
49
126
|
|
|
127
|
+
// Lint route modules + collect the route table (read sources once, no exec).
|
|
128
|
+
const routeRows = await inspectRoutes(appDir);
|
|
129
|
+
|
|
50
130
|
// Load user lifecycle hooks if defined (e.g. app/lifecycle.ts)
|
|
51
131
|
let lifecycle: LifecycleHooks = {};
|
|
52
132
|
try {
|
|
@@ -57,9 +137,26 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
|
|
|
57
137
|
// No lifecycle file — that's fine
|
|
58
138
|
}
|
|
59
139
|
|
|
60
|
-
|
|
140
|
+
let srv: ReturnType<typeof createServer>;
|
|
141
|
+
try {
|
|
142
|
+
srv = createServer({ port: appPort, ...merged, ...lifecycle });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
hmr.stop();
|
|
145
|
+
if ((err as { code?: string }).code === "EADDRINUSE") onPortInUse("app server", appPort);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
watchApp(appDir, async (file, info) => {
|
|
150
|
+
// Add/remove/rename of a route file changes the route set → regenerate
|
|
151
|
+
// typed routes. Saves (content changes) never alter the generated output
|
|
152
|
+
// (it uses type-only `typeof import(...)`), so skip codegen on those.
|
|
153
|
+
if (info.renameSeen && file.startsWith("routes/")) {
|
|
154
|
+
await syncRouteTypes(appDir);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Re-lint changed route modules (warn-once dedupes repeats).
|
|
158
|
+
if (file.startsWith("routes/")) await inspectRoutes(appDir);
|
|
61
159
|
|
|
62
|
-
watchApp(merged.appDir ?? "./app", async (file) => {
|
|
63
160
|
const { duration } = await rebuildClient(merged);
|
|
64
161
|
|
|
65
162
|
// Route files (not layout): do a fine-grained module swap without full reload.
|
|
@@ -81,7 +178,8 @@ export async function createDevServer(options?: DevServerOptions): Promise<DevSe
|
|
|
81
178
|
}
|
|
82
179
|
});
|
|
83
180
|
|
|
84
|
-
console.log(
|
|
181
|
+
console.log(formatRouteTable(routeRows));
|
|
182
|
+
console.log(`BractJS dev server on http://localhost:${appPort} (HMR ws://localhost:${hmrPort})`);
|
|
85
183
|
|
|
86
184
|
return {
|
|
87
185
|
stop() {
|
package/src/dev/watcher.ts
CHANGED
|
@@ -3,21 +3,42 @@ import { watch } from "node:fs";
|
|
|
3
3
|
|
|
4
4
|
const WATCHED_EXTENSIONS = new Set([".tsx", ".ts", ".css"]);
|
|
5
5
|
|
|
6
|
+
/** Extra info about a debounced change burst. */
|
|
7
|
+
export interface WatchChangeInfo {
|
|
8
|
+
/** The last file event type seen in the burst. */
|
|
9
|
+
event: "rename" | "change";
|
|
10
|
+
/**
|
|
11
|
+
* True when ANY event in the burst was a "rename" (add/remove/rename) — not
|
|
12
|
+
* just the last one. `fs.watch` collapses bursts, so this is OR-accumulated
|
|
13
|
+
* across the debounce window; the codegen trigger keys on it.
|
|
14
|
+
*/
|
|
15
|
+
renameSeen: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* Watches appDir for file changes and calls onChange with the changed file path.
|
|
8
20
|
* Debounces rapid changes within 50ms to avoid duplicate rebuilds.
|
|
9
21
|
*/
|
|
10
|
-
export function watchApp(
|
|
22
|
+
export function watchApp(
|
|
23
|
+
appDir: string,
|
|
24
|
+
onChange: (file: string, info: WatchChangeInfo) => void,
|
|
25
|
+
): void {
|
|
11
26
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
27
|
let pendingFile = "";
|
|
28
|
+
let lastEvent: "rename" | "change" = "change";
|
|
29
|
+
let renameSeen = false;
|
|
13
30
|
|
|
14
|
-
watch(appDir, { recursive: true }, (
|
|
31
|
+
watch(appDir, { recursive: true }, (eventType, filename) => {
|
|
15
32
|
if (!filename) return;
|
|
16
33
|
|
|
17
34
|
const ext = path.extname(filename);
|
|
18
35
|
if (!WATCHED_EXTENSIONS.has(ext)) return;
|
|
19
36
|
|
|
20
37
|
pendingFile = filename;
|
|
38
|
+
lastEvent = eventType === "rename" ? "rename" : "change";
|
|
39
|
+
// OR-accumulate across the debounce window: a save (change) immediately
|
|
40
|
+
// followed by a create (rename) must not lose the rename signal.
|
|
41
|
+
if (lastEvent === "rename") renameSeen = true;
|
|
21
42
|
|
|
22
43
|
if (debounceTimer !== null) {
|
|
23
44
|
clearTimeout(debounceTimer);
|
|
@@ -26,7 +47,8 @@ export function watchApp(appDir: string, onChange: (file: string) => void): void
|
|
|
26
47
|
debounceTimer = setTimeout(() => {
|
|
27
48
|
debounceTimer = null;
|
|
28
49
|
console.log(`✓ ${path.basename(pendingFile)} changed`);
|
|
29
|
-
onChange(pendingFile);
|
|
50
|
+
onChange(pendingFile, { event: lastEvent, renameSeen });
|
|
51
|
+
renameSeen = false;
|
|
30
52
|
}, 50);
|
|
31
53
|
});
|
|
32
54
|
}
|