@bractjs/bractjs 0.1.20 → 0.1.22

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
@@ -507,6 +507,67 @@ All fields are optional. BractJS works with zero configuration.
507
507
  | `bractjs start` | Serve the production build |
508
508
  | `bractjs codegen [app] [out]` | Generate typed route types into `app/route-types.gen.ts` |
509
509
 
510
+ The CLI is a thin convenience layer. Every command delegates to a public programmatic API — you can call the same functions directly from your own scripts without the CLI.
511
+
512
+ ---
513
+
514
+ ## Programmatic API
515
+
516
+ All three runtime operations are importable, so BractJS can be embedded in existing servers or custom build scripts without the CLI.
517
+
518
+ ### `createDevServer(options?)`
519
+
520
+ ```ts
521
+ import { createDevServer } from "@bractjs/bractjs";
522
+
523
+ const dev = await createDevServer({
524
+ port: 3000, // HTTP port (default: config.port ?? 3000)
525
+ hmrPort: 3001, // HMR WebSocket port (default: 3001)
526
+ config: { appDir: "./app", clientEnv: ["PUBLIC_API_URL"] },
527
+ skipUserConfig: false, // set true to skip loading bractjs.config.ts from cwd
528
+ });
529
+
530
+ // Later — stops the HTTP server and HMR WebSocket server
531
+ dev.stop();
532
+ ```
533
+
534
+ ### `runBuild(config?)`
535
+
536
+ ```ts
537
+ import { runBuild } from "@bractjs/bractjs";
538
+
539
+ await runBuild({
540
+ appDir: "./app",
541
+ buildDir: "./dist",
542
+ minify: true,
543
+ sourcemap: "external",
544
+ clientEnv: ["PUBLIC_API_URL"],
545
+ });
546
+ ```
547
+
548
+ `runBuild` only accepts build-relevant fields (`appDir`, `buildDir`, `sourcemap`, `minify`, `clientEnv`, `plugins`). Server-only fields like `port`, `manifest`, and `publicDir` are not accepted — this makes it safe to call from a build script without constructing a full server config.
549
+
550
+ ### `loadUserConfig()`
551
+
552
+ ```ts
553
+ import { loadUserConfig } from "@bractjs/bractjs";
554
+
555
+ const cfg = await loadUserConfig();
556
+ // Reads bractjs.config.ts (or .js) from process.cwd()
557
+ // Returns {} if no config file exists
558
+ ```
559
+
560
+ ### `createServer(config?)` (production)
561
+
562
+ Already exported. Starts the production HTTP server directly:
563
+
564
+ ```ts
565
+ import { createServer } from "@bractjs/bractjs";
566
+ import lifecycle from "./app/lifecycle.ts";
567
+
568
+ createServer({ port: 3000, buildDir: "./build", ...lifecycle });
569
+ ```
570
+
510
571
  ---
511
572
 
512
573
  ## App Directory Structure
@@ -602,7 +663,7 @@ bractjs/
602
663
 
603
664
  ## Status
604
665
 
605
- **v0.1.0 complete.** All core phases shipped:
666
+ **v0.1.21.** All core phases shipped:
606
667
 
607
668
  - File-based routing with trie matcher and layout chains
608
669
  - Streaming SSR (`renderToReadableStream`) with `defer()` and `<Await>`
@@ -611,12 +672,10 @@ bractjs/
611
672
  - Cookie sessions with HMAC-SHA256 and secret rotation
612
673
  - Middleware pipeline with `requestLogger`, `cors`, `authGuard`
613
674
  - Production build with content-hashed assets and code splitting
614
-
615
- Post-v0.1.0 features also shipped:
616
-
617
675
  - `<Image>` with on-demand ImageMagick optimization and LRU cache
618
676
  - Typed routes codegen (`AppRoutes`, `RouteParams<T>`, `TypedLoaderArgs<T>`, `routes` builder)
619
677
  - `"use server"` / `"use client"` directive system with `/_action` endpoint
