@bractjs/bractjs 0.1.22 → 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.
- package/README.md +137 -6
- package/bin/cli.ts +93 -5
- package/package.json +1 -1
- package/src/__tests__/action-registry.test.ts +54 -14
- package/src/__tests__/layout-registry.test.ts +95 -0
- package/src/__tests__/module-registry.test.ts +178 -0
- package/src/__tests__/prebuilt-handler.test.ts +94 -0
- package/src/__tests__/security.test.ts +12 -4
- package/src/__tests__/static-embedded.test.ts +74 -0
- package/src/build/bundler.ts +2 -2
- package/src/build/directives.ts +60 -21
- package/src/client/ClientRouter.tsx +4 -1
- package/src/codegen/module-registry.ts +312 -0
- package/src/dev/hmr-module-handler.ts +2 -2
- package/src/dev/rebuilder.ts +2 -2
- package/src/index.ts +30 -0
- package/src/server/action-registry.ts +41 -4
- package/src/server/layout.ts +74 -1
- package/src/server/lifecycle.ts +14 -0
- package/src/server/loader.ts +10 -5
- package/src/server/request-handler.ts +17 -6
- package/src/server/serve.ts +66 -17
- package/src/server/static.ts +27 -7
- package/templates/new-app/app/server.ts +30 -0
- package/templates/new-app/package.json +2 -1
- package/types/config.d.ts +18 -0
- package/types/index.d.ts +33 -0
- package/types/route.d.ts +8 -0
- package/src/__tests__/.tmp-security-action/routes/_index.tsx +0 -2
package/README.md
CHANGED
|
@@ -442,12 +442,13 @@ export const db = new Database(Bun.env.DATABASE_URL);
|
|
|
442
442
|
|
|
443
443
|
## Server Lifecycle Hooks
|
|
444
444
|
|
|
445
|
-
Use `defineLifecycle()` in `app/lifecycle.ts` to run code when the server starts or
|
|
445
|
+
Use `defineLifecycle()` in `app/lifecycle.ts` to run code when the server starts, shuts down, or encounters an error. The shutdown hook runs on **any** exit signal (`SIGTERM`, `SIGINT`, `SIGUSR2`, `beforeExit`, and uncaught exceptions), so database connections are always closed cleanly.
|
|
446
446
|
|
|
447
447
|
```ts
|
|
448
448
|
// app/lifecycle.ts
|
|
449
449
|
import { defineLifecycle } from "@bractjs/bractjs";
|
|
450
450
|
import { db } from "./db.server.ts";
|
|
451
|
+
import * as Sentry from "@sentry/bun";
|
|
451
452
|
|
|
452
453
|
export default defineLifecycle({
|
|
453
454
|
async onStart() {
|
|
@@ -458,6 +459,12 @@ export default defineLifecycle({
|
|
|
458
459
|
await db.disconnect();
|
|
459
460
|
console.log("Database disconnected");
|
|
460
461
|
},
|
|
462
|
+
async onError(err, request) {
|
|
463
|
+
// request is undefined for process-level uncaught exceptions
|
|
464
|
+
Sentry.captureException(err, {
|
|
465
|
+
extra: { url: request?.url },
|
|
466
|
+
});
|
|
467
|
+
},
|
|
461
468
|
});
|
|
462
469
|
```
|
|
463
470
|
|
|
@@ -474,7 +481,22 @@ createServer({ port: 3000, ...lifecycle });
|
|
|
474
481
|
| Hook | When it runs |
|
|
475
482
|
|------|-------------|
|
|
476
483
|
| `onStart` | Once, after the server begins accepting requests |
|
|
477
|
-
| `onShutdown` | Before process exit — any signal or uncaught exception |
|
|
484
|
+
| `onShutdown` | Before process exit — any signal, programmatic `stop()`, or uncaught exception |
|
|
485
|
+
| `onError` | Every unexpected error: loader failures, action throws, uncaught exceptions. Redirects and `HttpError` throws are intentional control flow and are **not** reported. |
|
|
486
|
+
|
|
487
|
+
### Programmatic stop vs signal-driven termination
|
|
488
|
+
|
|
489
|
+
`createServer()` returns a `{ stop }` handle:
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
const srv = createServer({ port: 3000 });
|
|
493
|
+
// later:
|
|
494
|
+
srv.stop(); // runs onShutdown, closes the listener, does NOT exit the process
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
`stop()` returns the process to a normal idle state — useful in tests, integration harnesses, or any parent that wants to manage its own lifecycle. It does **not** call `process.exit()`.
|
|
498
|
+
|
|
499
|
+
The full termination path (`process.exit(0)`) only fires when a signal handler picks up the shutdown: `SIGTERM`, `SIGINT`, `SIGUSR2`, or `uncaughtException`. If you want a programmatic stop to terminate the process, call `process.exit(0)` yourself after `stop()` returns.
|
|
478
500
|
|
|
479
501
|
---
|
|
480
502
|
|
|
@@ -494,6 +516,7 @@ All fields are optional. BractJS works with zero configuration.
|
|
|
494
516
|
| `clientEnv` | `string[]` | `[]` | `process.env` keys exposed to the client |
|
|
495
517
|
| `onStart` | `() => void \| Promise<void>` | — | Called once after the server starts listening |
|
|
496
518
|
| `onShutdown` | `() => void \| Promise<void>` | — | Called before process exit on any signal |
|
|
519
|
+
| `onError` | `(err, request?) => void \| Promise<void>` | — | Called on every unexpected error; `request` is `undefined` for process-level exceptions |
|
|
497
520
|
|
|
498
521
|
---
|
|
499
522
|
|
|
@@ -506,11 +529,102 @@ All fields are optional. BractJS works with zero configuration.
|
|
|
506
529
|
| `bractjs build` | Dual server + client build with content-hashed output |
|
|
507
530
|
| `bractjs start` | Serve the production build |
|
|
508
531
|
| `bractjs codegen [app] [out]` | Generate typed route types into `app/route-types.gen.ts` |
|
|
532
|
+
| `bractjs codegen:registry [app]` | Generate `app/_generated/{routes,actions}.ts` (single-binary prep) |
|
|
533
|
+
| `bractjs codegen:manifest [app] [build]` | Snapshot `route-manifest.json` into `app/_generated/manifest.ts` |
|
|
534
|
+
| `bractjs compile [outfile] [entry]` | Full single-binary pipeline (codegen → build → compile) |
|
|
509
535
|
|
|
510
536
|
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
537
|
|
|
512
538
|
---
|
|
513
539
|
|
|
540
|
+
## Single-Binary Deployment (`bun build --compile`)
|
|
541
|
+
|
|
542
|
+
BractJS can be packaged as a single executable using Bun's `--compile` flag. Because `bun build --compile` can't trace runtime filesystem scans or dynamic `import(absPath)` calls, BractJS provides a codegen step that materialises every route, layout, and server action into static imports. The compiled binary has zero filesystem dependence at startup.
|
|
543
|
+
|
|
544
|
+
### One-shot
|
|
545
|
+
|
|
546
|
+
```sh
|
|
547
|
+
bractjs compile ./myapp
|
|
548
|
+
# Equivalent to:
|
|
549
|
+
# bractjs codegen:registry # writes app/_generated/{routes,actions}.ts
|
|
550
|
+
# bractjs build # writes build/client/* + route-manifest.json
|
|
551
|
+
# bractjs codegen:manifest # snapshots manifest → app/_generated/manifest.ts
|
|
552
|
+
# bun build --compile app/server.ts --outfile ./myapp
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### Manual pipeline (custom build step)
|
|
556
|
+
|
|
557
|
+
```sh
|
|
558
|
+
bractjs codegen:registry # A — scan routes/actions
|
|
559
|
+
bractjs build # B — client + server bundles
|
|
560
|
+
bractjs codegen:manifest # C — embed manifest as a TS constant
|
|
561
|
+
bun build --compile app/server.ts \ # D — single binary
|
|
562
|
+
--asset build/client/ \ # (embeds JS/CSS into the binary)
|
|
563
|
+
--outfile ./myapp
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Asset embedding (`--asset build/client/`) is optional. Without it you ship `myapp` + the `build/client/` folder side-by-side. With it, you get a true single file.
|
|
567
|
+
|
|
568
|
+
### The `app/server.ts` entry
|
|
569
|
+
|
|
570
|
+
The scaffold template (`bractjs new`) includes `app/server.ts`:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
import { createServer } from "@bractjs/bractjs";
|
|
574
|
+
import { routeFiles, moduleRegistry } from "./_generated/routes.ts";
|
|
575
|
+
import { actionModules } from "./_generated/actions.ts";
|
|
576
|
+
import { manifest } from "./_generated/manifest.ts";
|
|
577
|
+
|
|
578
|
+
createServer({
|
|
579
|
+
port: Number(process.env.PORT ?? 3000),
|
|
580
|
+
appDir: "./app",
|
|
581
|
+
publicDir: "./public",
|
|
582
|
+
manifest,
|
|
583
|
+
routeFiles, // skips Bun.Glob route scan
|
|
584
|
+
moduleRegistry, // skips dynamic import(absPath) of route modules
|
|
585
|
+
actionModules, // skips Bun.Glob + dynamic import for "use server" files
|
|
586
|
+
});
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
When all four of `manifest`, `routeFiles`, `moduleRegistry`, and `actionModules` are present, the server boots with **no filesystem reads of `appDir`** — the routing trie, layout chain, server-action registry, and asset manifest all come from the pre-imported modules.
|
|
590
|
+
|
|
591
|
+
### Custom client builds — required plugins
|
|
592
|
+
|
|
593
|
+
If you write your own `Bun.build()` call (instead of running `bractjs build`), you MUST apply these plugins or face crashes / secret leaks:
|
|
594
|
+
|
|
595
|
+
| Bundle | Plugin | What breaks without it |
|
|
596
|
+
|---|---|---|
|
|
597
|
+
| Server | `useClientStubPlugin` | Server binary crashes when React tries to invoke browser-only hooks/APIs from `"use client"` modules |
|
|
598
|
+
| Client | `createUseServerProxyPlugin(appDir)` | Server-action bodies (DB queries, secrets) ship inside the browser JS |
|
|
599
|
+
| Client | `serverOnlyPlugin` | Imports of `*.server.ts` leak into the client bundle |
|
|
600
|
+
| Client | `clientEnvPlugin(allowedKeys, env)` | Server env vars leak into the browser bundle |
|
|
601
|
+
| Client | `cssModulesPlugin` | `*.module.css` imports don't resolve |
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
import {
|
|
605
|
+
useClientStubPlugin,
|
|
606
|
+
createUseServerProxyPlugin,
|
|
607
|
+
serverOnlyPlugin,
|
|
608
|
+
clientEnvPlugin,
|
|
609
|
+
cssModulesPlugin,
|
|
610
|
+
} from "@bractjs/bractjs";
|
|
611
|
+
|
|
612
|
+
// Server bundle (target: "bun"):
|
|
613
|
+
plugins: [useClientStubPlugin];
|
|
614
|
+
|
|
615
|
+
// Client bundle (target: "browser"):
|
|
616
|
+
plugins: [
|
|
617
|
+
serverOnlyPlugin,
|
|
618
|
+
createUseServerProxyPlugin("./app"),
|
|
619
|
+
clientEnvPlugin(["PUBLIC_API_URL"], Bun.env as Record<string, string>),
|
|
620
|
+
cssModulesPlugin,
|
|
621
|
+
];
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
The `createUseServerProxyPlugin(appDir)` factory exists because server-action IDs are SHA-256 hashes of the appDir-relative path. If the server and client compute different paths (e.g. CI vs prod), every `/_action?id=...` returns 404. Always pass the same `appDir` you pass to `createServer`.
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
514
628
|
## Programmatic API
|
|
515
629
|
|
|
516
630
|
All three runtime operations are importable, so BractJS can be embedded in existing servers or custom build scripts without the CLI.
|
|
@@ -576,8 +690,13 @@ createServer({ port: 3000, buildDir: "./build", ...lifecycle });
|
|
|
576
690
|
my-app/
|
|
577
691
|
├── app/
|
|
578
692
|
│ ├── root.tsx # required — <html> shell
|
|
579
|
-
│ ├──
|
|
693
|
+
│ ├── server.ts # bun build --compile entrypoint (single-binary build)
|
|
694
|
+
│ ├── lifecycle.ts # optional — onStart / onShutdown / onError hooks
|
|
580
695
|
│ ├── route-types.gen.ts # generated by bractjs codegen
|
|
696
|
+
│ ├── _generated/ # generated by bractjs codegen:registry / codegen:manifest
|
|
697
|
+
│ │ ├── routes.ts # static imports for routes + layouts + root
|
|
698
|
+
│ │ ├── actions.ts # static imports for "use server" modules
|
|
699
|
+
│ │ └── manifest.ts # inline ServerManifest constant
|
|
581
700
|
│ ├── actions.ts # "use server" actions
|
|
582
701
|
│ └── routes/
|
|
583
702
|
│ ├── _index.tsx # → /
|
|
@@ -596,6 +715,8 @@ my-app/
|
|
|
596
715
|
└── route-manifest.json
|
|
597
716
|
```
|
|
598
717
|
|
|
718
|
+
The `_generated/` directory is only required for the single-binary workflow. Regular `bractjs dev` / `bractjs build` / `bractjs start` work without it.
|
|
719
|
+
|
|
599
720
|
---
|
|
600
721
|
|
|
601
722
|
## Architecture
|
|
@@ -623,10 +744,19 @@ Build pipeline (`bractjs build`):
|
|
|
623
744
|
```
|
|
624
745
|
1. codegen → app/route-types.gen.ts
|
|
625
746
|
2. server bundle → Bun.build (target: bun) + useClientStubPlugin
|
|
626
|
-
3. client bundle → Bun.build (target: browser, splitting) +
|
|
747
|
+
3. client bundle → Bun.build (target: browser, splitting) + createUseServerProxyPlugin(appDir)
|
|
627
748
|
4. content-hash → rename outputs, write route-manifest.json
|
|
628
749
|
```
|
|
629
750
|
|
|
751
|
+
Single-binary pipeline (`bractjs compile`):
|
|
752
|
+
```
|
|
753
|
+
A. registry codegen → app/_generated/{routes,actions}.ts (static imports)
|
|
754
|
+
B. dual build → build/server/, build/client/, route-manifest.json
|
|
755
|
+
C. manifest codegen → app/_generated/manifest.ts (inline constant)
|
|
756
|
+
D. bun build → single executable with no runtime fs scans
|
|
757
|
+
--compile app/server.ts [--asset build/client/]
|
|
758
|
+
```
|
|
759
|
+
|
|
630
760
|
---
|
|
631
761
|
|
|
632
762
|
## Package Structure
|
|
@@ -637,7 +767,7 @@ bractjs/
|
|
|
637
767
|
│ ├── server/ # SSR, routing, loaders, actions, sessions, action-registry
|
|
638
768
|
│ ├── client/ # hydrateRoot, contexts, hooks, Link/Form/Image components
|
|
639
769
|
│ ├── build/ # Bun.build orchestration, manifest, hashing, directives
|
|
640
|
-
│ ├── codegen/ # route-types.gen.ts
|
|
770
|
+
│ ├── codegen/ # route-types.gen.ts + module-registry codegen (_generated/*)
|
|
641
771
|
│ ├── image/ # /_image handler, ImageMagick optimizer, LRU cache
|
|
642
772
|
│ ├── dev/ # watcher, HMR server + client, error overlay
|
|
643
773
|
│ ├── shared/ # types, errors, deferred, context
|
|
@@ -676,8 +806,9 @@ bractjs/
|
|
|
676
806
|
- Typed routes codegen (`AppRoutes`, `RouteParams<T>`, `TypedLoaderArgs<T>`, `routes` builder)
|
|
677
807
|
- `"use server"` / `"use client"` directive system with `/_action` endpoint
|
|
678
808
|
- **Programmatic API** — `createDevServer`, `runBuild`, `loadUserConfig` importable without the CLI
|
|
809
|
+
- **Native `bun build --compile` support** — module-registry codegen produces single-binary deployables; build plugins exported from the public API; action IDs use relative paths for cross-machine stability
|
|
679
810
|
|
|
680
|
-
Remaining on the roadmap:
|
|
811
|
+
Remaining on the roadmap: streaming `useFetcher()` (full implementation).
|
|
681
812
|
|
|
682
813
|
---
|
|
683
814
|
|
package/bin/cli.ts
CHANGED
|
@@ -35,6 +35,28 @@ async function scaffoldNew(appName: string): Promise<void> {
|
|
|
35
35
|
process.exit(result.exitCode ?? 1);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Seed `app/_generated/` so the template's `app/server.ts` typechecks
|
|
39
|
+
// before the user runs a build. Only the route/action registries can run
|
|
40
|
+
// here (no manifest yet — that needs `bractjs build` first). The manifest
|
|
41
|
+
// module is stubbed in below.
|
|
42
|
+
console.log("Seeding _generated/ registries...");
|
|
43
|
+
try {
|
|
44
|
+
const { writeModuleRegistries } = await import("../src/codegen/module-registry.ts");
|
|
45
|
+
await writeModuleRegistries(join(appDir, "app"));
|
|
46
|
+
// Manifest stub — overwritten by `bractjs codegen:manifest` after a build
|
|
47
|
+
const stubManifest = [
|
|
48
|
+
"// Stub manifest — replaced by `bractjs codegen:manifest` after running",
|
|
49
|
+
"// `bractjs build`. Allows `app/server.ts` to typecheck before the",
|
|
50
|
+
"// first build completes.",
|
|
51
|
+
`import type { ServerManifest } from "@bractjs/bractjs";`,
|
|
52
|
+
`export const manifest: ServerManifest = { clientEntry: "/build/client/client.js", routes: {} };`,
|
|
53
|
+
"",
|
|
54
|
+
].join("\n");
|
|
55
|
+
await Bun.write(join(appDir, "app", "_generated", "manifest.ts"), stubManifest);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.warn("[bract] codegen seed skipped:", err instanceof Error ? err.message : err);
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
console.log(`\n✓ Created ${appName}\n`);
|
|
39
61
|
console.log("Next steps:");
|
|
40
62
|
console.log(` cd ${appName}`);
|
|
@@ -99,14 +121,80 @@ switch (command) {
|
|
|
99
121
|
break;
|
|
100
122
|
}
|
|
101
123
|
|
|
124
|
+
case "codegen:registry": {
|
|
125
|
+
// Phase A of the `bun build --compile` pipeline: scan routes/layouts and
|
|
126
|
+
// server actions, then write static-import registries under
|
|
127
|
+
// `<appDir>/_generated/` so the resulting bundle has no fs-scan or
|
|
128
|
+
// `import(absPath)` calls at runtime.
|
|
129
|
+
const { writeModuleRegistries } = await import("../src/codegen/module-registry.ts");
|
|
130
|
+
const appDir = resolve(process.cwd(), process.argv[3] ?? "./app");
|
|
131
|
+
const { routesPath, actionsPath } = await writeModuleRegistries(appDir);
|
|
132
|
+
console.log("[bract] registry codegen →", routesPath);
|
|
133
|
+
console.log("[bract] registry codegen →", actionsPath);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "codegen:manifest": {
|
|
138
|
+
// Phase C of the pipeline: snapshot `<buildDir>/route-manifest.json`
|
|
139
|
+
// into `<appDir>/_generated/manifest.ts` so the compiled binary never
|
|
140
|
+
// reads the JSON from disk at startup. Must run AFTER the client build.
|
|
141
|
+
const { writeManifestModule } = await import("../src/codegen/module-registry.ts");
|
|
142
|
+
const appDir = resolve(process.cwd(), process.argv[3] ?? "./app");
|
|
143
|
+
const buildDir = resolve(process.cwd(), process.argv[4] ?? "./build");
|
|
144
|
+
const out = await writeManifestModule(appDir, buildDir);
|
|
145
|
+
console.log("[bract] manifest codegen →", out);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case "compile": {
|
|
150
|
+
// Convenience: run the entire `bun build --compile` pipeline.
|
|
151
|
+
// A) registry codegen → B) client build → C) manifest codegen → D) compile.
|
|
152
|
+
// The user can also invoke A/C and D separately if they want a custom
|
|
153
|
+
// client build step.
|
|
154
|
+
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
|
155
|
+
const { writeModuleRegistries, writeManifestModule } = await import("../src/codegen/module-registry.ts");
|
|
156
|
+
const { runBuild } = await import("../src/build/bundler.ts");
|
|
157
|
+
const { loadUserConfig } = await import("../src/config/load.ts");
|
|
158
|
+
|
|
159
|
+
const appDir = resolve(process.cwd(), "./app");
|
|
160
|
+
const buildDir = resolve(process.cwd(), "./build");
|
|
161
|
+
const outFile = process.argv[3] ?? "./bractjs-app";
|
|
162
|
+
const entryPath = process.argv[4] ?? "./app/server.ts";
|
|
163
|
+
|
|
164
|
+
console.log("[bract] (1/4) registry codegen…");
|
|
165
|
+
await writeModuleRegistries(appDir);
|
|
166
|
+
|
|
167
|
+
console.log("[bract] (2/4) client + server build…");
|
|
168
|
+
const userCfg = await loadUserConfig();
|
|
169
|
+
await runBuild({ appDir: "./app", buildDir: "./build", ...userCfg });
|
|
170
|
+
|
|
171
|
+
console.log("[bract] (3/4) manifest codegen…");
|
|
172
|
+
await writeManifestModule(appDir, buildDir);
|
|
173
|
+
|
|
174
|
+
console.log("[bract] (4/4) bun build --compile →", outFile);
|
|
175
|
+
const result = Bun.spawnSync(
|
|
176
|
+
["bun", "build", "--compile", entryPath, "--outfile", outFile],
|
|
177
|
+
{ cwd: process.cwd(), stdio: ["inherit", "inherit", "inherit"] },
|
|
178
|
+
);
|
|
179
|
+
if (result.exitCode !== 0) {
|
|
180
|
+
console.error("[bract] bun build --compile failed");
|
|
181
|
+
process.exit(result.exitCode ?? 1);
|
|
182
|
+
}
|
|
183
|
+
console.log(`[bract] ✓ single-binary build complete: ${outFile}`);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
|
|
102
187
|
default:
|
|
103
188
|
console.log(
|
|
104
189
|
"Usage: bractjs <command>\n" +
|
|
105
|
-
" new <app-name>
|
|
106
|
-
" dev
|
|
107
|
-
" build
|
|
108
|
-
" start
|
|
109
|
-
" codegen [app] [out]
|
|
190
|
+
" new <app-name> Scaffold a new BractJS app\n" +
|
|
191
|
+
" dev Start dev server with HMR\n" +
|
|
192
|
+
" build Build for production (build/ dir)\n" +
|
|
193
|
+
" start Start production server\n" +
|
|
194
|
+
" codegen [app] [out] Generate typed route types\n" +
|
|
195
|
+
" codegen:registry [app] Generate _generated/{routes,actions}.ts\n" +
|
|
196
|
+
" codegen:manifest [app] [build] Generate _generated/manifest.ts\n" +
|
|
197
|
+
" compile [outfile] [entry] Full single-binary pipeline",
|
|
110
198
|
);
|
|
111
199
|
process.exit(1);
|
|
112
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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",
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
2
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { resolve,
|
|
4
|
-
import { loadServerActions, resolveAction } from "../server/action-registry.ts";
|
|
3
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
4
|
+
import { loadServerActions, loadServerActionsFromRegistry, resolveAction } from "../server/action-registry.ts";
|
|
5
5
|
|
|
6
6
|
const TMP = resolve(import.meta.dir, ".tmp-action-registry");
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Mirrors `pathKeyForAction` in src/server/action-registry.ts + src/build/directives.ts.
|
|
9
|
+
// Action IDs are SHA-256(appDir-relative path + "#" + name) so the IDs are
|
|
10
|
+
// stable across machines and inside a compiled binary.
|
|
11
|
+
function pathKey(absPath: string, appDir: string): string {
|
|
12
|
+
const absAppDir = isAbsolute(appDir) ? appDir : resolve(appDir);
|
|
13
|
+
const rel = relative(absAppDir, absPath);
|
|
14
|
+
return rel.startsWith("..") ? absPath : rel;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function computeId(absPath: string, name: string): Promise<string> {
|
|
18
|
+
const raw = new TextEncoder().encode(pathKey(absPath, TMP) + "#" + name);
|
|
10
19
|
const buf = await crypto.subtle.digest("SHA-256", raw);
|
|
11
20
|
return Array.from(new Uint8Array(buf))
|
|
12
21
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
@@ -16,30 +25,30 @@ async function computeId(filePath: string, name: string): Promise<string> {
|
|
|
16
25
|
|
|
17
26
|
beforeAll(async () => {
|
|
18
27
|
await rm(TMP, { recursive: true, force: true });
|
|
19
|
-
await mkdir(
|
|
20
|
-
await mkdir(
|
|
28
|
+
await mkdir(resolve(TMP, "routes"), { recursive: true });
|
|
29
|
+
await mkdir(resolve(TMP, "lib"), { recursive: true });
|
|
21
30
|
|
|
22
31
|
// Eligible: routes/ file with real "use server" directive
|
|
23
32
|
await writeFile(
|
|
24
|
-
|
|
33
|
+
resolve(TMP, "routes", "_index.tsx"),
|
|
25
34
|
`"use server";\nexport async function realAction() { return 1; }\n`,
|
|
26
35
|
);
|
|
27
36
|
|
|
28
37
|
// Eligible by suffix: .server.ts
|
|
29
38
|
await writeFile(
|
|
30
|
-
|
|
39
|
+
resolve(TMP, "lib", "thing.server.ts"),
|
|
31
40
|
`"use server";\nexport async function suffixAction() { return 2; }\n`,
|
|
32
41
|
);
|
|
33
42
|
|
|
34
43
|
// Ineligible: arbitrary lib file (no .server suffix, not in routes/)
|
|
35
44
|
await writeFile(
|
|
36
|
-
|
|
45
|
+
resolve(TMP, "lib", "helpers.ts"),
|
|
37
46
|
`"use server";\nexport async function shouldNotLoad() { return 3; }\n`,
|
|
38
47
|
);
|
|
39
48
|
|
|
40
49
|
// Ineligible: "use server" inside a template literal (not at start-of-file)
|
|
41
50
|
await writeFile(
|
|
42
|
-
|
|
51
|
+
resolve(TMP, "routes", "fake.tsx"),
|
|
43
52
|
`const s = \`use server\`;\nexport async function notADirective() { return 4; }\n`,
|
|
44
53
|
);
|
|
45
54
|
|
|
@@ -52,22 +61,53 @@ afterAll(async () => {
|
|
|
52
61
|
|
|
53
62
|
describe("loadServerActions — eligibility", () => {
|
|
54
63
|
test("routes/ file with real directive registers exports", async () => {
|
|
55
|
-
const id = await computeId(
|
|
64
|
+
const id = await computeId(resolve(TMP, "routes", "_index.tsx"), "realAction");
|
|
56
65
|
expect(resolveAction(id)).not.toBeNull();
|
|
57
66
|
});
|
|
58
67
|
|
|
59
68
|
test(".server.ts file registers exports", async () => {
|
|
60
|
-
const id = await computeId(
|
|
69
|
+
const id = await computeId(resolve(TMP, "lib", "thing.server.ts"), "suffixAction");
|
|
61
70
|
expect(resolveAction(id)).not.toBeNull();
|
|
62
71
|
});
|
|
63
72
|
|
|
64
73
|
test("ineligible path (lib/*.ts) does NOT register", async () => {
|
|
65
|
-
const id = await computeId(
|
|
74
|
+
const id = await computeId(resolve(TMP, "lib", "helpers.ts"), "shouldNotLoad");
|
|
66
75
|
expect(resolveAction(id)).toBeNull();
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
test("'use server' inside template literal does NOT register", async () => {
|
|
70
|
-
const id = await computeId(
|
|
79
|
+
const id = await computeId(resolve(TMP, "routes", "fake.tsx"), "notADirective");
|
|
71
80
|
expect(resolveAction(id)).toBeNull();
|
|
72
81
|
});
|
|
73
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("loadServerActionsFromRegistry", () => {
|
|
85
|
+
test("statically-imported modules register under SHA-256(relPath#name)", async () => {
|
|
86
|
+
// Action ID must match what the client proxy plugin would emit. The
|
|
87
|
+
// codegen path passes relPath directly (no appDir stripping needed) so
|
|
88
|
+
// the hash input is the literal entry.relPath string.
|
|
89
|
+
async function rawId(relPath: string, name: string): Promise<string> {
|
|
90
|
+
const raw = new TextEncoder().encode(relPath + "#" + name);
|
|
91
|
+
const buf = await crypto.subtle.digest("SHA-256", raw);
|
|
92
|
+
return Array.from(new Uint8Array(buf))
|
|
93
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
94
|
+
.join("")
|
|
95
|
+
.slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fakeMod = {
|
|
99
|
+
sendEmail: async (to: string) => `sent ${to}`,
|
|
100
|
+
__ignored: 42, // non-function — must be skipped
|
|
101
|
+
};
|
|
102
|
+
await loadServerActionsFromRegistry([
|
|
103
|
+
{ relPath: "routes/contact.server.ts", mod: fakeMod },
|
|
104
|
+
]);
|
|
105
|
+
const id = await rawId("routes/contact.server.ts", "sendEmail");
|
|
106
|
+
const fn = resolveAction(id);
|
|
107
|
+
expect(fn).not.toBeNull();
|
|
108
|
+
expect(await fn!("a@b.com")).toBe("sent a@b.com");
|
|
109
|
+
|
|
110
|
+
const ignored = await rawId("routes/contact.server.ts", "__ignored");
|
|
111
|
+
expect(resolveAction(ignored)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
resolveRouteChain,
|
|
6
|
+
resolveLayoutChainFromRegistry,
|
|
7
|
+
type ModuleRegistry,
|
|
8
|
+
} from "../server/layout.ts";
|
|
9
|
+
|
|
10
|
+
const TMP = resolve(import.meta.dir, ".tmp-layout-registry");
|
|
11
|
+
|
|
12
|
+
// Sentinel default components let us distinguish which module ended up where.
|
|
13
|
+
const rootModule = { default: () => "root" } as const;
|
|
14
|
+
const blogLayoutModule = { default: () => "blog-layout" } as const;
|
|
15
|
+
const blogPostModule = { default: () => "blog-post" } as const;
|
|
16
|
+
const indexModule = { default: () => "index" } as const;
|
|
17
|
+
|
|
18
|
+
const registry: ModuleRegistry = {
|
|
19
|
+
"root.tsx": rootModule,
|
|
20
|
+
"routes/blog/layout.tsx": blogLayoutModule,
|
|
21
|
+
"routes/blog/[slug].tsx": blogPostModule,
|
|
22
|
+
"routes/_index.tsx": indexModule,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
await rm(TMP, { recursive: true, force: true });
|
|
27
|
+
await mkdir(join(TMP, "routes", "blog"), { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await rm(TMP, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("resolveLayoutChainFromRegistry", () => {
|
|
35
|
+
test("includes root + ancestor layouts in order", () => {
|
|
36
|
+
const r = resolveLayoutChainFromRegistry(
|
|
37
|
+
{ filePath: "routes/blog/[slug].tsx", urlPattern: "blog/[slug]", segments: ["blog", { param: "slug" }] },
|
|
38
|
+
registry,
|
|
39
|
+
);
|
|
40
|
+
expect(r.layoutFiles).toEqual(["root.tsx", "routes/blog/layout.tsx"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("omits root when registry has no root entry", () => {
|
|
44
|
+
const r = resolveLayoutChainFromRegistry(
|
|
45
|
+
{ filePath: "routes/_index.tsx", urlPattern: "", segments: [] },
|
|
46
|
+
{ "routes/_index.tsx": indexModule },
|
|
47
|
+
);
|
|
48
|
+
expect(r.layoutFiles).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("matches sibling-dir layouts only when route is deeper", () => {
|
|
52
|
+
// urlPattern "blog" → layoutDirs returns [] (no ancestor)
|
|
53
|
+
const r = resolveLayoutChainFromRegistry(
|
|
54
|
+
{ filePath: "routes/blog/_index.tsx", urlPattern: "blog", segments: ["blog"] },
|
|
55
|
+
registry,
|
|
56
|
+
);
|
|
57
|
+
expect(r.layoutFiles).toEqual(["root.tsx"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("resolveRouteChain — registry mode", () => {
|
|
62
|
+
test("returns the pre-loaded route module without touching disk", async () => {
|
|
63
|
+
const chain = await resolveRouteChain(
|
|
64
|
+
{ filePath: "routes/blog/[slug].tsx", urlPattern: "blog/[slug]", segments: ["blog", { param: "slug" }] },
|
|
65
|
+
// appDir intentionally points at a path that does NOT exist — registry mode
|
|
66
|
+
// must skip every fs check.
|
|
67
|
+
"/nonexistent/appdir",
|
|
68
|
+
registry,
|
|
69
|
+
);
|
|
70
|
+
expect(chain.root.default).toBe(rootModule.default);
|
|
71
|
+
expect(chain.layouts).toHaveLength(1);
|
|
72
|
+
expect(chain.layouts[0].default).toBe(blogLayoutModule.default);
|
|
73
|
+
expect(chain.route.default).toBe(blogPostModule.default);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("missing route key yields an empty module shape (no throw)", async () => {
|
|
77
|
+
const chain = await resolveRouteChain(
|
|
78
|
+
{ filePath: "routes/missing.tsx", urlPattern: "missing", segments: ["missing"] },
|
|
79
|
+
"/nonexistent",
|
|
80
|
+
registry,
|
|
81
|
+
);
|
|
82
|
+
expect(chain.route.default).toBeUndefined();
|
|
83
|
+
expect(chain.route.loader).toBeUndefined();
|
|
84
|
+
expect(chain.root.default).toBe(rootModule.default);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("forward-slash normalisation: filePath with backslashes resolves the same key", async () => {
|
|
88
|
+
const chain = await resolveRouteChain(
|
|
89
|
+
{ filePath: "routes\\blog\\[slug].tsx", urlPattern: "blog/[slug]", segments: ["blog", { param: "slug" }] },
|
|
90
|
+
"/nonexistent",
|
|
91
|
+
registry,
|
|
92
|
+
);
|
|
93
|
+
expect(chain.route.default).toBe(blogPostModule.default);
|
|
94
|
+
});
|
|
95
|
+
});
|