@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
|
@@ -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
|
+
});
|
package/src/build/bundler.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 ===
|
|
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) => {
|
package/src/build/directives.ts
CHANGED
|
@@ -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]);
|
package/src/build/env-plugin.ts
CHANGED
|
@@ -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) {
|
|
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) {
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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(
|
|
57
|
+
() => { void navCtx.navigate(href); },
|
|
35
58
|
);
|
|
36
59
|
} else {
|
|
37
|
-
void navCtx.navigate(
|
|
60
|
+
void navCtx.navigate(href);
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
function handleMouseEnter() {
|
|
42
|
-
if (prefetch === "hover" && routerCtx) prefetchRoute(
|
|
65
|
+
if (prefetch === "hover" && routerCtx) prefetchRoute(href, routerCtx.manifest);
|
|
43
66
|
}
|
|
44
67
|
|
|
45
68
|
return (
|
|
46
69
|
<a
|
|
47
|
-
href={
|
|
70
|
+
href={href}
|
|
48
71
|
onClick={handleClick}
|
|
49
72
|
onMouseEnter={handleMouseEnter}
|
|
50
73
|
aria-disabled={isLoading || undefined}
|