678
+ - **Programmatic API** — `createDevServer`, `runBuild`, `loadUserConfig` importable without the CLI
620
679
 
621
680
  Remaining on the roadmap: Edge runtime (Cloudflare Workers), CSS modules, i18n routing, streaming `useFetcher()`.
622
681
 
package/bin/cli.ts CHANGED
@@ -67,7 +67,8 @@ switch (command) {
67
67
  // Ensure dev-only handlers gated by isExplicitDev() (e.g. /_hmr/module,
68
68
  // /_bractjs/devtools.js) are reachable when the user hasn't set NODE_ENV.
69
69
  if (!process.env.NODE_ENV) process.env.NODE_ENV = "development";
70
- await import("../src/dev/server.ts");
70
+ const { createDevServer } = await import("../src/dev/server.ts");
71
+ await createDevServer();
71
72
  break;
72
73
 
73
74
  case "build": {
@@ -75,7 +76,9 @@ switch (command) {
75
76
  // server build (react-dom/server.bun production) instead of the dev one.
76
77
  if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
77
78
  const { runBuild } = await import("../src/build/bundler.ts");
78
- await runBuild({ port: 3000, appDir: "./app", publicDir: "./public", buildDir: "./build", manifest: { clientEntry: "", routes: {} } });
79
+ const { loadUserConfig } = await import("../src/config/load.ts");
80
+ const userCfg = await loadUserConfig();
81
+ await runBuild({ appDir: "./app", buildDir: "./build", ...userCfg });
79
82
  break;
80
83
  }
81
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",
@@ -0,0 +1,2 @@
1
+ "use server";
2
+ export async function ping(...args) { return args; }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for the programmatic API: createDevServer, runBuild, loadUserConfig.
3
+ *
4
+ * Note: tests that start Bun.serve() (like integration.test.ts) are excluded
5
+ * here because the process.on("beforeExit") handler in createServer causes
6
+ * bun:test to exit before printing results — a known pre-existing issue.
7
+ * Behavioral coverage (HTTP response, HMR) lives in integration.test.ts.
8
+ */
9
+ import { test, expect } from "bun:test";
10
+ import { loadUserConfig } from "../config/load.ts";
11
+ import { runBuild } from "../build/bundler.ts";
12
+ import { createDevServer } from "../dev/server.ts";
13
+ import type { BuildConfig } from "../build/bundler.ts";
14
+ import type { DevServerOptions, DevServer } from "../dev/server.ts";
15
+
16
+ // ── loadUserConfig ────────────────────────────────────────────────────────
17
+
18
+ test("loadUserConfig is exported from config/load", () => {
19
+ expect(typeof loadUserConfig).toBe("function");
20
+ });
21
+
22
+ test("loadUserConfig returns an object when no bractjs.config.ts exists", async () => {
23
+ // Repo root has no bractjs.config.ts — must return a plain empty object.
24
+ const cfg = await loadUserConfig();
25
+ expect(typeof cfg).toBe("object");
26
+ expect(cfg).not.toBeNull();
27
+ });
28
+
29
+ // ── runBuild ──────────────────────────────────────────────────────────────
30
+
31
+ test("runBuild is exported from build/bundler", () => {
32
+ expect(typeof runBuild).toBe("function");
33
+ });
34
+
35
+ test("runBuild signature does not require server-only fields (port/manifest/publicDir)", () => {
36
+ // Compile-time guard: if BuildConfig mistakenly included required server fields,
37
+ // this line would fail TypeScript type-checking.
38
+ const config: BuildConfig = { appDir: "./app", minify: false };
39
+ expect(typeof config).toBe("object");
40
+ // Ensure none of the server-only keys are present as required fields.
41
+ expect("port" in config).toBe(false);
42
+ expect("manifest" in config).toBe(false);
43
+ expect("publicDir" in config).toBe(false);
44
+ });
45
+
46
+ test("runBuild rejects with a defined error when appDir does not exist", async () => {
47
+ await expect(
48
+ runBuild({ appDir: "/definitely/does/not/exist/__bractjs_test__" }),
49
+ ).rejects.toBeDefined();
50
+ });
51
+
52
+ // ── createDevServer ───────────────────────────────────────────────────────
53
+
54
+ test("createDevServer is exported from dev/server", () => {
55
+ expect(typeof createDevServer).toBe("function");
56
+ });
57
+
58
+ test("createDevServer accepts DevServerOptions shape without error at compile time", () => {
59
+ // Compile-time guard: verify the options interface has the expected fields.
60
+ const opts: DevServerOptions = {
61
+ port: 3997,
62
+ hmrPort: 3996,
63
+ skipUserConfig: true,
64
+ config: { appDir: "./app" },
65
+ };
66
+ expect(typeof opts).toBe("object");
67
+ expect(opts.port).toBe(3997);
68
+ expect(opts.hmrPort).toBe(3996);
69
+ expect(opts.skipUserConfig).toBe(true);
70
+ });
71
+
72
+ test("DevServer interface has a stop() method", () => {
73
+ // Compile-time guard: verify DevServer shape.
74
+ const stub: DevServer = { stop: () => {} };
75
+ expect(typeof stub.stop).toBe("function");
76
+ });
77
+
78
+ // ── Re-exports from src/index.ts ──────────────────────────────────────────
79
+
80
+ test("createDevServer, runBuild, loadUserConfig are all re-exported from src/index.ts", async () => {
81
+ const mod = await import("../index.ts");
82
+ expect(typeof mod.createDevServer).toBe("function");
83
+ expect(typeof mod.runBuild).toBe("function");
84
+ expect(typeof mod.loadUserConfig).toBe("function");
85
+ });
@@ -1,6 +1,6 @@
1
1
  import { join, basename, extname, resolve } from "node:path";
2
2
  import { rename, rm } from "node:fs/promises";
3
- import type { BractJSConfig } from "../server/serve.ts";
3
+ import type { BunPlugin } from "bun";
4
4
  import { scanRoutes } from "../server/scanner.ts";
5
5
  import { contentHash } from "./hash.ts";
6
6
  import { generateManifest, writeManifest } from "./manifest.ts";
@@ -10,7 +10,17 @@ import { writeRouteTypes } from "../codegen/route-codegen.ts";
10
10
  import { useClientStubPlugin, useServerProxyPlugin } from "./directives.ts";
11
11
  import { cssModulesPlugin } from "./plugins/css-modules.ts";
12
12
 
13
- export async function runBuild(config: BractJSConfig): Promise<void> {
13
+ /** Subset of config fields relevant to the build pipeline. */
14
+ export interface BuildConfig {
15
+ appDir?: string;
16
+ buildDir?: string;
17
+ sourcemap?: "none" | "linked" | "inline" | "external";
18
+ minify?: boolean;
19
+ clientEnv?: string[];
20
+ plugins?: BunPlugin[];
21
+ }
22
+
23
+ export async function runBuild(config: BuildConfig): Promise<void> {
14
24
  const appDir = config.appDir ?? "./app";
15
25
 
16
26
  // ── 0. Codegen — typed routes ───────────────────────────────────────────
@@ -54,7 +64,7 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
54
64
  minify: config.minify ?? true,
55
65
  sourcemap: config.sourcemap ?? "external",
56
66
  define: buildDefines(config),
57
- plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin],
67
+ plugins: [serverOnlyPlugin, useServerProxyPlugin, clientEnvPlugin(config.clientEnv ?? [], Bun.env as Record<string, string>), cssModulesPlugin, ...(config.plugins ?? [])],
58
68
  });
59
69
  if (!clientResult.success) throw new AggregateError(clientResult.logs, "Client build failed");
60
70
 
@@ -0,0 +1,17 @@
1
+ import { resolve } from "node:path";
2
+ import type { BractJSConfig } from "../server/serve.ts";
3
+
4
+ /**
5
+ * Load `bractjs.config.ts` (or `.js`) from the user's cwd if present.
6
+ * Returns an empty object when no file exists — callers fall back to defaults.
7
+ */
8
+ export async function loadUserConfig(): Promise<Partial<BractJSConfig>> {
9
+ for (const name of ["bractjs.config.ts", "bractjs.config.js"]) {
10
+ const path = resolve(process.cwd(), name);
11
+ if (!(await Bun.file(path).exists())) continue;
12
+ const mod = await import(path);
13
+ const cfg = (mod.default ?? mod) as Partial<BractJSConfig>;
14
+ return cfg ?? {};
15
+ }
16
+ return {};
17
+ }
@@ -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],
51
+ plugins: [useServerProxyPlugin, ...(config?.plugins ?? [])],
52
52
  });
