@bractjs/bractjs 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +339 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- package/README.md +0 -1125
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { resolveHeaders, applyRouteHeaders } from "../server/headers.ts";
|
|
3
|
+
import type { LayoutChain } from "../server/layout.ts";
|
|
4
|
+
import type { LoaderResults } from "../server/loader.ts";
|
|
5
|
+
import type { HeadersFunction } from "../shared/route-types.ts";
|
|
6
|
+
|
|
7
|
+
const params = {};
|
|
8
|
+
const req = new Request("http://localhost/");
|
|
9
|
+
|
|
10
|
+
function chain(parts: {
|
|
11
|
+
root?: HeadersFunction;
|
|
12
|
+
layouts?: (HeadersFunction | undefined)[];
|
|
13
|
+
route?: HeadersFunction;
|
|
14
|
+
}): LayoutChain {
|
|
15
|
+
return {
|
|
16
|
+
root: parts.root ? { headers: parts.root } : {},
|
|
17
|
+
layouts: (parts.layouts ?? []).map((h) => (h ? { headers: h } : {})),
|
|
18
|
+
route: parts.route ? { headers: parts.route } : {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function results(over: Partial<LoaderResults> = {}): LoaderResults {
|
|
23
|
+
return { root: null, layouts: [], route: null, ...over };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("resolveHeaders", () => {
|
|
27
|
+
test("returns null when no module exports headers", () => {
|
|
28
|
+
expect(resolveHeaders(chain({}), results(), params, req)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("collects a single route's headers", () => {
|
|
32
|
+
const merged = resolveHeaders(
|
|
33
|
+
chain({ route: () => ({ "Cache-Control": "max-age=60" }) }),
|
|
34
|
+
results(),
|
|
35
|
+
params,
|
|
36
|
+
req,
|
|
37
|
+
);
|
|
38
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=60");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("innermost route wins per key (route over root)", () => {
|
|
42
|
+
const merged = resolveHeaders(
|
|
43
|
+
chain({
|
|
44
|
+
root: () => ({ "Cache-Control": "max-age=0", Vary: "Cookie" }),
|
|
45
|
+
route: () => ({ "Cache-Control": "max-age=300" }),
|
|
46
|
+
}),
|
|
47
|
+
results(),
|
|
48
|
+
params,
|
|
49
|
+
req,
|
|
50
|
+
);
|
|
51
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=300");
|
|
52
|
+
// Root-only key survives.
|
|
53
|
+
expect(merged?.get("Vary")).toBe("Cookie");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("parentHeaders carries accumulated values to inner links", () => {
|
|
57
|
+
let seen: string | null = "unset";
|
|
58
|
+
const merged = resolveHeaders(
|
|
59
|
+
chain({
|
|
60
|
+
root: () => ({ "X-From-Root": "1" }),
|
|
61
|
+
route: ({ parentHeaders }) => {
|
|
62
|
+
seen = parentHeaders.get("X-From-Root");
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
results(),
|
|
67
|
+
params,
|
|
68
|
+
req,
|
|
69
|
+
);
|
|
70
|
+
expect(seen).toBe("1");
|
|
71
|
+
expect(merged?.get("X-From-Root")).toBe("1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes the matching loaderData slice to each link", () => {
|
|
75
|
+
const merged = resolveHeaders(
|
|
76
|
+
chain({ route: ({ loaderData }) => ({ ETag: String((loaderData as { etag: string }).etag) }) }),
|
|
77
|
+
results({ route: { etag: "abc" } }),
|
|
78
|
+
params,
|
|
79
|
+
req,
|
|
80
|
+
);
|
|
81
|
+
expect(merged?.get("ETag")).toBe("abc");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("layout headers merge between root and route", () => {
|
|
85
|
+
const merged = resolveHeaders(
|
|
86
|
+
chain({
|
|
87
|
+
root: () => ({ "Cache-Control": "max-age=0" }),
|
|
88
|
+
layouts: [() => ({ "Cache-Control": "max-age=10", Vary: "Accept" })],
|
|
89
|
+
route: () => ({ "Cache-Control": "max-age=60" }),
|
|
90
|
+
}),
|
|
91
|
+
results({ layouts: [null] }),
|
|
92
|
+
params,
|
|
93
|
+
req,
|
|
94
|
+
);
|
|
95
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=60");
|
|
96
|
+
expect(merged?.get("Vary")).toBe("Accept");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("applyRouteHeaders", () => {
|
|
101
|
+
test("overrides same-key defaults and is a no-op for null", () => {
|
|
102
|
+
const base = new Headers({ "Cache-Control": "no-store", "X-Base": "1" });
|
|
103
|
+
applyRouteHeaders(base, new Headers({ "Cache-Control": "max-age=60" }));
|
|
104
|
+
expect(base.get("Cache-Control")).toBe("max-age=60");
|
|
105
|
+
expect(base.get("X-Base")).toBe("1");
|
|
106
|
+
|
|
107
|
+
const base2 = new Headers({ "X-Base": "1" });
|
|
108
|
+
applyRouteHeaders(base2, null);
|
|
109
|
+
expect(base2.get("X-Base")).toBe("1");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -34,6 +34,16 @@ test("GET /_data?path=/ returns JSON with route key", async () => {
|
|
|
34
34
|
expect(data).toHaveProperty("params");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
// Regression: soft navigation reads `meta` from the /_data payload to update
|
|
38
|
+
// the document head — when the payload omitted it, every soft-nav wiped the
|
|
39
|
+
// title/description back to nothing.
|
|
40
|
+
test("GET /_data?path=/ includes the route's merged meta", async () => {
|
|
41
|
+
const res = await fetch(`${BASE}/_data?path=/`);
|
|
42
|
+
const data = (await res.json()) as { meta?: Array<Record<string, string>> };
|
|
43
|
+
expect(Array.isArray(data.meta)).toBe(true);
|
|
44
|
+
expect(data.meta).toContainEqual({ title: "BractJS Test Home" });
|
|
45
|
+
});
|
|
46
|
+
|
|
37
47
|
test("POST / runs action and returns 200 HTML", async () => {
|
|
38
48
|
const form = new FormData();
|
|
39
49
|
form.set("name", "bract");
|
|
@@ -72,6 +82,52 @@ test("GET /nonexistent returns 404", async () => {
|
|
|
72
82
|
expect(res.status).toBe(404);
|
|
73
83
|
});
|
|
74
84
|
|
|
85
|
+
// defineActions: dispatch on the form's `intent` field through one route action.
|
|
86
|
+
test("defineActions dispatches POST intent=add to the right handler", async () => {
|
|
87
|
+
const form = new FormData();
|
|
88
|
+
form.set("intent", "add");
|
|
89
|
+
form.set("title", "Buy milk");
|
|
90
|
+
const res = await fetch(`${BASE}/intent-demo`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
body: form,
|
|
93
|
+
headers: { Origin: BASE, "X-BractJS-Action": "1" },
|
|
94
|
+
});
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
const data = (await res.json()) as { ok?: boolean; title?: string };
|
|
97
|
+
expect(data.ok).toBe(true);
|
|
98
|
+
expect(data.title).toBe("Buy milk");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("defineActions returns 400 for an unknown intent", async () => {
|
|
102
|
+
const form = new FormData();
|
|
103
|
+
form.set("intent", "bogus");
|
|
104
|
+
const res = await fetch(`${BASE}/intent-demo`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
body: form,
|
|
107
|
+
headers: { Origin: BASE, "X-BractJS-Action": "1" },
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(400);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// <Form intent="add"> renders the hidden input server-side (no DOM harness:
|
|
113
|
+
// assert on the SSR HTML directly).
|
|
114
|
+
test("<Form intent> renders the hidden intent input in SSR HTML", async () => {
|
|
115
|
+
const res = await fetch(`${BASE}/intent-demo`);
|
|
116
|
+
const html = await res.text();
|
|
117
|
+
expect(html).toContain('name="intent"');
|
|
118
|
+
expect(html).toContain('value="add"');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// A loader that throws is isolated into the route's __error slot (not a 500),
|
|
122
|
+
// so layout/root still render. (The dev-only `routeFile` field is covered as a
|
|
123
|
+
// unit in loader.test.ts; this server runs in prod mode.)
|
|
124
|
+
test("a throwing loader is captured in the route slot's __error", async () => {
|
|
125
|
+
const res = await fetch(`${BASE}/_data?path=/boom`);
|
|
126
|
+
expect(res.status).toBe(200);
|
|
127
|
+
const data = (await res.json()) as { route: { __error?: { message: string } } };
|
|
128
|
+
expect(data.route.__error).toBeDefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
75
131
|
test("HTML includes window.__BRACTJS_DATA__", async () => {
|
|
76
132
|
const res = await fetch(`${BASE}/`);
|
|
77
133
|
const html = await res.text();
|
|
@@ -126,3 +182,37 @@ test("/_data of a beforeLoad-gated route is blocked and never leaks loader data"
|
|
|
126
182
|
const body = await res.text();
|
|
127
183
|
expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
|
|
128
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── Route headers / useMatches / nested middleware (Phases 1, 2, 4) ──────────
|
|
187
|
+
|
|
188
|
+
test("route `headers` export sets Cache-Control on the document response", async () => {
|
|
189
|
+
const res = await fetch(`${BASE}/features-demo`);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.headers.get("Cache-Control")).toBe("public, max-age=120");
|
|
192
|
+
// Baseline hardening headers still present (not clobbered).
|
|
193
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("route `headers` export also applies to the /_data response", async () => {
|
|
197
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
198
|
+
expect(res.status).toBe(200);
|
|
199
|
+
expect(res.headers.get("Cache-Control")).toBe("public, max-age=120");
|
|
200
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("nested middleware runs (sets context read by the loader, stamps a header)", async () => {
|
|
204
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
205
|
+
expect(res.headers.get("X-Demo-Mw")).toBe("1");
|
|
206
|
+
const data = (await res.json()) as { route?: { user?: string } };
|
|
207
|
+
// The loader saw the context value the middleware set.
|
|
208
|
+
expect(data.route?.user).toBe("alice");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("/_data payload carries the matched chain (useMatches) with handle", async () => {
|
|
212
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
213
|
+
const data = (await res.json()) as { matches?: Array<{ id: string; handle?: { breadcrumb?: string } }> };
|
|
214
|
+
expect(Array.isArray(data.matches)).toBe(true);
|
|
215
|
+
// Leaf route carries its handle export.
|
|
216
|
+
const leaf = data.matches!.at(-1)!;
|
|
217
|
+
expect(leaf.handle?.breadcrumb).toBe("Features");
|
|
218
|
+
});
|
|
@@ -58,13 +58,17 @@ describe("resolveLayoutChainFromRegistry", () => {
|
|
|
58
58
|
expect(r.layoutFiles).toEqual([]);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
test("
|
|
62
|
-
//
|
|
61
|
+
test("wraps a folder index in that folder's layout", () => {
|
|
62
|
+
// routes/blog/_index.tsx (URL /blog) lives inside routes/blog/, so the
|
|
63
|
+
// sibling routes/blog/layout.tsx wraps it — matching Remix/RR/Next, where
|
|
64
|
+
// an index is nested under its directory's layout. Layout dirs are derived
|
|
65
|
+
// from the FILE path, so the `_index` → `blog` urlPattern collapse no
|
|
66
|
+
// longer hides the ancestor directory.
|
|
63
67
|
const r = resolveLayoutChainFromRegistry(
|
|
64
68
|
{ filePath: "routes/blog/_index.tsx", urlPattern: "blog", segments: ["blog"] },
|
|
65
69
|
registry,
|
|
66
70
|
);
|
|
67
|
-
expect(r.layoutFiles).toEqual(["root.tsx"]);
|
|
71
|
+
expect(r.layoutFiles).toEqual(["root.tsx", "routes/blog/layout.tsx"]);
|
|
68
72
|
});
|
|
69
73
|
});
|
|
70
74
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test, expect, describe } from "bun:test";
|
|
1
|
+
import { test, expect, describe, spyOn } from "bun:test";
|
|
2
2
|
import { safeRun, runLoaders, buildLoaderArgs } from "../server/loader.ts";
|
|
3
3
|
import { HttpError } from "../shared/errors.ts";
|
|
4
4
|
import type { LoaderArgs } from "../shared/route-types.ts";
|
|
@@ -9,6 +9,7 @@ const stubArgs: LoaderArgs = {
|
|
|
9
9
|
request: new Request("http://localhost/"),
|
|
10
10
|
params: {},
|
|
11
11
|
context: {},
|
|
12
|
+
search: {},
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
const emptyModule: RouteModule = {};
|
|
@@ -41,6 +42,36 @@ describe("safeRun", () => {
|
|
|
41
42
|
const fn = async () => { throw new Response(null, { status: 302, headers: { Location: "/" } }); };
|
|
42
43
|
await expect(safeRun(fn, stubArgs)).rejects.toBeInstanceOf(Response);
|
|
43
44
|
});
|
|
45
|
+
|
|
46
|
+
test("includes the `where` location in the error log", async () => {
|
|
47
|
+
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
48
|
+
try {
|
|
49
|
+
await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx");
|
|
50
|
+
const logged = spy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
51
|
+
expect(logged).toContain("loader error in routes/x.tsx");
|
|
52
|
+
} finally {
|
|
53
|
+
spy.mockRestore();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("dev __error carries the routeFile; prod stays generic", async () => {
|
|
58
|
+
const original = Bun.env.NODE_ENV;
|
|
59
|
+
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
60
|
+
try {
|
|
61
|
+
Bun.env.NODE_ENV = "development";
|
|
62
|
+
const dev = await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx");
|
|
63
|
+
expect(dev).toMatchObject({ __error: { routeFile: "routes/x.tsx" } });
|
|
64
|
+
|
|
65
|
+
Bun.env.NODE_ENV = "production";
|
|
66
|
+
const prod = await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx") as { __error: Record<string, unknown> };
|
|
67
|
+
expect(prod.__error.routeFile).toBeUndefined();
|
|
68
|
+
expect(prod.__error.message).toBe("Internal Server Error");
|
|
69
|
+
} finally {
|
|
70
|
+
if (original === undefined) delete Bun.env.NODE_ENV;
|
|
71
|
+
else Bun.env.NODE_ENV = original;
|
|
72
|
+
spy.mockRestore();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
44
75
|
});
|
|
45
76
|
|
|
46
77
|
describe("runLoaders", () => {
|
|
@@ -67,3 +67,32 @@ describe("matchRoute", () => {
|
|
|
67
67
|
expect(r2?.params.id).toBe("123");
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("optional segments [[id]]", () => {
|
|
72
|
+
test("matches with the segment present (binds the param)", () => {
|
|
73
|
+
const trie = buildTrie([makeRoute("users/[[id]]")]);
|
|
74
|
+
const r = matchRoute("/users/42", trie);
|
|
75
|
+
expect(r).not.toBeNull();
|
|
76
|
+
expect(r?.params).toEqual({ id: "42" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("matches with the segment absent (param unset)", () => {
|
|
80
|
+
const trie = buildTrie([makeRoute("users/[[id]]")]);
|
|
81
|
+
const r = matchRoute("/users", trie);
|
|
82
|
+
expect(r).not.toBeNull();
|
|
83
|
+
expect(r?.params).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("static sibling still wins over the optional param", () => {
|
|
87
|
+
const trie = buildTrie([makeRoute("users/[[id]]"), makeRoute("users/me")]);
|
|
88
|
+
const r = matchRoute("/users/me", trie);
|
|
89
|
+
expect(r?.routeFile.urlPattern).toBe("users/me");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("does not over-consume — extra segment falls through to catch-all", () => {
|
|
93
|
+
const trie = buildTrie([makeRoute("users/[[id]]"), makeRoute("users/[...rest]")]);
|
|
94
|
+
const r = matchRoute("/users/1/2", trie);
|
|
95
|
+
expect(r?.routeFile.urlPattern).toBe("users/[...rest]");
|
|
96
|
+
expect(r?.params.rest).toBe("1/2");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -18,9 +18,8 @@ beforeAll(async () => {
|
|
|
18
18
|
await writeFile(join(TMP, "root.tsx"), `export default function Root() { return null; }\n`);
|
|
19
19
|
await writeFile(join(TMP, "routes", "_index.tsx"), `export default function Home() { return null; }\n`);
|
|
20
20
|
await writeFile(join(TMP, "routes", "blog", "layout.tsx"), `export default function L({ children }: any) { return children; }\n`);
|
|
21
|
-
// Nested route — `routes/blog/layout.tsx`
|
|
22
|
-
//
|
|
23
|
-
// same path level (matches `layout.ts`'s `layoutDirs` resolution).
|
|
21
|
+
// Nested route under /blog/ — `routes/blog/layout.tsx` wraps everything in
|
|
22
|
+
// its directory (including a `blog/_index`), per `layoutDirsFromFilePath`.
|
|
24
23
|
await writeFile(join(TMP, "routes", "blog", "[slug].tsx"), `export default function P() { return null; }\n`);
|
|
25
24
|
|
|
26
25
|
await writeFile(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { parseTo, createLocationKey } from "../client/nav-utils.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseTo", () => {
|
|
5
|
+
test("plain pathname", () => {
|
|
6
|
+
expect(parseTo("/posts")).toEqual({ pathname: "/posts", search: "", hash: "" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("pathname + search", () => {
|
|
10
|
+
expect(parseTo("/posts?page=2")).toEqual({ pathname: "/posts", search: "?page=2", hash: "" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("pathname + hash", () => {
|
|
14
|
+
expect(parseTo("/docs#install")).toEqual({ pathname: "/docs", search: "", hash: "#install" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("pathname + search + hash", () => {
|
|
18
|
+
expect(parseTo("/docs?v=2#install")).toEqual({ pathname: "/docs", search: "?v=2", hash: "#install" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("hash containing a question mark stays in the hash", () => {
|
|
22
|
+
expect(parseTo("/docs#frag?notsearch")).toEqual({ pathname: "/docs", search: "", hash: "#frag?notsearch" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("empty string falls back to root", () => {
|
|
26
|
+
expect(parseTo("")).toEqual({ pathname: "/", search: "", hash: "" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("bare query string keeps root pathname", () => {
|
|
30
|
+
expect(parseTo("?page=2")).toEqual({ pathname: "/", search: "?page=2", hash: "" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("root with everything", () => {
|
|
34
|
+
expect(parseTo("/?a=1&b=2#top")).toEqual({ pathname: "/", search: "?a=1&b=2", hash: "#top" });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("createLocationKey", () => {
|
|
39
|
+
test("returns a short non-empty string and varies between calls", () => {
|
|
40
|
+
const a = createLocationKey();
|
|
41
|
+
const b = createLocationKey();
|
|
42
|
+
expect(a.length).toBeGreaterThanOrEqual(6);
|
|
43
|
+
expect(a.length).toBeLessThanOrEqual(10);
|
|
44
|
+
expect(a).not.toBe(b);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import { runPrerender, prerenderPaths } from "../build/prerender.ts";
|
|
6
|
+
import { createServer } from "../server/serve.ts";
|
|
7
|
+
|
|
8
|
+
const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
|
|
9
|
+
const TMP_BUILD = join(tmpdir(), `bract-prerender-${Date.now()}`);
|
|
10
|
+
const MANIFEST = { clientEntry: "/build/client/client.js", routes: {} };
|
|
11
|
+
|
|
12
|
+
// ── Unit: path mapping + safety ─────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe("prerenderPaths", () => {
|
|
15
|
+
test("maps / and nested paths to index.html + _data.json", () => {
|
|
16
|
+
expect(prerenderPaths("/")).toEqual({ html: "index.html", data: "_data.json" });
|
|
17
|
+
expect(prerenderPaths("/about")).toEqual({ html: "about/index.html", data: "about/_data.json" });
|
|
18
|
+
expect(prerenderPaths("/blog/intro")).toEqual({ html: "blog/intro/index.html", data: "blog/intro/_data.json" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects route patterns instead of silently writing junk", () => {
|
|
22
|
+
expect(() => prerenderPaths("/blog/:slug")).toThrow(/PATTERN/);
|
|
23
|
+
expect(() => prerenderPaths("/blog/[slug]")).toThrow(/PATTERN/);
|
|
24
|
+
expect(() => prerenderPaths("relative")).toThrow(/must start/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects dot segments — these strings become filesystem writes", () => {
|
|
28
|
+
expect(() => prerenderPaths("/../etc/passwd")).toThrow(/dot segments/);
|
|
29
|
+
expect(() => prerenderPaths("/a/./b")).toThrow(/dot segments/);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── Integration: generate + serve ───────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("runPrerender + production serving", () => {
|
|
36
|
+
let handle: ReturnType<typeof createServer>;
|
|
37
|
+
const PORT = 3992;
|
|
38
|
+
const BASE = `http://localhost:${PORT}`;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
const { written } = await runPrerender({
|
|
42
|
+
prerender: ["/", "/counter"],
|
|
43
|
+
appDir: FIXTURE_APP,
|
|
44
|
+
buildDir: TMP_BUILD,
|
|
45
|
+
manifest: MANIFEST,
|
|
46
|
+
});
|
|
47
|
+
expect(written.length).toBe(4); // 2 paths × (index.html + _data.json)
|
|
48
|
+
|
|
49
|
+
// Overwrite one artifact with a sentinel so the serving test below can
|
|
50
|
+
// prove the FILE was served, not a fresh SSR pass.
|
|
51
|
+
await Bun.write(join(TMP_BUILD, "client", "_prerender", "counter", "index.html"), "<!-- PRERENDERED-SENTINEL -->");
|
|
52
|
+
|
|
53
|
+
handle = createServer({
|
|
54
|
+
port: PORT,
|
|
55
|
+
appDir: FIXTURE_APP,
|
|
56
|
+
buildDir: TMP_BUILD,
|
|
57
|
+
manifest: MANIFEST,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
handle.stop();
|
|
63
|
+
await rm(TMP_BUILD, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("writes real SSR output at build time", async () => {
|
|
67
|
+
const html = await Bun.file(join(TMP_BUILD, "client", "_prerender", "index.html")).text();
|
|
68
|
+
expect(html).toContain("hello from bractjs"); // loader ran during prerender
|
|
69
|
+
const data = await Bun.file(join(TMP_BUILD, "client", "_prerender", "_data.json")).json() as { route: { message: string } };
|
|
70
|
+
expect(data.route.message).toBe("hello from bractjs");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("clean document GETs are served from the prerendered file", async () => {
|
|
74
|
+
const res = await fetch(`${BASE}/counter`);
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
expect(await res.text()).toContain("PRERENDERED-SENTINEL");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a query string opts back into dynamic SSR", async () => {
|
|
80
|
+
const res = await fetch(`${BASE}/counter?fresh=1`);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
expect(await res.text()).not.toContain("PRERENDERED-SENTINEL");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("clean /_data is served from the prerendered payload; queried /_data stays dynamic", async () => {
|
|
86
|
+
const filed = await fetch(`${BASE}/_data?path=/`);
|
|
87
|
+
expect(filed.headers.get("cache-control")).toContain("must-revalidate");
|
|
88
|
+
const data = (await filed.json()) as { route: { message: string } };
|
|
89
|
+
expect(data.route.message).toBe("hello from bractjs");
|
|
90
|
+
|
|
91
|
+
// With a query the file is skipped — the loader runs (search echoes back).
|
|
92
|
+
const dynamic = await fetch(`${BASE}/_data?path=${encodeURIComponent("/?q=1")}`);
|
|
93
|
+
const dyn = (await dynamic.json()) as { search: Record<string, unknown> };
|
|
94
|
+
expect(dyn.search).toEqual({ q: "1" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("non-prerendered paths fall through to dynamic SSR", async () => {
|
|
98
|
+
const res = await fetch(`${BASE}/search-demo?page=2`);
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
expect(await res.text()).toContain('"search":{"page":2}');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Behavioral coverage (HTTP response, HMR) lives in integration.test.ts.
|
|
8
8
|
*/
|
|
9
9
|
import { test, expect, describe } from "bun:test";
|
|
10
|
-
import { loadUserConfig, validateUserConfig } from "../config/load.ts";
|
|
10
|
+
import { loadUserConfig, validateUserConfig, defineConfig } from "../config/load.ts";
|
|
11
11
|
import { runBuild } from "../build/bundler.ts";
|
|
12
12
|
import { createDevServer } from "../dev/server.ts";
|
|
13
13
|
import type { BuildConfig } from "../build/bundler.ts";
|
|
@@ -60,11 +60,30 @@ describe("validateUserConfig", () => {
|
|
|
60
60
|
expect(() => validateUserConfig({ sourcemap: "yes" })).toThrow(/"sourcemap" must be/);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("rejects a string hmrPort but accepts a number", () => {
|
|
64
|
+
expect(() => validateUserConfig({ hmrPort: "3001" })).toThrow(/"hmrPort" must be a finite number/);
|
|
65
|
+
expect(validateUserConfig({ hmrPort: 3005 })).toEqual({ hmrPort: 3005 });
|
|
66
|
+
});
|
|
67
|
+
|
|
63
68
|
test("ignores unknown keys and undefined values", () => {
|
|
64
69
|
expect(() => validateUserConfig({ port: undefined, somethingCustom: 1 })).not.toThrow();
|
|
65
70
|
});
|
|
66
71
|
});
|
|
67
72
|
|
|
73
|
+
// ── defineConfig ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("defineConfig", () => {
|
|
76
|
+
test("is an identity function (returns the same reference)", () => {
|
|
77
|
+
const cfg = { port: 3000, clientEnv: ["X"] };
|
|
78
|
+
expect(defineConfig(cfg)).toBe(cfg);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("is re-exported from src/index.ts", async () => {
|
|
82
|
+
const mod = await import("../index.ts");
|
|
83
|
+
expect(typeof mod.defineConfig).toBe("function");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
68
87
|
// ── runBuild ──────────────────────────────────────────────────────────────
|
|
69
88
|
|
|
70
89
|
test("runBuild is exported from build/bundler", () => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { createServer } from "../server/serve.ts";
|
|
4
|
+
import { registerRevalidator, triggerRevalidation } from "../client/revalidation.ts";
|
|
5
|
+
|
|
6
|
+
// ── Unit: revalidator seam (router ↔ fetcher bridge) ───────────────────────
|
|
7
|
+
|
|
8
|
+
describe("revalidation seam", () => {
|
|
9
|
+
test("triggerRevalidation forwards info to the registered revalidator", async () => {
|
|
10
|
+
const calls: unknown[] = [];
|
|
11
|
+
registerRevalidator(async (info) => { calls.push(info); });
|
|
12
|
+
await triggerRevalidation({ formMethod: "POST", actionStatus: 200 });
|
|
13
|
+
expect(calls).toEqual([{ formMethod: "POST", actionStatus: 200 }]);
|
|
14
|
+
registerRevalidator(null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("resolves quietly when no router is mounted", async () => {
|
|
18
|
+
registerRevalidator(null);
|
|
19
|
+
await triggerRevalidation({ formMethod: "DELETE" }); // must not throw
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ── Integration: the submit → revalidate contract ───────────────────────────
|
|
24
|
+
// ClientRouter.submit POSTs with X-BractJS-Action (JSON action reply), then
|
|
25
|
+
// refetches /_data. This proves the server side of that contract: the mutation
|
|
26
|
+
// changes what the next /_data returns.
|
|
27
|
+
|
|
28
|
+
const PORT = 3995;
|
|
29
|
+
const BASE = `http://localhost:${PORT}`;
|
|
30
|
+
const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
|
|
31
|
+
|
|
32
|
+
let handle: ReturnType<typeof createServer>;
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
handle = createServer({
|
|
36
|
+
port: PORT,
|
|
37
|
+
appDir: FIXTURE_APP,
|
|
38
|
+
manifest: { clientEntry: "/build/client/client.js", routes: {} },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
handle.stop();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("mutation → revalidation contract", () => {
|
|
47
|
+
test("action mutates, JSON reply carries actionData, /_data reflects the new state", async () => {
|
|
48
|
+
const before = await (await fetch(`${BASE}/_data?path=/counter`)).json() as { route: { count: number } };
|
|
49
|
+
|
|
50
|
+
const post = await fetch(`${BASE}/counter`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
body: new FormData(),
|
|
53
|
+
headers: { Origin: BASE, "X-BractJS-Action": "1" },
|
|
54
|
+
});
|
|
55
|
+
expect(post.status).toBe(200);
|
|
56
|
+
expect(post.headers.get("content-type")).toContain("json");
|
|
57
|
+
const actionData = (await post.json()) as { ok: boolean; count: number };
|
|
58
|
+
expect(actionData.ok).toBe(true);
|
|
59
|
+
expect(actionData.count).toBe(before.route.count + 1);
|
|
60
|
+
|
|
61
|
+
// The revalidation fetch ClientRouter issues after the action:
|
|
62
|
+
const after = await (await fetch(`${BASE}/_data?path=/counter`)).json() as { route: { count: number } };
|
|
63
|
+
expect(after.route.count).toBe(before.route.count + 1);
|
|
64
|
+
});
|
|
65
|
+
});
|