@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.
Files changed (117) hide show
  1. package/bin/cli.ts +18 -1
  2. package/package.json +3 -2
  3. package/src/__tests__/codegen-write.test.ts +67 -0
  4. package/src/__tests__/codegen.test.ts +29 -2
  5. package/src/__tests__/compile-safety.test.ts +4 -0
  6. package/src/__tests__/csp.test.ts +10 -0
  7. package/src/__tests__/define-actions.test.ts +69 -0
  8. package/src/__tests__/env.test.ts +18 -0
  9. package/src/__tests__/fetcher-store.test.ts +67 -0
  10. package/src/__tests__/fixtures/app/root.tsx +7 -2
  11. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  12. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  13. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/headers.test.ts +111 -0
  21. package/src/__tests__/integration.test.ts +90 -0
  22. package/src/__tests__/layout-registry.test.ts +7 -3
  23. package/src/__tests__/loader.test.ts +32 -1
  24. package/src/__tests__/matcher.test.ts +29 -0
  25. package/src/__tests__/module-registry.test.ts +2 -3
  26. package/src/__tests__/nav-utils.test.ts +46 -0
  27. package/src/__tests__/prerender.test.ts +102 -0
  28. package/src/__tests__/programmatic-api.test.ts +20 -1
  29. package/src/__tests__/revalidation.test.ts +65 -0
  30. package/src/__tests__/route-lint.test.ts +79 -0
  31. package/src/__tests__/route-middleware.test.ts +84 -0
  32. package/src/__tests__/route-table.test.ts +33 -0
  33. package/src/__tests__/safe-validate.test.ts +96 -0
  34. package/src/__tests__/scanner.test.ts +46 -1
  35. package/src/__tests__/scroll-restoration.test.ts +66 -0
  36. package/src/__tests__/search-serializer.test.ts +42 -0
  37. package/src/__tests__/search-validation.test.ts +125 -0
  38. package/src/__tests__/security-fixes.test.ts +201 -0
  39. package/src/__tests__/security.test.ts +110 -1
  40. package/src/__tests__/selective-ssr.test.ts +85 -0
  41. package/src/__tests__/spa-mode.test.ts +77 -0
  42. package/src/__tests__/typed-routing.test.ts +51 -1
  43. package/src/__tests__/use-matches.test.ts +54 -0
  44. package/src/build/bundler.ts +33 -0
  45. package/src/build/prerender.ts +88 -0
  46. package/src/build/route-lint.ts +49 -0
  47. package/src/client/ClientRouter.tsx +339 -47
  48. package/src/client/cache.ts +8 -0
  49. package/src/client/components/Await.tsx +9 -2
  50. package/src/client/components/Form.tsx +23 -34
  51. package/src/client/components/Link.tsx +80 -9
  52. package/src/client/components/Outlet.tsx +8 -2
  53. package/src/client/components/ScrollRestoration.tsx +125 -0
  54. package/src/client/entry.tsx +39 -2
  55. package/src/client/fetcher-store.ts +61 -0
  56. package/src/client/form-utils.ts +3 -0
  57. package/src/client/hooks/useActionData.ts +7 -3
  58. package/src/client/hooks/useFetcher.ts +116 -33
  59. package/src/client/hooks/useFetchers.ts +23 -0
  60. package/src/client/hooks/useLoaderData.ts +8 -4
  61. package/src/client/hooks/useLocation.ts +27 -0
  62. package/src/client/hooks/useMatches.ts +32 -0
  63. package/src/client/hooks/useNavigate.ts +11 -6
  64. package/src/client/hooks/useRevalidator.ts +26 -0
  65. package/src/client/hooks/useSearch.ts +73 -0
  66. package/src/client/hooks/useSearchParams.ts +7 -2
  67. package/src/client/nav-utils.ts +26 -0
  68. package/src/client/prefetch.ts +110 -15
  69. package/src/client/registry.ts +24 -0
  70. package/src/client/revalidation.ts +25 -0
  71. package/src/client/router.tsx +34 -1
  72. package/src/client/rpc.ts +11 -1
  73. package/src/client/scroll-restoration.ts +48 -0
  74. package/src/client/search-serializer.ts +40 -0
  75. package/src/client/types.ts +6 -0
  76. package/src/codegen/module-registry.ts +13 -21
  77. package/src/codegen/route-codegen.ts +148 -10
  78. package/src/config/load.ts +22 -0
  79. package/src/dev/hmr-client.ts +3 -1
  80. package/src/dev/route-table.ts +27 -0
  81. package/src/dev/server.ts +106 -8
  82. package/src/dev/watcher.ts +25 -3
  83. package/src/index.ts +38 -6
  84. package/src/server/action-handler.ts +3 -13
  85. package/src/server/action-registry.ts +35 -0
  86. package/src/server/adapter.ts +16 -0
  87. package/src/server/api-route.ts +47 -0
  88. package/src/server/csp.ts +19 -4
  89. package/src/server/csrf.ts +36 -3
  90. package/src/server/env.ts +26 -5
  91. package/src/server/headers.ts +49 -0
  92. package/src/server/layout.ts +43 -20
  93. package/src/server/loader.ts +14 -8
  94. package/src/server/matcher.ts +29 -2
  95. package/src/server/matches.ts +50 -0
  96. package/src/server/middleware.ts +66 -0
  97. package/src/server/proto-guard.ts +56 -0
  98. package/src/server/render.ts +51 -18
  99. package/src/server/request-handler.ts +111 -29
  100. package/src/server/scanner.ts +45 -3
  101. package/src/server/search.ts +47 -0
  102. package/src/server/serve.ts +116 -4
  103. package/src/server/session.ts +12 -1
  104. package/src/server/spa.ts +62 -0
  105. package/src/server/stream-handler.ts +10 -1
  106. package/src/server/validate.ts +89 -14
  107. package/src/shared/context.ts +7 -0
  108. package/src/shared/define-actions.ts +39 -0
  109. package/src/shared/form-data.ts +34 -0
  110. package/src/shared/route-types.ts +191 -2
  111. package/templates/new-app/app/root.tsx +2 -1
  112. package/templates/new-app/bractjs.config.ts +7 -12
  113. package/types/config.d.ts +24 -0
  114. package/types/index.d.ts +182 -9
  115. package/types/route.d.ts +138 -3
  116. package/LICENSE +0 -21
  117. package/README.md +0 -1125
