@bractjs/bractjs 0.1.0
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/LICENSE +21 -0
- package/README.md +586 -0
- package/bin/cli.ts +101 -0
- package/package.json +58 -0
- package/src/__tests__/fixtures/app/root.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +20 -0
- package/src/__tests__/integration.test.ts +66 -0
- package/src/__tests__/loader.test.ts +89 -0
- package/src/__tests__/matcher.test.ts +69 -0
- package/src/__tests__/meta.test.ts +81 -0
- package/src/__tests__/scanner.test.ts +58 -0
- package/src/__tests__/session.test.ts +103 -0
- package/src/build/bundler.ts +75 -0
- package/src/build/defines.ts +16 -0
- package/src/build/directives.ts +67 -0
- package/src/build/env-plugin.ts +56 -0
- package/src/build/hash.ts +56 -0
- package/src/build/manifest.ts +60 -0
- package/src/client/ClientRouter.tsx +122 -0
- package/src/client/components/Await.tsx +26 -0
- package/src/client/components/Form.tsx +67 -0
- package/src/client/components/Image.tsx +79 -0
- package/src/client/components/Link.tsx +42 -0
- package/src/client/components/LiveReload.tsx +16 -0
- package/src/client/components/Outlet.tsx +64 -0
- package/src/client/components/Scripts.tsx +12 -0
- package/src/client/entry.tsx +49 -0
- package/src/client/form-utils.ts +12 -0
- package/src/client/hooks/useActionData.ts +14 -0
- package/src/client/hooks/useFetcher.ts +51 -0
- package/src/client/hooks/useLoaderData.ts +14 -0
- package/src/client/hooks/useNavigation.ts +12 -0
- package/src/client/hooks/useParams.ts +14 -0
- package/src/client/nav-utils.ts +35 -0
- package/src/client/prefetch.ts +32 -0
- package/src/client/route-cache.ts +20 -0
- package/src/client/router.tsx +54 -0
- package/src/client/types.ts +23 -0
- package/src/codegen/route-codegen.ts +99 -0
- package/src/dev/error-overlay.ts +33 -0
- package/src/dev/hmr-client.ts +43 -0
- package/src/dev/hmr-module-handler.ts +47 -0
- package/src/dev/hmr-server.ts +51 -0
- package/src/dev/rebuilder.ts +95 -0
- package/src/dev/server.ts +38 -0
- package/src/dev/watcher.ts +32 -0
- package/src/image/cache.ts +75 -0
- package/src/image/handler.ts +82 -0
- package/src/image/optimizer.ts +76 -0
- package/src/image/types.ts +27 -0
- package/src/index.ts +51 -0
- package/src/middleware/authGuard.ts +37 -0
- package/src/middleware/cors.ts +36 -0
- package/src/middleware/requestLogger.ts +15 -0
- package/src/server/action-handler.ts +35 -0
- package/src/server/action-registry.ts +41 -0
- package/src/server/env.ts +29 -0
- package/src/server/index.ts +8 -0
- package/src/server/layout.ts +92 -0
- package/src/server/loader.ts +80 -0
- package/src/server/matcher.ts +99 -0
- package/src/server/meta.ts +92 -0
- package/src/server/middleware.ts +47 -0
- package/src/server/render.ts +65 -0
- package/src/server/request-handler.ts +142 -0
- package/src/server/response.ts +17 -0
- package/src/server/scanner.ts +68 -0
- package/src/server/serve.ts +131 -0
- package/src/server/session.ts +111 -0
- package/src/server/static.ts +40 -0
- package/src/shared/context.ts +37 -0
- package/src/shared/deferred.ts +55 -0
- package/src/shared/error-boundary.tsx +58 -0
- package/src/shared/errors.ts +49 -0
- package/src/shared/route-types.ts +51 -0
- package/templates/new-app/app/root.tsx +20 -0
- package/templates/new-app/app/routes/_index.tsx +31 -0
- package/templates/new-app/app/routes/about.tsx +21 -0
- package/templates/new-app/bractjs.config.ts +14 -0
- package/templates/new-app/package.json +20 -0
- package/types/config.d.ts +25 -0
- package/types/index.d.ts +109 -0
- package/types/middleware.d.ts +19 -0
- package/types/route.d.ts +41 -0
- package/types/session.d.ts +32 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bractjs/bractjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/bractjs/bractjs#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/bractjs/bractjs.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/bractjs/bractjs/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"bun",
|
|
16
|
+
"react",
|
|
17
|
+
"react19",
|
|
18
|
+
"ssr",
|
|
19
|
+
"framework",
|
|
20
|
+
"routing",
|
|
21
|
+
"server-actions",
|
|
22
|
+
"streaming",
|
|
23
|
+
"typescript",
|
|
24
|
+
"full-stack"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"bin",
|
|
29
|
+
"types",
|
|
30
|
+
"templates"
|
|
31
|
+
],
|
|
32
|
+
"bin": {
|
|
33
|
+
"bractjs": "./bin/cli.ts"
|
|
34
|
+
},
|
|
35
|
+
"types": "./types/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./types/index.d.ts",
|
|
39
|
+
"default": "./src/index.ts"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"dev": "bun run src/dev/server.ts",
|
|
44
|
+
"build": "bun run src/build/bundler.ts",
|
|
45
|
+
"test": "bun test"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"react": "^19",
|
|
49
|
+
"react-dom": "^19"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/bun": "latest",
|
|
53
|
+
"@types/react": "^19",
|
|
54
|
+
"@types/react-dom": "^19",
|
|
55
|
+
"react": "^19",
|
|
56
|
+
"react-dom": "^19"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ActionArgs } from "../../../../shared/route-types.ts";
|
|
2
|
+
|
|
3
|
+
// Loader returns data that will appear in __BRACTJS_DATA__
|
|
4
|
+
export function loader() {
|
|
5
|
+
return { message: "hello from bractjs" };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Meta returns a title descriptor
|
|
9
|
+
export function meta() {
|
|
10
|
+
return [{ title: "BractJS Test Home" }];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Action echoes the submitted form field
|
|
14
|
+
export async function action({ formData }: ActionArgs) {
|
|
15
|
+
return { submitted: true, name: formData.get("name") };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function IndexPage() {
|
|
19
|
+
return <p>Index page content</p>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { createServer } from "../server/serve.ts";
|
|
4
|
+
|
|
5
|
+
const PORT = 3999;
|
|
6
|
+
const BASE = `http://localhost:${PORT}`;
|
|
7
|
+
const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
|
|
8
|
+
|
|
9
|
+
let handle: ReturnType<typeof createServer>;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
handle = createServer({
|
|
13
|
+
port: PORT,
|
|
14
|
+
appDir: FIXTURE_APP,
|
|
15
|
+
manifest: { clientEntry: "/build/client/client.js", routes: {} },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
handle.stop();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("GET / returns 200 HTML", async () => {
|
|
24
|
+
const res = await fetch(`${BASE}/`);
|
|
25
|
+
expect(res.status).toBe(200);
|
|
26
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("GET /_data?path=/ returns JSON with route key", async () => {
|
|
30
|
+
const res = await fetch(`${BASE}/_data?path=/`);
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
33
|
+
expect(data).toHaveProperty("route");
|
|
34
|
+
expect(data).toHaveProperty("params");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("POST / runs action and returns 200 HTML", async () => {
|
|
38
|
+
const form = new FormData();
|
|
39
|
+
form.set("name", "bract");
|
|
40
|
+
const res = await fetch(`${BASE}/`, { method: "POST", body: form });
|
|
41
|
+
expect(res.status).toBe(200);
|
|
42
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("GET /nonexistent returns 404", async () => {
|
|
46
|
+
const res = await fetch(`${BASE}/nonexistent`);
|
|
47
|
+
expect(res.status).toBe(404);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("HTML includes window.__BRACTJS_DATA__", async () => {
|
|
51
|
+
const res = await fetch(`${BASE}/`);
|
|
52
|
+
const html = await res.text();
|
|
53
|
+
expect(html).toContain("__BRACTJS_DATA__");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("HTML includes loader data from route", async () => {
|
|
57
|
+
const res = await fetch(`${BASE}/`);
|
|
58
|
+
const html = await res.text();
|
|
59
|
+
expect(html).toContain("hello from bractjs");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("HTML includes <title> from meta()", async () => {
|
|
63
|
+
const res = await fetch(`${BASE}/`);
|
|
64
|
+
const html = await res.text();
|
|
65
|
+
expect(html).toContain("BractJS Test Home");
|
|
66
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { safeRun, runLoaders, buildLoaderArgs } from "../server/loader.ts";
|
|
3
|
+
import { HttpError } from "../shared/errors.ts";
|
|
4
|
+
import type { LoaderArgs } from "../shared/route-types.ts";
|
|
5
|
+
import type { LayoutChain } from "../server/layout.ts";
|
|
6
|
+
import type { RouteModule } from "../shared/route-types.ts";
|
|
7
|
+
|
|
8
|
+
const stubArgs: LoaderArgs = {
|
|
9
|
+
request: new Request("http://localhost/"),
|
|
10
|
+
params: {},
|
|
11
|
+
context: {},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const emptyModule: RouteModule = {};
|
|
15
|
+
|
|
16
|
+
describe("safeRun", () => {
|
|
17
|
+
test("returns null when fn is undefined", async () => {
|
|
18
|
+
const result = await safeRun(undefined, stubArgs);
|
|
19
|
+
expect(result).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns loader data on success", async () => {
|
|
23
|
+
const result = await safeRun(async () => ({ name: "bract" }), stubArgs);
|
|
24
|
+
expect(result).toEqual({ name: "bract" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("wraps non-redirect errors in __error", async () => {
|
|
28
|
+
const result = await safeRun(async () => { throw new Error("boom"); }, stubArgs);
|
|
29
|
+
expect(result).toMatchObject({ __error: expect.any(Error) });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("re-throws HttpError (does not wrap)", async () => {
|
|
33
|
+
const fn = async () => { throw new HttpError(403, "Forbidden"); };
|
|
34
|
+
await expect(safeRun(fn, stubArgs)).rejects.toBeInstanceOf(HttpError);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("re-throws redirect Response", async () => {
|
|
38
|
+
const fn = async () => { throw new Response(null, { status: 302, headers: { Location: "/" } }); };
|
|
39
|
+
await expect(safeRun(fn, stubArgs)).rejects.toBeInstanceOf(Response);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("runLoaders", () => {
|
|
44
|
+
test("runs all loaders in parallel and returns results", async () => {
|
|
45
|
+
const chain: LayoutChain = {
|
|
46
|
+
root: { ...emptyModule, loader: async () => ({ root: true }) },
|
|
47
|
+
layouts: [{ ...emptyModule, loader: async () => ({ layout: true }) }],
|
|
48
|
+
route: { ...emptyModule, loader: async () => ({ route: true }) },
|
|
49
|
+
};
|
|
50
|
+
const results = await runLoaders(chain, stubArgs);
|
|
51
|
+
expect(results.root).toEqual({ root: true });
|
|
52
|
+
expect(results.layouts[0]).toEqual({ layout: true });
|
|
53
|
+
expect(results.route).toEqual({ route: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns null for modules without loaders", async () => {
|
|
57
|
+
const chain: LayoutChain = {
|
|
58
|
+
root: emptyModule,
|
|
59
|
+
layouts: [],
|
|
60
|
+
route: emptyModule,
|
|
61
|
+
};
|
|
62
|
+
const results = await runLoaders(chain, stubArgs);
|
|
63
|
+
expect(results.root).toBeNull();
|
|
64
|
+
expect(results.route).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("isolates errors — one loader failure doesn't prevent others", async () => {
|
|
68
|
+
const chain: LayoutChain = {
|
|
69
|
+
root: { ...emptyModule, loader: async () => { throw new Error("root fail"); } },
|
|
70
|
+
layouts: [],
|
|
71
|
+
route: { ...emptyModule, loader: async () => ({ ok: true }) },
|
|
72
|
+
};
|
|
73
|
+
const results = await runLoaders(chain, stubArgs);
|
|
74
|
+
expect(results.root).toMatchObject({ __error: expect.any(Error) });
|
|
75
|
+
expect(results.route).toEqual({ ok: true });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("buildLoaderArgs", () => {
|
|
80
|
+
test("assembles args from request, params, context", () => {
|
|
81
|
+
const req = new Request("http://localhost/blog/42");
|
|
82
|
+
const params = { id: "42" };
|
|
83
|
+
const context = { user: null };
|
|
84
|
+
const args = buildLoaderArgs(req, params, context);
|
|
85
|
+
expect(args.request).toBe(req);
|
|
86
|
+
expect(args.params).toBe(params);
|
|
87
|
+
expect(args.context).toBe(context);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { buildTrie, matchRoute } from "../server/matcher.ts";
|
|
3
|
+
import type { RouteFile } from "../server/scanner.ts";
|
|
4
|
+
import { pathToSegments } from "../server/scanner.ts";
|
|
5
|
+
|
|
6
|
+
function makeRoute(pattern: string): RouteFile {
|
|
7
|
+
return { filePath: `routes/${pattern}.tsx`, urlPattern: pattern, segments: pathToSegments(pattern) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("matchRoute", () => {
|
|
11
|
+
test("matches exact static route /about", () => {
|
|
12
|
+
const trie = buildTrie([makeRoute("about")]);
|
|
13
|
+
const result = matchRoute("/about", trie);
|
|
14
|
+
expect(result).not.toBeNull();
|
|
15
|
+
expect(result?.routeFile.urlPattern).toBe("about");
|
|
16
|
+
expect(result?.params).toEqual({});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("matches index route /", () => {
|
|
20
|
+
const trie = buildTrie([makeRoute("")]);
|
|
21
|
+
const result = matchRoute("/", trie);
|
|
22
|
+
expect(result).not.toBeNull();
|
|
23
|
+
expect(result?.params).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("matches /blog/42 with params.id = '42'", () => {
|
|
27
|
+
const trie = buildTrie([makeRoute("blog/[id]")]);
|
|
28
|
+
const result = matchRoute("/blog/42", trie);
|
|
29
|
+
expect(result).not.toBeNull();
|
|
30
|
+
expect(result?.params).toEqual({ id: "42" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("prefers static /blog/new over dynamic /blog/[id]", () => {
|
|
34
|
+
const trie = buildTrie([makeRoute("blog/[id]"), makeRoute("blog/new")]);
|
|
35
|
+
const result = matchRoute("/blog/new", trie);
|
|
36
|
+
expect(result?.routeFile.urlPattern).toBe("blog/new");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("matches /docs/a/b/c as catch-all with slug = 'a/b/c'", () => {
|
|
40
|
+
const trie = buildTrie([makeRoute("docs/[...slug]")]);
|
|
41
|
+
const result = matchRoute("/docs/a/b/c", trie);
|
|
42
|
+
expect(result).not.toBeNull();
|
|
43
|
+
expect(result?.params.slug).toBe("a/b/c");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns null for unmatched pathname", () => {
|
|
47
|
+
const trie = buildTrie([makeRoute("about")]);
|
|
48
|
+
expect(matchRoute("/missing", trie)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns null for empty trie", () => {
|
|
52
|
+
const trie = buildTrie([]);
|
|
53
|
+
expect(matchRoute("/anything", trie)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("matches nested static /blog/posts/featured", () => {
|
|
57
|
+
const trie = buildTrie([makeRoute("blog/posts/featured")]);
|
|
58
|
+
const result = matchRoute("/blog/posts/featured", trie);
|
|
59
|
+
expect(result).not.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("param does not match when static exists at same depth", () => {
|
|
63
|
+
const trie = buildTrie([makeRoute("users/profile"), makeRoute("users/[id]")]);
|
|
64
|
+
const r1 = matchRoute("/users/profile", trie);
|
|
65
|
+
expect(r1?.routeFile.urlPattern).toBe("users/profile");
|
|
66
|
+
const r2 = matchRoute("/users/123", trie);
|
|
67
|
+
expect(r2?.params.id).toBe("123");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { mergeMeta, renderMetaTags } from "../server/meta.ts";
|
|
3
|
+
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
4
|
+
|
|
5
|
+
describe("mergeMeta", () => {
|
|
6
|
+
test("keeps single descriptors unchanged", () => {
|
|
7
|
+
const input: MetaDescriptor[] = [
|
|
8
|
+
{ title: "Home" },
|
|
9
|
+
{ name: "description", content: "A site" },
|
|
10
|
+
];
|
|
11
|
+
const result = mergeMeta(input);
|
|
12
|
+
expect(result.some((d) => "title" in d && (d as { title: string }).title === "Home")).toBe(true);
|
|
13
|
+
expect(result.some((d) => "name" in d && (d as { name: string }).name === "description")).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("last title wins (dedup)", () => {
|
|
17
|
+
const input: MetaDescriptor[] = [
|
|
18
|
+
{ title: "First" },
|
|
19
|
+
{ title: "Last" },
|
|
20
|
+
];
|
|
21
|
+
const result = mergeMeta(input);
|
|
22
|
+
const titles = result.filter((d) => "title" in d);
|
|
23
|
+
expect(titles.length).toBe(1);
|
|
24
|
+
expect((titles[0] as { title: string }).title).toBe("Last");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("last name descriptor wins for same name key", () => {
|
|
28
|
+
const input: MetaDescriptor[] = [
|
|
29
|
+
{ name: "description", content: "old" },
|
|
30
|
+
{ name: "description", content: "new" },
|
|
31
|
+
];
|
|
32
|
+
const result = mergeMeta(input);
|
|
33
|
+
const descs = result.filter((d) => "name" in d && (d as { name: string }).name === "description");
|
|
34
|
+
expect(descs.length).toBe(1);
|
|
35
|
+
expect((descs[0] as { content: string }).content).toBe("new");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("last property descriptor wins for same property key", () => {
|
|
39
|
+
const input: MetaDescriptor[] = [
|
|
40
|
+
{ property: "og:title", content: "old" },
|
|
41
|
+
{ property: "og:title", content: "new" },
|
|
42
|
+
];
|
|
43
|
+
const result = mergeMeta(input);
|
|
44
|
+
const og = result.filter((d) => "property" in d);
|
|
45
|
+
expect(og.length).toBe(1);
|
|
46
|
+
expect((og[0] as { content: string }).content).toBe("new");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns empty array for empty input", () => {
|
|
50
|
+
expect(mergeMeta([])).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("renderMetaTags", () => {
|
|
55
|
+
test("renders <title> tag", () => {
|
|
56
|
+
const html = renderMetaTags([{ title: "Hello" }]);
|
|
57
|
+
expect(html).toContain("<title>Hello</title>");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("renders <meta name> tag", () => {
|
|
61
|
+
const html = renderMetaTags([{ name: "description", content: "A desc" }]);
|
|
62
|
+
expect(html).toContain('name="description"');
|
|
63
|
+
expect(html).toContain('content="A desc"');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("renders <meta property> tag", () => {
|
|
67
|
+
const html = renderMetaTags([{ property: "og:title", content: "OG" }]);
|
|
68
|
+
expect(html).toContain('property="og:title"');
|
|
69
|
+
expect(html).toContain('content="OG"');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("escapes HTML in title", () => {
|
|
73
|
+
const html = renderMetaTags([{ title: "<script>alert(1)</script>" }]);
|
|
74
|
+
expect(html).not.toContain("<script>");
|
|
75
|
+
expect(html).toContain("<script>");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns empty string for empty input", () => {
|
|
79
|
+
expect(renderMetaTags([])).toBe("");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { filePathToPattern, pathToSegments } from "../server/scanner.ts";
|
|
3
|
+
|
|
4
|
+
describe("filePathToPattern", () => {
|
|
5
|
+
test("_index maps to empty pattern (root index)", () => {
|
|
6
|
+
expect(filePathToPattern("routes/_index.tsx")).toBe("");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("about.tsx → 'about'", () => {
|
|
10
|
+
expect(filePathToPattern("routes/about.tsx")).toBe("about");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("[id].tsx → '[id]'", () => {
|
|
14
|
+
expect(filePathToPattern("routes/blog/[id].tsx")).toBe("blog/[id]");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("[...slug].tsx → 'docs/[...slug]'", () => {
|
|
18
|
+
expect(filePathToPattern("routes/docs/[...slug].tsx")).toBe("docs/[...slug]");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("nested _index → parent pattern", () => {
|
|
22
|
+
expect(filePathToPattern("routes/blog/_index.tsx")).toBe("blog");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("strips .ts extension too", () => {
|
|
26
|
+
expect(filePathToPattern("routes/api/data.ts")).toBe("api/data");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("pathToSegments", () => {
|
|
31
|
+
test("empty pattern → empty segments", () => {
|
|
32
|
+
expect(pathToSegments("")).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("static segment", () => {
|
|
36
|
+
expect(pathToSegments("about")).toEqual(["about"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("[id] → param segment", () => {
|
|
40
|
+
expect(pathToSegments("blog/[id]")).toEqual(["blog", { param: "id" }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("[...slug] → catchAll segment", () => {
|
|
44
|
+
expect(pathToSegments("docs/[...slug]")).toEqual(["docs", { catchAll: "slug" }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("nested static path", () => {
|
|
48
|
+
expect(pathToSegments("a/b/c")).toEqual(["a", "b", "c"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("mixed static and param", () => {
|
|
52
|
+
expect(pathToSegments("users/[id]/posts")).toEqual([
|
|
53
|
+
"users",
|
|
54
|
+
{ param: "id" },
|
|
55
|
+
"posts",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { createCookieSession } from "../server/session.ts";
|
|
3
|
+
|
|
4
|
+
const sessionStorage = createCookieSession({
|
|
5
|
+
name: "__test",
|
|
6
|
+
secrets: ["secret-one", "secret-two"],
|
|
7
|
+
secure: false,
|
|
8
|
+
sameSite: "Lax",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("createCookieSession — getSession", () => {
|
|
12
|
+
test("returns empty session for null cookie", async () => {
|
|
13
|
+
const s = await sessionStorage.getSession(null);
|
|
14
|
+
expect(s.get("user")).toBeUndefined();
|
|
15
|
+
expect(s.has("user")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns empty session for empty string cookie", async () => {
|
|
19
|
+
const s = await sessionStorage.getSession("");
|
|
20
|
+
expect(s.has("anything")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns empty session for missing cookie name", async () => {
|
|
24
|
+
const s = await sessionStorage.getSession("other=value");
|
|
25
|
+
expect(s.has("x")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns empty session for tampered signature", async () => {
|
|
29
|
+
const s1 = await sessionStorage.getSession(null);
|
|
30
|
+
s1.set("user", { id: 1 });
|
|
31
|
+
const cookie = await sessionStorage.commitSession(s1);
|
|
32
|
+
// Tamper: replace last char of cookie value
|
|
33
|
+
const tampered = cookie.replace(/=([^;]+)/, (_, val) => `=${val.slice(0, -1)}X`);
|
|
34
|
+
const s2 = await sessionStorage.getSession(tampered);
|
|
35
|
+
expect(s2.has("user")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("createCookieSession — commitSession + roundtrip", () => {
|
|
40
|
+
test("round-trips session data through cookie", async () => {
|
|
41
|
+
const s1 = await sessionStorage.getSession(null);
|
|
42
|
+
s1.set("userId", 42);
|
|
43
|
+
s1.set("role", "admin");
|
|
44
|
+
const cookieHeader = await sessionStorage.commitSession(s1);
|
|
45
|
+
|
|
46
|
+
// Extract just the Set-Cookie value (first segment before ";")
|
|
47
|
+
const cookieValue = cookieHeader.split(";")[0];
|
|
48
|
+
const s2 = await sessionStorage.getSession(cookieValue);
|
|
49
|
+
expect(s2.get("userId")).toBe(42);
|
|
50
|
+
expect(s2.get("role")).toBe("admin");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("includes HttpOnly in Set-Cookie string", async () => {
|
|
54
|
+
const s = await sessionStorage.getSession(null);
|
|
55
|
+
const cookie = await sessionStorage.commitSession(s);
|
|
56
|
+
expect(cookie).toContain("HttpOnly");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("includes SameSite=Lax in Set-Cookie string", async () => {
|
|
60
|
+
const s = await sessionStorage.getSession(null);
|
|
61
|
+
const cookie = await sessionStorage.commitSession(s);
|
|
62
|
+
expect(cookie).toContain("SameSite=Lax");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("includes Max-Age when opts.maxAge provided", async () => {
|
|
66
|
+
const s = await sessionStorage.getSession(null);
|
|
67
|
+
const cookie = await sessionStorage.commitSession(s, { maxAge: 3600 });
|
|
68
|
+
expect(cookie).toContain("Max-Age=3600");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("secret rotation: old secret still verifies", async () => {
|
|
72
|
+
const oldStorage = createCookieSession({
|
|
73
|
+
name: "__test",
|
|
74
|
+
secrets: ["secret-two"], // only the old secret
|
|
75
|
+
secure: false,
|
|
76
|
+
});
|
|
77
|
+
const s1 = await oldStorage.getSession(null);
|
|
78
|
+
s1.set("x", "y");
|
|
79
|
+
const cookie = await oldStorage.commitSession(s1);
|
|
80
|
+
|
|
81
|
+
// New storage has new secret first, old secret second (rotation)
|
|
82
|
+
const newStorage = createCookieSession({
|
|
83
|
+
name: "__test",
|
|
84
|
+
secrets: ["secret-one", "secret-two"],
|
|
85
|
+
secure: false,
|
|
86
|
+
});
|
|
87
|
+
const cookieValue = cookie.split(";")[0];
|
|
88
|
+
const s2 = await newStorage.getSession(cookieValue);
|
|
89
|
+
expect(s2.get("x")).toBe("y");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("Session methods", () => {
|
|
94
|
+
test("set / get / has / delete", async () => {
|
|
95
|
+
const s = await sessionStorage.getSession(null);
|
|
96
|
+
s.set("key", "value");
|
|
97
|
+
expect(s.has("key")).toBe(true);
|
|
98
|
+
expect(s.get("key")).toBe("value");
|
|
99
|
+
s.delete("key");
|
|
100
|
+
expect(s.has("key")).toBe(false);
|
|
101
|
+
expect(s.get("key")).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { rename } from "node:fs/promises";
|
|
3
|
+
import type { BractJSConfig } from "../server/serve.ts";
|
|
4
|
+
import { scanRoutes } from "../server/scanner.ts";
|
|
5
|
+
import { contentHash } from "./hash.ts";
|
|
6
|
+
import { generateManifest, writeManifest } from "./manifest.ts";
|
|
7
|
+
import { serverOnlyPlugin, clientEnvPlugin } from "./env-plugin.ts";
|
|
8
|
+
import { buildDefines } from "./defines.ts";
|
|
9
|
+
import { writeRouteTypes } from "../codegen/route-codegen.ts";
|
|
10
|
+
import { useClientStubPlugin, useServerProxyPlugin } from "./directives.ts";
|
|
11
|
+
|
|
12
|
+
export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
13
|
+
const appDir = config.appDir ?? "./app";
|
|
14
|
+
|
|
15
|
+
// ── 0. Codegen — typed routes ───────────────────────────────────────────
|
|
16
|
+
await writeRouteTypes(appDir);
|
|
17
|
+
const routes = await scanRoutes(appDir);
|
|
18
|
+
const routeFilePaths = routes.map((r) => r.filePath);
|
|
19
|
+
const rootFilePath = join(appDir, "root.tsx");
|
|
20
|
+
|
|
21
|
+
// ── 1. Server bundle ────────────────────────────────────────────────────
|
|
22
|
+
const serverResult = await Bun.build({
|
|
23
|
+
entrypoints: ["src/server/index.ts"],
|
|
24
|
+
target: "bun",
|
|
25
|
+
outdir: "build/server",
|
|
26
|
+
sourcemap: config.sourcemap ?? "external",
|
|
27
|
+
plugins: [useClientStubPlugin],
|
|
28
|
+
});
|
|
29
|
+
if (!serverResult.success) throw new AggregateError(serverResult.logs, "Server build failed");
|
|
30
|
+
|
|
31
|
+
// ── 2. Client bundle (code-split) ───────────────────────────────────────
|
|
32
|
+
const clientResult = await Bun.build({
|
|
33
|
+
entrypoints: ["src/client/entry.tsx", rootFilePath, ...routeFilePaths],
|
|
34
|
+
target: "browser",
|
|
35
|
+
splitting: true,
|
|
36
|
+
outdir: "build/client",
|
|
37
|
+
// No publicPath: relative chunk refs work correctly when files are served
|
|
38
|
+
// at URLs matching their outdir structure (e.g. /build/client/chunk-xxx.js).
|
|
39
|
+
minify: config.minify ?? true,
|
|
40
|
+
sourcemap: config.sourcemap ?? "external",
|
|
41
|
+
define: buildDefines(config),
|
|
42
|
+
plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>)],
|
|
43
|
+
});
|
|
44
|
+
if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
|
|
45
|
+
|
|
46
|
+
// ── 3. Hash + rename output files ──────────────────────────────────────
|
|
47
|
+
const routeChunks = new Map<string, string>();
|
|
48
|
+
let clientEntry = "";
|
|
49
|
+
let rootChunk: string | undefined;
|
|
50
|
+
|
|
51
|
+
for (const artifact of clientResult.outputs) {
|
|
52
|
+
if (artifact.kind !== "chunk" && artifact.kind !== "entry-point") continue;
|
|
53
|
+
const hash = await contentHash(artifact.path);
|
|
54
|
+
const ext = artifact.path.slice(artifact.path.lastIndexOf("."));
|
|
55
|
+
const base = artifact.path.slice(0, artifact.path.lastIndexOf("."));
|
|
56
|
+
const hashedPath = `${base}.${hash}${ext}`;
|
|
57
|
+
await rename(artifact.path, hashedPath);
|
|
58
|
+
|
|
59
|
+
const publicPath = "/" + hashedPath.replace(/^build\//, "build/");
|
|
60
|
+
if (artifact.kind === "entry-point" && artifact.path.includes("entry")) {
|
|
61
|
+
clientEntry = publicPath;
|
|
62
|
+
} else if (artifact.kind === "entry-point" && artifact.path.includes("root")) {
|
|
63
|
+
rootChunk = publicPath;
|
|
64
|
+
} else {
|
|
65
|
+
// Map route file path back to URL pattern
|
|
66
|
+
const matched = routes.find((r) => artifact.path.includes(r.filePath.replace("./", "")));
|
|
67
|
+
if (matched) routeChunks.set(matched.urlPattern, publicPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── 4. Write manifest ──────────────────────────────────────────────────
|
|
72
|
+
const manifest = generateManifest({ clientEntry, rootChunk, routeChunks });
|
|
73
|
+
await writeManifest(manifest, "build");
|
|
74
|
+
console.log("[bract] build complete →", Object.keys(manifest.routes).length, "routes");
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BractJSConfig } from "../server/serve.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the `define` map passed to Bun.build() for the client bundle.
|
|
5
|
+
* Always injects process.env.NODE_ENV = "production".
|
|
6
|
+
* For each key in config.clientEnv, injects process.env.KEY = value from Bun.env.
|
|
7
|
+
*/
|
|
8
|
+
export function buildDefines(config: Pick<BractJSConfig, "clientEnv">): Record<string, string> {
|
|
9
|
+
const defines: Record<string, string> = {
|
|
10
|
+
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
11
|
+
};
|
|
12
|
+
for (const key of config.clientEnv ?? []) {
|
|
13
|
+
defines[`process.env.${key}`] = JSON.stringify(Bun.env[key] ?? "");
|
|
14
|
+
}
|
|
15
|
+
return defines;
|
|
16
|
+
}
|