@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.
- package/README.md +10 -7
- package/dist/raven +33 -109
- package/dist/raven.map +3 -3
- package/dist/registry.json +1 -9
- package/dist/source/core/GUIDE.md +14 -0
- package/dist/source/core/PLUGIN.md +225 -0
- package/dist/source/core/README.md +427 -0
- package/dist/source/core/index.ts +624 -0
- package/dist/source/core/router.ts +128 -0
- package/dist/source/schema-validator/GUIDE.md +12 -0
- package/dist/source/schema-validator/README.md +229 -0
- package/dist/source/schema-validator/index.ts +139 -0
- package/dist/source/schema-validator/standard-schema.ts +76 -0
- package/dist/source/sql/GUIDE.md +12 -0
- package/dist/source/sql/README.md +271 -0
- package/dist/source/sql/index.ts +14 -0
- package/package.json +2 -2
|
@@ -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
|
+
```
|