@bractjs/bractjs 0.1.21 → 0.1.23

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.
@@ -0,0 +1,312 @@
1
+ import { join, resolve } from "node:path";
2
+ import { scanRoutes, type RouteFile } from "../server/scanner.ts";
3
+
4
+ // Codegen entry-points: `bun build --compile` can't statically trace
5
+ // `Bun.Glob` scans or `import(absPath)` calls, so we materialise the route /
6
+ // layout / action graph into static `import` statements at build time. The
7
+ // emitted files live at `<appDir>/_generated/{routes,actions}.ts` and are
8
+ // imported by the user-facing `app/server.ts` entrypoint.
9
+
10
+ // ── Path safety ────────────────────────────────────────────────────────────
11
+
12
+ // Allow ASCII filename characters, `/` for nested directories, and `[`/`]`
13
+ // for file-based dynamic route syntax (`[id]`, `[...slug]`). All emit sites
14
+ // wrap the path in JSON.stringify, but we still allowlist the charset as
15
+ // defense-in-depth against a hostile filename containing a backtick, $, quote,
16
+ // backslash, or whitespace breaking out of the generated literal. `..` as a
17
+ // whole segment is rejected separately below (path-traversal guard).
18
+ const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]]+$/;
19
+
20
+ function assertSafeFilePath(filePath: string): void {
21
+ if (!SAFE_FILEPATH_RE.test(filePath)) {
22
+ throw new Error(`[bractjs] codegen: refusing to emit unsafe file path: ${JSON.stringify(filePath)}`);
23
+ }
24
+ if (filePath.split("/").includes("..")) {
25
+ throw new Error(`[bractjs] codegen: refusing to emit path with .. segment: ${JSON.stringify(filePath)}`);
26
+ }
27
+ }
28
+
29
+ // Derive a JS identifier from an arbitrary relative path. Any character outside
30
+ // `[A-Za-z0-9_]` collapses to `_`. The leading digit (if any) gets an `_`
31
+ // prefix. This is purely for variable naming in the generated source; the
32
+ // authoritative key for the registry is the JSON-stringified relPath.
33
+ function pathToIdent(prefix: string, relPath: string): string {
34
+ const safe = relPath.replace(/[^A-Za-z0-9_]/g, "_");
35
+ return /^[0-9]/.test(safe) ? `${prefix}__${safe}` : `${prefix}_${safe}`;
36
+ }
37
+
38
+ // ── Layout discovery ───────────────────────────────────────────────────────
39
+
40
+ function layoutDirsForPattern(urlPattern: string): string[] {
41
+ if (urlPattern === "") return [];
42
+ const segments = urlPattern.split("/");
43
+ segments.pop();
44
+ const dirs: string[] = [];
45
+ for (let i = 1; i <= segments.length; i++) {
46
+ dirs.push(segments.slice(0, i).join("/"));
47
+ }
48
+ return dirs;
49
+ }
50
+
51
+ /**
52
+ * Find every `routes/<dir>/layout.tsx` (or `.ts`) that exists on disk for the
53
+ * given set of routes. Mirrors the runtime probe in `resolveLayoutChain` but
54
+ * runs once at codegen time so the generated registry is exhaustive.
55
+ */
56
+ async function collectLayouts(appDir: string, routes: RouteFile[]): Promise<string[]> {
57
+ const layoutPaths = new Set<string>();
58
+ for (const route of routes) {
59
+ for (const dir of layoutDirsForPattern(route.urlPattern)) {
60
+ for (const ext of ["tsx", "ts"]) {
61
+ const rel = `routes/${dir}/layout.${ext}`;
62
+ const abs = resolve(join(appDir, rel));
63
+ if (await Bun.file(abs).exists()) {
64
+ layoutPaths.add(rel);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return [...layoutPaths].sort();
70
+ }
71
+
72
+ // ── Action discovery ───────────────────────────────────────────────────────
73
+
74
+ const SERVER_DIRECTIVE_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
75
+
76
+ function isEligibleActionPath(rel: string): boolean {
77
+ return (
78
+ rel.endsWith(".server.ts") ||
79
+ rel.endsWith(".server.tsx") ||
80
+ rel.startsWith("routes/") ||
81
+ rel.startsWith("routes\\")
82
+ );
83
+ }
84
+
85
+ async function collectActionFiles(appDir: string): Promise<string[]> {
86
+ const glob = new Bun.Glob("**/*.{ts,tsx}");
87
+ const found: string[] = [];
88
+ for await (const rel of glob.scan(appDir)) {
89
+ if (!isEligibleActionPath(rel)) continue;
90
+ // Skip the codegen output directory itself.
91
+ if (rel.startsWith("_generated/")) continue;
92
+ const abs = resolve(join(appDir, rel));
93
+ let src: string;
94
+ try {
95
+ src = await Bun.file(abs).text();
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (!SERVER_DIRECTIVE_RE.test(src)) continue;
100
+ // Normalise Windows separators to POSIX for the registry key.
101
+ found.push(rel.split("\\").join("/"));
102
+ }
103
+ return found.sort();
104
+ }
105
+
106
+ // ── Generators ─────────────────────────────────────────────────────────────
107
+
108
+ const HEADER =
109
+ "// Generated by `bractjs codegen` — do not edit manually.\n" +
110
+ "// Static imports here let `bun build --compile` trace every route, layout,\n" +
111
+ "// and server-action module instead of relying on runtime fs scans.\n" +
112
+ "\n" +
113
+ "/* eslint-disable */\n";
114
+
115
+ export interface RouteRegistryInput {
116
+ appDir: string;
117
+ routes: RouteFile[];
118
+ layoutRelPaths: string[]; // e.g. ["routes/blog/layout.tsx"]
119
+ hasRoot: boolean; // true if appDir/root.tsx exists
120
+ }
121
+
122
+ export function generateRouteRegistry(input: RouteRegistryInput): string {
123
+ const { routes, layoutRelPaths, hasRoot } = input;
124
+
125
+ // ── Build the import statements ──
126
+ const imports: string[] = [];
127
+ const entries: string[] = [];
128
+
129
+ if (hasRoot) {
130
+ assertSafeFilePath("root.tsx");
131
+ const ident = pathToIdent("mod", "root_tsx");
132
+ imports.push(`import * as ${ident} from "../root.tsx";`);
133
+ entries.push(` ${JSON.stringify("root.tsx")}: ${ident},`);
134
+ }
135
+
136
+ for (const rel of layoutRelPaths) {
137
+ assertSafeFilePath(rel);
138
+ const ident = pathToIdent("mod", rel);
139
+ imports.push(`import * as ${ident} from ${JSON.stringify("../" + rel)};`);
140
+ entries.push(` ${JSON.stringify(rel)}: ${ident},`);
141
+ }
142
+
143
+ for (const r of routes) {
144
+ const rel = r.filePath.split("\\").join("/");
145
+ assertSafeFilePath(rel);
146
+ const ident = pathToIdent("mod", rel);
147
+ imports.push(`import * as ${ident} from ${JSON.stringify("../" + rel)};`);
148
+ entries.push(` ${JSON.stringify(rel)}: ${ident},`);
149
+ }
150
+
151
+ // ── routeFiles array literal (mirrors scanRoutes output shape) ──
152
+ const routeFilesLines: string[] = [];
153
+ for (const r of routes) {
154
+ const rel = r.filePath.split("\\").join("/");
155
+ assertSafeFilePath(rel);
156
+ const segments = JSON.stringify(r.segments);
157
+ routeFilesLines.push(
158
+ ` { filePath: ${JSON.stringify(rel)}, urlPattern: ${JSON.stringify(r.urlPattern)}, segments: ${segments} },`,
159
+ );
160
+ }
161
+
162
+ return [
163
+ HEADER,
164
+ `import type { RouteFile } from "@bractjs/bractjs";`,
165
+ `import type { RouteModule } from "@bractjs/bractjs";`,
166
+ "",
167
+ imports.join("\n"),
168
+ "",
169
+ "// Modules keyed by appDir-relative path. The framework looks up route,",
170
+ "// layout, and root modules here at request time instead of doing a",
171
+ "// dynamic `import(absPath)` call that would fail in a compiled binary.",
172
+ "export const moduleRegistry: Record<string, RouteModule> = {",
173
+ entries.join("\n"),
174
+ "};",
175
+ "",
176
+ "export const routeFiles: RouteFile[] = [",
177
+ routeFilesLines.join("\n"),
178
+ "];",
179
+ "",
180
+ ].join("\n");
181
+ }
182
+
183
+ export interface ActionRegistryInput {
184
+ appDir: string;
185
+ actionRelPaths: string[];
186
+ }
187
+
188
+ export function generateActionRegistry(input: ActionRegistryInput): string {
189
+ const { actionRelPaths } = input;
190
+
191
+ const imports: string[] = [];
192
+ const entries: string[] = [];
193
+
194
+ for (const rel of actionRelPaths) {
195
+ assertSafeFilePath(rel);
196
+ const ident = pathToIdent("act", rel);
197
+ imports.push(`import * as ${ident} from ${JSON.stringify("../" + rel)};`);
198
+ entries.push(` { relPath: ${JSON.stringify(rel)}, mod: ${ident} as Record<string, unknown> },`);
199
+ }
200
+
201
+ return [
202
+ HEADER,
203
+ imports.join("\n"),
204
+ "",
205
+ "// Server-action modules, statically imported so `bun build --compile`",
206
+ "// traces them. The framework iterates this list and registers each",
207
+ "// exported function under SHA-256(relPath + \"#\" + name) — identical to",
208
+ "// the ID the client proxy plugin embeds in the bundled JS.",
209
+ "export const actionModules: Array<{ relPath: string; mod: Record<string, unknown> }> = [",
210
+ entries.join("\n"),
211
+ "];",
212
+ "",
213
+ ].join("\n");
214
+ }
215
+
216
+ // ── Manifest codegen ───────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Shape produced by `src/build/manifest.ts` (`RouteManifest`). Re-typed here
220
+ * so the codegen module stays decoupled from the build pipeline.
221
+ */
222
+ interface DiskManifest {
223
+ version?: number;
224
+ mode?: string;
225
+ clientEntry: string;
226
+ rootChunk?: string;
227
+ routes: Record<string, { chunk: string; pattern: string }>;
228
+ }
229
+
230
+ /**
231
+ * Convert the disk-format manifest (RouteManifest) into a TypeScript module
232
+ * exporting a `ServerManifest` constant. This decouples the compiled-binary
233
+ * server entry from the disk manifest — at startup it imports the constant
234
+ * instead of reading `build/route-manifest.json`.
235
+ */
236
+ export function generateManifestModule(disk: DiskManifest): string {
237
+ // Mirror the RouteManifest → ServerManifest projection in serve.ts so the
238
+ // compiled binary sees the same shape `buildFetchHandler` would have built.
239
+ const projectedRoutes: Record<string, { file: string; chunk: string }> = {};
240
+ for (const [pat, entry] of Object.entries(disk.routes ?? {})) {
241
+ projectedRoutes[pat] = { file: entry.chunk, chunk: entry.chunk };
242
+ }
243
+ const projected = {
244
+ clientEntry: disk.clientEntry,
245
+ rootChunk: disk.rootChunk,
246
+ routes: projectedRoutes,
247
+ };
248
+ // JSON.stringify is safe for embedding — all strings get properly escaped.
249
+ // No template literals or backtick interpolation can leak through.
250
+ return [
251
+ HEADER,
252
+ `import type { ServerManifest } from "@bractjs/bractjs";`,
253
+ "",
254
+ "// Snapshot of build/route-manifest.json, frozen into the binary so the",
255
+ "// server never needs to read it from disk at startup.",
256
+ `export const manifest: ServerManifest = ${JSON.stringify(projected, null, 2)};`,
257
+ "",
258
+ ].join("\n");
259
+ }
260
+
261
+ /**
262
+ * Read `<buildDir>/route-manifest.json` and write
263
+ * `<appDir>/_generated/manifest.ts`. Must run AFTER the client `Bun.build()`
264
+ * step — chunk filenames are content-hashed and not known until then.
265
+ */
266
+ export async function writeManifestModule(
267
+ appDir: string,
268
+ buildDir: string,
269
+ ): Promise<string> {
270
+ const absAppDir = resolve(appDir);
271
+ const absBuildDir = resolve(buildDir);
272
+ const src = join(absBuildDir, "route-manifest.json");
273
+ const file = Bun.file(src);
274
+ if (!(await file.exists())) {
275
+ throw new Error(
276
+ `[bractjs] manifest codegen: ${src} not found. Run the client build before manifest codegen.`,
277
+ );
278
+ }
279
+ const disk = (await file.json()) as DiskManifest;
280
+ const out = join(absAppDir, "_generated", "manifest.ts");
281
+ await Bun.write(out, generateManifestModule(disk));
282
+ return out;
283
+ }
284
+
285
+ // ── Driver ─────────────────────────────────────────────────────────────────
286
+
287
+ export interface CodegenResult {
288
+ routesPath: string;
289
+ actionsPath: string;
290
+ }
291
+
292
+ export async function writeModuleRegistries(appDir: string): Promise<CodegenResult> {
293
+ const absAppDir = resolve(appDir);
294
+ const routes = await scanRoutes(absAppDir);
295
+ const layoutRelPaths = await collectLayouts(absAppDir, routes);
296
+ const hasRoot =
297
+ (await Bun.file(resolve(join(absAppDir, "root.tsx"))).exists()) ||
298
+ (await Bun.file(resolve(join(absAppDir, "root.ts"))).exists());
299
+ const actionRelPaths = await collectActionFiles(absAppDir);
300
+
301
+ const routesSrc = generateRouteRegistry({ appDir: absAppDir, routes, layoutRelPaths, hasRoot });
302
+ const actionsSrc = generateActionRegistry({ appDir: absAppDir, actionRelPaths });
303
+
304
+ const outDir = resolve(join(absAppDir, "_generated"));
305
+ const routesPath = join(outDir, "routes.ts");
306
+ const actionsPath = join(outDir, "actions.ts");
307
+
308
+ await Bun.write(routesPath, routesSrc);
309
+ await Bun.write(actionsPath, actionsSrc);
310
+
311
+ return { routesPath, actionsPath };
312
+ }
@@ -1,7 +1,7 @@
1
1
  import { resolve, join, sep } from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import { serverOnlyPlugin } from "../build/env-plugin.ts";
4
- import { useServerProxyPlugin } from "../build/directives.ts";
4
+ import { createUseServerProxyPlugin } from "../build/directives.ts";
5
5
 
6
6
  /**
7
7
  * Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
@@ -55,7 +55,7 @@ export async function handleHmrModuleRequest(
55
55
  target: "browser",
56
56
  minify: false,
57
57
  sourcemap: "inline",
58
- plugins: [serverOnlyPlugin, useServerProxyPlugin],
58
+ plugins: [serverOnlyPlugin, createUseServerProxyPlugin(rootDir)],
59
59
  });
60
60
 
61
61
  if (!result.success || result.outputs.length === 0) {
@@ -1,5 +1,5 @@
1
1
  import type { BractJSConfig } from "../server/serve.ts";
2
- import { useServerProxyPlugin } from "../build/directives.ts";
2
+ import { createUseServerProxyPlugin } from "../build/directives.ts";
3
3
  import { scanRoutes } from "../server/scanner.ts";
4
4
  import { generateManifest, writeManifest } from "../build/manifest.ts";
5
5
  import { mkdir, rename, rm } from "node:fs/promises";
@@ -48,7 +48,7 @@ export async function rebuildClient(
48
48
  // structure. publicPath + ../ traversals produce wrong absolute URLs.
49
49
  minify: false,
50
50
  sourcemap: "inline",
51
- plugins: [useServerProxyPlugin, ...(config?.plugins ?? [])],
51
+ plugins: [createUseServerProxyPlugin(appDir), ...(config?.plugins ?? [])],
52
52
  });
53
53
  } finally {
54
54
  await rm(shimPath, { force: true });
package/src/dev/server.ts CHANGED
@@ -7,51 +7,88 @@ import { filePathToPattern } from "../server/scanner.ts";
7
7
  import { basename, extname } from "node:path";
8
8
  import type { LifecycleHooks } from "../server/lifecycle.ts";
9
9
  import { loadUserConfig } from "../config/load.ts";
10
+ import type { BractJSConfig } from "../server/serve.ts";
10
11
 
11
- // Must precede any user-code import so SSR-time isDevRuntime() checks
12
- // (e.g. inside <LiveReload>) observe the dev mode.
13
- setRuntimeMode("dev");
12
+ export interface DevServerOptions {
13
+ /** HTTP port for the app server. Default: config.port ?? 3000. */
14
+ port?: number;
15
+ /** WebSocket port for HMR. Default: 3001. */
16
+ hmrPort?: number;
17
+ /** Merged over values from bractjs.config.ts. */
18
+ config?: Partial<BractJSConfig>;
19
+ /**
20
+ * Skip loading bractjs.config.ts from cwd.
21
+ * Useful when the caller supplies the full config via the `config` option.
22
+ */
23
+ skipUserConfig?: boolean;
24
+ }
25
+
26
+ export interface DevServer {
27
+ stop(): void;
28
+ }
14
29
 
15
- const userConfig = await loadUserConfig();
30
+ export async function createDevServer(options?: DevServerOptions): Promise<DevServer> {
31
+ // Must precede any user-code import so SSR-time isDevRuntime() checks
32
+ // (e.g. inside <LiveReload>) observe the dev mode.
33
+ setRuntimeMode("dev");
16
34
 
17
- const hmr = createHmrServer(3001);
35
+ const userConfig = options?.skipUserConfig ? {} : await loadUserConfig();
36
+ const merged: Partial<BractJSConfig> = { ...userConfig, ...options?.config };
18
37
 
19
- // Build client bundle before the HTTP server starts accepting requests
20
- const { duration: initialMs } = await rebuildClient(userConfig);
21
- console.log(`[bractjs] initial client build in ${initialMs}ms`);
38
+ const hmrPort = options?.hmrPort ?? 3001;
39
+ const appPort = options?.port ?? merged.port ?? 3000;
22
40
 
23
- // Load user lifecycle hooks if defined (e.g. app/lifecycle.ts)
24
- let lifecycle: LifecycleHooks = {};
25
- try {
26
- const lifecyclePath = `${process.cwd()}/app/lifecycle.ts`;
27
- const mod = await import(lifecyclePath);
28
- if (mod.default) lifecycle = mod.default;
29
- } catch {
30
- // No lifecycle file — that's fine
31
- }
41
+ const hmr = createHmrServer(hmrPort);
32
42
 
33
- createServer({ port: 3000, ...userConfig, ...lifecycle });
34
-
35
- watchApp(userConfig.appDir ?? "./app", async (file) => {
36
- const { duration } = await rebuildClient(userConfig);
37
-
38
- // Route files (not layout): do a fine-grained module swap without full reload.
39
- // Root, layouts, and other files: fall back to full page reload.
40
- const isRoute =
41
- file.startsWith("routes/") &&
42
- !file.endsWith("layout.tsx") &&
43
- !file.endsWith("layout.ts");
44
-
45
- if (isRoute) {
46
- const pattern = filePathToPattern(file);
47
- // Chunk URL = same basename as route file; splitting build puts it in build/client/
48
- const chunkUrl = `/build/client/${basename(file, extname(file))}.js`;
49
- hmr.broadcast({ type: "hmr:route", pattern, chunkUrl, file, duration });
50
- console.log(`✓ ${file} → module swap (pattern="${pattern}") in ${duration}ms`);
51
- } else {
52
- hmr.broadcast({ type: "hmr:reload", file, duration });
53
- console.log(`✓ ${file} → full reload in ${duration}ms`);
43
+ // Build client bundle before the HTTP server starts accepting requests
44
+ const { duration: initialMs } = await rebuildClient(merged);
45
+ console.log(`[bractjs] initial client build in ${initialMs}ms`);
46
+
47
+ // Load user lifecycle hooks if defined (e.g. app/lifecycle.ts)
48
+ let lifecycle: LifecycleHooks = {};
49
+ try {
50
+ const lifecyclePath = `${process.cwd()}/app/lifecycle.ts`;
51
+ const mod = await import(lifecyclePath);
52
+ if (mod.default) lifecycle = mod.default;
53
+ } catch {
54
+ // No lifecycle file — that's fine
54
55
  }
55
- });
56
56
 
57
- console.log("BractJS dev server on http://localhost:3000");
57
+ const srv = createServer({ port: appPort, ...merged, ...lifecycle });
58
+
59
+ watchApp(merged.appDir ?? "./app", async (file) => {
60
+ const { duration } = await rebuildClient(merged);
61
+
62
+ // Route files (not layout): do a fine-grained module swap without full reload.
63
+ // Root, layouts, and other files: fall back to full page reload.
64
+ const isRoute =
65
+ file.startsWith("routes/") &&
66
+ !file.endsWith("layout.tsx") &&
67
+ !file.endsWith("layout.ts");
68
+
69
+ if (isRoute) {
70
+ const pattern = filePathToPattern(file);
71
+ // Chunk URL = same basename as route file; splitting build puts it in build/client/
72
+ const chunkUrl = `/build/client/${basename(file, extname(file))}.js`;
73
+ hmr.broadcast({ type: "hmr:route", pattern, chunkUrl, file, duration });
74
+ console.log(`✓ ${file} → module swap (pattern="${pattern}") in ${duration}ms`);
75
+ } else {
76
+ hmr.broadcast({ type: "hmr:reload", file, duration });
77
+ console.log(`✓ ${file} → full reload in ${duration}ms`);
78
+ }
79
+ });
80
+
81
+ console.log(`BractJS dev server on http://localhost:${appPort}`);
82
+
83
+ return {
84
+ stop() {
85
+ srv.stop();
86
+ hmr.stop();
87
+ },
88
+ };
89
+ }
90
+
91
+ // Preserve direct-run behavior: bun run src/dev/server.ts
92
+ if (import.meta.main) {
93
+ await createDevServer();
94
+ }
package/src/index.ts CHANGED
@@ -14,7 +14,36 @@ export { BunAdapter } from "./server/adapter.ts";
14
14
  export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloudflare.ts";
15
15
 
16
16
  // Build plugins
17
+ //
18
+ // These plugins are REQUIRED when users compose their own `Bun.build` call
19
+ // (e.g. native `bun build --compile` or a custom client bundle step). Missing
20
+ // any of them breaks security or runtime behaviour:
21
+ //
22
+ // - `useClientStubPlugin` (server bundle): replaces "use client" modules with
23
+ // null stubs. Without it, the server binary crashes when React tries to
24
+ // call browser-only hooks/APIs.
25
+ // - `createUseServerProxyPlugin(appDir)` (client bundle): replaces
26
+ // "use server" exports with fetch proxies. Without it, server-action
27
+ // bodies — including DB queries and secrets — ship inside the browser JS.
28
+ // - `serverOnlyPlugin` (client bundle): hard-fails imports of `*.server.ts`
29
+ // files so server-only code can never be tree-walked into the client bundle.
30
+ // - `clientEnvPlugin(allowedKeys, env)` (client bundle): allowlists which
31
+ // `process.env.*` references survive into the browser bundle.
32
+ // - `cssModulesPlugin` (client bundle): handles `*.module.css` imports.
17
33
  export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
34
+ export { useClientStubPlugin, createUseServerProxyPlugin, useServerProxyPlugin } from "./build/directives.ts";
35
+ export { serverOnlyPlugin, clientEnvPlugin } from "./build/env-plugin.ts";
36
+
37
+ // Module-registry codegen (drives `bun build --compile` workflow)
38
+ export {
39
+ writeModuleRegistries,
40
+ writeManifestModule,
41
+ generateRouteRegistry,
42
+ generateActionRegistry,
43
+ generateManifestModule,
44
+ } from "./codegen/module-registry.ts";
45
+ export type { CodegenResult } from "./codegen/module-registry.ts";
46
+ export type { ModuleRegistry } from "./server/layout.ts";
18
47
 
19
48
  // Client RPC
20
49
  export { createClient } from "./client/rpc.ts";
@@ -34,6 +63,7 @@ export type {
34
63
  RouteModule,
35
64
  RouteDefinition,
36
65
  } from "./shared/route-types.ts";
66
+ export type { RouteFile, Segment } from "./server/scanner.ts";
37
67
 
38
68
  export { BractJSError, HttpError, isRedirect, isHttpError, isBractJSError } from "./shared/errors.ts";
39
69
  export { Deferred, defer, isDeferred } from "./shared/deferred.ts";
@@ -78,3 +108,10 @@ export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
78
108
  // i18n utilities (server-side)
79
109
  export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
80
110
  export type { I18nConfig } from "./server/serve.ts";
111
+
112
+ // Programmatic API — importable alternatives to the CLI commands
113
+ export { createDevServer } from "./dev/server.ts";
114
+ export type { DevServerOptions, DevServer } from "./dev/server.ts";
115
+ export { runBuild } from "./build/bundler.ts";
116
+ export type { BuildConfig } from "./build/bundler.ts";
117
+ export { loadUserConfig } from "./config/load.ts";
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, relative, resolve, isAbsolute } from "node:path";
2
2
 
3
3
  // Anchored at start-of-file. Allow whitespace and line/block comments before
4
4
  // the "use server" string literal. This prevents false matches from a "use
@@ -6,8 +6,12 @@ import { join } from "node:path";
6
6
  const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
7
7
  const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
8
8
 
9
- async function computeId(filePath: string, name: string): Promise<string> {
10
- const raw = new TextEncoder().encode(filePath + "#" + name);
9
+ /**
10
+ * Hash key for an action must use the same string the client-side proxy
11
+ * plugin hashes (`pathKey + "#" + name`). Mismatch → `/_action?id=...` 404.
12
+ */
13
+ async function computeId(pathKey: string, name: string): Promise<string> {
14
+ const raw = new TextEncoder().encode(pathKey + "#" + name);
11
15
  const buf = await crypto.subtle.digest("SHA-256", raw);
12
16
  return Array.from(new Uint8Array(buf))
13
17
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -15,6 +19,18 @@ async function computeId(filePath: string, name: string): Promise<string> {
15
19
  .slice(0, 16);
16
20
  }
17
21
 
22
+ /**
23
+ * Convert an absolute file path to the appDir-relative key used for hashing.
24
+ * Matches `pathKeyForAction` in `src/build/directives.ts`. Files outside
25
+ * appDir keep their absolute path so external imports stay hashable but
26
+ * distinct from in-tree files.
27
+ */
28
+ function pathKeyForAction(absPath: string, appDir: string): string {
29
+ const absAppDir = isAbsolute(appDir) ? appDir : resolve(appDir);
30
+ const rel = relative(absAppDir, absPath);
31
+ return rel.startsWith("..") ? absPath : rel;
32
+ }
33
+
18
34
  export function resolveAction(id: string): ((...args: unknown[]) => Promise<unknown>) | null {
19
35
  return registry.get(id) ?? null;
20
36
  }
@@ -47,7 +63,28 @@ export async function loadServerActions(appDir: string): Promise<void> {
47
63
 
48
64
  for (const [name, val] of Object.entries(mod)) {
49
65
  if (typeof val !== "function") continue;
50
- const id = await computeId(filePath, name);
66
+ const id = await computeId(pathKeyForAction(filePath, appDir), name);
67
+ registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Registry-driven counterpart to `loadServerActions`. Skips the filesystem
74
+ * scan and dynamic imports — every entry was already statically imported by
75
+ * `_generated/actions.ts`, so we just iterate and register.
76
+ *
77
+ * Each entry's `relPath` MUST be appDir-relative (matches what
78
+ * `createUseServerProxyPlugin(appDir)` hashed during the client build).
79
+ * Mismatched relPaths produce silent `/_action?id=...` 404s.
80
+ */
81
+ export async function loadServerActionsFromRegistry(
82
+ entries: Array<{ relPath: string; mod: Record<string, unknown> }>,
83
+ ): Promise<void> {
84
+ for (const { relPath, mod } of entries) {
85
+ for (const [name, val] of Object.entries(mod)) {
86
+ if (typeof val !== "function") continue;
87
+ const id = await computeId(relPath, name);
51
88
  registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
52
89
  }
53
90
  }