@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
package/bin/cli.ts
CHANGED
|
@@ -43,6 +43,10 @@ async function scaffoldNew(appName: string): Promise<void> {
|
|
|
43
43
|
try {
|
|
44
44
|
const { writeModuleRegistries } = await import("../src/codegen/module-registry.ts");
|
|
45
45
|
await writeModuleRegistries(join(appDir, "app"));
|
|
46
|
+
// Generate typed routes so the scaffold has working <Link>/useParams typing
|
|
47
|
+
// out of the box (no manual `bractjs codegen` step before first dev run).
|
|
48
|
+
const { writeRouteTypes } = await import("../src/codegen/route-codegen.ts");
|
|
49
|
+
await writeRouteTypes(join(appDir, "app"));
|
|
46
50
|
// Manifest stub — overwritten by `bractjs codegen:manifest` after a build
|
|
47
51
|
const stubManifest = [
|
|
48
52
|
"// Stub manifest — replaced by `bractjs codegen:manifest` after running",
|
|
@@ -101,6 +105,16 @@ switch (command) {
|
|
|
101
105
|
const { loadUserConfig } = await import("../src/config/load.ts");
|
|
102
106
|
const userCfg = await loadUserConfig();
|
|
103
107
|
await runBuild({ appDir: "./app", buildDir: "./build", ...userCfg });
|
|
108
|
+
if (userCfg.prerender) {
|
|
109
|
+
const { runPrerender } = await import("../src/build/prerender.ts");
|
|
110
|
+
const { written } = await runPrerender({
|
|
111
|
+
prerender: userCfg.prerender,
|
|
112
|
+
appDir: userCfg.appDir ?? "./app",
|
|
113
|
+
publicDir: userCfg.publicDir,
|
|
114
|
+
buildDir: userCfg.buildDir ?? "./build",
|
|
115
|
+
});
|
|
116
|
+
console.log(`[bract] prerender → ${written.length} files`);
|
|
117
|
+
}
|
|
104
118
|
break;
|
|
105
119
|
}
|
|
106
120
|
|
|
@@ -109,7 +123,10 @@ switch (command) {
|
|
|
109
123
|
// output. Users can still override with `NODE_ENV=staging bractjs start`.
|
|
110
124
|
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
|
111
125
|
const { createServer } = await import("../src/server/serve.ts");
|
|
112
|
-
|
|
126
|
+
const { loadUserConfig } = await import("../src/config/load.ts");
|
|
127
|
+
// The config carries runtime-relevant fields too (ssr, port, dirs).
|
|
128
|
+
const userCfg = await loadUserConfig();
|
|
129
|
+
createServer({ port: 3000, buildDir: "./build", ...userCfg });
|
|
113
130
|
break;
|
|
114
131
|
}
|
|
115
132
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
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",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"dev": "bun run src/dev/server.ts",
|
|
47
47
|
"build": "bun run src/build/bundler.ts",
|
|
48
|
-
"test": "bun test"
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"typecheck": "bunx tsc --noEmit"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"react": "^19",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
writeRouteTypes,
|
|
7
|
+
explainStalenessForApp,
|
|
8
|
+
} from "../codegen/route-codegen.ts";
|
|
9
|
+
|
|
10
|
+
let appDir = "";
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
appDir = join(tmpdir(), `bract-codegen-write-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
|
+
await mkdir(join(appDir, "routes"), { recursive: true });
|
|
15
|
+
await writeFile(join(appDir, "routes", "_index.tsx"), "export default () => null;");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(appDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("writeRouteTypes — idempotency", () => {
|
|
23
|
+
test("writes on first run, skips identical re-run, rewrites on route change", async () => {
|
|
24
|
+
const first = await writeRouteTypes(appDir);
|
|
25
|
+
expect(first.written).toBe(true);
|
|
26
|
+
|
|
27
|
+
const destStat = await stat(first.dest);
|
|
28
|
+
const mtime1 = destStat.mtimeMs;
|
|
29
|
+
|
|
30
|
+
// Identical re-run: no write (so no file-watcher event / editor reload loop).
|
|
31
|
+
const second = await writeRouteTypes(appDir);
|
|
32
|
+
expect(second.written).toBe(false);
|
|
33
|
+
expect((await stat(first.dest)).mtimeMs).toBe(mtime1);
|
|
34
|
+
|
|
35
|
+
// Add a route → content changes → write happens.
|
|
36
|
+
await writeFile(join(appDir, "routes", "about.tsx"), "export default () => null;");
|
|
37
|
+
const third = await writeRouteTypes(appDir);
|
|
38
|
+
expect(third.written).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("explainStalenessForApp", () => {
|
|
43
|
+
test("missing generated file → reason mentions missing", async () => {
|
|
44
|
+
const reason = await explainStalenessForApp(appDir);
|
|
45
|
+
expect(reason).toMatch(/missing/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("fresh after codegen → null", async () => {
|
|
49
|
+
await writeRouteTypes(appDir);
|
|
50
|
+
expect(await explainStalenessForApp(appDir)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("added route → reports +1", async () => {
|
|
54
|
+
await writeRouteTypes(appDir);
|
|
55
|
+
await writeFile(join(appDir, "routes", "about.tsx"), "export default () => null;");
|
|
56
|
+
const reason = await explainStalenessForApp(appDir);
|
|
57
|
+
expect(reason).toMatch(/\+1 added/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("removed route → reports -1", async () => {
|
|
61
|
+
await writeFile(join(appDir, "routes", "about.tsx"), "export default () => null;");
|
|
62
|
+
await writeRouteTypes(appDir);
|
|
63
|
+
await rm(join(appDir, "routes", "about.tsx"));
|
|
64
|
+
const reason = await explainStalenessForApp(appDir);
|
|
65
|
+
expect(reason).toMatch(/-1 removed/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -2,7 +2,11 @@ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
|
2
2
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
generateRouteTypes,
|
|
7
|
+
routesFingerprint,
|
|
8
|
+
readFingerprint,
|
|
9
|
+
} from "../codegen/route-codegen.ts";
|
|
6
10
|
|
|
7
11
|
let appDir = "";
|
|
8
12
|
|
|
@@ -29,6 +33,23 @@ describe("route-codegen — output shape", () => {
|
|
|
29
33
|
expect(out).toMatch(/\| "\/users\/:id"/);
|
|
30
34
|
});
|
|
31
35
|
|
|
36
|
+
test("emits a fingerprint matching routesFingerprint, and is deterministic", async () => {
|
|
37
|
+
const out = await generateRouteTypes(appDir);
|
|
38
|
+
// Header carries the route fingerprint.
|
|
39
|
+
const embedded = readFingerprint(out);
|
|
40
|
+
expect(embedded).toMatch(/^[0-9a-f]+$/);
|
|
41
|
+
expect(embedded).toBe(await routesFingerprint(["/", "/users/:id"]));
|
|
42
|
+
// Same input → byte-identical output (order-independent / reproducible).
|
|
43
|
+
expect(await generateRouteTypes(appDir)).toBe(out);
|
|
44
|
+
// Pattern union is sorted (deterministic across filesystems).
|
|
45
|
+
expect(out.indexOf('| "/"')).toBeLessThan(out.indexOf('| "/users/:id"'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("routesFingerprint is order-independent", async () => {
|
|
49
|
+
expect(await routesFingerprint(["/a", "/b"])).toBe(await routesFingerprint(["/b", "/a"]));
|
|
50
|
+
expect(await routesFingerprint(["/a"])).not.toBe(await routesFingerprint(["/a", "/b"]));
|
|
51
|
+
});
|
|
52
|
+
|
|
32
53
|
test("wires the Register augmentation for typed routing", async () => {
|
|
33
54
|
const regApp = join(tmpdir(), `bract-codegen-register-${Date.now()}`);
|
|
34
55
|
await mkdir(join(regApp, "routes", "users"), { recursive: true });
|
|
@@ -45,7 +66,7 @@ describe("route-codegen — output shape", () => {
|
|
|
45
66
|
// re-declared as bare top-level interfaces in the app file.
|
|
46
67
|
expect(out).not.toMatch(/^export interface RouteSearchParamsMap/m);
|
|
47
68
|
expect(out).not.toMatch(/^export interface RouteContextMap/m);
|
|
48
|
-
expect(out).toContain('import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs"');
|
|
69
|
+
expect(out).toContain('import type { RouteSearchParamsMap, RouteContextMap, InferSchemaOutput } from "@bractjs/bractjs"');
|
|
49
70
|
|
|
50
71
|
// The Register seam carries the route union and a per-route params map.
|
|
51
72
|
expect(out).toContain("interface Register {");
|
|
@@ -53,6 +74,12 @@ describe("route-codegen — output shape", () => {
|
|
|
53
74
|
expect(out).toMatch(/"\/users\/:id": \{ id: string \};/); // dynamic route → typed params
|
|
54
75
|
expect(out).toMatch(/"\/about": \{\};/); // static route → no params
|
|
55
76
|
|
|
77
|
+
// Schema-inferred search shapes: a per-route map derived from each route
|
|
78
|
+
// module's `searchSchema` export, registered under `searchOutput`.
|
|
79
|
+
expect(out).toContain("export type GeneratedSearchOutput = {");
|
|
80
|
+
expect(out).toContain('typeof import("./routes/about.tsx") extends { searchSchema: infer S }');
|
|
81
|
+
expect(out).toContain("searchOutput: GeneratedSearchOutput;");
|
|
82
|
+
|
|
56
83
|
await rm(regApp, { recursive: true, force: true });
|
|
57
84
|
});
|
|
58
85
|
|
|
@@ -51,6 +51,10 @@ const ALLOWED: Record<string, string[]> = {
|
|
|
51
51
|
// calls it when no `registry` is provided; registry mode uses pickRouteModule
|
|
52
52
|
// (a plain Record lookup, no import).
|
|
53
53
|
"layout.ts": ["await import(filePath)"],
|
|
54
|
+
// renderSpaShell(): source-mode root.tsx load for the SPA shell. Compiled
|
|
55
|
+
// binaries always pass a moduleRegistry, which takes the registry branch
|
|
56
|
+
// (plain Record lookup) before this import is reached.
|
|
57
|
+
"spa.ts": ["await import(rootPath)"],
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
async function serverFiles(): Promise<string[]> {
|
|
@@ -54,6 +54,16 @@ describe("csp middleware", () => {
|
|
|
54
54
|
expect(res.headers.get("Content-Security-Policy")).not.toContain("object-src");
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("default style-src allows 'unsafe-inline'; strict drops it", async () => {
|
|
58
|
+
const def = await runCsp(csp(), () => Promise.resolve(new Response("ok")));
|
|
59
|
+
expect(def.res.headers.get("Content-Security-Policy")).toContain("style-src 'self' 'unsafe-inline'");
|
|
60
|
+
|
|
61
|
+
const strict = await runCsp(csp({ strict: true }), () => Promise.resolve(new Response("ok")));
|
|
62
|
+
const policy = strict.res.headers.get("Content-Security-Policy")!;
|
|
63
|
+
expect(policy).toContain("style-src 'self'");
|
|
64
|
+
expect(policy).not.toContain("'unsafe-inline'");
|
|
65
|
+
});
|
|
66
|
+
|
|
57
67
|
test("reportOnly emits the report-only header instead", async () => {
|
|
58
68
|
const { res } = await runCsp(csp({ reportOnly: true }), () => Promise.resolve(new Response("ok")));
|
|
59
69
|
expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeTruthy();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { test, expect, describe, afterEach } from "bun:test";
|
|
2
|
+
import { defineActions } from "../shared/define-actions.ts";
|
|
3
|
+
import type { ActionArgs } from "../shared/route-types.ts";
|
|
4
|
+
|
|
5
|
+
function argsWith(intent?: string, extra: Record<string, string> = {}): ActionArgs {
|
|
6
|
+
const fd = new FormData();
|
|
7
|
+
if (intent !== undefined) fd.set("intent", intent);
|
|
8
|
+
for (const [k, v] of Object.entries(extra)) fd.set(k, v);
|
|
9
|
+
return {
|
|
10
|
+
request: new Request("http://localhost/"),
|
|
11
|
+
params: {},
|
|
12
|
+
context: {},
|
|
13
|
+
search: {},
|
|
14
|
+
formData: fd,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ORIGINAL_ENV = Bun.env.NODE_ENV;
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (ORIGINAL_ENV === undefined) delete Bun.env.NODE_ENV;
|
|
21
|
+
else Bun.env.NODE_ENV = ORIGINAL_ENV;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("defineActions", () => {
|
|
25
|
+
test("dispatches to the matching handler with full args", async () => {
|
|
26
|
+
let seen: ActionArgs | null = null;
|
|
27
|
+
const action = defineActions({
|
|
28
|
+
add: (args) => { seen = args; return { ok: true, who: "add" }; },
|
|
29
|
+
remove: () => ({ ok: true, who: "remove" }),
|
|
30
|
+
});
|
|
31
|
+
const result = await action(argsWith("add", { title: "x" }));
|
|
32
|
+
expect(result).toEqual({ ok: true, who: "add" });
|
|
33
|
+
expect(seen!.formData.get("title")).toBe("x");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("unknown intent → 400 listing known intents in dev", async () => {
|
|
37
|
+
Bun.env.NODE_ENV = "development";
|
|
38
|
+
const action = defineActions({ add: () => ({}), remove: () => ({}) });
|
|
39
|
+
const res = await action(argsWith("nope"));
|
|
40
|
+
expect(res).toBeInstanceOf(Response);
|
|
41
|
+
expect((res as Response).status).toBe(400);
|
|
42
|
+
const body = await (res as Response).json() as { error: string };
|
|
43
|
+
expect(body.error).toContain("add");
|
|
44
|
+
expect(body.error).toContain("remove");
|
|
45
|
+
expect(body.error).toContain("nope");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unknown intent → terse 400 in production", async () => {
|
|
49
|
+
Bun.env.NODE_ENV = "production";
|
|
50
|
+
const action = defineActions({ add: () => ({}) });
|
|
51
|
+
const res = await action(argsWith("nope")) as Response;
|
|
52
|
+
expect(res.status).toBe(400);
|
|
53
|
+
const body = await res.json() as { error: string };
|
|
54
|
+
expect(body.error).toBe("Unknown action intent.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("missing intent → 400", async () => {
|
|
58
|
+
const action = defineActions({ add: () => ({}) });
|
|
59
|
+
const res = await action(argsWith(undefined)) as Response;
|
|
60
|
+
expect(res.status).toBe(400);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("awaits async handlers", async () => {
|
|
64
|
+
const action = defineActions({
|
|
65
|
+
slow: async () => { await Promise.resolve(); return { done: true }; },
|
|
66
|
+
});
|
|
67
|
+
expect(await action(argsWith("slow"))).toEqual({ done: true });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -45,6 +45,24 @@ describe("safeStringify", () => {
|
|
|
45
45
|
expect(parsed.self).toBe("[Circular]");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
// Regression: a SHARED (non-cyclic) reference must serialize normally. The
|
|
49
|
+
// old WeakSet-of-everything approach flagged the second occurrence as
|
|
50
|
+
// circular — which corrupted __BRACTJS_DATA__ whenever a loader echoed
|
|
51
|
+
// `args.search` (also present at the payload's top level).
|
|
52
|
+
test("shared references are not flagged as circular", () => {
|
|
53
|
+
const shared = { page: 5 };
|
|
54
|
+
const out = safeStringify({ a: shared, b: { inner: shared } });
|
|
55
|
+
expect(JSON.parse(out)).toEqual({ a: { page: 5 }, b: { inner: { page: 5 } } });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("deep cycles through arrays are still caught", () => {
|
|
59
|
+
const arr: unknown[] = [];
|
|
60
|
+
const obj = { arr };
|
|
61
|
+
arr.push(obj);
|
|
62
|
+
const parsed = JSON.parse(safeStringify(obj)) as { arr: string[] };
|
|
63
|
+
expect(parsed.arr[0]).toBe("[Circular]");
|
|
64
|
+
});
|
|
65
|
+
|
|
48
66
|
test("handles nested objects", () => {
|
|
49
67
|
const out = safeStringify({ a: { b: { c: 42 } } });
|
|
50
68
|
expect(JSON.parse(out)).toEqual({ a: { b: { c: 42 } } });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { fetcherStore, EMPTY_FETCHERS } from "../client/fetcher-store.ts";
|
|
3
|
+
|
|
4
|
+
// The store is a module-level singleton — use unique keys per test so cases
|
|
5
|
+
// stay independent.
|
|
6
|
+
|
|
7
|
+
describe("fetcherStore", () => {
|
|
8
|
+
test("update creates an entry with idle defaults merged", () => {
|
|
9
|
+
fetcherStore.update("t1", { state: "submitting", formMethod: "POST" });
|
|
10
|
+
const entry = fetcherStore.get("t1");
|
|
11
|
+
expect(entry).toEqual({
|
|
12
|
+
key: "t1",
|
|
13
|
+
state: "submitting",
|
|
14
|
+
data: undefined,
|
|
15
|
+
formMethod: "POST",
|
|
16
|
+
});
|
|
17
|
+
fetcherStore.remove("t1");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("partial updates preserve other fields", () => {
|
|
21
|
+
fetcherStore.update("t2", { state: "submitting", formMethod: "DELETE" });
|
|
22
|
+
fetcherStore.update("t2", { data: { ok: true } });
|
|
23
|
+
const entry = fetcherStore.get("t2")!;
|
|
24
|
+
expect(entry.state).toBe("submitting");
|
|
25
|
+
expect(entry.formMethod).toBe("DELETE");
|
|
26
|
+
expect(entry.data).toEqual({ ok: true });
|
|
27
|
+
fetcherStore.remove("t2");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("subscribe fires on update and remove; unsubscribe stops it", () => {
|
|
31
|
+
let calls = 0;
|
|
32
|
+
const unsub = fetcherStore.subscribe(() => calls++);
|
|
33
|
+
fetcherStore.update("t3", { state: "loading" });
|
|
34
|
+
expect(calls).toBe(1);
|
|
35
|
+
fetcherStore.remove("t3");
|
|
36
|
+
expect(calls).toBe(2);
|
|
37
|
+
unsub();
|
|
38
|
+
fetcherStore.update("t3b", { state: "loading" });
|
|
39
|
+
expect(calls).toBe(2);
|
|
40
|
+
fetcherStore.remove("t3b");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("removing a missing key does not notify", () => {
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const unsub = fetcherStore.subscribe(() => calls++);
|
|
46
|
+
fetcherStore.remove("never-existed");
|
|
47
|
+
expect(calls).toBe(0);
|
|
48
|
+
unsub();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("snapshot is referentially stable between updates (useSyncExternalStore contract)", () => {
|
|
52
|
+
fetcherStore.update("t4", { state: "idle" });
|
|
53
|
+
const a = fetcherStore.getSnapshot();
|
|
54
|
+
const b = fetcherStore.getSnapshot();
|
|
55
|
+
expect(a).toBe(b);
|
|
56
|
+
fetcherStore.update("t4", { state: "loading" });
|
|
57
|
+
const c = fetcherStore.getSnapshot();
|
|
58
|
+
expect(c).not.toBe(a);
|
|
59
|
+
expect(c.find((e) => e.key === "t4")?.state).toBe("loading");
|
|
60
|
+
fetcherStore.remove("t4");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("EMPTY_FETCHERS is a stable empty server snapshot", () => {
|
|
64
|
+
expect(EMPTY_FETCHERS).toEqual([]);
|
|
65
|
+
expect(EMPTY_FETCHERS).toBe(EMPTY_FETCHERS);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
// Minimal root component for integration tests.
|
|
1
|
+
// Minimal root component for integration tests. Renders the matched route
|
|
2
|
+
// through <Outlet/> so body-level SSR assertions (components, Fallbacks) work.
|
|
3
|
+
import { Outlet } from "../../../client/components/Outlet.tsx";
|
|
4
|
+
|
|
2
5
|
export default function Root() {
|
|
3
6
|
return (
|
|
4
7
|
<html>
|
|
5
8
|
<head></head>
|
|
6
|
-
<body
|
|
9
|
+
<body>
|
|
10
|
+
<Outlet />
|
|
11
|
+
</body>
|
|
7
12
|
</html>
|
|
8
13
|
);
|
|
9
14
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// A route whose loader throws a plain Error — for asserting that the failure is
|
|
2
|
+
// reported with the route file's location (in dev) rather than anonymously.
|
|
3
|
+
export function loader() {
|
|
4
|
+
throw new Error("kaboom from loader");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export default function Boom() {
|
|
8
|
+
return <p>unreachable — loader throws</p>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Selective SSR: `ssr: false` — the loader must NOT run during document SSR
|
|
2
|
+
// and the Fallback must render in the component's place. The client completes
|
|
3
|
+
// the render via /_data after hydration.
|
|
4
|
+
export const ssr = false;
|
|
5
|
+
|
|
6
|
+
export function loader() {
|
|
7
|
+
return { secret: "CLIENT-ONLY-LOADER-DATA" };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Fallback() {
|
|
11
|
+
return <p>client-only fallback</p>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ClientOnlyPage() {
|
|
15
|
+
return <p>client-only component</p>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Module-level mutable state so tests can prove the submit → revalidate
|
|
2
|
+
// contract: a mutation changes what the loader returns on the next /_data.
|
|
3
|
+
let count = 0;
|
|
4
|
+
|
|
5
|
+
export function loader() {
|
|
6
|
+
return { count };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function action() {
|
|
10
|
+
count++;
|
|
11
|
+
return { ok: true, count };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function CounterPage() {
|
|
15
|
+
return <p>counter</p>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Selective SSR: `ssr: "data-only"` — loaders DO run during document SSR (the
|
|
2
|
+
// data ships in the bootstrap payload), but the component renders only on the
|
|
3
|
+
// client; the Fallback SSRs in its place.
|
|
4
|
+
export const ssr = "data-only";
|
|
5
|
+
|
|
6
|
+
export function loader() {
|
|
7
|
+
return { payload: "DATA-ONLY-LOADER-DATA" };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Fallback() {
|
|
11
|
+
return <p>data-only fallback</p>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function DataOnlyPage() {
|
|
15
|
+
return <p>data-only component</p>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Fixture exercising the Phase 1/2/4 route exports end-to-end:
|
|
2
|
+
// - `headers` → Cache-Control on the document + /_data response
|
|
3
|
+
// - `handle` → surfaced via useMatches() (asserted from the payload)
|
|
4
|
+
// - `middleware` → sets context (read by the loader) and stamps a header
|
|
5
|
+
import type { RouteMiddlewareFunction, HeadersArgs } from "../../../../shared/route-types.ts";
|
|
6
|
+
|
|
7
|
+
const setUser: RouteMiddlewareFunction = async (ctx, next) => {
|
|
8
|
+
ctx.context.demoUser = "alice";
|
|
9
|
+
const res = await next();
|
|
10
|
+
res.headers.set("X-Demo-Mw", "1");
|
|
11
|
+
return res;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const middleware = [setUser];
|
|
15
|
+
|
|
16
|
+
export const handle = { breadcrumb: "Features" };
|
|
17
|
+
|
|
18
|
+
export function loader({ context }: { context: Record<string, unknown> }) {
|
|
19
|
+
return { user: context.demoUser ?? null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function headers(_args: HeadersArgs) {
|
|
23
|
+
return { "Cache-Control": "public, max-age=120" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function FeaturesDemo() {
|
|
27
|
+
return <p>features</p>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Exercises defineActions + <Form intent> + safeValidate end to end.
|
|
2
|
+
import { defineActions, safeValidate, formText } from "../../../../index.ts";
|
|
3
|
+
import { Form } from "../../../../client/components/Form.tsx";
|
|
4
|
+
import type { Schema } from "../../../../server/validate.ts";
|
|
5
|
+
|
|
6
|
+
const TitleSchema: Schema<{ title: string }> = {
|
|
7
|
+
safeParse(input: unknown) {
|
|
8
|
+
const t = typeof (input as { title?: unknown })?.title === "string"
|
|
9
|
+
? ((input as { title: string }).title).trim()
|
|
10
|
+
: "";
|
|
11
|
+
return t
|
|
12
|
+
? { success: true, data: { title: t } }
|
|
13
|
+
: { success: false, error: { issues: [{ path: ["title"], message: "Title required" }] } };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let count = 0;
|
|
18
|
+
|
|
19
|
+
export function loader() {
|
|
20
|
+
return { count };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const action = defineActions({
|
|
24
|
+
add: async ({ formData }) => {
|
|
25
|
+
const r = await safeValidate(TitleSchema, formData);
|
|
26
|
+
if (!r.ok) return { error: r.firstError };
|
|
27
|
+
count++;
|
|
28
|
+
return { ok: true, title: r.data.title, count };
|
|
29
|
+
},
|
|
30
|
+
remove: ({ formData }) => {
|
|
31
|
+
formText(formData, "id"); // exercise the helper
|
|
32
|
+
if (count > 0) count--;
|
|
33
|
+
return { ok: true, count };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export default function IntentDemo() {
|
|
38
|
+
return (
|
|
39
|
+
<main>
|
|
40
|
+
<Form intent="add">
|
|
41
|
+
<input name="title" />
|
|
42
|
+
<button type="submit">Add</button>
|
|
43
|
+
</Form>
|
|
44
|
+
</main>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Security invariant: `ssr: false` must NOT skip beforeLoad — it is the auth
|
|
2
|
+
// gate, and it runs for document GETs and /_data alike regardless of SSR mode.
|
|
3
|
+
export const ssr = false;
|
|
4
|
+
|
|
5
|
+
export function beforeLoad() {
|
|
6
|
+
return new Response("Forbidden", { status: 403 });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function loader() {
|
|
10
|
+
return { secret: "GATED-CLIENT-ONLY-DATA" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function ProtectedClientOnlyPage() {
|
|
14
|
+
return <p>protected client-only</p>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LoaderArgs } from "../../../../shared/route-types.ts";
|
|
2
|
+
|
|
3
|
+
// Hand-rolled Zod-compatible schema (the repo has no zod dependency): coerces
|
|
4
|
+
// `page` to a positive integer defaulting to 1; passes `tag` through as an
|
|
5
|
+
// array. Mirrors what `z.object({ page: z.coerce.number().int().positive()
|
|
6
|
+
// .default(1), tag: z.array(z.string()).optional() })` would do.
|
|
7
|
+
export const searchSchema = {
|
|
8
|
+
safeParse(input: unknown) {
|
|
9
|
+
const obj = (input ?? {}) as Record<string, unknown>;
|
|
10
|
+
const issues: Array<{ path: (string | number)[]; message: string }> = [];
|
|
11
|
+
|
|
12
|
+
let page = 1;
|
|
13
|
+
if (obj.page !== undefined) {
|
|
14
|
+
const n = Number(obj.page);
|
|
15
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
16
|
+
issues.push({ path: ["page"], message: "page must be a positive integer" });
|
|
17
|
+
} else {
|
|
18
|
+
page = n;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (issues.length > 0) return { success: false, error: { issues } };
|
|
23
|
+
|
|
24
|
+
const data: Record<string, unknown> = { page };
|
|
25
|
+
if (typeof obj.tag === "string") data.tag = [obj.tag];
|
|
26
|
+
else if (Array.isArray(obj.tag)) data.tag = obj.tag;
|
|
27
|
+
return { success: true, data };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Echo the validated search object so tests can assert loaders receive the
|
|
32
|
+
// coerced shape, not raw strings.
|
|
33
|
+
export function loader({ search }: LoaderArgs) {
|
|
34
|
+
return { receivedSearch: search };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function SearchDemoPage() {
|
|
38
|
+
return <p>search demo</p>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { formText, formValues } from "../shared/form-data.ts";
|
|
3
|
+
|
|
4
|
+
describe("formText", () => {
|
|
5
|
+
test("returns the string value", () => {
|
|
6
|
+
const fd = new FormData();
|
|
7
|
+
fd.set("id", "42");
|
|
8
|
+
expect(formText(fd, "id")).toBe("42");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('returns "" for a missing key', () => {
|
|
12
|
+
expect(formText(new FormData(), "nope")).toBe("");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('returns "" for a File value', () => {
|
|
16
|
+
const fd = new FormData();
|
|
17
|
+
fd.set("upload", new File(["x"], "a.txt"));
|
|
18
|
+
expect(formText(fd, "upload")).toBe("");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("formValues", () => {
|
|
23
|
+
test("collects all string entries (skips files)", () => {
|
|
24
|
+
const fd = new FormData();
|
|
25
|
+
fd.set("a", "1");
|
|
26
|
+
fd.set("b", "2");
|
|
27
|
+
fd.set("file", new File(["x"], "a.txt"));
|
|
28
|
+
expect(formValues(fd)).toEqual({ a: "1", b: "2" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("first occurrence wins for repeated keys", () => {
|
|
32
|
+
const fd = new FormData();
|
|
33
|
+
fd.append("tag", "one");
|
|
34
|
+
fd.append("tag", "two");
|
|
35
|
+
expect(formValues(fd)).toEqual({ tag: "one" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("named subset defaults missing keys to ''", () => {
|
|
39
|
+
const fd = new FormData();
|
|
40
|
+
fd.set("title", "Hello");
|
|
41
|
+
expect(formValues(fd, ["title", "body"])).toEqual({ title: "Hello", body: "" });
|
|
42
|
+
});
|
|
43
|
+
});
|