@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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Type-level guardrail for end-to-end typed routing.
3
+ *
4
+ * The runtime helpers (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`)
5
+ * gain their type-safety from a declaration-merging seam: `bractjs codegen`
6
+ * augments the package's `Register` interface, and the helpers resolve route
7
+ * types from it. None of that is observable at runtime — only `tsc` proves it.
8
+ * This test writes a fixture, generates its `route-types.gen.ts`, and runs
9
+ * `tsc --noEmit` over:
10
+ *
11
+ * - positive usage (typed <Link>/useNavigate/useParams must compile), and
12
+ * - negative usage guarded by `@ts-expect-error` (bad params/routes MUST error,
13
+ * so a directive that goes unused fails the build), and
14
+ * - a SECOND fixture with NO codegen, proving un-registered apps still compile
15
+ * with the loose `string` fallback (the backwards-compat contract).
16
+ *
17
+ * It guards the subtle failure mode that nearly shipped: a too-clever resolution
18
+ * type silently falling back to loose `string`, which compiles but enforces
19
+ * nothing.
20
+ *
21
+ * The fixture lives inside the repo (`.tmp-types-*`, gitignored) so its
22
+ * `@bractjs/bractjs` import self-resolves to the in-repo framework via the root
23
+ * tsconfig `paths` mapping.
24
+ */
25
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
26
+ import { mkdir, rm, writeFile } from "node:fs/promises";
27
+ import { resolve, join } from "node:path";
28
+ import { generateRouteTypes } from "../codegen/route-codegen.ts";
29
+
30
+ const REPO_ROOT = resolve(import.meta.dir, "../..");
31
+ const TMP = resolve(import.meta.dir, `.tmp-types-${Date.now()}`);
32
+
33
+ // Invoke TypeScript via `bunx tsc` — it works whether tsc is installed locally
34
+ // or fetched on demand. (A direct node_modules/.bin/tsc path is unreliable here:
35
+ // it may be a dangling symlink to an un-installed package.)
36
+ const TSC_CMD = ["bunx", "tsc"] as const;
37
+
38
+ // A fixture tsconfig that resolves `@bractjs/bractjs` → the in-repo entry, mirrors
39
+ // the example apps' compiler options, and type-checks only this fixture's files.
40
+ function tsconfig(includeGlobDir: string): string {
41
+ return JSON.stringify(
42
+ {
43
+ compilerOptions: {
44
+ target: "ESNext",
45
+ module: "ESNext",
46
+ moduleResolution: "bundler",
47
+ lib: ["ESNext", "DOM", "DOM.Iterable"],
48
+ jsx: "react-jsx",
49
+ jsxImportSource: "react",
50
+ strict: true,
51
+ noEmit: true,
52
+ skipLibCheck: true,
53
+ allowImportingTsExtensions: true,
54
+ types: ["react", "react-dom"],
55
+ // `paths` with absolute targets needs no `baseUrl` (and baseUrl is
56
+ // deprecated as of TS6, which would fail the compile here).
57
+ paths: { "@bractjs/bractjs": [join(REPO_ROOT, "src/index.ts")] },
58
+ },
59
+ include: [join(includeGlobDir, "**/*.ts"), join(includeGlobDir, "**/*.tsx")],
60
+ },
61
+ null,
62
+ 2,
63
+ );
64
+ }
65
+
66
+ async function runTsc(projectDir: string): Promise<{ code: number; output: string }> {
67
+ const proc = Bun.spawn([...TSC_CMD, "--noEmit", "-p", join(projectDir, "tsconfig.json")], {
68
+ cwd: projectDir,
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+ const [stdout, stderr, code] = await Promise.all([
73
+ new Response(proc.stdout).text(),
74
+ new Response(proc.stderr).text(),
75
+ proc.exited,
76
+ ]);
77
+ return { code, output: stdout + stderr };
78
+ }
79
+
80
+ let tscAvailable = false;
81
+
82
+ beforeAll(async () => {
83
+ await mkdir(TMP, { recursive: true });
84
+ // Real availability probe: actually run the compiler. Earlier this checked a
85
+ // symlink's existence and silently skipped when it dangled — making the whole
86
+ // suite a no-op that still "passed". Run `tsc --version` and trust the exit.
87
+ try {
88
+ const probe = Bun.spawn([...TSC_CMD, "--version"], { stdout: "pipe", stderr: "pipe" });
89
+ const out = await new Response(probe.stdout).text();
90
+ tscAvailable = (await probe.exited) === 0 && /Version/.test(out);
91
+ } catch {
92
+ tscAvailable = false;
93
+ }
94
+ });
95
+
96
+ afterAll(async () => {
97
+ await rm(TMP, { recursive: true, force: true });
98
+ });
99
+
100
+ describe("typed routing (type-level)", () => {
101
+ test("tsc is available (else the type-level assertions below are skipped)", () => {
102
+ // Surfaced as its own test so a skipped type-check is visible in the report
103
+ // rather than masquerading as a silent pass.
104
+ if (!tscAvailable) console.warn("[typed-routing] tsc unavailable — type-level checks skipped");
105
+ expect(typeof tscAvailable).toBe("boolean");
106
+ });
107
+
108
+ test("registered app: typed Link/useNavigate/useParams compile; bad usage errors", async () => {
109
+ if (!tscAvailable) return; // tsc not available in this environment — skip gracefully
110
+
111
+ const app = join(TMP, "registered");
112
+ await mkdir(join(app, "routes", "blog"), { recursive: true });
113
+ await writeFile(join(app, "routes", "_index.tsx"), "export default () => null;\n");
114
+ await writeFile(join(app, "routes", "blog", "[id].tsx"), "export default () => null;\n");
115
+
116
+ // Generate the registration file (augments Register on the package).
117
+ await writeFile(join(app, "route-types.gen.ts"), await generateRouteTypes(app));
118
+ await writeFile(join(app, "tsconfig.json"), tsconfig("."));
119
+
120
+ await writeFile(
121
+ join(app, "usage.tsx"),
122
+ `import { Link, useNavigate, useParams, useSearchParams } from "@bractjs/bractjs";\n` +
123
+ `import "./route-types.gen.ts";\n` +
124
+ `export function Ok() {\n` +
125
+ ` const navigate = useNavigate();\n` +
126
+ ` const p = useParams<"/blog/:id">();\n` +
127
+ ` const id: string = p.id;\n` +
128
+ ` useSearchParams<"/blog/:id">();\n` +
129
+ ` return (<>\n` +
130
+ ` <Link to="/blog/:id" params={{ id }}>typed</Link>\n` +
131
+ ` <Link to="/">static literal</Link>\n` +
132
+ ` <Link to={\`/\${id}\`}>built string (BC)</Link>\n` +
133
+ ` <button onClick={() => { void navigate("/blog/:id", { params: { id } }); }}>go</button>\n` +
134
+ ` <button onClick={() => { void navigate("/"); }}>home</button>\n` +
135
+ ` </>);\n` +
136
+ `}\n` +
137
+ `export function Bad() {\n` +
138
+ ` const navigate = useNavigate();\n` +
139
+ ` const p = useParams<"/blog/:id">();\n` +
140
+ ` return (<>\n` +
141
+ ` {/* @ts-expect-error wrong param key */}\n` +
142
+ ` <Link to="/blog/:id" params={{ wrong: "1" }}>x</Link>\n` +
143
+ ` {/* @ts-expect-error missing required param */}\n` +
144
+ ` <Link to="/blog/:id" params={{}}>x</Link>\n` +
145
+ ` <button onClick={() => {\n` +
146
+ ` // @ts-expect-error wrong param key in navigate\n` +
147
+ ` void navigate("/blog/:id", { params: { wrong: "1" } });\n` +
148
+ ` }}>go</button>\n` +
149
+ ` {/* @ts-expect-error /blog/:id has no \`nope\` param */}\n` +
150
+ ` <span>{p.nope}</span>\n` +
151
+ ` </>);\n` +
152
+ `}\n`,
153
+ );
154
+
155
+ const { code, output } = await runTsc(app);
156
+ // Exit 0 means: positives compiled AND every @ts-expect-error was satisfied
157
+ // by a real error. A silently-loose type would leave directives unused → TS2578.
158
+ // (`output` may carry bunx "Resolving dependencies" noise — key on TS errors.)
159
+ expect(output).not.toContain("TS2578"); // unused @ts-expect-error → typing too loose
160
+ expect(output).not.toMatch(/error TS/);
161
+ expect(code).toBe(0);
162
+ }, 60_000);
163
+
164
+ test("un-registered app (no codegen): loose string fallback still compiles", async () => {
165
+ if (!tscAvailable) return;
166
+
167
+ const app = join(TMP, "loose");
168
+ await mkdir(app, { recursive: true });
169
+ await writeFile(join(app, "tsconfig.json"), tsconfig("."));
170
+ // No route-types.gen.ts → Register stays empty → everything falls back to string.
171
+ await writeFile(
172
+ join(app, "usage.tsx"),
173
+ `import { Link, useNavigate, useParams } from "@bractjs/bractjs";\n` +
174
+ `export function App({ href }: { href: string }) {\n` +
175
+ ` const navigate = useNavigate();\n` +
176
+ ` const p = useParams();\n` +
177
+ ` return (<>\n` +
178
+ ` <Link to={href}>any string</Link>\n` +
179
+ ` <Link to="/anything/at/all">arbitrary literal</Link>\n` +
180
+ ` <button onClick={() => { void navigate("/wherever"); }}>{p.x}</button>\n` +
181
+ ` </>);\n` +
182
+ `}\n`,
183
+ );
184
+
185
+ const { code, output } = await runTsc(app);
186
+ expect(output).not.toMatch(/error TS/);
187
+ expect(code).toBe(0);
188
+ }, 60_000);
189
+ });
@@ -4,11 +4,12 @@ import type { BunPlugin } from "bun";
4
4
  import { scanRoutes } from "../server/scanner.ts";
