@raven.js/cli 1.1.1 → 1.2.0

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.
@@ -0,0 +1,225 @@
1
+ # PLUGIN AUTHORING GUIDE
2
+
3
+ A plugin is a **named object** returned by a factory function and registered via `app.register()`.
4
+
5
+ ```typescript
6
+ import { definePlugin, type Raven } from "@raven.js/core";
7
+
8
+ export function myPlugin(config: { prefix: string }) {
9
+ return definePlugin({
10
+ name: "my-plugin", // required — shown in error messages
11
+ states: [], // ScopedState instances created inside this factory
12
+ load(app: Raven) { // called during registration
13
+ app.onRequest((req) => { /* ... */ });
14
+ },
15
+ });
16
+ }
17
+
18
+ const app = new Raven();
19
+ await app.register(myPlugin({ prefix: "/api" }));
20
+ ```
21
+
22
+ **`definePlugin`** is a type helper — it ensures TypeScript infers `states` as a tuple rather than an array, which makes the return type of `register()` precise. It has no runtime effect.
23
+
24
+ Use `app.onLoaded(hook)` for one-time app initialization that should happen after plugin registration and before requests are served.
25
+
26
+ ---
27
+
28
+ # STATE PATTERNS
29
+
30
+ Plugins interact with the `ScopedState` DI system in two patterns. Choose based on who owns the state.
31
+
32
+ ## Pattern A — Per-registration isolated states
33
+
34
+ Declare states **inside the factory function**. Each call to the factory creates a new state instance. `app.register()` returns these states — the caller destructures them.
35
+
36
+ ```typescript
37
+ // config-plugin.ts
38
+ import { definePlugin, createAppState, type Raven } from "@raven.js/core";
39
+
40
+ interface Config { value: string }
41
+
42
+ export function configPlugin(value: string) {
43
+ const ConfigState = createAppState<Config>(); // new instance per factory call
44
+ return definePlugin({
45
+ name: "config-plugin",
46
+ states: [ConfigState] as const,
47
+ load(app: Raven) {
48
+ ConfigState.set({ value });
49
+ },
50
+ });
51
+ }
52
+ ```
53
+
54
+ ```typescript
55
+ // app.ts
56
+ import { configPlugin } from "./config-plugin.ts";
57
+
58
+ const app = new Raven();
59
+ const [primaryConfig] = await app.register(configPlugin("primary-value"));
60
+ const [secondaryConfig] = await app.register(configPlugin("secondary-value"));
61
+
62
+ app.get("/", () => {
63
+ const a = primaryConfig.getOrFailed();
64
+ const b = secondaryConfig.getOrFailed();
65
+ return Response.json({ a: a.value, b: b.value });
66
+ });
67
+ ```
68
+
69
+ **When to use**: any plugin that owns its state — whether registered once or multiple times. Each registration holds independent state; the caller gets it back from `register()`.
70
+
71
+ > **Note**: `as const` on the `states` array is required to help TypeScript infer a tuple type, so the destructured value from `register()` is typed correctly.
72
+
73
+ ---
74
+
75
+ ## Pattern B — Caller-provided state (inter-plugin dependency)
76
+
77
+ Accept a `ScopedState` instance as a parameter. The caller creates and owns the state; the plugin only writes to it. This is the standard way for one plugin to depend on another.
78
+
79
+ ```typescript
80
+ // logger-plugin.ts
81
+ import { definePlugin, type AppState, type Raven } from "@raven.js/core";
82
+
83
+ interface Logger { log: (msg: string) => void }
84
+
85
+ export function loggerPlugin(loggerState: AppState<Logger>) {
86
+ return definePlugin({
87
+ name: "logger-plugin",
88
+ states: [],
89
+ load(app: Raven) {
90
+ loggerState.set({ log: (msg) => console.log(msg) });
91
+ },
92
+ });
93
+ }
94
+ ```
95
+
96
+ ```typescript
97
+ // app.ts
98
+ import { loggerPlugin } from "./logger-plugin.ts";
99
+ import { createAppState } from "@raven.js/core";
100
+
101
+ interface Logger { log: (msg: string) => void }
102
+
103
+ const loggerState = createAppState<Logger>();
104
+
105
+ const app = new Raven();
106
+ await app.register(loggerPlugin(loggerState));
107
+
108
+ // pass loggerState to any other plugin that needs it
109
+ await app.register(someOtherPlugin(loggerState));
110
+ ```
111
+
112
+ **When to use**: a plugin depends on state owned by another plugin, or the caller needs explicit control over the state's identity and lifecycle.
113
+
114
+ ---
115
+
116
+ # GOTCHAS
117
+
118
+ ## Do not declare state outside the factory
119
+
120
+ States declared at module level are shared across all `Raven` instances in the same process. This breaks test isolation and makes it impossible to register the same plugin more than once with independent state.
121
+
122
+ ```typescript
123
+ // ❌ Wrong: shared across all app instances
124
+ export const DbState = createAppState<DB>();
125
+
126
+ export function dbPlugin(dsn: string) {
127
+ return definePlugin({
128
+ name: "db-plugin",
129
+ states: [],
130
+ async load() { DbState.set(await connectDatabase(dsn)); },
131
+ });
132
+ }
133
+ ```
134
+
135
+ ```typescript
136
+ // ✓ Correct: one state instance per registration
137
+ export function dbPlugin(dsn: string) {
138
+ const DbState = createAppState<DB>();
139
+ return definePlugin({
140
+ name: "db-plugin",
141
+ states: [DbState] as const,
142
+ async load() { DbState.set(await connectDatabase(dsn)); },
143
+ });
144
+ }
145
+
146
+ // app.ts
147
+ export const [DbState] = await app.register(dbPlugin("postgres://localhost/mydb"));
148
+ ```
149
+
150
+ ---
151
+
152
+ ## `register()` must be awaited
153
+
154
+ `register()` returns `Promise<states>`. Not awaiting it means `load()` may not have run before you register routes or subsequent plugins.
155
+
156
+ ```typescript
157
+ // ❌ Wrong
158
+ app.register(myPlugin());
159
+ app.get("/", handler); // load() may not have run yet
160
+
161
+ // ✓ Correct
162
+ await app.register(myPlugin());
163
+ app.get("/", handler);
164
+ ```
165
+
166
+ ## `AppState.set()` requires AppStorage context
167
+
168
+ `AppState.set()` only works when `currentAppStorage` is active. Inside `load()` this is always the case. Outside of `load()` (e.g. at module top level) it throws `ERR_STATE_CANNOT_SET`.
169
+
170
+ ```typescript
171
+ const dbState = createAppState<DB>();
172
+
173
+ // ❌ Wrong: outside any context
174
+ dbState.set(db);
175
+
176
+ // ✓ Correct: inside load()
177
+ export function myPlugin() {
178
+ return definePlugin({
179
+ name: "my-plugin",
180
+ states: [],
181
+ load(app) {
182
+ dbState.set(db); // ✓ safe
183
+ },
184
+ });
185
+ }
186
+ ```
187
+
188
+ ## Hooks registered in `load()` apply in registration order
189
+
190
+ Hooks added in `load()` are appended to the global hook list. Plugins registered first have their hooks run first. Register plugins before routes to ensure hooks apply to all routes.
191
+
192
+ ```typescript
193
+ await app.register(authPlugin()); // onRequest hook added first
194
+ await app.register(loggerPlugin()); // onRequest hook added second
195
+
196
+ app.get("/", handler); // both hooks apply to this route
197
+ ```
198
+
199
+ ## `onLoaded` runs once before the first request
200
+
201
+ `onLoaded` is app-level (not request-level). Hooks run in registration order, are awaited serially, and execute once before the first request lifecycle.
202
+
203
+ ```typescript
204
+ await app.register(authPlugin());
205
+ await app.register(loggerPlugin());
206
+
207
+ app.onLoaded(async () => {
208
+ await initMetrics();
209
+ });
210
+
211
+ // onLoaded executes once on the first request
212
+ await app.handle(new Request("http://localhost/"));
213
+ ```
214
+
215
+ If an `onLoaded` hook throws, remaining `onLoaded` hooks are skipped and the error is propagated.
216
+
217
+ ## `load()` errors are attributed to the plugin
218
+
219
+ If `load()` throws, the error message is wrapped with the plugin name:
220
+
221
+ ```
222
+ [my-plugin] Plugin load failed: connection refused
223
+ ```
224
+
225
+ The original error is available as `error.cause`.
@@ -0,0 +1,427 @@
1
+ # OVERVIEW
2
+
3
+ RavenJS Core is a lightweight, high-performance Web framework reference implementation for Bun.
4
+
5
+ **Features**:
6
+ - Logic layer: `app.handle` (FetchHandler)
7
+ - Radix tree router (path parameters)
8
+ - Dependency injection (DI) via AsyncLocalStorage (ScopedState)
9
+ - Lifecycle hooks (onLoaded, onRequest, beforeHandle, beforeResponse, onError)
10
+ - Plugin system
11
+
12
+ ---
13
+
14
+ # ARCHITECTURE
15
+
16
+ **Lifecycle overview**:
17
+
18
+ ```
19
+ app startup / first handle()
20
+
21
+
22
+ [plugin register/load] ← `await app.register(plugin)`; plugin hooks/states are installed here.
23
+
24
+
25
+ [onLoaded hooks] ← app-level init; runs once before the first request lifecycle.
26
+
27
+
28
+ app ready
29
+ ```
30
+
31
+ ```
32
+ incoming request (each request)
33
+
34
+
35
+ [onRequest hooks] ← global; receives raw Request. Returning a Response short-circuits.
36
+
37
+
38
+ [route matching] ← no match → 404
39
+
40
+
41
+ [processStates] ← populates ParamsState / QueryState / HeadersState / BodyState
42
+
43
+
44
+ [beforeHandle hooks] ← route-scoped; no args. Returning a Response short-circuits.
45
+
46
+
47
+ [handler()] ← no args; returns Response
48
+
49
+
50
+ [beforeResponse hooks] ← route-scoped; receives Response. Returning a new Response replaces it.
51
+
52
+
53
+ outgoing response
54
+
55
+ any uncaught exception → [onError hooks] → fallback 500
56
+ ```
57
+
58
+ ---
59
+
60
+ # CORE CONCEPTS
61
+
62
+ ## Raven
63
+
64
+ The main application class. Register routes with full paths (e.g. `app.get('/api/v1/users', handler)`).
65
+ Raven is a **logic layer**—it exposes `handle(request) => Promise<Response>`:
66
+
67
+ ```typescript
68
+ const app = new Raven();
69
+ app.get("/", () => new Response("Hello"));
70
+ Bun.serve({ fetch: (req) => app.handle(req) });
71
+ ```
72
+
73
+ ## Context
74
+
75
+ The per-request context object, exposing `request`, `params`, `query`, `url`, `method`, `headers`, and `body`.
76
+
77
+ Access it via the built-in `RavenContext` state:
78
+
79
+ ```typescript
80
+ const ctx = RavenContext.getOrFailed();
81
+ console.log(ctx.method); // "GET"
82
+ console.log(ctx.params); // { id: "42" }
83
+ ```
84
+
85
+ ## Dependency Injection (DI)
86
+
87
+ **ScopedState is RavenJS's dependency injection (DI) implementation.** It uses `AsyncLocalStorage` to inject state into the async call chain, allowing handlers and hooks to access dependencies without explicit parameter passing. Each ScopedState is a state container with a well-defined lifetime:
88
+
89
+ | Type | Lifetime | Typical use |
90
+ | -------------- | ---------------------------- | ------------------------------------ |
91
+ | `AppState` | Shared across the entire app | DB connections, config, counters |
92
+ | `RequestState` | Isolated per HTTP request | Current user, parsed body, auth info |
93
+
94
+ **Built-in states** (populated automatically by the framework — do not set manually):
95
+
96
+ | State | Type | Description |
97
+ | -------------- | ------------------------ | --------------------------------------- |
98
+ | `RavenContext` | `Context` | Full request context |
99
+ | `ParamsState` | `Record<string, string>` | URL path parameters |
100
+ | `QueryState` | `Record<string, string>` | Query string parameters |
101
+ | `HeadersState` | `Record<string, string>` | Request headers (lowercased keys) |
102
+ | `BodyState` | `unknown` | Request body (JSON only; cast required) |
103
+
104
+ ## Plugin
105
+
106
+ A plugin is a **named object** with a `load(app)` method, registered via `app.register()`. Plugins are created by factory functions so they can accept configuration. `app.register()` returns a `Promise` resolving to the plugin's `states` tuple — always `await` it.
107
+
108
+ For post-registration initialization, use `app.onLoaded(hook)`. `onLoaded` hooks run in registration order, are awaited serially, and execute once before the first request is processed.
109
+
110
+ → **Creating a plugin?** See [PLUGIN.md](./PLUGIN.md) for the full authoring guide and state patterns.
111
+
112
+ ---
113
+
114
+ # DESIGN DECISIONS
115
+
116
+ ## Why AsyncLocalStorage for state?
117
+
118
+ ScopedState uses AsyncLocalStorage as the underlying mechanism for DI. Compared to traditional DI containers or class decorators:
119
+
120
+ - **Async-safe**: state propagates automatically through the async call chain without cross-request leakage
121
+ - **Zero boilerplate**: handlers don't need to receive a context argument — dependencies are injected into the call chain automatically
122
+ - **Flexible access**: unlike decorators that bake dependencies into constructor or method signatures, you can obtain injected dependencies anywhere in the call chain, only when needed — no rigid injection points
123
+ - **High performance**: zero-copy access, lighter than decorators or full-featured DI containers
124
+
125
+ ## Why are handlers zero-argument functions?
126
+
127
+ ```typescript
128
+ type Handler = () => Response | Promise<Response>;
129
+ ```
130
+
131
+ This is intentional:
132
+
133
+ - A handler only needs to return a `Response` — no framework-specific types required
134
+ - Data is accessed on demand via `BodyState.get()`, `RavenContext.getOrFailed()`, etc.
135
+ - Any plain function can be a handler with zero migration cost
136
+
137
+ ## Why is the hook pipeline snapshotted at route registration?
138
+
139
+ When `addRoute()` is called, it snapshots all currently registered hooks and stores them in the route's `pipeline`. This means:
140
+
141
+ - **Hooks must be registered before routes** — hooks added after a route is registered will not apply to it
142
+ - Each route's pipeline is an independent snapshot and does not affect other routes
143
+
144
+ ---
145
+
146
+ # GOTCHAS
147
+
148
+ ## 1. Hooks must be declared before routes
149
+
150
+ ```typescript
151
+ // ❌ Wrong: beforeHandle will NOT apply to /users
152
+ app.get("/users", handler);
153
+ app.beforeHandle(authHook);
154
+
155
+ // ✓ Correct: register hooks first, then routes
156
+ app.beforeHandle(authHook);
157
+ app.get("/users", handler);
158
+ ```
159
+
160
+ Reason: `addRoute()` snapshots all current hooks at call time.
161
+
162
+ ## 2. `register()` is async — always await it
163
+
164
+ `register()` returns `Promise<states>`, not `Promise<app>`. Always await and destructure the returned states if needed.
165
+
166
+ ```typescript
167
+ // ❌ Wrong: plugin may not have run yet
168
+ app.register(myPlugin());
169
+
170
+ // ✓ Correct: await it
171
+ await app.register(myPlugin());
172
+
173
+ // ✓ Correct: destructure states when plugin declares them
174
+ const [configState] = await app.register(configPlugin({ dsn: "..." }));
175
+ ```
176
+
177
+ ## 3. `AppState.set()` only works inside an AppStorage context
178
+
179
+ `AppState.set()` depends on `currentAppStorage`. It is only valid inside:
180
+
181
+ - a plugin's `load(app)` callback
182
+ - a request handler (after `handle` establishes the context)
183
+
184
+ Calling it outside these locations throws `ERR_STATE_CANNOT_SET`.
185
+
186
+ ```typescript
187
+ const dbState = createAppState<DB>({ name: "db" });
188
+
189
+ // ❌ Wrong: called outside AppStorage context
190
+ dbState.set(db);
191
+
192
+ // ✓ Correct: called inside plugin load()
193
+ await app.register(definePlugin({
194
+ name: "db",
195
+ states: [],
196
+ load(app) {
197
+ dbState.set(db);
198
+ },
199
+ }));
200
+ ```
201
+
202
+ ## 4. `BodyState` only parses JSON
203
+
204
+ The framework automatically parses the body and populates `BodyState` only when `Content-Type: application/json` is present. For all other content types (form-data, plain text, binary), `BodyState.get()` returns `undefined` — read the body manually via `RavenContext`.
205
+
206
+ ```typescript
207
+ // Content-Type: application/json → populated automatically
208
+ const body = BodyState.getOrFailed() as { name: string };
209
+
210
+ // Content-Type: multipart/form-data → read manually
211
+ const ctx = RavenContext.getOrFailed();
212
+ const formData = await ctx.request.formData();
213
+ ```
214
+
215
+ ## 5. `BodyState` is typed `unknown` — cast required
216
+
217
+ `ParamsState`, `QueryState`, and `HeadersState` are typed as `Record<string, string>` and can be used directly. Only `BodyState` remains `unknown` because JSON structure is arbitrary.
218
+
219
+ ```typescript
220
+ // ParamsState / QueryState / HeadersState — use directly, no cast needed
221
+ const { id } = ParamsState.getOrFailed();
222
+
223
+ // BodyState — cast required
224
+ const body = BodyState.getOrFailed() as { name: string };
225
+ ```
226
+
227
+ ## 6. `onRequest` has a different signature from all other hooks
228
+
229
+ `onRequest` is the only hook that receives an argument — the raw `Request` object. At this point, `RavenContext`, `ParamsState`, and other states **are not yet initialized** (route matching hasn't happened).
230
+
231
+ ```typescript
232
+ // onRequest: receives the raw Request
233
+ app.onRequest((request) => {
234
+ // RavenContext is NOT set here — do not call RavenContext.get()
235
+ const token = request.headers.get("authorization");
236
+ });
237
+
238
+ // beforeHandle: no args, all states are ready
239
+ app.beforeHandle(() => {
240
+ const ctx = RavenContext.getOrFailed(); // ✓ safe
241
+ });
242
+ ```
243
+
244
+ ## 7. Do not pass `app.handle` directly to `Bun.serve`
245
+
246
+ `handle` is a class method. Passing `app.handle` as the `fetch` callback loses the `this` context when Bun calls it, so `handle`'s internal use of `this` (e.g. for `currentAppStorage.run(this, ...)`) will break.
247
+
248
+ ```typescript
249
+ // ❌ Wrong: this is lost when Bun invokes the callback
250
+ Bun.serve({ fetch: app.handle });
251
+
252
+ // ✓ Correct: wrap in an arrow function (or use app.handle.bind(app))
253
+ Bun.serve({ fetch: (req) => app.handle(req), port: 3000 });
254
+ ```
255
+
256
+ ## 8. Register `onLoaded` before serving traffic
257
+
258
+ `onLoaded` is triggered once, right before the first successful request lifecycle starts. Register all plugins and `onLoaded` hooks before calling `handle()` from live traffic.
259
+
260
+ ```typescript
261
+ const app = new Raven();
262
+
263
+ await app.register(authPlugin());
264
+ await app.register(dbPlugin());
265
+
266
+ app.onLoaded(async () => {
267
+ await warmupCaches();
268
+ });
269
+
270
+ Bun.serve({ fetch: (req) => app.handle(req), port: 3000 });
271
+ ```
272
+
273
+ ---
274
+
275
+ # ANTI-PATTERNS
276
+
277
+ ## Do not store request-scoped data in AppState
278
+
279
+ ```typescript
280
+ const userState = createAppState<User>({ name: "user" }); // ❌
281
+
282
+ // AppState is shared across the entire app — concurrent requests will overwrite each other
283
+ app.beforeHandle(async () => {
284
+ userState.set(await fetchUser()); // dangerous! concurrent requests corrupt this value
285
+ });
286
+
287
+ // ✓ Use RequestState instead
288
+ const userState = createRequestState<User>({ name: "user" });
289
+ ```
290
+
291
+ ## Do not register route-specific hooks globally
292
+
293
+ ```typescript
294
+ // ❌ Intended to only hook /api, but the hook applies to ALL routes
295
+ app.get("/api/users", handler);
296
+ app.beforeHandle(apiOnlyHook); // applies to all routes registered after this
297
+
298
+ // ✓ Use path checks inside the hook, or structure routes so hooks apply only where needed
299
+ app.beforeHandle(() => {
300
+ const ctx = RavenContext.getOrFailed();
301
+ if (!ctx.url.pathname.startsWith("/api")) return;
302
+ return apiOnlyHook();
303
+ });
304
+ app.get("/api/users", handler);
305
+ ```
306
+
307
+ ## Do not forget to return a Response from `onError`
308
+
309
+ ```typescript
310
+ // ❌ Missing return — the framework falls through to subsequent hooks or the default 500
311
+ app.onError((error) => {
312
+ console.error(error);
313
+ // no return!
314
+ });
315
+
316
+ // ✓ Always return a Response
317
+ app.onError((error) => {
318
+ console.error(error);
319
+ return new Response("Something went wrong", { status: 500 });
320
+ });
321
+ ```
322
+
323
+ ---
324
+
325
+ # USAGE EXAMPLES
326
+
327
+ ## Minimal
328
+
329
+ ```typescript
330
+ import { Raven } from "./index.ts";
331
+
332
+ const app = new Raven();
333
+
334
+ app.get("/", () => new Response("Hello, World!"));
335
+
336
+ Bun.serve({ fetch: (req) => app.handle(req), port: 3000 });
337
+ ```
338
+
339
+ ## Path parameters
340
+
341
+ ```typescript
342
+ import { Raven, ParamsState } from "./index.ts";
343
+
344
+ const app = new Raven();
345
+
346
+ app.get("/user/:id", () => {
347
+ const { id } = ParamsState.getOrFailed();
348
+ return new Response(`User ID: ${id}`);
349
+ });
350
+ ```
351
+
352
+ ## Route prefix (use full paths)
353
+
354
+ ```typescript
355
+ import { Raven } from "./index.ts";
356
+
357
+ const app = new Raven();
358
+
359
+ app.get("/api/v1/users", () => new Response("Users list"));
360
+ app.post("/api/v1/users", () => new Response("Create user", { status: 201 }));
361
+ ```
362
+
363
+ ## Auth middleware (hooks must come before routes)
364
+
365
+ ```typescript
366
+ import { Raven, createRequestState, RavenContext } from "./index.ts";
367
+
368
+ interface User {
369
+ id: string;
370
+ role: string;
371
+ }
372
+ const currentUser = createRequestState<User>({ name: "currentUser" });
373
+
374
+ const app = new Raven();
375
+
376
+ // register hook first
377
+ app.beforeHandle(async () => {
378
+ const ctx = RavenContext.getOrFailed();
379
+ const token = ctx.headers.get("authorization");
380
+ if (!token) return new Response("Unauthorized", { status: 401 });
381
+ currentUser.set(await verifyToken(token));
382
+ });
383
+
384
+ // then register routes
385
+ app.get("/profile", () => {
386
+ const user = currentUser.getOrFailed();
387
+ return Response.json({ id: user.id });
388
+ });
389
+ ```
390
+
391
+ ## Error handling
392
+
393
+ ```typescript
394
+ import { Raven, RavenError, isRavenError, ParamsState } from "./index.ts";
395
+
396
+ const app = new Raven();
397
+
398
+ app.onError((error) => {
399
+ if (isRavenError(error)) {
400
+ return error.toResponse(); // serializes to JSON using statusCode
401
+ }
402
+ console.error("Unexpected error:", error);
403
+ return new Response("Internal Server Error", { status: 500 });
404
+ });
405
+
406
+ app.get("/items/:id", () => {
407
+ const { id } = ParamsState.getOrFailed();
408
+ if (!id.match(/^\d+$/)) {
409
+ throw RavenError.ERR_BAD_REQUEST("id must be numeric");
410
+ }
411
+ return Response.json({ id });
412
+ });
413
+ ```
414
+
415
+ ## Mutating the response (beforeResponse hook)
416
+
417
+ ```typescript
418
+ const app = new Raven();
419
+
420
+ app.beforeResponse((response) => {
421
+ const newResponse = new Response(response.body, response);
422
+ newResponse.headers.set("Access-Control-Allow-Origin", "*");
423
+ return newResponse;
424
+ });
425
+
426
+ app.get("/data", () => Response.json({ ok: true }));
427
+ ```