@bractjs/bractjs 0.1.15 → 0.1.17

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/README.md CHANGED
@@ -440,6 +440,44 @@ export const db = new Database(Bun.env.DATABASE_URL);
440
440
 
441
441
  ---
442
442
 
443
+ ## Server Lifecycle Hooks
444
+
445
+ Use `defineLifecycle()` in `app/lifecycle.ts` to run code when the server starts or shuts down. The shutdown hook runs on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, and uncaught exceptions), so database connections are always closed cleanly.
446
+
447
+ ```ts
448
+ // app/lifecycle.ts
449
+ import { defineLifecycle } from "@bractjs/bractjs";
450
+ import { db } from "./db.server.ts";
451
+
452
+ export default defineLifecycle({
453
+ async onStart() {
454
+ await db.connect();
455
+ console.log("Database connected");
456
+ },
457
+ async onShutdown() {
458
+ await db.disconnect();
459
+ console.log("Database disconnected");
460
+ },
461
+ });
462
+ ```
463
+
464
+ BractJS picks up `app/lifecycle.ts` automatically in dev mode. In production, spread the hooks into `createServer()`:
465
+
466
+ ```ts
467
+ // server.ts (production entry)
468
+ import { createServer } from "@bractjs/bractjs";
469
+ import lifecycle from "./app/lifecycle.ts";
470
+
471
+ createServer({ port: 3000, ...lifecycle });
472
+ ```
473
+
474
+ | Hook | When it runs |
475
+ |------|-------------|
476
+ | `onStart` | Once, after the server begins accepting requests |
477
+ | `onShutdown` | Before process exit — any signal or uncaught exception |
478
+
479
+ ---
480
+
443
481
  ## Configuration Reference
444
482
 
445
483
  All fields are optional. BractJS works with zero configuration.
@@ -454,6 +492,8 @@ All fields are optional. BractJS works with zero configuration.
454
492
  | `sourcemap` | `string` | `"external"` | `"none"` \| `"inline"` \| `"external"` |
455
493
  | `minify` | `boolean` | `true` | Minify client bundles |