5
5
  import { contentHash } from "./hash.ts";
6
6
  import { generateManifest, writeManifest } from "./manifest.ts";
7
- import { serverOnlyPlugin, clientEnvPlugin } from "./env-plugin.ts";
7
+ import { serverModuleStubPlugin, clientEnvPlugin } from "./env-plugin.ts";
8
8
  import { buildDefines } from "./defines.ts";
9
9
  import { writeRouteTypes } from "../codegen/route-codegen.ts";
10
10
  import { useClientStubPlugin, createUseServerProxyPlugin } from "./directives.ts";
11
11
  import { cssModulesPlugin } from "./plugins/css-modules.ts";
12
+ import { reactDedupePlugin } from "./react-dedupe.ts";
12
13
 
13
14
  /** Subset of config fields relevant to the build pipeline. */
14
15
  export interface BuildConfig {
@@ -54,18 +55,35 @@ export async function runBuild(config: BuildConfig): Promise<void> {
54
55
  if (!serverResult.success) throw new AggregateError(serverResult.logs, "Server build failed");
55
56
 
56
57
  // ── 3. Client bundle (code-split) ───────────────────────────────────────
57
- const clientResult = await Bun.build({
58
- entrypoints: [join(pkgRoot, "src/client/entry.tsx"), rootFilePath, ...routeFilePaths],
59
- target: "browser",
60
- splitting: true,
61
- outdir: "build/client",
62
- // No publicPath: relative chunk refs work correctly when files are served
63
- // at URLs matching their outdir structure (e.g. /build/client/chunk-xxx.js).
64
- minify: config.minify ?? true,
65
- sourcemap: config.sourcemap ?? "external",
66
- define: buildDefines(config),
67
- plugins: [serverOnlyPlugin, createUseServerProxyPlugin(appDir), clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin, ...(config.plugins ?? [])],
68
- });
58
+ // The framework's entry.tsx lives OUTSIDE the user's cwd (in pkgRoot). When
59
+ // Bun sees an entrypoint outside cwd it roots outputs at a common ancestor,
60
+ // emitting the entry at a nested path (build/client/src/client/entry.js) and
61
+ // baking ../ traversals into chunk refs — which makes `clientEntry` resolve to
62
+ // a URL that doesn't exist (e.g. /src/client/entry.<hash>.js). A shim file
63
+ // inside cwd keeps every entrypoint under one root so the entry output is flat
64
+ // and chunk refs stay correct. (Same technique as the dev rebuilder.)
65
+ const SHIM = ".bractjs-entry.tsx";
66
+ const shimPath = resolve(process.cwd(), SHIM);
67
+ await Bun.write(shimPath, `import "${join(pkgRoot, "src/client/entry.tsx")}";\nexport {};\n`);
68
+ const shimBase = basename(SHIM, extname(SHIM)); // ".bractjs-entry"
69
+
70
+ let clientResult: Awaited<ReturnType<typeof Bun.build>>;
71
+ try {
72
+ clientResult = await Bun.build({
73
+ entrypoints: [shimPath, rootFilePath, ...routeFilePaths],
74
+ target: "browser",
75
+ splitting: true,
76
+ outdir: "build/client",
77
+ // No publicPath: relative chunk refs work correctly when files are served
78
+ // at URLs matching their outdir structure (e.g. /build/client/chunk-xxx.js).
79
+ minify: config.minify ?? true,
80
+ sourcemap: config.sourcemap ?? "external",
81
+ define: buildDefines(config),
82
+ plugins: [reactDedupePlugin(process.cwd()), serverModuleStubPlugin, createUseServerProxyPlugin(appDir), clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin, ...(config.plugins ?? [])],
83
+ });
84
+ } finally {
85
+ await rm(shimPath, { force: true });
86
+ }
69
87
  if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
70
88
 
71
89
  // ── 4. Hash + rename output files ──────────────────────────────────────
@@ -74,7 +92,6 @@ export async function runBuild(config: BuildConfig): Promise<void> {
74
92
  let rootChunk: string | undefined;
75
93
  const outdirAbs = resolve("build/client");
76
94
  const appDirClean = appDir.replace(/^\.\//, "");
77
- const entryBase = basename("src/client/entry.tsx", extname("src/client/entry.tsx")); // "entry"
78
95
  const rootBase = basename(rootFilePath, extname(rootFilePath)); // "root"
79
96
 
80
97
  for (const artifact of clientResult.outputs) {
@@ -83,8 +100,22 @@ export async function runBuild(config: BuildConfig): Promise<void> {
83
100
  // would break sibling import refs, which Bun bakes in at bundle time and
84
101
  // does NOT rewrite after rename.
85
102
  if (artifact.kind !== "entry-point") continue;
103
+ // Compute the source-relative path BEFORE renaming, to classify the output.
104
+ const absPath = resolve(artifact.path);
105
+ const rel = absPath.startsWith(outdirAbs + "/") ? absPath.slice(outdirAbs.length + 1) : basename(artifact.path);
106
+ const outBase = basename(artifact.path, extname(artifact.path));
86
107
  const hash = await contentHash(artifact.path);
87
108
  const ext = artifact.path.slice(artifact.path.lastIndexOf("."));
109
+
110
+ // The shim is the real client entry — rename it to a flat client.<hash>.js
111
+ // at the outdir root so its URL is /build/client/client.<hash>.js.
112
+ if (outBase === shimBase) {
113
+ const hashedPath = join(outdirAbs, `client.${hash}${ext}`);
114
+ await rename(artifact.path, hashedPath);
115
+ clientEntry = "/build/client/" + basename(hashedPath);
116
+ continue;
117
+ }
118
+
88
119
  const base = artifact.path.slice(0, artifact.path.lastIndexOf("."));
89
120
  const hashedPath = `${base}.${hash}${ext}`;
90
121
  await rename(artifact.path, hashedPath);
@@ -94,13 +125,8 @@ export async function runBuild(config: BuildConfig): Promise<void> {
94
125
  const publicPath = hashedAbs.startsWith(cwdAbs + "/")
95
126
  ? "/" + hashedAbs.slice(cwdAbs.length + 1).replace(/\\/g, "/")
96
127
  : "/" + hashedPath.replace(/^build\//, "build/");
97
- const absPath = resolve(artifact.path);
98
- const rel = absPath.startsWith(outdirAbs + "/") ? absPath.slice(outdirAbs.length + 1) : basename(artifact.path);
99
- const outBase = basename(artifact.path, extname(artifact.path));
100
128
 
101
- if (outBase === entryBase) {
102
- clientEntry = publicPath;
103
- } else if (outBase === rootBase) {
129
+ if (outBase === rootBase) {
104
130
  rootChunk = publicPath;
105
131
  } else {
106
132
  const matched = routes.find((r) => {
@@ -10,14 +10,14 @@ const SERVER_RE = /^["']use server["']/m;
10
10
  function normalizeForDirectiveCheck(src: string): string {
11
11
  return src.replace(/^/, "").replace(/^\s+/, "");
12
12
  }
13
- function hasClientDirective(src: string): boolean {
13
+ export function hasClientDirective(src: string): boolean {
14
14
  return CLIENT_RE.test(normalizeForDirectiveCheck(src));
15
15
  }
16
16
  function hasServerDirective(src: string): boolean {
17
17
  return SERVER_RE.test(normalizeForDirectiveCheck(src));
18
18
  }
19
19
 
20
- function extractExports(src: string): string[] {
20
+ export function extractExports(src: string): string[] {
21
21
  const names: string[] = [];
22
22
  for (const m of src.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
23
23
  for (const m of src.matchAll(/^export\s+(?:let|const|var)\s+(\w+)\s*=/gm)) names.push(m[1]);
@@ -1,5 +1,6 @@
1
1
  import type { BunPlugin } from "bun";
2
2
  import { resolve } from "node:path";
3
+ import { extractExports } from "./directives.ts";
3
4
 
4
5
  // Lazy: this module is re-exported from the package barrel, so it may be
5
6
  // statically pulled into client bundles. `import.meta.dir` is undefined in the
@@ -41,6 +42,68 @@ export const serverOnlyPlugin: BunPlugin = {
41
42
  },
42
43
  };
43
44
 
45
+ // ── Server-only module stub ────────────────────────────────────────────────
46
+
47
+ const SERVER_FILE_RE = /\.server\.(tsx?|jsx?)$/;
48
+ const DEFAULT_EXPORT_RE = /^export\s+default\b/m;
49
+
50
+ // Runtime stub injected for every named/default export of a `*.server.ts`
51
+ // module on the client. It is a callable Proxy that throws on call AND on
52
+ // property access, so:
53
+ // • the route module's loader/action keep referencing the symbols (the
54
+ // bundle still resolves `import { db } from "./db.server.ts"`), and
55
+ // • the bodies are inert dead code on the client (the server runs them), but
56
+ // • any *accidental* use from real client code throws a clear error instead
57
+ // of silently shipping a broken `undefined`.
58
+ const SERVER_STUB_FACTORY = `const __bractServerStub = (name) => {
59
+ const fail = () => {
60
+ throw new Error(
61
+ "[BractJS] '" + name + "' comes from a *.server.ts module and is not " +
62
+ "available in the browser. Call it only inside a loader() or action()."
63
+ );
64
+ };
65
+ return new Proxy(fail, { get: (_t, prop) => (prop === "name" ? name : fail()), apply: fail });
66
+ };`;
67
+
68
+ /**
69
+ * Client build: replace every export of a `*.server.ts` module with an inert
70
+ * stub instead of hard-failing the build.
71
+ *
72
+ * BractJS ships the *entire* route module — loader and action included — to the
73
+ * client bundle (the server never strips them). A route that legitimately does
74
+ * `import { db } from "./db.server.ts"` inside its loader therefore drags the
75
+ * server module into the client graph. Hard-failing that import (the old
76
+ * `serverOnlyPlugin` behaviour) made the documented "import a server module in
77
+ * a loader" pattern impossible. Stubbing instead:
78
+ * - keeps named/default imports resolvable, so the route module compiles,
79
+ * - guarantees **zero** server source (DB drivers, secrets, `bun:sqlite`,
80
+ * etc.) reaches the browser — the original file is never read for content,
81
+ * - throws loudly if a stub is ever actually used on the client.
82
+ *
83
+ * Loaders/actions are dead code on the client (only the server invokes them),
84
+ * so the stubs are never called in correct usage.
85
+ */
86
+ export const serverModuleStubPlugin: BunPlugin = {
87
+ name: "bractjs-server-module-stub",
88
+ setup(build) {
89
+ build.onLoad({ filter: SERVER_FILE_RE }, async ({ path }) => {
90
+ const src = await Bun.file(path).text();
91
+ const names = extractExports(src);
92
+ const lines = [SERVER_STUB_FACTORY];
93
+ for (const name of names) {
94
+ lines.push(`export const ${name} = __bractServerStub(${JSON.stringify(name)});`);
95
+ }
96
+ if (DEFAULT_EXPORT_RE.test(src)) {
97
+ lines.push(`export default __bractServerStub("default");`);
98
+ }
99
+ // `export {};` guarantees the module is treated as ESM even when the
100
+ // server file had no statically-detectable exports.
101
+ lines.push("export {};");
102
+ return { contents: lines.join("\n"), loader: "ts" };
103
+ });
104
+ },
105
+ };
106
+
44
107
  // ── Client env allowlist ───────────────────────────────────────────────────
45
108
 
46
109
  /**
@@ -0,0 +1,41 @@
1
+ import type { BunPlugin } from "bun";
2
+
3
+ /**
4
+ * Force every `react` / `react-dom` import in the CLIENT bundle to resolve to a
5
+ * single physical copy (the app's cwd copy).
6
+ *
7
+ * The client build mixes entrypoints from two roots: the framework's
8
+ * `src/client/entry.tsx` (which resolves react from the framework's
9
+ * node_modules) and the app's route files (which resolve react from the app's
10
+ * node_modules). When the `file:..`-linked framework carries its own react copy
11
+ * — even at the same version — those are two distinct module instances. The
12
+ * result is a dual-React "invalid hook call" (`ReactSharedInternals.H` is null)
13
+ * the moment a `"use client"` component runs a hook during hydration.
14
+ *
15
+ * Pinning all react specifiers to one resolved path eliminates the duplication.
16
+ */
17
+ const REACT_RE = /^(react|react-dom)(\/.*)?$/;
18
+
19
+ export function reactDedupePlugin(appCwd: string = process.cwd()): BunPlugin {
20
+ const cache = new Map<string, string>();
21
+ const resolveOne = (spec: string): string | null => {
22
+ if (cache.has(spec)) return cache.get(spec)!;
23
+ try {
24
+ const resolved = Bun.resolveSync(spec, appCwd);
25
+ cache.set(spec, resolved);
26
+ return resolved;
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+
32
+ return {
33
+ name: "bractjs:react-dedupe",
34
+ setup(build) {
35
+ build.onResolve({ filter: REACT_RE }, (args) => {
36
+ const resolved = resolveOne(args.path);
37
+ return resolved ? { path: resolved } : undefined;
38
+ });
39
+ },
40
+ };
41
+ }
@@ -10,13 +10,16 @@ import {
10
10
  type RouteModuleClient,
11
11
  } from "./router.tsx";
12
12
  import type { ServerManifest } from "../server/render.ts";
13
- import { matchPatternForPath } from "./nav-utils.ts";
13
+ import { matchPatternForPath, toSamePath } from "./nav-utils.ts";
14
14
  import { loaderCache, cacheKey } from "./cache.ts";
15
+ import { MetaTags } from "../shared/meta-tags.tsx";
16
+ import type { MetaDescriptor } from "../shared/route-types.ts";
15
17
 
16
18
  // ── Types ──────────────────────────────────────────────────────────────────
17
19
 
18
20
  export interface BractJSInitialData extends RouteState {
19
21
  manifest: ServerManifest;
22
+ meta?: MetaDescriptor[];
20
23
  }
21
24
 
22
25
  interface ClientRouterProps {
@@ -34,6 +37,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
34
37
  const [pathname, setPathname] = useState(initialData.pathname);
35
38
  const [navState, setNavState] = useState<NavigationState>("idle");
36
39
  const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
40
+ const [meta, setMeta] = useState<MetaDescriptor[]>(initialData.meta ?? []);
37
41
 
38
42
  const manifest = initialData.manifest;
39
43
 
@@ -50,6 +54,15 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
50
54
  /** Load route data + module without touching history. */
51
55
  const loadRoute = useCallback(async (to: string) => {
52
56
  setNavState("loading");
57
+ // Follow a redirect Location from client-side beforeLoad. Same-origin
58
+ // targets stay in the SPA; an off-origin/protocol-relative Location is NOT
59
+ // fed to the router — we do a full-page navigation so the browser's own
60
+ // cross-origin handling applies and we never open-redirect via pushState.
61
+ const followRedirect = (loc: string) => {
62
+ const safe = toSamePath(loc);
63
+ if (safe) { void navigateRef.current(safe); return; }
64
+ window.location.href = loc;
65
+ };
53
66
  try {
54
67
  const toPathname = to.split("?")[0];
55
68
  const pattern = matchPatternForPath(toPathname, manifest);
@@ -75,12 +88,12 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
75
88
  });
76
89
  if (result instanceof Response) {
77
90
  const loc = result.headers.get("Location");
78
- if (loc) { void navigateRef.current(loc); return; }
91
+ if (loc) { followRedirect(loc); return; }
79
92
  }
80
93
  } catch (err) {
81
94
  if (err instanceof Response) {
82
95
  const loc = (err as Response).headers.get("Location");
83
- if (loc) { void navigateRef.current(loc); return; }
96
+ if (loc) { followRedirect(loc); return; }
84
97
  }
85
98
  throw err;
86
99
  }
@@ -176,11 +189,11 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
176
189
  setPathname(to);
177
190
  setCurrentModule(routeModule);
178
191
  });
179
- const metaList = data.meta as Array<Record<string, unknown>> | undefined;
180
- const titleEntry = metaList?.find((m) => "title" in m);
181
- if (titleEntry && typeof titleEntry.title === "string") {
182
- document.title = titleEntry.title;
183
- }
192
+ // Re-render the document head from the new route's merged meta. React 19
193
+ // hoists the <title>/<meta> elements rendered by <MetaTags> into <head>,
194
+ // so description/OG tags update on soft navigation, not just the title.
195
+ const nextMeta = (data.meta as MetaDescriptor[] | undefined) ?? [];
196
+ startTransition(() => setMeta(nextMeta));
184
197
  } catch (err) {
185
198
  console.error("[bractjs] loadRoute error:", err);
186
199
  } finally {
@@ -229,6 +242,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
229
242
  return (
230
243
  <RouterContext.Provider value={{ loaderData, actionData, params, pathname, manifest, currentModule, setRoute }}>
231
244
  <NavigationContext.Provider value={{ state: navState, navigate, submit }}>
245
+ <MetaTags meta={meta} />
232
246
  {children}
233
247
  </NavigationContext.Provider>
234
248
  </RouterContext.Provider>
@@ -0,0 +1,24 @@
1
+ // Substitute `:name` segments in a colon-style route pattern with param values.
2
+ //
3
+ // Mirrors the substitution `bractjs codegen` bakes into the generated `routes`
4
+ // builder (`src/codegen/route-codegen.ts`). The framework's own `<Link>` /
5
+ // `useNavigate` can't import that app-local generated object, so they share this
6
+ // helper instead. Values are URL-encoded; an absent param leaves its `:name`
7
+ // segment intact (surfaced as an obviously-wrong URL rather than silently dropped).
8
+ //
9
+ // Patterns without a `:` (static routes, or already-built hrefs) pass straight
10
+ // through, so this is safe to call unconditionally.
11
+ export function buildPath(
12
+ pattern: string,
13
+ params: Record<string, string | number>,
14
+ ): string {
15
+ if (!pattern.includes(":")) return pattern;
16
+ return pattern
17
+ .split("/")
18
+ .map((seg) => {
19
+ if (!seg.startsWith(":")) return seg;
20
+ const value = params[seg.slice(1)];
21
+ return value === undefined ? seg : encodeURIComponent(String(value));
22
+ })
23
+ .join("/");
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { useContext, type FormEvent, type ReactNode, type FormHTMLAttributes } from "react";
2
2
  import { RouterContext, NavigationContext } from "../router.tsx";
3
3
  import { reloadLoaders } from "../form-utils.ts";
4
+ import { toSamePath } from "../nav-utils.ts";
4
5
 
5
6
  // ── Types ──────────────────────────────────────────────────────────────────
6
7
 
@@ -49,8 +50,16 @@ export function Form({ method = "post", action, children, ...rest }: FormProps)
49
50
  headers: { "X-BractJS-Action": "1" },
50
51
  });
51
52
 
53
+ // The action returned (or threw) a redirect. The browser auto-follows the
54
+ // 3xx, so `response.url` is the *absolute* final URL — normalize it to a
55
+ // same-origin path before handing it to the client router, which matches a
56
+ // route pattern against the pathname (an absolute URL wouldn't match). An
57
+ // off-origin final URL is NOT handed to the SPA router: fall back to a
58
+ // full-page navigation so we don't open-redirect through it.
52
59
  if (response.redirected) {
53
- await navigate(response.url);
60
+ const to = toSamePath(response.url);
61
+ if (to) { await navigate(to); return; }
62
+ window.location.href = response.url;
54
63
  return;
55
64
  }
56
65
 
@@ -1,16 +1,28 @@
1
1
  import { useContext, type AnchorHTMLAttributes, type ReactNode } from "react";
2
2
  import { NavigationContext, RouterContext } from "../router.tsx";
3
3
  import { prefetchRoute } from "../prefetch.ts";
4
+ import { buildPath } from "../build-path.ts";
5
+ import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
4
6
 
5
7
  // ── Types ──────────────────────────────────────────────────────────────────
6
8
 
7
- interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
8
- to: string;
9
+ // `to` accepts any registered route literal (autocomplete + typed `params`) but
10
+ // also any string via `(string & {})`, so existing call sites that build the URL
11
+ // themselves — `to={`/posts/${slug}`}`, `to={item.href}` — keep compiling. Run
12
+ // `bractjs codegen` to register the app's routes and unlock autocomplete; until
13
+ // then `RegisteredRoutes` is `string` and this is just today's loose prop.
14
+ type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = Omit<
15
+ AnchorHTMLAttributes<HTMLAnchorElement>,
16
+ "href"
17
+ > & {
18
+ to: TTo | (string & {});
19
+ /** Path params for a dynamic `to` (e.g. `params={{ id }}` for `/blog/:id`). */
20
+ params?: ParamsFor<TTo>;
9
21
  prefetch?: "hover" | "none";
10
22
  /** Opt in to View Transitions API for this navigation (E1). */
11
23
  viewTransition?: boolean;
12
24
  children: ReactNode;
13
- }
25
+ };
14
26
 
15
27
  // ── Component ──────────────────────────────────────────────────────────────
16
28
 
@@ -19,11 +31,22 @@ const supportsViewTransitions =
19
31
  typeof document !== "undefined" &&
20
32
  typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === "function";
21
33
 
22
- export function Link({ to, prefetch = "none", viewTransition = false, children, ...rest }: LinkProps) {
34
+ export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
35
+ to,
36
+ params,
37
+ prefetch = "none",
38
+ viewTransition = false,
39
+ children,
40
+ ...rest
41
+ }: LinkProps<TTo>) {
23
42
  const navCtx = useContext(NavigationContext);
24
43
  const routerCtx = useContext(RouterContext);
25
44
  const isLoading = navCtx?.state === "loading";
26
45
 
46
+ // Resolve the final href once: substitute params into a dynamic pattern, or
47
+ // pass an already-built string straight through.
48
+ const href = params ? buildPath(to as string, params as Record<string, string>) : (to as string);
49
+
27
50
  function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
28
51
  if (!navCtx) return; // SSR: let browser handle naturally
29
52
  if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
@@ -31,20 +54,20 @@ export function Link({ to, prefetch = "none", viewTransition = false, children,
31
54
 
32
55
  if (viewTransition && supportsViewTransitions) {
33
56
  (document as Document & { startViewTransition(cb: () => void): void }).startViewTransition(
34
- () => { void navCtx.navigate(to); },
57
+ () => { void navCtx.navigate(href); },
35
58
  );
36
59
  } else {
37
- void navCtx.navigate(to);
60
+ void navCtx.navigate(href);
38
61
  }
39
62
  }
40
63
 
41
64
  function handleMouseEnter() {
42
- if (prefetch === "hover" && routerCtx) prefetchRoute(to, routerCtx.manifest);
65
+ if (prefetch === "hover" && routerCtx) prefetchRoute(href, routerCtx.manifest);
43
66
  }
44
67
 
45
68
  return (
46
69
  <a
47
- href={to}
70
+ href={href}
48
71
  onClick={handleClick}
49
72
  onMouseEnter={handleMouseEnter}
50
73
  aria-disabled={isLoading || undefined}