@bractjs/bractjs 0.1.22 → 0.1.24

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.
@@ -1,7 +1,7 @@
1
1
  import { createElement } from "react";
2
2
  import type { TrieNode } from "./matcher.ts";
3
3
  import { matchRoute } from "./matcher.ts";
4
- import { resolveRouteChain } from "./layout.ts";
4
+ import { resolveRouteChain, type ModuleRegistry } from "./layout.ts";
5
5
  import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } from "./loader.ts";
6
6
  import { renderRoute, type ServerManifest } from "./render.ts";
7
7
  import { resolveMeta } from "./meta.ts";
@@ -11,11 +11,19 @@ import { isExplicitDev } from "./env.ts";
11
11
  import { pipeline, type MiddlewareContext } from "./middleware.ts";
12
12
  import { BractJSProvider } from "../shared/context.ts";
13
13
  import { isAllowedMutation } from "./csrf.ts";
14
+ import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
14
15
 
15
16
  export interface HandlerConfig {
16
17
  appDir: string;
17
18
  publicDir: string;
18
19
  manifest: ServerManifest;
20
+ onError?: OnErrorHook;
21
+ /**
22
+ * Pre-loaded route/layout/root modules keyed by appDir-relative path.
23
+ * Provided by codegen (`_generated/routes.ts`) for compiled binaries
24
+ * where dynamic `import(absPath)` is unavailable. Falsy in dev mode.
25
+ */
26
+ moduleRegistry?: ModuleRegistry;
19
27
  }
20
28
 
21
29
  const MUTATING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