@@ -19,6 +19,8 @@ export interface BuildConfig {
19
19
  minify?: boolean;
20
20
  clientEnv?: string[];
21
21
  plugins?: BunPlugin[];
22
+ /** SPA mode: when `false`, the build also emits the static document shell. */
23
+ ssr?: boolean;
22
24
  }
23
25
 
24
26
  export async function runBuild(config: BuildConfig): Promise<void> {
@@ -30,6 +32,16 @@ export async function runBuild(config: BuildConfig): Promise<void> {
30
32
  const routeFilePaths = routes.map((r) => join(appDir, r.filePath));
31
33
  const rootFilePath = join(appDir, "root.tsx");
32
34
 
35
+ // Static route-module lint: surface empty routes and miscased exports at
36
+ // build time (no execution — just source analysis).
37
+ const { lintRouteModuleSource } = await import("./route-lint.ts");
38
+ for (const r of routes) {
39
+ const src = await Bun.file(join(appDir, r.filePath)).text().catch(() => "");
40
+ for (const warning of lintRouteModuleSource(src, r.filePath)) {
41
+ console.warn(`[bract] ${warning}`);
42
+ }
43
+ }
44
+
33
45
  // ── 1. Clean stale artefacts ────────────────────────────────────────────
34
46
  const buildDir = config.buildDir ?? "build";
35
47
  await Promise.all([
@@ -140,5 +152,26 @@ export async function runBuild(config: BuildConfig): Promise<void> {
140
152
  // ── 5. Write manifest ──────────────────────────────────────────────────
141
153
  const manifest = generateManifest({ clientEntry, rootChunk, routeChunks, mode: "production" });
142
154
  await writeManifest(manifest, "build");
155
+
156
+ // ── 6. SPA shell (ssr: false) ───────────────────────────────────────────
157
+ // Emit the static document shell every document GET will serve in SPA mode.
158
+ if (config.ssr === false) {
159
+ const { renderSpaShell } = await import("../server/spa.ts");
160
+ const { installUseClientServerStub } = await import("../server/use-client-runtime.ts");
161
+ // root.tsx is imported from source here — "use client" components inside
162
+ // it must null-render exactly as they do on the running server.
163
+ installUseClientServerStub(appDir);
164
+ const serverManifest = {
165
+ clientEntry,
166
+ rootChunk,
167
+ routes: Object.fromEntries(
168
+ Object.entries(manifest.routes).map(([pat, e]) => [pat, { file: e.chunk, chunk: e.chunk }]),
169
+ ),
170
+ };
171
+ const html = await renderSpaShell(appDir, serverManifest);
172
+ await Bun.write(join(buildDir, "client", "__spa.html"), html);
173
+ console.log("[bract] SPA shell → build/client/__spa.html");
174
+ }
175
+
143
176
  console.log("[bract] build complete →", Object.keys(manifest.routes).length, "routes");
144
177
  }
@@ -0,0 +1,88 @@
1
+ import { join } from "node:path";
2
+ import { buildFetchHandler } from "../server/serve.ts";
3
+ import type { ServerManifest } from "../server/render.ts";
4
+
5
+ export interface PrerenderOptions {
6
+ /** Concrete paths to prerender (or a function resolving them, e.g. from a DB). */
7
+ prerender: string[] | (() => string[] | Promise<string[]>);
8
+ appDir?: string;
9
+ publicDir?: string;
10
+ buildDir?: string;
11
+ /** Override the manifest instead of loading `<buildDir>/route-manifest.json`. */
12
+ manifest?: ServerManifest;
13
+ }
14
+
15
+ export interface PrerenderResult {
16
+ written: string[];
17
+ }
18
+
19
+ /**
20
+ * Where a path's prerendered files live under `<buildDir>/client/_prerender`.
21
+ * Throws on anything that isn't a clean absolute path — these strings come
22
+ * from user config but become filesystem writes.
23
+ */
24
+ export function prerenderPaths(path: string): { html: string; data: string } {
25
+ if (!path.startsWith("/")) {
26
+ throw new Error(`[bractjs] prerender: paths must start with "/", got ${JSON.stringify(path)}`);
27
+ }
28
+ if (path.includes(":") || path.includes("[") || path.includes("*")) {
29
+ throw new Error(
30
+ `[bractjs] prerender: ${JSON.stringify(path)} looks like a route PATTERN — ` +
31
+ `expand dynamic routes to concrete paths (e.g. "/blog/intro", not "/blog/:slug").`,
32
+ );
33
+ }
34
+ const segments = path.split("/").filter(Boolean);
35
+ if (segments.some((s) => s === ".." || s === ".")) {
36
+ throw new Error(`[bractjs] prerender: refusing path with dot segments: ${JSON.stringify(path)}`);
37
+ }
38
+ const dir = segments.join("/");
39
+ return {
40
+ html: dir === "" ? "index.html" : `${dir}/index.html`,
41
+ data: dir === "" ? "_data.json" : `${dir}/_data.json`,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Build-time prerendering (SSG): run the production fetch handler in-process
47
+ * against each configured path and write the HTML document plus its `/_data`
48
+ * payload (used by client navigations INTO a prerendered page) under
49
+ * `<buildDir>/client/_prerender/`. The production server serves these before
50
+ * falling back to dynamic SSR — query-carrying requests stay dynamic.
51
+ *
52
+ * Loaders run for real at build time: anything they need (DB, env) must be
53
+ * available to the build.
54
+ */
55
+ export async function runPrerender(options: PrerenderOptions): Promise<PrerenderResult> {
56
+ const buildDir = options.buildDir ?? "./build";
57
+ const paths = typeof options.prerender === "function" ? await options.prerender() : options.prerender;
58
+
59
+ const handler = buildFetchHandler({
60
+ appDir: options.appDir ?? "./app",
61
+ publicDir: options.publicDir,
62
+ buildDir,
63
+ manifest: options.manifest,
64
+ });
65
+
66
+ const written: string[] = [];
67
+ for (const path of paths) {
68
+ const out = prerenderPaths(path);
69
+
70
+ const htmlRes = await handler(new Request("http://prerender.local" + path));
71
+ if (htmlRes.status !== 200) {
72
+ throw new Error(`[bractjs] prerender: GET ${path} returned ${htmlRes.status}`);
73
+ }
74
+ const htmlFile = join(buildDir, "client", "_prerender", out.html);
75
+ await Bun.write(htmlFile, await htmlRes.text());
76
+ written.push(htmlFile);
77
+
78
+ const dataRes = await handler(
79
+ new Request("http://prerender.local/_data?path=" + encodeURIComponent(path)),
80
+ );
81
+ if (dataRes.status === 200) {
82
+ const dataFile = join(buildDir, "client", "_prerender", out.data);
83
+ await Bun.write(dataFile, await dataRes.text());
84
+ written.push(dataFile);
85
+ }
86
+ }
87
+ return { written };
88
+ }
@@ -0,0 +1,49 @@
1
+ import { extractExports } from "./directives.ts";
2
+
3
+ // The canonical route-module export names. A route file exporting a near-miss
4
+ // (wrong case) of one of these is almost always a mistake — the framework's
5
+ // projection is case-sensitive, so `Loader` is silently ignored.
6
+ export const ROUTE_EXPORT_NAMES = [
7
+ "default", "loader", "action", "clientLoader", "clientAction", "meta", "headers",
8
+ "middleware", "beforeLoad", "shouldRevalidate", "searchSchema", "ssr", "Fallback",
9
+ "handle", "ErrorBoundary", "config", "loaderDeps", "context",
10
+ ] as const;
11
+
12
+ const CANONICAL_LOWER = new Map(ROUTE_EXPORT_NAMES.map((n) => [n.toLowerCase(), n]));
13
+ const CANONICAL_SET = new Set<string>(ROUTE_EXPORT_NAMES);
14
+
15
+ /** A route is "renderable or does work" if it has any of these. */
16
+ const MEANINGFUL = ["default", "loader", "action", "beforeLoad"];
17
+
18
+ /**
19
+ * Static lint of a route module's SOURCE (no execution). Returns human-readable
20
+ * warning strings. Used by the dev rebuilder and the production build to catch
21
+ * two common, silent mistakes: a route that renders nothing, and an export
22
+ * whose casing doesn't match a framework export (so it's ignored).
23
+ */
24
+ export function lintRouteModuleSource(src: string, filePath: string): string[] {
25
+ const warnings: string[] = [];
26
+ const names = extractExports(src);
27
+ // extractExports misses anonymous `export default () => …` / `export default function() {}`.
28
+ const hasAnonDefault = /^export\s+default\b/m.test(src) && !names.includes("default");
29
+ const exportSet = new Set(names);
30
+ if (hasAnonDefault) exportSet.add("default");
31
+
32
+ if (!MEANINGFUL.some((n) => exportSet.has(n))) {
33
+ warnings.push(
34
+ `${filePath}: route has no default/loader/action/beforeLoad export — it renders an empty page.`,
35
+ );
36
+ }
37
+
38
+ for (const name of exportSet) {
39
+ if (CANONICAL_SET.has(name)) continue;
40
+ const canonical = CANONICAL_LOWER.get(name.toLowerCase());
41
+ if (canonical && canonical !== name) {
42
+ warnings.push(
43
+ `${filePath}: export "${name}" looks like "${canonical}" — route exports are case-sensitive, so "${name}" is ignored.`,
44
+ );
45
+ }
46
+ }
47
+
48
+ return warnings;
49
+ }