@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
package/bin/cli.ts
CHANGED
|
@@ -173,8 +173,29 @@ switch (command) {
|
|
|
173
173
|
|
|
174
174
|
console.log("[bract] (4/4) bun build --compile →", outFile);
|
|
175
175
|
const result = Bun.spawnSync(
|
|
176
|
-
[
|
|
177
|
-
|
|
176
|
+
[
|
|
177
|
+
"bun",
|
|
178
|
+
"build",
|
|
179
|
+
"--compile",
|
|
180
|
+
// Bun executables disable tsconfig autoload by default. Re-enable it so
|
|
181
|
+
// React TSX keeps using the app's jsx settings at runtime.
|
|
182
|
+
"--compile-autoload-tsconfig",
|
|
183
|
+
entryPath,
|
|
184
|
+
"--outfile",
|
|
185
|
+
outFile,
|
|
186
|
+
],
|
|
187
|
+
{
|
|
188
|
+
cwd: process.cwd(),
|
|
189
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
190
|
+
env: {
|
|
191
|
+
...process.env,
|
|
192
|
+
// Bun executable compile currently miscompiles React TSX under
|
|
193
|
+
// NODE_ENV=production (emits jsxDEV calls against a runtime that
|
|
194
|
+
// doesn't provide jsxDEV). Force a safe compile-time env while still
|
|
195
|
+
// keeping Bract's client/server build phase in production mode.
|
|
196
|
+
NODE_ENV: "development",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
178
199
|
);
|
|
179
200
|
if (result.exitCode !== 0) {
|
|
180
201
|
console.error("[bract] bun build --compile failed");
|
|
@@ -198,4 +219,3 @@ switch (command) {
|
|
|
198
219
|
);
|
|
199
220
|
process.exit(1);
|
|
200
221
|
}
|
|
201
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/bractjs/bractjs#readme",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { buildPath } from "../client/build-path.ts";
|
|
3
|
+
|
|
4
|
+
describe("buildPath", () => {
|
|
5
|
+
test("substitutes a single :param", () => {
|
|
6
|
+
expect(buildPath("/blog/:id", { id: "42" })).toBe("/blog/42");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("substitutes multiple params", () => {
|
|
10
|
+
expect(buildPath("/u/:user/post/:post", { user: "ann", post: "7" })).toBe("/u/ann/post/7");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("passes static patterns through untouched", () => {
|
|
14
|
+
expect(buildPath("/about", {})).toBe("/about");
|
|
15
|
+
expect(buildPath("/", {})).toBe("/");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("URL-encodes param values", () => {
|
|
19
|
+
expect(buildPath("/search/:q", { q: "a b/c" })).toBe("/search/a%20b%2Fc");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("coerces numbers to strings", () => {
|
|
23
|
+
expect(buildPath("/n/:id", { id: 7 })).toBe("/n/7");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("leaves an absent param's segment intact (surfaces as an obvious bad URL)", () => {
|
|
27
|
+
expect(buildPath("/blog/:id", {})).toBe("/blog/:id");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -29,6 +29,42 @@ describe("route-codegen — output shape", () => {
|
|
|
29
29
|
expect(out).toMatch(/\| "\/users\/:id"/);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
test("wires the Register augmentation for typed routing", async () => {
|
|
33
|
+
const regApp = join(tmpdir(), `bract-codegen-register-${Date.now()}`);
|
|
34
|
+
await mkdir(join(regApp, "routes", "users"), { recursive: true });
|
|
35
|
+
await writeFile(join(regApp, "routes", "about.tsx"), "export default () => null;");
|
|
36
|
+
await writeFile(join(regApp, "routes", "users", "[id].tsx"), "export default () => null;");
|
|
37
|
+
|
|
38
|
+
const out = await generateRouteTypes(regApp);
|
|
39
|
+
|
|
40
|
+
// Bug 1 regression: the augmentation must target the real package name.
|
|
41
|
+
expect(out).toContain('declare module "@bractjs/bractjs"');
|
|
42
|
+
expect(out).not.toMatch(/declare module ['"]bractjs['"]/);
|
|
43
|
+
|
|
44
|
+
// Bug 2 regression: the customization maps are AUGMENTED on the package, not
|
|
45
|
+
// re-declared as bare top-level interfaces in the app file.
|
|
46
|
+
expect(out).not.toMatch(/^export interface RouteSearchParamsMap/m);
|
|
47
|
+
expect(out).not.toMatch(/^export interface RouteContextMap/m);
|
|
48
|
+
expect(out).toContain('import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs"');
|
|
49
|
+
|
|
50
|
+
// The Register seam carries the route union and a per-route params map.
|
|
51
|
+
expect(out).toContain("interface Register {");
|
|
52
|
+
expect(out).toContain("routes: AppRoutes;");
|
|
53
|
+
expect(out).toMatch(/"\/users\/:id": \{ id: string \};/); // dynamic route → typed params
|
|
54
|
+
expect(out).toMatch(/"\/about": \{\};/); // static route → no params
|
|
55
|
+
|
|
56
|
+
await rm(regApp, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("emits no Register augmentation when there are no routes", async () => {
|
|
60
|
+
const emptyApp = join(tmpdir(), `bract-codegen-empty-${Date.now()}`);
|
|
61
|
+
await mkdir(join(emptyApp, "routes"), { recursive: true });
|
|
62
|
+
const out = await generateRouteTypes(emptyApp);
|
|
63
|
+
expect(out).toContain("export type AppRoutes =\n never;");
|
|
64
|
+
expect(out).not.toContain("interface Register {");
|
|
65
|
+
await rm(emptyApp, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
32
68
|
test("rejects hostile filenames at codegen time", async () => {
|
|
33
69
|
const hostileApp = join(tmpdir(), `bract-codegen-hostile-${Date.now()}`);
|
|
34
70
|
await mkdir(join(hostileApp, "routes"), { recursive: true });
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static guardrail for the `bun build --compile` single-executable feature.
|
|
3
|
+
*
|
|
4
|
+
* `bun build --compile` CANNOT trace two things at runtime:
|
|
5
|
+
* 1. `Bun.Glob(...).scan()` filesystem scans
|
|
6
|
+
* 2. dynamic `import(<variable>)` calls
|
|
7
|
+
*
|
|
8
|
+
* The framework supports single-binary deployment by materialising routes,
|
|
9
|
+
* layouts, actions, and the manifest into STATIC imports (`app/_generated/*`)
|
|
10
|
+
* and having `createServer()` accept them via `routeFiles` / `moduleRegistry`
|
|
11
|
+
* / `actionModules` / `manifest`. The registry-mode code path therefore must
|
|
12
|
+
* contain NO glob scans and NO variable `import()` — otherwise the compiled
|
|
13
|
+
* binary silently breaks at request time.
|
|
14
|
+
*
|
|
15
|
+
* This test scans the server source (the only graph compiled into the binary)
|
|
16
|
+
* and fails if a NEW unguarded `Bun.Glob` / variable-`import()` appears outside
|
|
17
|
+
* the known dev-fallback functions. It runs in milliseconds — a fast tripwire
|
|
18
|
+
* that complements the heavyweight compile-smoke e2e test.
|
|
19
|
+
*
|
|
20
|
+
* NOTE: `src/client/**` is intentionally NOT scanned — the client bundle is
|
|
21
|
+
* built for the browser (target=browser) and lazy-loads route chunks via
|
|
22
|
+
* dynamic `import(chunkUrl)`, which is correct there. Only the SERVER binary is
|
|
23
|
+
* subject to `bun build --compile` tracing. `src/dev/**` is also excluded: it
|
|
24
|
+
* never ships in a production/compiled server (all dev endpoints are gated by
|
|
25
|
+
* `isExplicitDev()` and dev modules are loaded via string-literal imports).
|
|
26
|
+
*/
|
|
27
|
+
import { test, expect, describe } from "bun:test";
|
|
28
|
+
import { resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
const SERVER_DIR = resolve(import.meta.dir, "../server");
|
|
31
|
+
|
|
32
|
+
// Occurrences of fs-scan / variable-import that are KNOWN-SAFE because they
|
|
33
|
+
// live in the dev-fallback path, which is skipped whenever the registry config
|
|
34
|
+
// (routeFiles / actionModules / moduleRegistry) is provided — i.e. always in a
|
|
35
|
+
// compiled binary. Keyed by file (relative to src/server) → the substrings of
|
|
36
|
+
// the enclosing dev-fallback function/usage we accept.
|
|
37
|
+
//
|
|
38
|
+
// If you add a new fs-scan or variable-import on the server path, this test
|
|
39
|
+
// will fail. Either gate it behind the registry config (and the isExplicitDev
|
|
40
|
+
// pattern), or — if it is genuinely a new dev-only fallback — extend this map
|
|
41
|
+
// with a justification.
|
|
42
|
+
const ALLOWED: Record<string, string[]> = {
|
|
43
|
+
// scanRoutes(): the startup route scan, bypassed when `routeFiles` is set
|
|
44
|
+
// (see buildFetchHandler in serve.ts).
|
|
45
|
+
"scanner.ts": ["routes/**/*.{tsx,ts}"],
|
|
46
|
+
// loadServerActions(): the startup action scan + dynamic import, bypassed
|
|
47
|
+
// when `actionModules` is set. loadServerActionsFromRegistry() is the
|
|
48
|
+
// compiled-binary counterpart and uses neither.
|
|
49
|
+
"action-registry.ts": ["**/*.{ts,tsx}", "await import(filePath)"],
|
|
50
|
+
// importRouteModule(): dev-mode route module load. resolveRouteChain() only
|
|
51
|
+
// calls it when no `registry` is provided; registry mode uses pickRouteModule
|
|
52
|
+
// (a plain Record lookup, no import).
|
|
53
|
+
"layout.ts": ["await import(filePath)"],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
async function serverFiles(): Promise<string[]> {
|
|
57
|
+
const glob = new Bun.Glob("**/*.ts");
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for await (const rel of glob.scan(SERVER_DIR)) out.push(rel);
|
|
60
|
+
return out.sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A dynamic import whose argument is NOT a single string literal. We allow
|
|
64
|
+
// import("./literal.ts") import('../x.ts')
|
|
65
|
+
// and reject
|
|
66
|
+
// import(filePath) import(`${x}`) import(someVar)
|
|
67
|
+
const VARIABLE_IMPORT_RE = /\bimport\(\s*(?!["'])/;
|
|
68
|
+
const STRING_LITERAL_IMPORT_RE = /\bimport\(\s*["'][^"']+["']\s*\)/;
|
|
69
|
+
|
|
70
|
+
// Return the executable (non-comment) lines of a source file.
|
|
71
|
+
//
|
|
72
|
+
// We deliberately avoid regex comment-stripping over the whole file: glob
|
|
73
|
+
// patterns and route literals contain comment-terminator and slash sequences
|
|
74
|
+
// inside string literals, and a naive block-comment regex spans across them
|
|
75
|
+
// and corrupts real code (e.g. swallowing a later `import(filePath)`),
|
|
76
|
+
// producing false negatives. A line-oriented filter is coarse but safe for our
|
|
77
|
+
// purpose — detecting `import(...)` / `Bun.Glob` constructs, which never
|
|
78
|
+
// legitimately begin inside a doc-comment block in this codebase.
|
|
79
|
+
function codeLines(src: string): string[] {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
let inBlock = false;
|
|
82
|
+
for (const raw of src.split("\n")) {
|
|
83
|
+
const line = raw;
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (inBlock) {
|
|
86
|
+
if (trimmed.includes("*/")) inBlock = false;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Whole-line block comment open without close on the same line.
|
|
90
|
+
if (trimmed.startsWith("/*") && !trimmed.includes("*/")) { inBlock = true; continue; }
|
|
91
|
+
if (trimmed.startsWith("*") || trimmed.startsWith("//")) continue;
|
|
92
|
+
out.push(line);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A line carries a runtime dynamic import with a non-literal argument. */
|
|
98
|
+
function hasVariableImport(line: string): boolean {
|
|
99
|
+
// Ignore type-position imports like `import("bun").BunPlugin`.
|
|
100
|
+
const cleaned = line.replace(/import\(\s*["'][^"']+["']\s*\)\s*\./g, "TYPE.");
|
|
101
|
+
return VARIABLE_IMPORT_RE.test(cleaned);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("compile-safety: server path is single-binary compatible", () => {
|
|
105
|
+
test("no Bun.Glob/.scan() on the server path outside dev-fallback functions", async () => {
|
|
106
|
+
const files = await serverFiles();
|
|
107
|
+
const violations: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const rel of files) {
|
|
110
|
+
// Use the RAW source here: glob patterns like "**/*.{tsx,ts}" contain a
|
|
111
|
+
// literal `*/` that a naive comment-stripper would mistake for a comment
|
|
112
|
+
// terminator and corrupt the string.
|
|
113
|
+
const src = await Bun.file(resolve(SERVER_DIR, rel)).text();
|
|
114
|
+
if (!/new Bun\.Glob|\.scan\(/.test(src)) continue;
|
|
115
|
+
const allowed = ALLOWED[rel] ?? [];
|
|
116
|
+
// Accept only if the file's glob usage matches an allowlisted pattern.
|
|
117
|
+
const ok = allowed.some((a) => src.includes(a));
|
|
118
|
+
if (!ok) violations.push(rel);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(violations).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("no variable (non-literal) dynamic import() on the server path outside dev-fallback functions", async () => {
|
|
125
|
+
const files = await serverFiles();
|
|
126
|
+
const violations: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const rel of files) {
|
|
129
|
+
const lines = codeLines(await Bun.file(resolve(SERVER_DIR, rel)).text());
|
|
130
|
+
const offending = lines.filter(hasVariableImport);
|
|
131
|
+
if (offending.length === 0) continue;
|
|
132
|
+
const allowed = ALLOWED[rel] ?? [];
|
|
133
|
+
// Every offending line must match an allowlisted dev-fallback construct.
|
|
134
|
+
const ok = offending.every((line) => allowed.some((a) => line.includes(a)));
|
|
135
|
+
if (!ok) violations.push(`${rel}: ${offending.map((l) => l.trim()).join(" | ")}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
expect(violations).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("self-check: the allowlisted files really do still contain their guarded constructs", async () => {
|
|
142
|
+
// Guards against the allowlist going stale (e.g. a file is refactored so the
|
|
143
|
+
// construct moves, leaving a dead allowlist entry that would mask a future
|
|
144
|
+
// real violation in that file).
|
|
145
|
+
for (const [rel, needles] of Object.entries(ALLOWED)) {
|
|
146
|
+
const src = await Bun.file(resolve(SERVER_DIR, rel)).text();
|
|
147
|
+
for (const needle of needles) {
|
|
148
|
+
expect(src.includes(needle)).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("serve.ts dynamic imports are all string literals (traceable by bun build --compile)", async () => {
|
|
154
|
+
const lines = codeLines(await Bun.file(resolve(SERVER_DIR, "serve.ts")).text());
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
// Skip type-position imports like `import("bun").BunPlugin[]`.
|
|
157
|
+
const cleaned = line.replace(/import\(\s*["'][^"']+["']\s*\)\s*[.[]/g, "TYPE");
|
|
158
|
+
if (!/\bimport\(/.test(cleaned)) continue;
|
|
159
|
+
// Any remaining runtime import( on this line must be a string literal.
|
|
160
|
+
expect(line).toMatch(STRING_LITERAL_IMPORT_RE);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end guardrail for the `bun build --compile` single-executable feature.
|
|
3
|
+
*
|
|
4
|
+
* This test runs the REAL single-binary pipeline against a minimal app and
|
|
5
|
+
* boots the produced executable:
|
|
6
|
+
*
|
|
7
|
+
* writeModuleRegistries() → app/_generated/{routes,actions}.ts (static imports)
|
|
8
|
+
* runBuild() → build/client/* + route-manifest.json
|
|
9
|
+
* writeManifestModule() → app/_generated/manifest.ts (inline constant)
|
|
10
|
+
* bun build --compile → a self-contained binary (no runtime fs scans)
|
|
11
|
+
*
|
|
12
|
+
* It then launches the binary and asserts the SSR response is correct —
|
|
13
|
+
* crucially that the route's `meta()` <title>/<meta> tags render into the HTML
|
|
14
|
+
* (the recently-added SSR meta path) and that `__BRACTJS_DATA__` is present.
|
|
15
|
+
* This converts "we believe it still compiles" into "CI proves the binary
|
|
16
|
+
* boots and serves correct HTML."
|
|
17
|
+
*
|
|
18
|
+
* It mirrors the CLI's `compile` command (bin/cli.ts) but drives the exported
|
|
19
|
+
* programmatic functions directly so it stays in-process and fast to author.
|
|
20
|
+
*
|
|
21
|
+
* The whole suite is skipped gracefully if `bun build --compile` isn't usable
|
|
22
|
+
* in the current environment (it is intentionally heavyweight).
|
|
23
|
+
*/
|
|
24
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
25
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
import { resolve, join } from "node:path";
|
|
27
|
+
import {
|
|
28
|
+
writeModuleRegistries,
|
|
29
|
+
writeManifestModule,
|
|
30
|
+
} from "../codegen/module-registry.ts";
|
|
31
|
+
import { runBuild } from "../build/bundler.ts";
|
|
32
|
+
|
|
33
|
+
const REPO_ROOT = resolve(import.meta.dir, "../..");
|
|
34
|
+
// Inside the repo tree so the app's `@bractjs/bractjs` import resolves to the
|
|
35
|
+
// in-repo framework (the package self-resolves its own name). `.tmp-*` is
|
|
36
|
+
// gitignored, so the working tree stays clean even if a run aborts.
|
|
37
|
+
const TMP = resolve(import.meta.dir, `.tmp-compile-${Date.now()}`);
|
|
38
|
+
const APP = join(TMP, "app");
|
|
39
|
+
const BIN = join(TMP, "bin", "app");
|
|
40
|
+
const PORT = 3987;
|
|
41
|
+
|
|
42
|
+
let compileAvailable = false;
|
|
43
|
+
let serverProc: Bun.Subprocess | null = null;
|
|
44
|
+
const originalCwd = process.cwd();
|
|
45
|
+
|
|
46
|
+
// Probe once: can we run `bun build --compile` at all here?
|
|
47
|
+
async function probeCompile(): Promise<boolean> {
|
|
48
|
+
const dir = join(TMP, ".probe");
|
|
49
|
+
await mkdir(dir, { recursive: true });
|
|
50
|
+
const entry = join(dir, "entry.ts");
|
|
51
|
+
const out = join(dir, "out");
|
|
52
|
+
await writeFile(entry, `console.log("ok");\n`);
|
|
53
|
+
try {
|
|
54
|
+
const proc = Bun.spawn(["bun", "build", "--compile", entry, "--outfile", out], {
|
|
55
|
+
stdout: "ignore",
|
|
56
|
+
stderr: "ignore",
|
|
57
|
+
});
|
|
58
|
+
return (await proc.exited) === 0;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function scaffoldApp(): Promise<void> {
|
|
65
|
+
await mkdir(join(APP, "routes"), { recursive: true });
|
|
66
|
+
await mkdir(join(TMP, "bin"), { recursive: true });
|
|
67
|
+
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(APP, "root.tsx"),
|
|
70
|
+
`import { Outlet, Scripts } from "@bractjs/bractjs";
|
|
71
|
+
export default function Root() {
|
|
72
|
+
return (
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head><meta charSet="utf-8" /></head>
|
|
75
|
+
<body><Outlet /><Scripts /></body>
|
|
76
|
+
</html>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await writeFile(
|
|
83
|
+
join(APP, "routes", "_index.tsx"),
|
|
84
|
+
`import type { LoaderArgs } from "@bractjs/bractjs";
|
|
85
|
+
export function loader(_args: LoaderArgs) {
|
|
86
|
+
return { greeting: "compiled-hello" };
|
|
87
|
+
}
|
|
88
|
+
export function meta() {
|
|
89
|
+
return [
|
|
90
|
+
{ title: "Compiled Title" },
|
|
91
|
+
{ name: "description", content: "Compiled description" },
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
export default function Index() {
|
|
95
|
+
return <main>index</main>;
|
|
96
|
+
}
|
|
97
|
+
`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await writeFile(
|
|
101
|
+
join(APP, "server.ts"),
|
|
102
|
+
`import { createServer } from "@bractjs/bractjs";
|
|
103
|
+
import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
|
|
104
|
+
import { actionModules } from "./_generated/actions.ts";
|
|
105
|
+
import { manifest } from "./_generated/manifest.ts";
|
|
106
|
+
|
|
107
|
+
createServer({
|
|
108
|
+
port: Number(process.env.PORT ?? ${PORT}),
|
|
109
|
+
appDir: "./app",
|
|
110
|
+
publicDir: "./public",
|
|
111
|
+
manifest,
|
|
112
|
+
routeFiles,
|
|
113
|
+
moduleRegistry,
|
|
114
|
+
actionModules,
|
|
115
|
+
});
|
|
116
|
+
`,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// tsconfig so --compile-autoload-tsconfig picks up the JSX runtime, mirroring
|
|
120
|
+
// the scaffold template.
|
|
121
|
+
await writeFile(
|
|
122
|
+
join(TMP, "tsconfig.json"),
|
|
123
|
+
JSON.stringify(
|
|
124
|
+
{
|
|
125
|
+
compilerOptions: {
|
|
126
|
+
target: "ESNext",
|
|
127
|
+
module: "ESNext",
|
|
128
|
+
moduleResolution: "bundler",
|
|
129
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
130
|
+
jsx: "react-jsx",
|
|
131
|
+
jsxImportSource: "react",
|
|
132
|
+
strict: true,
|
|
133
|
+
allowImportingTsExtensions: true,
|
|
134
|
+
noEmit: true,
|
|
135
|
+
skipLibCheck: true,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
2,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
await mkdir(join(TMP, "public"), { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `stdout`/`stderr` are typed as `number | ReadableStream` on a Bun
|
|
147
|
+
// Subprocess (the number branch is for inherited/ignored fds). Only read when
|
|
148
|
+
// it's an actual stream.
|
|
149
|
+
async function readStream(s: number | ReadableStream<Uint8Array> | undefined | null): Promise<string> {
|
|
150
|
+
if (!s || typeof s === "number") return "";
|
|
151
|
+
return new Response(s).text();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function waitForServer(url: string, timeoutMs = 15_000): Promise<boolean> {
|
|
155
|
+
const deadline = Date.now() + timeoutMs;
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetch(url);
|
|
159
|
+
if (res.ok || res.status === 404) return true;
|
|
160
|
+
} catch {
|
|
161
|
+
// not up yet
|
|
162
|
+
}
|
|
163
|
+
await Bun.sleep(150);
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
beforeAll(async () => {
|
|
169
|
+
await rm(TMP, { recursive: true, force: true });
|
|
170
|
+
await mkdir(TMP, { recursive: true });
|
|
171
|
+
compileAvailable = await probeCompile();
|
|
172
|
+
if (!compileAvailable) {
|
|
173
|
+
console.warn("[compile-smoke] `bun build --compile` unavailable — skipping e2e binary test.");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await scaffoldApp();
|
|
178
|
+
|
|
179
|
+
// Run the pipeline from inside the app dir (runBuild + manifest use cwd-relative
|
|
180
|
+
// `build/` paths, matching how the CLI runs).
|
|
181
|
+
process.chdir(TMP);
|
|
182
|
+
try {
|
|
183
|
+
// A) static registries for routes + actions
|
|
184
|
+
await writeModuleRegistries(resolve(TMP, "app"));
|
|
185
|
+
// B) client + server bundle (writes build/client + route-manifest.json)
|
|
186
|
+
await runBuild({ appDir: "./app", buildDir: "./build" });
|
|
187
|
+
// C) snapshot manifest → app/_generated/manifest.ts
|
|
188
|
+
await writeManifestModule(resolve(TMP, "app"), resolve(TMP, "build"));
|
|
189
|
+
|
|
190
|
+
// D) bun build --compile (mirror bin/cli.ts: dev NODE_ENV avoids the React
|
|
191
|
+
// TSX jsxDEV miscompile; --compile-autoload-tsconfig keeps JSX settings).
|
|
192
|
+
const compile = Bun.spawn(
|
|
193
|
+
[
|
|
194
|
+
"bun",
|
|
195
|
+
"build",
|
|
196
|
+
"--compile",
|
|
197
|
+
"--compile-autoload-tsconfig",
|
|
198
|
+
"app/server.ts",
|
|
199
|
+
"--outfile",
|
|
200
|
+
BIN,
|
|
201
|
+
],
|
|
202
|
+
{
|
|
203
|
+
cwd: TMP,
|
|
204
|
+
env: { ...process.env, NODE_ENV: "development" },
|
|
205
|
+
stdout: "pipe",
|
|
206
|
+
stderr: "pipe",
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
const code = await compile.exited;
|
|
210
|
+
if (code !== 0) {
|
|
211
|
+
const err = await readStream(compile.stderr);
|
|
212
|
+
throw new Error(`bun build --compile failed (${code}):\n${err}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Boot the binary (NODE_ENV=production so dev gates stay off — the real
|
|
216
|
+
// single-binary deployment mode).
|
|
217
|
+
serverProc = Bun.spawn([BIN], {
|
|
218
|
+
cwd: TMP,
|
|
219
|
+
env: { ...process.env, NODE_ENV: "production", PORT: String(PORT) },
|
|
220
|
+
stdout: "ignore",
|
|
221
|
+
stderr: "pipe",
|
|
222
|
+
});
|
|
223
|
+
const up = await waitForServer(`http://localhost:${PORT}/`);
|
|
224
|
+
if (!up) {
|
|
225
|
+
const err = await readStream(serverProc.stderr);
|
|
226
|
+
throw new Error(`compiled binary did not start listening:\n${err}`);
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
process.chdir(originalCwd);
|
|
230
|
+
}
|
|
231
|
+
}, 120_000);
|
|
232
|
+
|
|
233
|
+
afterAll(async () => {
|
|
234
|
+
try { serverProc?.kill(); } catch { /* already dead */ }
|
|
235
|
+
process.chdir(originalCwd);
|
|
236
|
+
await rm(TMP, { recursive: true, force: true });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("bun build --compile single-binary", () => {
|
|
240
|
+
test("compiled binary serves SSR HTML with 200", async () => {
|
|
241
|
+
if (!compileAvailable) return;
|
|
242
|
+
const res = await fetch(`http://localhost:${PORT}/`);
|
|
243
|
+
expect(res.status).toBe(200);
|
|
244
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("compiled binary renders meta() <title> and <meta> into the SSR head", async () => {
|
|
248
|
+
if (!compileAvailable) return;
|
|
249
|
+
const res = await fetch(`http://localhost:${PORT}/`);
|
|
250
|
+
const html = await res.text();
|
|
251
|
+
// Strip the data island so we assert on the rendered document, not the
|
|
252
|
+
// __BRACTJS_DATA__ JSON (which also carries the meta text).
|
|
253
|
+
const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/g, "");
|
|
254
|
+
expect(withoutScripts).toMatch(/<title>Compiled Title<\/title>/);
|
|
255
|
+
expect(withoutScripts).toMatch(/<meta[^>]+name="description"[^>]+content="Compiled description"/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("compiled binary embeds loader data + bootstrap island", async () => {
|
|
259
|
+
if (!compileAvailable) return;
|
|
260
|
+
const res = await fetch(`http://localhost:${PORT}/`);
|
|
261
|
+
const html = await res.text();
|
|
262
|
+
expect(html).toContain("__BRACTJS_DATA__");
|
|
263
|
+
expect(html).toContain("compiled-hello");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("compiled binary did not fall back to a runtime fs scan (registry mode)", async () => {
|
|
267
|
+
if (!compileAvailable) return;
|
|
268
|
+
// A 404 for an unmapped path proves routing came from the embedded trie,
|
|
269
|
+
// not a crash from a missing appDir scan.
|
|
270
|
+
const res = await fetch(`http://localhost:${PORT}/definitely-not-a-route`);
|
|
271
|
+
expect(res.status).toBe(404);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Keep REPO_ROOT referenced (documents where framework resolution comes from).
|
|
276
|
+
void REPO_ROOT;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { csp, getCspNonce, CSP_NONCE_KEY } from "../server/csp.ts";
|
|
3
|
+
import { MiddlewarePipeline, type MiddlewareContext } from "../server/middleware.ts";
|
|
4
|
+
import { renderRoute } from "../server/render.ts";
|
|
5
|
+
|
|
6
|
+
async function runCsp(
|
|
7
|
+
mw: ReturnType<typeof csp>,
|
|
8
|
+
handler: (ctx: MiddlewareContext) => Promise<Response>,
|
|
9
|
+
): Promise<{ res: Response; ctx: MiddlewareContext }> {
|
|
10
|
+
const ctx: MiddlewareContext = { request: new Request("http://x/"), params: {}, context: {} };
|
|
11
|
+
const pipeline = new MiddlewarePipeline();
|
|
12
|
+
pipeline.use(mw);
|
|
13
|
+
const res = await pipeline.run(ctx, () => handler(ctx));
|
|
14
|
+
return { res, ctx };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("csp middleware", () => {
|
|
18
|
+
test("sets Content-Security-Policy header with a script-src nonce", async () => {
|
|
19
|
+
const { res, ctx } = await runCsp(csp(), () => Promise.resolve(new Response("ok")));
|
|
20
|
+
const policy = res.headers.get("Content-Security-Policy");
|
|
21
|
+
expect(policy).toBeTruthy();
|
|
22
|
+
const nonce = getCspNonce(ctx.context);
|
|
23
|
+
expect(nonce).toBeTruthy();
|
|
24
|
+
expect(policy).toContain(`'nonce-${nonce}'`);
|
|
25
|
+
expect(policy).toContain("default-src 'self'");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("stashes the nonce on the context under CSP_NONCE_KEY", async () => {
|
|
29
|
+
const { ctx } = await runCsp(csp(), () => Promise.resolve(new Response("ok")));
|
|
30
|
+
expect(typeof ctx.context[CSP_NONCE_KEY]).toBe("string");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("generates a fresh nonce per request", async () => {
|
|
34
|
+
const a = await runCsp(csp(), () => Promise.resolve(new Response("ok")));
|
|
35
|
+
const b = await runCsp(csp(), () => Promise.resolve(new Response("ok")));
|
|
36
|
+
expect(getCspNonce(a.ctx.context)).not.toBe(getCspNonce(b.ctx.context));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("custom directives override/extend the defaults", async () => {
|
|
40
|
+
const { res } = await runCsp(
|
|
41
|
+
csp({ directives: { "img-src": "'self' https://cdn.example", "frame-ancestors": "'none'" } }),
|
|
42
|
+
() => Promise.resolve(new Response("ok")),
|
|
43
|
+
);
|
|
44
|
+
const policy = res.headers.get("Content-Security-Policy")!;
|
|
45
|
+
expect(policy).toContain("img-src 'self' https://cdn.example");
|
|
46
|
+
expect(policy).toContain("frame-ancestors 'none'");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a null directive value removes that directive", async () => {
|
|
50
|
+
const { res } = await runCsp(
|
|
51
|
+
csp({ directives: { "object-src": null } }),
|
|
52
|
+
() => Promise.resolve(new Response("ok")),
|
|
53
|
+
);
|
|
54
|
+
expect(res.headers.get("Content-Security-Policy")).not.toContain("object-src");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("reportOnly emits the report-only header instead", async () => {
|
|
58
|
+
const { res } = await runCsp(csp({ reportOnly: true }), () => Promise.resolve(new Response("ok")));
|
|
59
|
+
expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeTruthy();
|
|
60
|
+
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("csp + render", () => {
|
|
65
|
+
test("renderRoute stamps the nonce onto the inline bootstrap script", async () => {
|
|
66
|
+
const res = await renderRoute({
|
|
67
|
+
shell: null,
|
|
68
|
+
loaderData: {},
|
|
69
|
+
actionData: null,
|
|
70
|
+
params: {},
|
|
71
|
+
pathname: "/",
|
|
72
|
+
manifest: { clientEntry: "/build/client/client.js", routes: {} },
|
|
73
|
+
meta: [],
|
|
74
|
+
nonce: "test-nonce-123",
|
|
75
|
+
});
|
|
76
|
+
const html = await res.text();
|
|
77
|
+
// The inline bootstrap script React emits should carry our nonce.
|
|
78
|
+
expect(html).toMatch(/<script[^>]*nonce="test-nonce-123"/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -5,9 +5,13 @@ export function loader() {
|
|
|
5
5
|
return { message: "hello from bractjs" };
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
// Meta returns a title descriptor
|
|
8
|
+
// Meta returns a title + description descriptor
|
|
9
9
|
export function meta() {
|
|
10
|
-
return [
|
|
10
|
+
return [
|
|
11
|
+
{ title: "BractJS Test Home" },
|
|
12
|
+
{ name: "description", content: "Bract test description" },
|
|
13
|
+
{ property: "og:title", content: "Bract OG Title" },
|
|
14
|
+
];
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
// Action echoes the submitted form field
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Fixture for the /_data auth-parity test. beforeLoad() is the contract point
|
|
2
|
+
// where auth must live: it runs for BOTH full-page GET and the /_data soft-nav
|
|
3
|
+
// JSON endpoint, so a route gated here cannot leak loader data via /_data.
|
|
4
|
+
export function beforeLoad(): Response {
|
|
5
|
+
return new Response("Forbidden", { status: 403 });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function loader() {
|
|
9
|
+
// Must never reach the client — beforeLoad short-circuits first.
|
|
10
|
+
return { secret: "TOP-SECRET-LOADER-DATA" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function Protected() {
|
|
14
|
+
return <p>protected</p>;
|
|
15
|
+
}
|