@@ -45,7 +53,7 @@ async function route(
45
53
  config: HandlerConfig,
46
54
  context: Record<string, unknown>,
47
55
  ): Promise<Response> {
48
- const { appDir, manifest } = config;
56
+ const { appDir, manifest, onError, moduleRegistry } = config;
49
57
  const url = new URL(request.url);
50
58
  const { pathname, searchParams } = url;
51
59
 
@@ -68,7 +76,7 @@ async function route(
68
76
  if (!match) return json({ error: "Not Found" }, { status: 404 });
69
77
 
70
78
  try {
71
- const chain = await resolveRouteChain(match.routeFile, appDir);
79
+ const chain = await resolveRouteChain(match.routeFile, appDir, moduleRegistry);
72
80
  // Reconstruct a Request that carries the original search params so loaders
73
81
  // can access them via request.url / new URL(request.url).searchParams.
74
82
  const targetUrl = new URL(request.url);
@@ -90,12 +98,13 @@ async function route(
90
98
  const args = buildLoaderArgs(loaderRequest, match.params, routeContext);
91
99
  const beforeLoadResponse = await runBeforeLoad(chain.route, args);
92
100
  if (beforeLoadResponse) return beforeLoadResponse;
93
- const results = await runLoaders(chain, args);
101
+ const results = await runLoaders(chain, args, onError);
94
102
  return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
95
103
  } catch (err) {
96
104
  if (isRedirect(err)) return err as Response;
97
105
  if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
98
106
  console.error("[bractjs] /_data error:", err);
107
+ await fireOnError(onError, err, request);
99
108
  return json({ error: "Internal Server Error" }, { status: 500 });
100
109
  }
101
110
  }
@@ -104,7 +113,7 @@ async function route(
104
113
  const match = matchRoute(pathname, trie);
105
114
  if (!match) return error("Not Found", 404);
106
115
 
107
- const chain = await resolveRouteChain(match.routeFile, appDir);
116
+ const chain = await resolveRouteChain(match.routeFile, appDir, moduleRegistry);
108
117
  // Run per-route context factory (defineContext export) before loaders.
109
118
  const routeContext = await runRouteContext(
110
119
  chain.route as Parameters<typeof runRouteContext>[0],
@@ -138,6 +147,7 @@ async function route(
138
147
  } catch (err) {
139
148
  if (isRedirect(err)) return err as Response;
140
149
  if (isHttpError(err)) return error(err.message, err.status);
150
+ await fireOnError(onError, err, request);
141
151
  if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
142
152
  return error("Internal Server Error", 500);
143
153
  }
@@ -151,10 +161,11 @@ async function route(
151
161
  // ── Loaders ───────────────────────────────────────────────────────────
152
162
  let loaderResults;
153
163
  try {
154
- loaderResults = await runLoaders(chain, args);
164
+ loaderResults = await runLoaders(chain, args, onError);
155
165
  } catch (err) {
156
166
  if (isRedirect(err)) return err as Response;
157
167
  if (isHttpError(err)) return error(err.message, err.status);
168
+ await fireOnError(onError, err, request);
158
169
  if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
159
170
  return error("Internal Server Error", 500);
160
171
  }
@@ -1,4 +1,4 @@
1
- import { scanRoutes } from "./scanner.ts";
1
+ import { scanRoutes, type RouteFile } from "./scanner.ts";
2
2
  import { buildTrie } from "./matcher.ts";
3
3
  import { handleRequest, type HandlerConfig } from "./request-handler.ts";
4
4
  import { type ServerManifest } from "./render.ts";
@@ -6,10 +6,12 @@ import { isDevRuntime, isExplicitDev } from "./env.ts";
6
6
  import { loadManifest } from "../build/manifest.ts";
7
7
  import { serveStatic } from "./static.ts";
8
8
  import { handleImageRequest } from "../image/handler.ts";
9
- import { loadServerActions } from "./action-registry.ts";
9
+ import { loadServerActions, loadServerActionsFromRegistry } from "./action-registry.ts";
10
10
  import { handleActionRequest } from "./action-handler.ts";
11
11
  import { BunAdapter, type BractAdapter } from "./adapter.ts";
12
+ import type { ModuleRegistry } from "./layout.ts";
12
13
  import { resolve, join } from "node:path";
14
+ import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
13
15
 
14
16
  export interface I18nConfig {
15
17
  locales: string[];
@@ -38,6 +40,27 @@ export interface BractJSConfig {
38
40
  onStart?: () => Promise<void> | void;
39
41
  /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
40
42
  onShutdown?: () => Promise<void> | void;
43
+ /** Called for every unexpected error: loader failures, action throws, and uncaught process exceptions. Redirects and HttpErrors are intentional control flow and are NOT reported here. The request is undefined for process-level exceptions. */
44
+ onError?: OnErrorHook;
45
+ /**
46
+ * Pre-scanned route list (typically exported from `app/_generated/routes.ts`).
47
+ * When provided, skips the startup `Bun.Glob` scan of `appDir`. Required for
48
+ * `bun build --compile` binaries where the embedded filesystem has no
49
+ * scannable routes/ directory.
50
+ */
51
+ routeFiles?: RouteFile[];
52
+ /**
53
+ * Pre-loaded route/layout/root modules keyed by appDir-relative path.
54
+ * Required alongside `routeFiles` for compiled binaries — `resolveRouteChain`
55
+ * uses this map instead of `import(absPath)` at request time.
56
+ */
57
+ moduleRegistry?: ModuleRegistry;
58
+ /**
59
+ * Pre-imported server-action modules (typically `app/_generated/actions.ts`).
60
+ * When provided, skips the startup `Bun.Glob` scan + dynamic import that
61
+ * `loadServerActions` does.
62
+ */
63
+ actionModules?: Array<{ relPath: string; mod: Record<string, unknown> }>;
41
64
  }
42
65
 
43
66
  const DEFAULT_MANIFEST: ServerManifest = {
@@ -84,8 +107,18 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
84
107
  }))
85
108
  : Promise.resolve(config.manifest ?? DEFAULT_MANIFEST);
86
109
 
87
- const trieReady = scanRoutes(appDir).then(buildTrie);
88
- const actionsReady = loadServerActions(appDir);
110
+ // Codegen / compiled-binary path: when the caller supplies pre-scanned
111
+ // routes, skip the runtime `Bun.Glob` scan that `bun build --compile`
112
+ // can't satisfy (the routes/ directory isn't on the filesystem in a
113
+ // single-binary deployment). Same idea for server actions.
114
+ const trieReady = config.routeFiles
115
+ ? Promise.resolve(buildTrie(config.routeFiles))
116
+ : scanRoutes(appDir).then(buildTrie);
117
+ const actionsReady = config.actionModules
118
+ ? loadServerActionsFromRegistry(config.actionModules)
119
+ : loadServerActions(appDir);
120
+ const moduleRegistry = config.moduleRegistry;
121
+ const onError = config.onError;
89
122
 
90
123
  return async function fetch(request: Request): Promise<Response> {
91
124
  const url = new URL(request.url);
@@ -154,7 +187,7 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
154
187
 
155
188
  const trie = await trieReady;
156
189
  const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
157
- const handlerConfig: HandlerConfig = { appDir, publicDir, manifest };
190
+ const handlerConfig: HandlerConfig = { appDir, publicDir, manifest, onError, moduleRegistry };
158
191
  return handleRequest(request, trie, handlerConfig);
159
192
  };
160
193
  }
@@ -186,6 +219,7 @@ async function warnIfStaleBuild(buildDir: string): Promise<void> {
186
219
  let signalsRegistered = false;
187
220
  let isShuttingDown = false;
188
221
  let activeOnShutdown: (() => Promise<void> | void) | undefined;
222
+ let activeOnError: OnErrorHook | undefined;
189
223
 
190
224
  export function createServer(config?: Partial<BractJSConfig>): {
191
225
  stop(): void;
@@ -213,6 +247,7 @@ export function createServer(config?: Partial<BractJSConfig>): {
213
247
  }
214
248
 
215
249
  activeOnShutdown = config?.onShutdown;
250
+ activeOnError = config?.onError;
216
251
 
217
252
  console.log(`[bract] Server running at http://localhost:${port}`);
218
253
 
@@ -224,37 +259,46 @@ export function createServer(config?: Partial<BractJSConfig>): {
224
259
  }
225
260
  };
226
261
 
227
- const gracefulShutdown = (signal?: string) => {
262
+ // Programmatic / beforeExit path runs the user hook, stops the adapter,
263
+ // and returns. Does NOT call process.exit() so callers (tests, parent
264
+ // supervisors) can keep running. `gracefulShutdown` below wraps this and
265
+ // adds an explicit exit for signal handlers, where termination is the
266
+ // whole point.
267
+ const shutdownOnce = async (signal?: string): Promise<void> => {
228
268
  if (isShuttingDown) return;
229
269
  isShuttingDown = true;
230
270
  if (signal) console.log(`\n[bract] Received ${signal}, shutting down…`);
231
271
  try {
232
272
  const result = activeOnShutdown?.();
233
273
  if (result instanceof Promise) {
234
- result.catch((err) => console.error("[bract] onShutdown error:", err)).finally(() => {
235
- stopAdapter();
236
- process.exit(0);
237
- });
238
- } else {
239
- stopAdapter();
240
- process.exit(0);
274
+ try { await result; }
275
+ catch (err) { console.error("[bract] onShutdown error:", err); }
241
276
  }
242
277
  } catch (err) {
243
278
  console.error("[bract] onShutdown error:", err);
279
+ } finally {
244
280
  stopAdapter();
245
- process.exit(1);
246
281
  }
247
282
  };
248
283
 
284
+ const gracefulShutdown = (signal?: string, exitCode = 0): void => {
285
+ void shutdownOnce(signal).finally(() => process.exit(exitCode));
286
+ };
287
+
249
288
  if (!signalsRegistered) {
250
289
  signalsRegistered = true;
251
290
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
252
291
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
253
292
  process.on("SIGUSR2", () => gracefulShutdown("SIGUSR2"));
254
- process.on("beforeExit", () => gracefulShutdown());
293
+ // `beforeExit` fires when the event loop is naturally draining — we
294
+ // already shut down the adapter at that point, but we must NOT call
295
+ // process.exit(). Doing so re-enters the lifecycle and prevents test
296
+ // runners (and any parent process supervising us) from observing a
297
+ // clean exit code. Just run the user hook + stop the listener.
298
+ process.on("beforeExit", () => { void shutdownOnce(); });
255
299
  process.on("uncaughtException", (err) => {
256
300
  console.error("[bract] Uncaught exception:", err);
257
- gracefulShutdown("uncaughtException");
301
+ void fireOnError(activeOnError, err).then(() => gracefulShutdown("uncaughtException", 1));
258
302
  });
259
303
  }
260
304
 
@@ -263,7 +307,12 @@ export function createServer(config?: Partial<BractJSConfig>): {
263
307
  });
264
308
 
265
309
  return {
266
- stop() { void gracefulShutdown(); },
310
+ // Programmatic stop runs `onShutdown`, then closes the listener. Does
311
+ // NOT call `process.exit()`. Tests rely on this so the runner can print
312
+ // its summary; long-running supervisors rely on it so a stop() doesn't
313
+ // tear down the whole worker. Use SIGTERM/SIGINT if you actually want
314
+ // the process to exit.
315
+ stop() { void shutdownOnce(); },
267
316
  };
268
317
  }
269
318
 
@@ -4,20 +4,40 @@ import { realpath } from "node:fs/promises";
4
4
  const IMMUTABLE = "public, max-age=31536000, immutable";
5
5
  const NO_CACHE = "no-cache";
6
6
 
7
- // Resolve to a canonical path that follows symlinks. Returns null if the
8
- // target doesn't exist OR escapes the given root after symlink expansion.
7
+ /**
8
+ * Resolve to a canonical path that follows symlinks, with a fallback for
9
+ * `bun build --compile` binaries.
10
+ *
11
+ * Three outcomes:
12
+ * 1. realpath succeeds + stays inside `root` → return resolved path
13
+ * 2. realpath throws AND `Bun.file(candidate)` exists → return the candidate
14
+ * path. This is the embedded-asset case: `bun --compile` exposes assets
15
+ * through virtual paths that don't appear in the filesystem, so realpath
16
+ * errors with ENOENT/EINVAL but `Bun.file()` still reads them.
17
+ * 3. otherwise → null (escape, ENOENT, etc.)
18
+ *
19
+ * The structural `startsWith(root + sep)` check at the top runs before any
20
+ * I/O and is the authoritative traversal guard — the realpath check is
21
+ * defense-in-depth against symlink escape, which can't happen inside an
22
+ * embedded virtual filesystem.
23
+ */
9
24
  async function safeRealpath(root: string, requested: string): Promise<string | null> {
10
25
  const candidate = resolve(join(root, requested));
11
- // Cheap structural reject before touching the FS.
26
+ // Cheap structural reject before touching the FS. This blocks `..` escapes
27
+ // unconditionally, in both the normal-FS and embedded-binary paths.
12
28
  if (!candidate.startsWith(root + sep) && candidate !== root) return null;
13
- let real: string;
14
29
  try {
15
- real = await realpath(candidate);
30
+ const real = await realpath(candidate);
31
+ if (!real.startsWith(root + sep) && real !== root) return null;
32
+ return real;
16
33
  } catch {
34
+ // realpath fails for paths embedded by `bun build --compile --asset`.
35
+ // The structural check above already prevented traversal, so the only
36
+ // remaining concern is whether the asset actually exists — defer to
37
+ // Bun.file() which reads from the embed table.
38
+ if (await Bun.file(candidate).exists()) return candidate;
17
39
  return null;
18
40
  }
19
- if (!real.startsWith(root + sep) && real !== root) return null;
20
- return real;
21
41
  }
22
42
 
23
43
  /**
@@ -0,0 +1,30 @@
1
+ // Entry point for `bun build --compile`.
2
+ //
3
+ // Pre-generated registries below come from `bractjs codegen:registry` and
4
+ // `bractjs codegen:manifest`. They turn every runtime fs scan / dynamic
5
+ // import the framework would normally do into static, traceable imports so
6
+ // `bun build --compile` can produce a single executable.
7
+ //
8
+ // Generate-then-compile:
9
+ // bractjs codegen:registry # writes routes.ts + actions.ts
10
+ // bractjs build # writes build/client/* + manifest
11
+ // bractjs codegen:manifest # snapshots manifest into TS
12
+ // bun build --compile app/server.ts --outfile myapp [--asset build/client/]
13
+ //
14
+ // Or run all of the above in one shot:
15
+ // bractjs compile
16
+
17
+ import { createServer } from "@bractjs/bractjs";
18
+ import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
19
+ import { actionModules } from "./_generated/actions.ts";
20
+ import { manifest } from "./_generated/manifest.ts";
21
+
22
+ createServer({
23
+ port: Number(process.env.PORT ?? 3000),
24
+ appDir: "./app",
25
+ publicDir: "./public",
26
+ manifest,
27
+ routeFiles,
28
+ moduleRegistry,
29
+ actionModules,
30
+ });
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "bractjs dev",
7
7
  "build": "NODE_ENV=production bractjs build",
8
- "start": "NODE_ENV=production bractjs start"
8
+ "start": "NODE_ENV=production bractjs start",
9
+ "compile": "NODE_ENV=production bractjs compile"
9
10
  },
10
11
  "dependencies": {
11
12
  "@bractjs/bractjs": "latest",
package/types/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BunPlugin } from "bun";
2
+ import type { RouteFile, RouteModule } from "./route.d.ts";
2
3
 
3
4
  export interface BractJSConfig {
4
5
  /** TCP port to listen on. Default: 3000. */
@@ -23,13 +24,32 @@ export interface BractJSConfig {
23
24
  onStart?: () => Promise<void> | void;
24
25
  /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
25
26
  onShutdown?: () => Promise<void> | void;
27
+ /**
28
+ * Pre-scanned route list. Typically imported from `app/_generated/routes.ts`.
29
+ * Required for `bun build --compile` binaries where the routes/ directory
30
+ * isn't on a scannable filesystem.
31
+ */
32
+ routeFiles?: RouteFile[];
33
+ /**
34
+ * Pre-loaded route/layout/root modules keyed by appDir-relative path.
35
+ * Typically imported from `app/_generated/routes.ts`.
36
+ */
37
+ moduleRegistry?: Record<string, RouteModule | Record<string, unknown>>;
38
+ /**
39
+ * Pre-imported server-action modules. Typically imported from
40
+ * `app/_generated/actions.ts`. Each `relPath` MUST match what the client
41
+ * proxy plugin hashed during the client build.
42
+ */
43
+ actionModules?: Array<{ relPath: string; mod: Record<string, unknown> }>;
26
44
  }
27
45
 
28
46
  export interface ServerManifest {
29
47
  /** Hashed path to the main client entry bundle. */
30
48
  clientEntry: string;
49
+ /** Hashed path to the root.tsx chunk (when emitted as a separate entry). */
50
+ rootChunk?: string;
31
51
  /** Map of URL pattern → route asset info. */
32
- routes: Record<string, { file?: string; chunk?: string }>;
52
+ routes: Record<string, { file?: string; chunk?: string; imports?: string[] }>;
33
53
  }
34
54
 
35
55
  /** Subset of BractJSConfig used by the build pipeline. All fields optional. */
package/types/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { ReactNode, Context } from "react";
4
4
  export type {
5
5
  LoaderArgs, ActionArgs, MetaDescriptor, MetaArgs,
6
6
  LoaderFunction, ActionFunction, MetaFunction, RouteModule,
7
+ RouteFile, Segment,
7
8
  } from "./route.d.ts";
8
9
 
9
10
  // ── Config + Server ───────────────────────────────────────────────────────
@@ -22,9 +23,13 @@ export interface RenderOptions {
22
23
  status?: number;
23
24
  }
24
25
 
26
+ export type OnErrorHook = (err: unknown, request?: Request) => Promise<void> | void;
27
+
25
28
  export interface LifecycleHooks {
26
29
  onStart?: () => Promise<void> | void;
27
30
  onShutdown?: () => Promise<void> | void;
31
+ /** Called for every unexpected error: loader failures, action throws, and uncaught process exceptions. Redirects and HttpErrors are NOT reported here. The request is undefined for process-level exceptions. */
32
+ onError?: OnErrorHook;
28
33
  }
29
34
  export declare function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks;
30
35
  export declare function createServer(config?: Partial<BractJSConfig>): { stop(): void };
@@ -203,6 +208,34 @@ export declare function makeCloudflareHandler(
203
208
  export declare const cssModulesPlugin: unknown; // BunPlugin
204
209
  export declare function transformCssModule(filePath: string): Promise<{ map: Record<string, string>; css: string }>;
205
210
 
211
+ // ── Build plugins (required for native `bun build --compile` workflow) ───
212
+ //
213
+ // Apply all of these on the relevant bundle or face crashes / secret leaks:
214
+ // server bundle → useClientStubPlugin
215
+ // client bundle → createUseServerProxyPlugin(appDir), serverOnlyPlugin,
216
+ // clientEnvPlugin(allowedKeys, env), cssModulesPlugin
217
+ export declare const useClientStubPlugin: unknown; // BunPlugin
218
+ export declare function createUseServerProxyPlugin(appDir?: string): unknown; // BunPlugin
219
+ export declare const useServerProxyPlugin: unknown; // BunPlugin (legacy — uses absolute paths)
220
+ export declare const serverOnlyPlugin: unknown; // BunPlugin
221
+ export declare function clientEnvPlugin(
222
+ allowedKeys: string[],
223
+ envValues: Record<string, string>,
224
+ ): unknown; // BunPlugin
225
+
226
+ // ── Module-registry codegen (drives `bun build --compile`) ────────────────
227
+ export interface CodegenResult {
228
+ routesPath: string;
229
+ actionsPath: string;
230
+ }
231
+ /** Scan appDir; write `<appDir>/_generated/routes.ts` and `actions.ts`. */
232
+ export declare function writeModuleRegistries(appDir: string): Promise<CodegenResult>;
233
+ /** Read `<buildDir>/route-manifest.json`; write `<appDir>/_generated/manifest.ts`. */
234
+ export declare function writeManifestModule(appDir: string, buildDir: string): Promise<string>;
235
+
236
+ /** Pre-loaded route/layout/root modules keyed by appDir-relative path. */
237
+ export type ModuleRegistry = Record<string, import("./route.d.ts").RouteModule | Record<string, unknown>>;
238
+
206
239
  // ── buildFetchHandler (D1) ───────────────────────────────────────────────
207
240
  export declare function buildFetchHandler(config: Partial<import("./config.d.ts").BractJSConfig>): (request: Request) => Promise<Response>;
208
241
 
package/types/route.d.ts CHANGED
@@ -50,3 +50,11 @@ export interface RouteModule<TLoader = unknown, TAction = unknown> {
50
50
  ErrorBoundary?: ComponentType<{ error: unknown }>;
51
51
  default?: ComponentType;
52
52
  }
53
+
54
+ export type Segment = string | { param: string } | { catchAll: string };
55
+
56
+ export interface RouteFile {
57
+ filePath: string;
58
+ urlPattern: string;
59
+ segments: Segment[];
60
+ }
@@ -1,2 +0,0 @@
1
- "use server";
2
- export async function ping(...args) { return args; }