456
494
  | `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
495
+ | `onStart` | `() => void \| Promise<void>` | — | Called once after the server starts listening |
496
+ | `onShutdown` | `() => void \| Promise<void>` | — | Called before process exit on any signal |
457
497
 
458
498
  ---
459
499
 
@@ -475,6 +515,7 @@ All fields are optional. BractJS works with zero configuration.
475
515
  my-app/
476
516
  ├── app/
477
517
  │ ├── root.tsx # required — <html> shell
518
+ │ ├── lifecycle.ts # optional — onStart / onShutdown hooks
478
519
  │ ├── route-types.gen.ts # generated by bractjs codegen
479
520
  │ ├── actions.ts # "use server" actions
480
521
  │ └── routes/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",
@@ -102,7 +102,7 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
102
102
  }
103
103
 
104
104
  // ── 5. Write manifest ──────────────────────────────────────────────────
105
- const manifest = generateManifest({ clientEntry, rootChunk, routeChunks });
105
+ const manifest = generateManifest({ clientEntry, rootChunk, routeChunks, mode: "production" });
106
106
  await writeManifest(manifest, "build");
107
107
  console.log("[bract] build complete →", Object.keys(manifest.routes).length, "routes");
108
108
  }
@@ -9,6 +9,8 @@ export interface RouteManifestEntry {
9
9
 
10
10
  export interface RouteManifest {
11
11
  version: 1;
12
+ /** "production" = produced by `bractjs build`. Absent on dev-rebuilder manifests. */
13
+ mode?: "production";
12
14
  clientEntry: string;
13
15
  rootChunk?: string;
14
16
  routes: Record<string, RouteManifestEntry>;
@@ -29,12 +31,13 @@ export function generateManifest(opts: {
29
31
  clientEntry: string;
30
32
  rootChunk?: string;
31
33
  routeChunks: Map<string, string>;
34
+ mode?: "production";
32
35
  }): RouteManifest {
33
36
  const routes: Record<string, RouteManifestEntry> = {};
34
37
  for (const [pattern, chunk] of opts.routeChunks) {
35
38
  routes[pattern] = { chunk, pattern };
36
39
  }
37
- return { version: 1, clientEntry: opts.clientEntry, rootChunk: opts.rootChunk, routes };
40
+ return { version: 1, mode: opts.mode, clientEntry: opts.clientEntry, rootChunk: opts.rootChunk, routes };
38
41
  }
39
42
 
40
43
  /**
@@ -1,12 +1,21 @@
1
1
  import { type ReactElement } from "react";
2
2
  import { hmrClientScript } from "../../dev/hmr-client.ts";
3
+ import { isDevRuntime } from "../../server/env.ts";
3
4
 
4
5
  /**
5
6
  * Renders an inline WebSocket HMR client in development.
6
7
  * Returns null in production.
8
+ *
9
+ * Two gates:
10
+ * 1. Build-time `process.env.NODE_ENV === "production"` — strips the script from
11
+ * the client bundle entirely (Bun substitutes this define at build time).
12
+ * 2. Runtime `isDevRuntime()` — kills SSR injection unless the server was
13
+ * actually started via `bractjs dev`. Prevents `NODE_ENV=development
14
+ * bractjs start` from shipping an HMR client that retries WS forever.
7
15
  */
8
16
  export function LiveReload(): ReactElement | null {
9
17
  if (process.env.NODE_ENV === "production") return null;
18
+ if (!isDevRuntime()) return null;
10
19
 
11
20
  // SECURITY(low): dangerouslySetInnerHTML is safe here — hmrClientScript is a
12
21
  // build-time constant string with no user input. The NODE_ENV gate above
package/src/dev/server.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { createServer } from "../server/serve.ts";
2
+ import { setRuntimeMode } from "../server/env.ts";
2
3
  import { createHmrServer } from "./hmr-server.ts";
3
4
  import { watchApp } from "./watcher.ts";
4
5
  import { rebuildClient } from "./rebuilder.ts";
5
6
  import { filePathToPattern } from "../server/scanner.ts";
6
7
  import { basename, extname } from "node:path";
8
+ import type { LifecycleHooks } from "../server/lifecycle.ts";
9
+
10
+ // Must precede any user-code import so SSR-time isDevRuntime() checks
11
+ // (e.g. inside <LiveReload>) observe the dev mode.
12
+ setRuntimeMode("dev");
7
13
 
8
14
  const hmr = createHmrServer(3001);
9
15
 
@@ -11,7 +17,16 @@ const hmr = createHmrServer(3001);
11
17
  const { duration: initialMs } = await rebuildClient();
12
18
  console.log(`[bractjs] initial client build in ${initialMs}ms`);
13
19
 
14
- createServer({ port: 3000 });
20
+ // Load user lifecycle hooks if defined (e.g. app/lifecycle.ts)
21
+ let lifecycle: LifecycleHooks = {};
22
+ try {
23
+ const mod = await import("../../app/lifecycle.ts");
24
+ if (mod.default) lifecycle = mod.default;
25
+ } catch {
26
+ // No lifecycle file — that's fine
27
+ }
28
+
29
+ createServer({ port: 3000, ...lifecycle });
15
30
 
16
31
  watchApp("./app", async (file) => {
17
32
  const { duration } = await rebuildClient();
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-module
19
19
  // Client RPC
20
20
  export { createClient } from "./client/rpc.ts";
21
21
  export type { BractJSConfig, RenderOptions, ServerManifest } from "./server/index.ts";
22
+ export { defineLifecycle } from "./server/lifecycle.ts";
23
+ export type { LifecycleHooks } from "./server/lifecycle.ts";
22
24
 
23
25
  // Shared types
24
26
  export type {
package/src/server/env.ts CHANGED
@@ -2,6 +2,24 @@ export function isDev(): boolean {
2
2
  return Bun.env.NODE_ENV !== "production";
3
3
  }
4
4
 
5
+ /**
6
+ * Runtime mode — what the server is actually doing, independent of NODE_ENV.
7
+ * `bractjs dev` sets this to "dev". `bractjs start` leaves it at "prod".
8
+ *
9
+ * Use this (not isDev()) to gate dev-only behavior like HMR injection or
10
+ * re-reading the route manifest on every request. NODE_ENV alone is unreliable:
11
+ * a user running `NODE_ENV=development bractjs start` would otherwise get a
12
+ * production server that still ships an HMR client trying to reconnect to a
13
+ * non-existent ws://localhost:3001 forever.
14
+ */
15
+ let _runtimeMode: "dev" | "prod" = "prod";
16
+ export function setRuntimeMode(m: "dev" | "prod"): void {
17
+ _runtimeMode = m;
18
+ }
19
+ export function isDevRuntime(): boolean {
20
+ return _runtimeMode === "dev";
21
+ }
22
+
5
23
  /**
6
24
  * Strict "is development?" check used to gate sensitive output (error
7
25
  * messages, stack traces) that would otherwise leak in production.
@@ -5,4 +5,4 @@ export { renderRoute } from "./render.ts";
5
5
  export type { RenderOptions, ServerManifest } from "./render.ts";
6
6
 
7
7
  export { redirect, json, error } from "./response.ts";
8
- export { isDev, requireEnv, safeStringify } from "./env.ts";
8
+ export { isDev, isDevRuntime, setRuntimeMode, requireEnv, safeStringify } from "./env.ts";
@@ -0,0 +1,9 @@
1
+ export interface LifecycleHooks {
2
+ onStart?: () => Promise<void> | void;
3
+ onShutdown?: () => Promise<void> | void;
4
+ }
5
+
6
+ /** Type-safe helper for declaring server lifecycle hooks in app/lifecycle.ts. */
7
+ export function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks {
8
+ return hooks;
9
+ }
@@ -1,7 +1,7 @@
1
1
  import { renderToReadableStream } from "react-dom/server";
2
2
  import type { ReactNode } from "react";
3
3
  import type { MetaDescriptor } from "../shared/route-types.ts";
4
- import { safeStringify, isDev } from "./env.ts";
4
+ import { safeStringify, isDevRuntime } from "./env.ts";
5
5
  import { errorOverlayScript } from "../dev/error-overlay.ts";
6
6
  import { mergeMeta, renderMetaTags } from "./meta.ts";
7
7
 
@@ -35,8 +35,8 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
35
35
  status = 200,
36
36
  } = options;
37
37
 
38
- const devFlag = isDev() ? "window.__BRACT_DEV__=true;" : "";
39
- const devOverlay = isDev() ? devFlag + errorOverlayScript + "\n" : "";
38
+ const devFlag = isDevRuntime() ? "window.__BRACT_DEV__=true;" : "";
39
+ const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
40
40
  const mergedMeta = mergeMeta(options.meta ?? []);
41
41
  // metaHtml is injected into <head> via React (the renderToReadableStream tree
42
42
  // is expected to use it). The merged descriptor array is what the client
@@ -2,7 +2,7 @@ import { scanRoutes } 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";
5
- import { isDev, isExplicitDev } from "./env.ts";
5
+ 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";
@@ -32,6 +32,10 @@ export interface BractJSConfig {
32
32
  buildDir?: string;
33
33
  /** Directory for transformed image cache. Defaults to .bract-image-cache */
34
34
  imageCacheDir?: string;
35
+ /** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
36
+ onStart?: () => Promise<void> | void;
37
+ /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
38
+ onShutdown?: () => Promise<void> | void;
35
39
  }
36
40
 
37
41
  const DEFAULT_MANIFEST: ServerManifest = {
@@ -68,7 +72,7 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
68
72
  const buildDir = resolve(config.buildDir ?? "./build");
69
73
  const imageCacheDir = resolve(config.imageCacheDir ?? ".bract-image-cache");
70
74
 
71
- const manifestReady: Promise<ServerManifest> = !isDev() && !config.manifest
75
+ const manifestReady: Promise<ServerManifest> = !isDevRuntime() && !config.manifest
72
76
  ? loadManifest(buildDir).then((m) => ({
73
77
  clientEntry: m.clientEntry,
74
78
  rootChunk: m.rootChunk,
@@ -147,17 +151,48 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
147
151
  if (staticRes) return staticRes;
148
152
 
149
153
  const trie = await trieReady;
150
- const manifest = isDev() ? await readDevManifest(buildDir) : await manifestReady;
154
+ const manifest = isDevRuntime() ? await readDevManifest(buildDir) : await manifestReady;
151
155
  const handlerConfig: HandlerConfig = { appDir, publicDir, manifest };
152
156
  return handleRequest(request, trie, handlerConfig);
153
157
  };
154
158
  }
155
159
 
160
+ /**
161
+ * In production-runtime mode, surface a warning when the manifest on disk
162
+ * wasn't produced by `bractjs build` (missing `"mode": "production"`).
163
+ * Almost always means the user is running `bractjs start` against a dev
164
+ * rebuilder's manifest, or hasn't run `bractjs build` at all.
165
+ */
166
+ async function warnIfStaleBuild(buildDir: string): Promise<void> {
167
+ const f = Bun.file(join(buildDir, "route-manifest.json"));
168
+ if (!(await f.exists())) {
169
+ console.warn(`[bract] No build found at ${buildDir}/route-manifest.json. Run \`bractjs build\` before \`bractjs start\`.`);
170
+ return;
171
+ }
172
+ try {
173
+ const m = (await f.json()) as { mode?: string };
174
+ if (m.mode !== "production") {
175
+ console.warn(`[bract] Build at ${buildDir} was not produced by \`bractjs build\` (mode=${m.mode ?? "unset"}). Re-run \`bractjs build\` for a production-ready manifest.`);
176
+ }
177
+ } catch {
178
+ // Malformed manifest — the request path will surface the real error.
179
+ }
180
+ }
181
+
182
+ // Module-level guards so signal handlers are registered exactly once across
183
+ // HMR restarts and multiple createServer() calls in the same process.
184
+ let signalsRegistered = false;
185
+ let isShuttingDown = false;
186
+
156
187
  export function createServer(config?: Partial<BractJSConfig>): {
157
188
  stop(): void;
158
189
  } {
159
190
  const port = config?.port ?? 3000;
160
191
 
192
+ if (!isDevRuntime()) {
193
+ void warnIfStaleBuild(resolve(config?.buildDir ?? "./build"));
194
+ }
195
+
161
196
  const fetchHandler = buildFetchHandler(config ?? {});
162
197
 
163
198
  // Use provided adapter or fall back to the default Bun adapter.
@@ -166,28 +201,55 @@ export function createServer(config?: Partial<BractJSConfig>): {
166
201
  if (adapter instanceof BunAdapter) {
167
202
  adapter.setHandler(fetchHandler);
168
203
  adapter.listen(port);
204
+ } else {
205
+ // Custom adapter: wire fetch handler in and call listen if available.
206
+ if ("setHandler" in adapter && typeof (adapter as unknown as { setHandler: unknown }).setHandler === "function") {
207
+ (adapter as unknown as { setHandler: (h: (r: Request) => Promise<Response>) => void }).setHandler(fetchHandler);
208
+ }
209
+ adapter.listen?.(port);
210
+ }
169
211
 
170
- console.log(`[bract] Server running at http://localhost:${port}`);
212
+ console.log(`[bract] Server running at http://localhost:${port}`);
171
213
 
172
- return {
173
- stop() { adapter.stop(); },
174
- };
175
- }
214
+ const stopAdapter = () => {
215
+ if (adapter instanceof BunAdapter) {
216
+ adapter.stop();
217
+ } else if ("stop" in adapter && typeof (adapter as unknown as { stop: unknown }).stop === "function") {
218
+ (adapter as unknown as { stop: () => void }).stop();
219
+ }
220
+ };
176
221
 
177
- // Custom adapter: wire fetch handler in and call listen if available.
178
- if ("setHandler" in adapter && typeof (adapter as unknown as { setHandler: unknown }).setHandler === "function") {
179
- (adapter as unknown as { setHandler: (h: (r: Request) => Promise<Response>) => void }).setHandler(fetchHandler);
222
+ const gracefulShutdown = async (signal?: string) => {
223
+ if (isShuttingDown) return;
224
+ isShuttingDown = true;
225
+ if (signal) console.log(`\n[bract] Received ${signal}, shutting down…`);
226
+ try {
227
+ await config?.onShutdown?.();
228
+ } catch (err) {
229
+ console.error("[bract] onShutdown error:", err);
230
+ }
231
+ stopAdapter();
232
+ process.exit(0);
233
+ };
234
+
235
+ if (!signalsRegistered) {
236
+ signalsRegistered = true;
237
+ process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
238
+ process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
239
+ process.on("SIGUSR2", () => void gracefulShutdown("SIGUSR2"));
240
+ process.on("beforeExit", () => void gracefulShutdown());
241
+ process.on("uncaughtException", (err) => {
242
+ console.error("[bract] Uncaught exception:", err);
243
+ void gracefulShutdown("uncaughtException");
244
+ });
180
245
  }
181
- adapter.listen?.(port);
182
246
 
183
- console.log(`[bract] Server running at http://localhost:${port}`);
247
+ void Promise.resolve(config?.onStart?.()).catch((err) => {
248
+ console.error("[bract] onStart error:", err);
249
+ });
184
250
 
185
251
  return {
186
- stop() {
187
- if ("stop" in adapter && typeof (adapter as unknown as { stop: unknown }).stop === "function") {
188
- (adapter as unknown as { stop: () => void }).stop();
189
- }
190
- },
252
+ stop() { void gracefulShutdown(); },
191
253
  };
192
254
  }
193
255
 
package/types/config.d.ts CHANGED
@@ -15,6 +15,10 @@ export interface BractJSConfig {
15
15
  minify?: boolean;
16
16
  /** process.env keys allowed to be inlined into client bundles. */
17
17
  clientEnv?: string[];
18
+ /** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
19
+ onStart?: () => Promise<void> | void;
20
+ /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
21
+ onShutdown?: () => Promise<void> | void;
18
22
  }
19
23
 
20
24
  export interface ServerManifest {
package/types/index.d.ts CHANGED
@@ -22,6 +22,11 @@ export interface RenderOptions {
22
22
  status?: number;
23
23
  }
24
24
 
25
+ export interface LifecycleHooks {
26
+ onStart?: () => Promise<void> | void;
27
+ onShutdown?: () => Promise<void> | void;
28
+ }
29
+ export declare function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks;
25
30
  export declare function createServer(config?: Partial<BractJSConfig>): { stop(): void };
26
31
  export declare function renderRoute(options: RenderOptions): Promise<Response>;
27
32
  export declare function redirect(url: string, status?: number): Response;