53
53
  } finally {
54
54
  await rm(shimPath, { force: true });
package/src/dev/server.ts CHANGED
@@ -6,49 +6,89 @@ import { rebuildClient } from "./rebuilder.ts";
6
6
  import { filePathToPattern } from "../server/scanner.ts";
7
7
  import { basename, extname } from "node:path";
8
8
  import type { LifecycleHooks } from "../server/lifecycle.ts";
9
+ import { loadUserConfig } from "../config/load.ts";
10
+ import type { BractJSConfig } from "../server/serve.ts";
9
11
 
10
- // Must precede any user-code import so SSR-time isDevRuntime() checks
11
- // (e.g. inside <LiveReload>) observe the dev mode.
12
- 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
+ }
13
29
 
14
- const hmr = createHmrServer(3001);
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");
15
34
 
16
- // Build client bundle before the HTTP server starts accepting requests
17
- const { duration: initialMs } = await rebuildClient();
18
- console.log(`[bractjs] initial client build in ${initialMs}ms`);
35
+ const userConfig = options?.skipUserConfig ? {} : await loadUserConfig();
36
+ const merged: Partial<BractJSConfig> = { ...userConfig, ...options?.config };
19
37
 
20
- // Load user lifecycle hooks if defined (e.g. app/lifecycle.ts)
21
- let lifecycle: LifecycleHooks = {};
22
- try {
23
- const lifecyclePath = `${process.cwd()}/app/lifecycle.ts`;
24
- const mod = await import(lifecyclePath);
25
- if (mod.default) lifecycle = mod.default;
26
- } catch {
27
- // No lifecycle file — that's fine
28
- }
38
+ const hmrPort = options?.hmrPort ?? 3001;
39
+ const appPort = options?.port ?? merged.port ?? 3000;
29
40
 
