@bractjs/bractjs 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -10
- package/bin/cli.ts +96 -14
- 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__/programmatic-api.test.ts +85 -0
- package/src/__tests__/security.test.ts +12 -4
- package/src/__tests__/static-embedded.test.ts +74 -0
- package/src/build/bundler.ts +14 -4
- 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/dev/server.ts +77 -40
- package/src/index.ts +37 -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 +28 -0
- package/types/index.d.ts +50 -1
- package/types/route.d.ts +8 -0
package/src/server/layout.ts
CHANGED
|
@@ -14,6 +14,14 @@ export interface ResolvedRoute extends RouteFile {
|
|
|
14
14
|
layoutFiles: string[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Pre-loaded module map keyed by appDir-relative path (e.g. "root.tsx",
|
|
19
|
+
* "routes/blog/layout.tsx"). When `resolveRouteChain` is called with a
|
|
20
|
+
* registry, all module lookups go through the registry instead of dynamic
|
|
21
|
+
* `import(absPath)` — this is what makes `bun build --compile` viable.
|
|
22
|
+
*/
|
|
23
|
+
export type ModuleRegistry = Record<string, RouteModule | Record<string, unknown>>;
|
|
24
|
+
|
|
17
25
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
18
26
|
|
|
19
27
|
/** Derive the ancestor directory segments from a route's urlPattern. */
|
|
@@ -55,6 +63,31 @@ export async function resolveLayoutChain(
|
|
|
55
63
|
return { ...routeFile, layoutFiles };
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Registry-driven equivalent of `resolveLayoutChain`. Skips all filesystem
|
|
68
|
+
* checks — returns the appDir-relative keys that exist in the registry, in
|
|
69
|
+
* the same root-first, outermost-to-innermost order. Required for compiled
|
|
70
|
+
* binaries where `Bun.file().exists()` against the original app paths is
|
|
71
|
+
* unreliable.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveLayoutChainFromRegistry(
|
|
74
|
+
routeFile: RouteFile,
|
|
75
|
+
registry: ModuleRegistry,
|
|
76
|
+
): ResolvedRoute {
|
|
77
|
+
const layoutFiles: string[] = [];
|
|
78
|
+
if (registry["root.tsx"]) layoutFiles.push("root.tsx");
|
|
79
|
+
else if (registry["root.ts"]) layoutFiles.push("root.ts");
|
|
80
|
+
|
|
81
|
+
for (const dir of layoutDirs(routeFile.urlPattern)) {
|
|
82
|
+
const tsxKey = `routes/${dir}/layout.tsx`;
|
|
83
|
+
const tsKey = `routes/${dir}/layout.ts`;
|
|
84
|
+
if (registry[tsxKey]) layoutFiles.push(tsxKey);
|
|
85
|
+
else if (registry[tsKey]) layoutFiles.push(tsKey);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { ...routeFile, layoutFiles };
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
// ── importRouteModule ──────────────────────────────────────────────────────
|
|
59
92
|
|
|
60
93
|
export async function importRouteModule(filePath: string): Promise<RouteModule> {
|
|
@@ -69,12 +102,52 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
|
|
|
69
102
|
};
|
|
70
103
|
}
|
|
71
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Project a registry entry (raw `import * as ns` namespace) into the
|
|
107
|
+
* subset shape `RouteModule` requires. Mirrors `importRouteModule` but
|
|
108
|
+
* skips the dynamic `import()` because the module is already loaded.
|
|
109
|
+
*/
|
|
110
|
+
function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined): RouteModule {
|
|
111
|
+
if (!mod) return {};
|
|
112
|
+
const m = mod as Record<string, unknown>;
|
|
113
|
+
return {
|
|
114
|
+
loader: m.loader as RouteModule["loader"],
|
|
115
|
+
action: m.action as RouteModule["action"],
|
|
116
|
+
meta: m.meta as RouteModule["meta"],
|
|
117
|
+
handle: m.handle as RouteModule["handle"],
|
|
118
|
+
ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
|
|
119
|
+
default: m.default as RouteModule["default"],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
72
123
|
// ── resolveRouteChain ──────────────────────────────────────────────────────
|
|
73
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Build the route + layout chain for a matched route.
|
|
127
|
+
*
|
|
128
|
+
* Two modes:
|
|
129
|
+
* - Registry mode (production / compiled binary): when `registry` is provided,
|
|
130
|
+
* no filesystem checks and no dynamic imports run. Every module lookup is a
|
|
131
|
+
* `Record` access keyed by appDir-relative path.
|
|
132
|
+
* - Dev mode (no registry): existing filesystem-probe + `import(absPath)`
|
|
133
|
+
* path, used by `bractjs dev` so edits to layouts/routes don't require a
|
|
134
|
+
* codegen rerun.
|
|
135
|
+
*/
|
|
74
136
|
export async function resolveRouteChain(
|
|
75
137
|
routeFile: RouteFile,
|
|
76
|
-
appDir: string
|
|
138
|
+
appDir: string,
|
|
139
|
+
registry?: ModuleRegistry,
|
|
77
140
|
): Promise<LayoutChain> {
|
|
141
|
+
if (registry) {
|
|
142
|
+
const resolved = resolveLayoutChainFromRegistry(routeFile, registry);
|
|
143
|
+
const [rootKey, ...layoutKeys] = resolved.layoutFiles;
|
|
144
|
+
const rootMod = rootKey ? pickRouteModule(registry[rootKey]) : {};
|
|
145
|
+
const layoutMods = layoutKeys.map((k) => pickRouteModule(registry[k]));
|
|
146
|
+
const routeKey = routeFile.filePath.split("\\").join("/");
|
|
147
|
+
const routeMod = pickRouteModule(registry[routeKey]);
|
|
148
|
+
return { root: rootMod, layouts: layoutMods, route: routeMod };
|
|
149
|
+
}
|
|
150
|
+
|
|
78
151
|
const resolved = await resolveLayoutChain(routeFile, appDir);
|
|
79
152
|
|
|
80
153
|
const [rootMod, ...layoutMods] = await Promise.all(
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
export type OnErrorHook = (err: unknown, request?: Request) => Promise<void> | void;
|
|
2
|
+
|
|
1
3
|
export interface LifecycleHooks {
|
|
2
4
|
onStart?: () => Promise<void> | void;
|
|
3
5
|
onShutdown?: () => Promise<void> | void;
|
|
6
|
+
/** 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. Use this to send errors to Sentry, Datadog, etc. The request is undefined for process-level exceptions. */
|
|
7
|
+
onError?: OnErrorHook;
|
|
4
8
|
}
|
|
5
9
|
|
|
6
10
|
/** Type-safe helper for declaring server lifecycle hooks in app/lifecycle.ts. */
|
|
7
11
|
export function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks {
|
|
8
12
|
return hooks;
|
|
9
13
|
}
|
|
14
|
+
|
|
15
|
+
/** Safely invokes the onError hook. Errors thrown inside the hook are caught and logged so they never mask the original error or alter the response. */
|
|
16
|
+
export async function fireOnError(hook: OnErrorHook | undefined, err: unknown, request?: Request): Promise<void> {
|
|
17
|
+
if (!hook) return;
|
|
18
|
+
try {
|
|
19
|
+
await hook(err, request);
|
|
20
|
+
} catch (hookErr) {
|
|
21
|
+
console.error("[bractjs] onError hook threw:", hookErr);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/server/loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { LayoutChain } from "./layout.ts";
|
|
|
3
3
|
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
4
4
|
import { isExplicitDev } from "./env.ts";
|
|
5
5
|
import type { ContextFactory } from "./context.ts";
|
|
6
|
+
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
6
7
|
|
|
7
8
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -18,7 +19,8 @@ export interface LoaderResults {
|
|
|
18
19
|
|
|
19
20
|
export async function safeRun<T>(
|
|
20
21
|
fn: ((args: LoaderArgs) => Promise<T> | T) | undefined,
|
|
21
|
-
args: LoaderArgs
|
|
22
|
+
args: LoaderArgs,
|
|
23
|
+
onError?: OnErrorHook,
|
|
22
24
|
): Promise<T | { __error: unknown } | null> {
|
|
23
25
|
if (!fn) return null;
|
|
24
26
|
|
|
@@ -35,6 +37,7 @@ export async function safeRun<T>(
|
|
|
35
37
|
// surface structured user-facing errors should throw an HttpError, not
|
|
36
38
|
// a custom Error subclass.
|
|
37
39
|
console.error("[bractjs] loader error:", err);
|
|
40
|
+
await fireOnError(onError, err, args.request);
|
|
38
41
|
const safe = isExplicitDev()
|
|
39
42
|
? {
|
|
40
43
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -74,20 +77,22 @@ export async function runBeforeLoad(
|
|
|
74
77
|
|
|
75
78
|
export async function runLoaders(
|
|
76
79
|
chain: LayoutChain,
|
|
77
|
-
args: LoaderArgs
|
|
80
|
+
args: LoaderArgs,
|
|
81
|
+
onError?: OnErrorHook,
|
|
78
82
|
): Promise<LoaderResults> {
|
|
79
83
|
const layoutLoaders = chain.layouts.map((mod) =>
|
|
80
|
-
safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args)
|
|
84
|
+
safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError)
|
|
81
85
|
);
|
|
82
86
|
|
|
83
87
|
const [root, ...layoutResults] = await Promise.all([
|
|
84
|
-
safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args),
|
|
88
|
+
safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
|
|
85
89
|
...layoutLoaders,
|
|
86
90
|
]);
|
|
87
91
|
|
|
88
92
|
const route = await safeRun(
|
|
89
93
|
chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined,
|
|
90
|
-
args
|
|
94
|
+
args,
|
|
95
|
+
onError,
|
|
91
96
|
);
|
|
92
97
|
|
|
93
98
|
return { root, layouts: layoutResults, route };
|
|
@@ -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
|
}
|
package/src/server/serve.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/server/static.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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,6 +24,23 @@ 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 {
|
|
@@ -31,3 +49,13 @@ export interface ServerManifest {
|
|
|
31
49
|
/** Map of URL pattern → route asset info. */
|
|
32
50
|
routes: Record<string, { file?: string; chunk?: string }>;
|
|
33
51
|
}
|
|
52
|
+
|
|
53
|
+
/** Subset of BractJSConfig used by the build pipeline. All fields optional. */
|
|
54
|
+
export interface BuildConfig {
|
|
55
|
+
appDir?: string;
|
|
56
|
+
buildDir?: string;
|
|
57
|
+
sourcemap?: "none" | "linked" | "inline" | "external";
|
|
58
|
+
minify?: boolean;
|
|
59
|
+
clientEnv?: string[];
|
|
60
|
+
plugins?: import("bun").BunPlugin[];
|
|
61
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -4,10 +4,11 @@ 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 ───────────────────────────────────────────────────────
|
|
10
|
-
export type { BractJSConfig, ServerManifest } from "./config.d.ts";
|
|
11
|
+
export type { BractJSConfig, ServerManifest, BuildConfig } from "./config.d.ts";
|
|
11
12
|
import type { MetaDescriptor } from "./route.d.ts";
|
|
12
13
|
import type { BractJSConfig, ServerManifest } from "./config.d.ts";
|
|
13
14
|
|
|
@@ -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,5 +208,49 @@ 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>;
|
|
241
|
+
|
|
242
|
+
// ── Programmatic API ─────────────────────────────────────────────────────
|
|
243
|
+
export declare function runBuild(config: import("./config.d.ts").BuildConfig): Promise<void>;
|
|
244
|
+
|
|
245
|
+
export interface DevServerOptions {
|
|
246
|
+
port?: number;
|
|
247
|
+
hmrPort?: number;
|
|
248
|
+
config?: Partial<BractJSConfig>;
|
|
249
|
+
skipUserConfig?: boolean;
|
|
250
|
+
}
|
|
251
|
+
export interface DevServer {
|
|
252
|
+
stop(): void;
|
|
253
|
+
}
|
|
254
|
+
export declare function createDevServer(options?: DevServerOptions): Promise<DevServer>;
|
|
255
|
+
|
|
256
|
+
export declare function loadUserConfig(): Promise<Partial<BractJSConfig>>;
|
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
|
+
}
|