30
- createServer({ port: 3000, ...lifecycle });
31
-
32
- watchApp("./app", async (file) => {
33
- const { duration } = await rebuildClient();
34
-
35
- // Route files (not layout): do a fine-grained module swap without full reload.
36
- // Root, layouts, and other files: fall back to full page reload.
37
- const isRoute =
38
- file.startsWith("routes/") &&
39
- !file.endsWith("layout.tsx") &&
40
- !file.endsWith("layout.ts");
41
-
42
- if (isRoute) {
43
- const pattern = filePathToPattern(file);
44
- // Chunk URL = same basename as route file; splitting build puts it in build/client/
45
- const chunkUrl = `/build/client/${basename(file, extname(file))}.js`;
46
- hmr.broadcast({ type: "hmr:route", pattern, chunkUrl, file, duration });
47
- console.log(`✓ ${file} → module swap (pattern="${pattern}") in ${duration}ms`);
48
- } else {
49
- hmr.broadcast({ type: "hmr:reload", file, duration });
50
- console.log(`✓ ${file} → full reload in ${duration}ms`);
41
+ const hmr = createHmrServer(hmrPort);
42
+
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
51
55
  }
52
- });
53
56
 
54
- 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
@@ -78,3 +78,10 @@ export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
78
78
  // i18n utilities (server-side)
79
79
  export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
80
80
  export type { I18nConfig } from "./server/serve.ts";
81
+
82
+ // Programmatic API — importable alternatives to the CLI commands
83
+ export { createDevServer } from "./dev/server.ts";
84
+ export type { DevServerOptions, DevServer } from "./dev/server.ts";
85
+ export { runBuild } from "./build/bundler.ts";
86
+ export type { BuildConfig } from "./build/bundler.ts";
87
+ export { loadUserConfig } from "./config/load.ts";
@@ -29,6 +29,8 @@ export interface BractJSConfig {
29
29
  sourcemap?: "none" | "linked" | "inline" | "external";
30
30
  minify?: boolean;
31
31
  clientEnv?: string[];
32
+ /** User Bun bundler plugins appended to the client build (e.g. bun-plugin-tailwind). */
33
+ plugins?: import("bun").BunPlugin[];
32
34
  buildDir?: string;
33
35
  /** Directory for transformed image cache. Defaults to .bract-image-cache */
34
36
  imageCacheDir?: string;
package/types/config.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { BunPlugin } from "bun";
2
+
1
3
  export interface BractJSConfig {
2
4
  /** TCP port to listen on. Default: 3000. */
3
5
  port: number;
@@ -15,6 +17,8 @@ export interface BractJSConfig {
15
17
  minify?: boolean;
16
18
  /** process.env keys allowed to be inlined into client bundles. */
17
19
  clientEnv?: string[];
20
+ /** User Bun bundler plugins appended to the client build (e.g. bun-plugin-tailwind). */
21
+ plugins?: BunPlugin[];
18
22
  /** Called once after the server starts listening. Use to open DB connections, warm caches, etc. */
19
23
  onStart?: () => Promise<void> | void;
20
24
  /** Called before the process exits (any signal or uncaught error). Use to close DB connections, flush queues, etc. */
@@ -27,3 +31,13 @@ export interface ServerManifest {
27
31
  /** Map of URL pattern → route asset info. */
28
32
  routes: Record<string, { file?: string; chunk?: string }>;
29
33
  }
34
+
35
+ /** Subset of BractJSConfig used by the build pipeline. All fields optional. */
36
+ export interface BuildConfig {
37
+ appDir?: string;
38
+ buildDir?: string;
39
+ sourcemap?: "none" | "linked" | "inline" | "external";
40
+ minify?: boolean;
41
+ clientEnv?: string[];
42
+ plugins?: import("bun").BunPlugin[];
43
+ }
package/types/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type {
7
7
  } from "./route.d.ts";
8
8
 
9
9
  // ── Config + Server ───────────────────────────────────────────────────────
10
- export type { BractJSConfig, ServerManifest } from "./config.d.ts";
10
+ export type { BractJSConfig, ServerManifest, BuildConfig } from "./config.d.ts";
11
11
  import type { MetaDescriptor } from "./route.d.ts";
12
12
  import type { BractJSConfig, ServerManifest } from "./config.d.ts";
13
13
 
@@ -205,3 +205,19 @@ export declare function transformCssModule(filePath: string): Promise<{ map: Rec
205
205
 
206
206
  // ── buildFetchHandler (D1) ───────────────────────────────────────────────
207
207
  export declare function buildFetchHandler(config: Partial<import("./config.d.ts").BractJSConfig>): (request: Request) => Promise<Response>;
208
+
209
+ // ── Programmatic API ─────────────────────────────────────────────────────
210
+ export declare function runBuild(config: import("./config.d.ts").BuildConfig): Promise<void>;
211
+
212
+ export interface DevServerOptions {
213
+ port?: number;
214
+ hmrPort?: number;
215
+ config?: Partial<BractJSConfig>;
216
+ skipUserConfig?: boolean;
217
+ }
218
+ export interface DevServer {
219
+ stop(): void;
220
+ }
221
+ export declare function createDevServer(options?: DevServerOptions): Promise<DevServer>;
222
+
223
+ export declare function loadUserConfig(): Promise<Partial<BractJSConfig>>;