@dangao/bun-server 2.1.0 → 2.3.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/dist/ai/providers/anthropic-provider.d.ts.map +1 -1
- package/dist/ai/providers/google-provider.d.ts.map +1 -1
- package/dist/ai/providers/ollama-provider.d.ts.map +1 -1
- package/dist/ai/providers/openai-provider.d.ts.map +1 -1
- package/dist/ai/service.d.ts.map +1 -1
- package/dist/ai/types.d.ts +5 -0
- package/dist/ai/types.d.ts.map +1 -1
- package/dist/core/application.d.ts +17 -0
- package/dist/core/application.d.ts.map +1 -1
- package/dist/core/context.d.ts +5 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/server.d.ts +17 -0
- package/dist/core/server.d.ts.map +1 -1
- package/dist/di/container.d.ts +16 -0
- package/dist/di/container.d.ts.map +1 -1
- package/dist/di/lifecycle.d.ts +48 -0
- package/dist/di/lifecycle.d.ts.map +1 -1
- package/dist/di/module-registry.d.ts +10 -6
- package/dist/di/module-registry.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +316 -108
- package/dist/mcp/server.d.ts +5 -2
- package/dist/mcp/server.d.ts.map +1 -1
- package/docs/idle-timeout.md +99 -8
- package/docs/lifecycle.md +74 -4
- package/docs/zh/idle-timeout.md +97 -6
- package/docs/zh/lifecycle.md +47 -8
- package/package.json +1 -1
- package/src/ai/providers/anthropic-provider.ts +5 -2
- package/src/ai/providers/google-provider.ts +3 -0
- package/src/ai/providers/ollama-provider.ts +3 -0
- package/src/ai/providers/openai-provider.ts +5 -2
- package/src/ai/service.ts +17 -5
- package/src/ai/types.ts +5 -0
- package/src/core/application.ts +55 -23
- package/src/core/context.ts +7 -0
- package/src/core/server.ts +121 -18
- package/src/di/container.ts +55 -1
- package/src/di/lifecycle.ts +114 -0
- package/src/di/module-registry.ts +58 -10
- package/src/index.ts +4 -0
- package/src/mcp/server.ts +6 -15
- package/tests/di/lifecycle.test.ts +102 -1
- package/tests/di/scoped-lifecycle.test.ts +61 -0
package/dist/mcp/server.d.ts
CHANGED
|
@@ -20,8 +20,11 @@ export declare class McpServer {
|
|
|
20
20
|
*/
|
|
21
21
|
handleHttp(req: Request): Promise<Response>;
|
|
22
22
|
/**
|
|
23
|
-
* Create an SSE response that keeps the connection open for streaming
|
|
24
|
-
*
|
|
23
|
+
* Create an SSE response that keeps the connection open for streaming.
|
|
24
|
+
*
|
|
25
|
+
* Heartbeat / keep-alive pings are handled automatically by the framework's
|
|
26
|
+
* SSE post-processor (`sseKeepAlive`), so this method no longer injects its
|
|
27
|
+
* own `setInterval`.
|
|
25
28
|
*/
|
|
26
29
|
createSseResponse(): Response;
|
|
27
30
|
private dispatch;
|
package/dist/mcp/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC9E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAc;IACvC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAgB;gBAExB,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa;IAKnE;;OAEG;IACU,MAAM,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;IAgBtE;;;OAGG;IACU,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAQxD
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC9E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAc;IACvC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAgB;gBAExB,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa;IAKnE;;OAEG;IACU,MAAM,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;IAgBtE;;;OAGG;IACU,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAQxD;;;;;;OAMG;IACI,iBAAiB,IAAI,QAAQ;YAuBtB,QAAQ;CAkEvB"}
|
package/docs/idle-timeout.md
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
# idleTimeout
|
|
1
|
+
# idleTimeout & SSE Keep-Alive
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Two layers of timeout
|
|
4
|
+
|
|
5
|
+
Bun Server has **two independent timeout mechanisms** — understanding their difference is critical for SSE / streaming use cases.
|
|
6
|
+
|
|
7
|
+
| Layer | Config | Scope | Mechanism |
|
|
8
|
+
|-------|--------|-------|-----------|
|
|
9
|
+
| **TCP connection** | `Application({ idleTimeout })` | Bun.serve level | Bun kernel closes the TCP socket when no bytes flow for N seconds |
|
|
10
|
+
| **Handler logic** | `@IdleTimeout(ms)` decorator | Per-route `Promise.race` | Returns `408 Request Timeout` if the handler doesn't resolve in time |
|
|
11
|
+
|
|
12
|
+
> **Key point:** For SSE responses the handler returns a `Response` immediately (with a streaming body). The handler-level `@IdleTimeout` has already resolved at that point and will **not** protect or kill the stream. Only the TCP-level `idleTimeout` can break an SSE connection.
|
|
13
|
+
|
|
14
|
+
---
|
|
4
15
|
|
|
5
16
|
## Global idle timeout (milliseconds)
|
|
6
17
|
|
|
7
|
-
Set in `Application` options using milliseconds.
|
|
8
|
-
Framework converts internally before passing to `Bun.serve
|
|
18
|
+
Set in `Application` options using milliseconds.
|
|
19
|
+
Framework converts internally before passing to `Bun.serve` (`Math.ceil(ms / 1000)` → seconds).
|
|
9
20
|
|
|
10
21
|
```ts
|
|
11
22
|
const app = new Application({
|
|
12
23
|
port: 3000,
|
|
13
|
-
idleTimeout: 15000, //
|
|
24
|
+
idleTimeout: 15000, // 15 s — applies to all non-SSE connections
|
|
14
25
|
});
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
## Per-route timeout (
|
|
28
|
+
## Per-route timeout — `@IdleTimeout(ms)`
|
|
18
29
|
|
|
19
|
-
Use `@IdleTimeout(ms)` on controller class or handler method.
|
|
30
|
+
Use `@IdleTimeout(ms)` on a controller class or a handler method.
|
|
20
31
|
|
|
21
32
|
```ts
|
|
22
33
|
import { Controller, GET, IdleTimeout } from '@dangao/bun-server';
|
|
@@ -38,5 +49,85 @@ class ApiController {
|
|
|
38
49
|
}
|
|
39
50
|
```
|
|
40
51
|
|
|
41
|
-
|
|
52
|
+
### Matching & precedence
|
|
53
|
+
|
|
54
|
+
1. **Method-level** `@IdleTimeout` is checked first — if present, it wins.
|
|
55
|
+
2. **Class-level** `@IdleTimeout` is used as fallback.
|
|
56
|
+
3. If neither is set, no handler-level timeout is applied (the route runs until the TCP timeout or until it completes).
|
|
57
|
+
|
|
58
|
+
When the handler-level timeout fires, the framework throws `HttpException(408, "Request Timeout")`.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## SSE Keep-Alive (automatic)
|
|
63
|
+
|
|
64
|
+
When the framework detects a response with `Content-Type: text/event-stream`, it automatically:
|
|
65
|
+
|
|
66
|
+
1. **Disables the TCP idle timeout** for that request via `server.timeout(req, 0)`, preventing Bun from killing the long-lived connection.
|
|
67
|
+
2. **Injects SSE comment heartbeats** (`: keepalive\n\n`) at a configurable interval, preventing intermediate proxies (nginx, cloud load balancers) from closing the connection due to inactivity.
|
|
68
|
+
|
|
69
|
+
### Configuration
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const app = new Application({
|
|
73
|
+
port: 3000,
|
|
74
|
+
idleTimeout: 10000, // normal requests: 10 s
|
|
75
|
+
|
|
76
|
+
// SSE keep-alive — defaults shown below
|
|
77
|
+
sseKeepAlive: {
|
|
78
|
+
enabled: true, // auto-detect SSE and inject heartbeat
|
|
79
|
+
intervalMs: 15000, // heartbeat every 15 s
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Option | Type | Default | Description |
|
|
85
|
+
|--------|------|---------|-------------|
|
|
86
|
+
| `sseKeepAlive.enabled` | `boolean` | `true` | Enable automatic SSE detection, TCP timeout reset, and heartbeat injection |
|
|
87
|
+
| `sseKeepAlive.intervalMs` | `number` | `15000` | Heartbeat interval in milliseconds |
|
|
88
|
+
|
|
89
|
+
When `enabled` is `true` (default), any response whose `Content-Type` header contains `text/event-stream` triggers the SSE post-processor. You do **not** need any special decorator or annotation — detection is purely based on the response header.
|
|
90
|
+
|
|
91
|
+
When `enabled` is `false`, no SSE-specific processing is applied. You would need to manage keep-alive and `server.timeout` yourself.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Signal cascading (`ctx.signal`)
|
|
96
|
+
|
|
97
|
+
`Context` exposes the client's `AbortSignal` via `ctx.signal`. When the client disconnects (network failure, browser tab closed, `curl` interrupted), this signal aborts.
|
|
98
|
+
|
|
99
|
+
For AI streaming endpoints, pass `ctx.signal` to `AiService` so the upstream API request is cancelled immediately — **stopping token consumption**:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { Controller, GET, Context as Ctx } from '@dangao/bun-server';
|
|
103
|
+
import type { Context } from '@dangao/bun-server';
|
|
104
|
+
|
|
105
|
+
@Controller('/chat')
|
|
106
|
+
class ChatController {
|
|
107
|
+
constructor(private readonly ai: AiService) {}
|
|
108
|
+
|
|
109
|
+
@GET('/stream')
|
|
110
|
+
public stream(@Ctx() ctx: Context) {
|
|
111
|
+
const stream = this.ai.stream({
|
|
112
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
113
|
+
signal: ctx.signal, // ← cascade client disconnect
|
|
114
|
+
});
|
|
115
|
+
return new Response(stream, {
|
|
116
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
> **Note:** The `Context` parameter decorator is exported as both `Context` (from `'./controller'`) and `ContextParam` (from the package root). Use whichever alias avoids name collision with the `Context` type.
|
|
123
|
+
|
|
124
|
+
The full cancellation chain:
|
|
42
125
|
|
|
126
|
+
```
|
|
127
|
+
Client disconnects
|
|
128
|
+
→ request.signal aborts
|
|
129
|
+
→ heartbeat timer cleared
|
|
130
|
+
→ wrapped stream cancelled
|
|
131
|
+
→ AI provider fetch() aborted
|
|
132
|
+
→ upstream API connection closed (tokens saved)
|
|
133
|
+
```
|
package/docs/lifecycle.md
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
# Lifecycle Hooks
|
|
2
2
|
|
|
3
|
-
Bun Server supports lifecycle hooks that let
|
|
3
|
+
Bun Server supports lifecycle hooks that let components (`@Injectable` / `@Controller`) participate from creation to destruction.
|
|
4
4
|
|
|
5
5
|
## Interfaces
|
|
6
6
|
|
|
7
7
|
| Interface | Method | When Called |
|
|
8
8
|
|-----------|--------|-------------|
|
|
9
|
+
| `ComponentClassBeforeCreate` | `static onBeforeCreate()` | Right before the component instance is created |
|
|
10
|
+
| `OnAfterCreate` | `onAfterCreate()` | Right after instance creation and post processors |
|
|
9
11
|
| `OnModuleInit` | `onModuleInit()` | After all module providers are registered |
|
|
10
12
|
| `OnModuleDestroy` | `onModuleDestroy()` | During shutdown (reverse order) |
|
|
13
|
+
| `OnBeforeDestroy` | `onBeforeDestroy()` | Before `onModuleDestroy` during shutdown (reverse order) |
|
|
14
|
+
| `OnAfterDestroy` | `onAfterDestroy()` | After `onModuleDestroy` during shutdown (reverse order) |
|
|
11
15
|
| `OnApplicationBootstrap` | `onApplicationBootstrap()` | After all modules init, before server listens |
|
|
12
16
|
| `OnApplicationShutdown` | `onApplicationShutdown(signal?)` | When graceful shutdown begins |
|
|
13
17
|
|
|
14
18
|
## Execution Order
|
|
15
19
|
|
|
20
|
+
**Creation (per component instance)**: `onBeforeCreate` (static) -> instantiate -> `onAfterCreate`
|
|
21
|
+
|
|
16
22
|
**Startup**: `onModuleInit` (all modules) -> `onApplicationBootstrap` (all modules) -> server starts
|
|
17
23
|
|
|
18
|
-
**Shutdown**: `onApplicationShutdown` (reverse order) -> `onModuleDestroy` (reverse order)
|
|
24
|
+
**Shutdown**: `onApplicationShutdown` (reverse order) -> `onBeforeDestroy` (reverse order) -> `onModuleDestroy` (reverse order) -> `onAfterDestroy` (reverse order)
|
|
25
|
+
|
|
26
|
+
For `Lifecycle.Scoped` components, destroy hooks are executed automatically at the end of each request context.
|
|
19
27
|
|
|
20
28
|
## Provider Deduplication
|
|
21
29
|
|
|
@@ -23,19 +31,41 @@ When the same provider instance is exported or registered by multiple tokens,
|
|
|
23
31
|
`onModuleInit` now runs only once for that instance. This avoids duplicate
|
|
24
32
|
initialization side effects in shared singleton objects.
|
|
25
33
|
|
|
26
|
-
## Example:
|
|
34
|
+
## Example: Component hooks from create to destroy
|
|
27
35
|
|
|
28
36
|
```ts
|
|
29
37
|
import {
|
|
30
38
|
Injectable,
|
|
39
|
+
Controller,
|
|
40
|
+
GET,
|
|
41
|
+
Module,
|
|
31
42
|
OnModuleInit,
|
|
32
43
|
OnModuleDestroy,
|
|
44
|
+
type ComponentClassBeforeCreate,
|
|
45
|
+
OnAfterCreate,
|
|
46
|
+
OnBeforeDestroy,
|
|
47
|
+
OnAfterDestroy,
|
|
33
48
|
} from '@dangao/bun-server';
|
|
34
49
|
|
|
35
50
|
@Injectable()
|
|
36
|
-
class DatabaseService
|
|
51
|
+
class DatabaseService
|
|
52
|
+
implements
|
|
53
|
+
OnAfterCreate,
|
|
54
|
+
OnModuleInit,
|
|
55
|
+
OnBeforeDestroy,
|
|
56
|
+
OnModuleDestroy,
|
|
57
|
+
OnAfterDestroy
|
|
58
|
+
{
|
|
37
59
|
private connected = false;
|
|
38
60
|
|
|
61
|
+
public static onBeforeCreate(): void {
|
|
62
|
+
console.log('[DatabaseService] Before create');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public onAfterCreate(): void {
|
|
66
|
+
console.log('[DatabaseService] After create');
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
public async onModuleInit(): Promise<void> {
|
|
40
70
|
console.log('[DatabaseService] Connecting...');
|
|
41
71
|
await this.connect();
|
|
@@ -48,6 +78,14 @@ class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
|
|
48
78
|
this.connected = false;
|
|
49
79
|
}
|
|
50
80
|
|
|
81
|
+
public onBeforeDestroy(): void {
|
|
82
|
+
console.log('[DatabaseService] Before destroy');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public onAfterDestroy(): void {
|
|
86
|
+
console.log('[DatabaseService] After destroy');
|
|
87
|
+
}
|
|
88
|
+
|
|
51
89
|
public isConnected(): boolean {
|
|
52
90
|
return this.connected;
|
|
53
91
|
}
|
|
@@ -60,6 +98,36 @@ class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
|
|
60
98
|
// Close DB connection
|
|
61
99
|
}
|
|
62
100
|
}
|
|
101
|
+
|
|
102
|
+
@Controller('/health')
|
|
103
|
+
class HealthController implements OnAfterCreate, OnBeforeDestroy, OnAfterDestroy {
|
|
104
|
+
public static onBeforeCreate(): void {
|
|
105
|
+
console.log('[HealthController] Before create');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public onAfterCreate(): void {
|
|
109
|
+
console.log('[HealthController] After create');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public onBeforeDestroy(): void {
|
|
113
|
+
console.log('[HealthController] Before destroy');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public onAfterDestroy(): void {
|
|
117
|
+
console.log('[HealthController] After destroy');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@GET('/')
|
|
121
|
+
public get(): object {
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Module({
|
|
127
|
+
controllers: [HealthController],
|
|
128
|
+
providers: [DatabaseService],
|
|
129
|
+
})
|
|
130
|
+
class AppModule {}
|
|
63
131
|
```
|
|
64
132
|
|
|
65
133
|
## Example: Application-level hooks
|
|
@@ -75,4 +143,6 @@ class AppService implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
|
75
143
|
console.log(`Shutting down (signal: ${signal ?? 'none'})`);
|
|
76
144
|
}
|
|
77
145
|
}
|
|
146
|
+
|
|
147
|
+
const _beforeCreateHook: ComponentClassBeforeCreate = DatabaseService;
|
|
78
148
|
```
|
package/docs/zh/idle-timeout.md
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
# idleTimeout
|
|
1
|
+
# idleTimeout 与 SSE 保活
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 两层超时机制
|
|
4
|
+
|
|
5
|
+
Bun Server 有**两套独立的超时机制**——理解它们的区别对 SSE / 流式场景至关重要。
|
|
6
|
+
|
|
7
|
+
| 层面 | 配置方式 | 作用域 | 机制 |
|
|
8
|
+
|------|----------|--------|------|
|
|
9
|
+
| **TCP 连接级** | `Application({ idleTimeout })` | Bun.serve 底层 | Bun 内核在连接无数据流动 N 秒后直接断开 socket |
|
|
10
|
+
| **Handler 逻辑级** | `@IdleTimeout(ms)` 装饰器 | 路由粒度的 `Promise.race` | handler 未在指定时间内 resolve 则返回 `408 Request Timeout` |
|
|
11
|
+
|
|
12
|
+
> **关键:** 对于 SSE 响应,handler 会立即返回一个带流式 body 的 `Response`。此时 handler 层面的 `@IdleTimeout` 已经 resolve,**不会**保护或终止该流。只有 TCP 级别的 `idleTimeout` 才能断开 SSE 连接。
|
|
13
|
+
|
|
14
|
+
---
|
|
4
15
|
|
|
5
16
|
## 全局 idleTimeout(毫秒)
|
|
6
17
|
|
|
7
|
-
在 `Application`
|
|
18
|
+
在 `Application` 中按毫秒设置,框架内部自动转换后传给 `Bun.serve`(`Math.ceil(ms / 1000)` → 秒)。
|
|
8
19
|
|
|
9
20
|
```ts
|
|
10
21
|
const app = new Application({
|
|
11
22
|
port: 3000,
|
|
12
|
-
idleTimeout: 15000, //
|
|
23
|
+
idleTimeout: 15000, // 15 秒 — 对所有非 SSE 连接生效
|
|
13
24
|
});
|
|
14
25
|
```
|
|
15
26
|
|
|
16
|
-
##
|
|
27
|
+
## 路由级超时 — `@IdleTimeout(ms)`
|
|
17
28
|
|
|
18
29
|
使用 `@IdleTimeout(ms)` 装饰器配置控制器级或方法级超时。
|
|
19
30
|
|
|
@@ -37,5 +48,85 @@ class ApiController {
|
|
|
37
48
|
}
|
|
38
49
|
```
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
### 匹配与生效规则
|
|
52
|
+
|
|
53
|
+
1. **方法级** `@IdleTimeout` 优先检测——存在即生效。
|
|
54
|
+
2. **类级** `@IdleTimeout` 作为兜底。
|
|
55
|
+
3. 若均未设置,则不应用 handler 级超时(路由将持续运行直到 TCP 超时或自然完成)。
|
|
56
|
+
|
|
57
|
+
handler 级超时触发时,框架抛出 `HttpException(408, "Request Timeout")`。
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## SSE 保活(自动)
|
|
62
|
+
|
|
63
|
+
当框架检测到响应的 `Content-Type` 包含 `text/event-stream` 时,会自动执行:
|
|
64
|
+
|
|
65
|
+
1. **禁用该请求的 TCP 空闲超时** —— 通过 `server.timeout(req, 0)` 阻止 Bun 断开长连接。
|
|
66
|
+
2. **注入 SSE 注释心跳** —— 按配置间隔发送 `: keepalive\n\n`,防止中间代理(nginx、云 LB)因空闲而断连。
|
|
67
|
+
|
|
68
|
+
### 配置
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const app = new Application({
|
|
72
|
+
port: 3000,
|
|
73
|
+
idleTimeout: 10000, // 普通请求:10 秒
|
|
74
|
+
|
|
75
|
+
// SSE 保活 — 以下为默认值
|
|
76
|
+
sseKeepAlive: {
|
|
77
|
+
enabled: true, // 自动检测 SSE 并注入心跳
|
|
78
|
+
intervalMs: 15000, // 每 15 秒一次心跳
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
84
|
+
|------|------|--------|------|
|
|
85
|
+
| `sseKeepAlive.enabled` | `boolean` | `true` | 启用 SSE 自动检测、TCP 超时解除和心跳注入 |
|
|
86
|
+
| `sseKeepAlive.intervalMs` | `number` | `15000` | 心跳间隔(毫秒) |
|
|
87
|
+
|
|
88
|
+
当 `enabled` 为 `true`(默认)时,任何 `Content-Type` 头包含 `text/event-stream` 的响应都会触发 SSE 后处理器。**无需任何特殊装饰器或注解**——纯粹基于响应头自动检测。
|
|
89
|
+
|
|
90
|
+
当 `enabled` 为 `false` 时,框架不做任何 SSE 特殊处理。你需要自行管理 keep-alive 和 `server.timeout`。
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 信号级联(`ctx.signal`)
|
|
95
|
+
|
|
96
|
+
`Context` 通过 `ctx.signal` 暴露客户端的 `AbortSignal`。当客户端断连(网络故障、关闭浏览器标签、中断 `curl`)时,该信号会 abort。
|
|
97
|
+
|
|
98
|
+
对于 AI 流式端点,将 `ctx.signal` 传递给 `AiService`,可立即取消上游 API 请求——**停止 token 消耗**:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { Controller, GET, Context as Ctx } from '@dangao/bun-server';
|
|
102
|
+
import type { Context } from '@dangao/bun-server';
|
|
103
|
+
|
|
104
|
+
@Controller('/chat')
|
|
105
|
+
class ChatController {
|
|
106
|
+
constructor(private readonly ai: AiService) {}
|
|
107
|
+
|
|
108
|
+
@GET('/stream')
|
|
109
|
+
public stream(@Ctx() ctx: Context) {
|
|
110
|
+
const stream = this.ai.stream({
|
|
111
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
112
|
+
signal: ctx.signal, // ← 级联客户端断连
|
|
113
|
+
});
|
|
114
|
+
return new Response(stream, {
|
|
115
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> **注意:** `Context` 参数装饰器既可通过 `Context`(从 `'./controller'`)导入,也可通过包根导出的别名 `ContextParam` 导入。推荐使用 `ContextParam` 或 `Context as Ctx` 以避免与 `Context` 类型名冲突。
|
|
122
|
+
|
|
123
|
+
完整取消链路:
|
|
41
124
|
|
|
125
|
+
```
|
|
126
|
+
客户端断连
|
|
127
|
+
→ request.signal abort
|
|
128
|
+
→ 心跳定时器清理
|
|
129
|
+
→ 包裹流取消
|
|
130
|
+
→ AI Provider 内部 fetch() abort
|
|
131
|
+
→ 上游 API 连接关闭(节省 token)
|
|
132
|
+
```
|
package/docs/zh/lifecycle.md
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
# 生命周期钩子
|
|
2
2
|
|
|
3
|
-
Bun Server
|
|
3
|
+
Bun Server 支持组件级生命周期钩子,可让 `@Injectable` / `@Controller` 从创建前到销毁后执行自定义逻辑。
|
|
4
4
|
|
|
5
5
|
## 接口定义
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
7
|
+
- **ComponentClassBeforeCreate**:`static onBeforeCreate()`,组件实例创建前调用
|
|
8
|
+
- **OnAfterCreate**:`onAfterCreate()`,组件实例创建并完成后处理后调用
|
|
9
|
+
- **OnModuleInit**:`onModuleInit()`,在模块所有组件初始化阶段调用
|
|
9
10
|
- **OnApplicationBootstrap**:`onApplicationBootstrap()`,在所有模块初始化完成后、服务器开始监听前调用
|
|
10
11
|
- **OnApplicationShutdown**:`onApplicationShutdown(signal?)`,在优雅停机开始时调用
|
|
12
|
+
- **OnBeforeDestroy**:`onBeforeDestroy()`,在 `onModuleDestroy()` 前调用(反向顺序)
|
|
13
|
+
- **OnModuleDestroy**:`onModuleDestroy()`,在应用关闭时调用(反向顺序)
|
|
14
|
+
- **OnAfterDestroy**:`onAfterDestroy()`,在 `onModuleDestroy()` 后调用(反向顺序)
|
|
11
15
|
|
|
12
16
|
## 执行顺序
|
|
13
17
|
|
|
18
|
+
**创建阶段(每个组件实例)**:`onBeforeCreate`(静态)→ 实例化 → `onAfterCreate`
|
|
19
|
+
|
|
14
20
|
**启动阶段**:`onModuleInit` → `onApplicationBootstrap`
|
|
15
21
|
|
|
16
|
-
**关闭阶段**:`onApplicationShutdown` → `onModuleDestroy`(均为反向顺序,即后注册的先执行)
|
|
22
|
+
**关闭阶段**:`onApplicationShutdown` → `onBeforeDestroy` → `onModuleDestroy` → `onAfterDestroy`(均为反向顺序,即后注册的先执行)
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
对于 `Lifecycle.Scoped` 组件,请求上下文结束时会自动触发其销毁钩子。
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
## 组件去重行为
|
|
27
|
+
|
|
28
|
+
当同一个组件实例通过多个 token 重复注册/导出时,生命周期钩子只会执行一次,避免共享单例出现重复副作用。
|
|
22
29
|
|
|
23
30
|
## 示例:DatabaseService 的初始化和销毁
|
|
24
31
|
|
|
@@ -41,6 +48,14 @@ import type {
|
|
|
41
48
|
class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
|
42
49
|
private connected = false;
|
|
43
50
|
|
|
51
|
+
public static onBeforeCreate(): void {
|
|
52
|
+
console.log('[DatabaseService] 创建前');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public onAfterCreate(): void {
|
|
56
|
+
console.log('[DatabaseService] 创建后');
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
public async onModuleInit(): Promise<void> {
|
|
45
60
|
console.log('[DatabaseService] 正在连接数据库...');
|
|
46
61
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
@@ -54,6 +69,14 @@ class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
|
|
54
69
|
console.log('[DatabaseService] 已断开');
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
public onBeforeDestroy(): void {
|
|
73
|
+
console.log('[DatabaseService] 销毁前');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public onAfterDestroy(): void {
|
|
77
|
+
console.log('[DatabaseService] 销毁后');
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
public isConnected(): boolean {
|
|
58
81
|
return this.connected;
|
|
59
82
|
}
|
|
@@ -72,6 +95,22 @@ class AppService implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
|
72
95
|
|
|
73
96
|
@Controller('/api')
|
|
74
97
|
class AppController {
|
|
98
|
+
public static onBeforeCreate(): void {
|
|
99
|
+
console.log('[AppController] 创建前');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public onAfterCreate(): void {
|
|
103
|
+
console.log('[AppController] 创建后');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public onBeforeDestroy(): void {
|
|
107
|
+
console.log('[AppController] 销毁前');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public onAfterDestroy(): void {
|
|
111
|
+
console.log('[AppController] 销毁后');
|
|
112
|
+
}
|
|
113
|
+
|
|
75
114
|
@GET('/status')
|
|
76
115
|
public status(): object {
|
|
77
116
|
return { status: 'running', timestamp: Date.now() };
|
|
@@ -89,4 +128,4 @@ app.registerModule(AppModule);
|
|
|
89
128
|
await app.listen();
|
|
90
129
|
```
|
|
91
130
|
|
|
92
|
-
按 Ctrl+C 触发关闭时,将依次执行 `onApplicationShutdown
|
|
131
|
+
按 Ctrl+C 触发关闭时,将依次执行 `onApplicationShutdown`、`onBeforeDestroy`、`onModuleDestroy`、`onAfterDestroy`。
|
package/package.json
CHANGED
|
@@ -56,7 +56,7 @@ export class AnthropicProvider implements LlmProvider {
|
|
|
56
56
|
}));
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const response = await this.post('/v1/messages', body);
|
|
59
|
+
const response = await this.post('/v1/messages', body, request.signal);
|
|
60
60
|
const usage = (response['usage'] as { input_tokens: number; output_tokens: number }) ?? { input_tokens: 0, output_tokens: 0 };
|
|
61
61
|
|
|
62
62
|
let content = '';
|
|
@@ -108,6 +108,7 @@ export class AnthropicProvider implements LlmProvider {
|
|
|
108
108
|
const baseUrl = this.baseUrl;
|
|
109
109
|
const anthropicVersion = this.anthropicVersion;
|
|
110
110
|
const encoder = new TextEncoder();
|
|
111
|
+
const signal = request.signal;
|
|
111
112
|
|
|
112
113
|
return new ReadableStream<Uint8Array>({
|
|
113
114
|
async start(controller) {
|
|
@@ -120,6 +121,7 @@ export class AnthropicProvider implements LlmProvider {
|
|
|
120
121
|
'anthropic-version': anthropicVersion,
|
|
121
122
|
},
|
|
122
123
|
body: JSON.stringify(body),
|
|
124
|
+
signal,
|
|
123
125
|
});
|
|
124
126
|
|
|
125
127
|
if (!res.ok || !res.body) {
|
|
@@ -170,7 +172,7 @@ export class AnthropicProvider implements LlmProvider {
|
|
|
170
172
|
return Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
private async post(path: string, body: Record<string, unknown
|
|
175
|
+
private async post(path: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
|
174
176
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
175
177
|
method: 'POST',
|
|
176
178
|
headers: {
|
|
@@ -179,6 +181,7 @@ export class AnthropicProvider implements LlmProvider {
|
|
|
179
181
|
'anthropic-version': this.anthropicVersion,
|
|
180
182
|
},
|
|
181
183
|
body: JSON.stringify(body),
|
|
184
|
+
signal,
|
|
182
185
|
});
|
|
183
186
|
|
|
184
187
|
if (res.status === 429) throw new AiRateLimitError(this.name);
|
|
@@ -50,6 +50,7 @@ export class GoogleProvider implements LlmProvider {
|
|
|
50
50
|
method: 'POST',
|
|
51
51
|
headers: { 'Content-Type': 'application/json' },
|
|
52
52
|
body: JSON.stringify(body),
|
|
53
|
+
signal: request.signal,
|
|
53
54
|
},
|
|
54
55
|
);
|
|
55
56
|
|
|
@@ -95,6 +96,7 @@ export class GoogleProvider implements LlmProvider {
|
|
|
95
96
|
const apiKey = this.apiKey;
|
|
96
97
|
const baseUrl = this.baseUrl;
|
|
97
98
|
const encoder = new TextEncoder();
|
|
99
|
+
const signal = request.signal;
|
|
98
100
|
|
|
99
101
|
const body: Record<string, unknown> = {
|
|
100
102
|
contents,
|
|
@@ -111,6 +113,7 @@ export class GoogleProvider implements LlmProvider {
|
|
|
111
113
|
method: 'POST',
|
|
112
114
|
headers: { 'Content-Type': 'application/json' },
|
|
113
115
|
body: JSON.stringify(body),
|
|
116
|
+
signal,
|
|
114
117
|
},
|
|
115
118
|
);
|
|
116
119
|
|
|
@@ -33,6 +33,7 @@ export class OllamaProvider implements LlmProvider {
|
|
|
33
33
|
num_predict: request.maxTokens,
|
|
34
34
|
},
|
|
35
35
|
}),
|
|
36
|
+
signal: request.signal,
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
if (!res.ok) {
|
|
@@ -61,6 +62,7 @@ export class OllamaProvider implements LlmProvider {
|
|
|
61
62
|
const model = request.model ?? this.defaultModel;
|
|
62
63
|
const baseUrl = this.baseUrl;
|
|
63
64
|
const encoder = new TextEncoder();
|
|
65
|
+
const signal = request.signal;
|
|
64
66
|
|
|
65
67
|
return new ReadableStream<Uint8Array>({
|
|
66
68
|
async start(controller) {
|
|
@@ -77,6 +79,7 @@ export class OllamaProvider implements LlmProvider {
|
|
|
77
79
|
num_predict: request.maxTokens,
|
|
78
80
|
},
|
|
79
81
|
}),
|
|
82
|
+
signal,
|
|
80
83
|
});
|
|
81
84
|
|
|
82
85
|
if (!res.ok || !res.body) {
|
|
@@ -79,7 +79,7 @@ export class OpenAIProvider implements LlmProvider {
|
|
|
79
79
|
}));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const response = await this.post('/chat/completions', body);
|
|
82
|
+
const response = await this.post('/chat/completions', body, request.signal);
|
|
83
83
|
const choice = response.choices?.[0];
|
|
84
84
|
const usage = response.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
85
85
|
const message = choice?.message;
|
|
@@ -127,6 +127,7 @@ export class OpenAIProvider implements LlmProvider {
|
|
|
127
127
|
const encoder = new TextEncoder();
|
|
128
128
|
const apiKey = this.apiKey;
|
|
129
129
|
const baseUrl = this.baseUrl;
|
|
130
|
+
const signal = request.signal;
|
|
130
131
|
|
|
131
132
|
return new ReadableStream<Uint8Array>({
|
|
132
133
|
async start(controller) {
|
|
@@ -138,6 +139,7 @@ export class OpenAIProvider implements LlmProvider {
|
|
|
138
139
|
'Authorization': `Bearer ${apiKey}`,
|
|
139
140
|
},
|
|
140
141
|
body: JSON.stringify(body),
|
|
142
|
+
signal,
|
|
141
143
|
});
|
|
142
144
|
|
|
143
145
|
if (!res.ok || !res.body) {
|
|
@@ -191,7 +193,7 @@ export class OpenAIProvider implements LlmProvider {
|
|
|
191
193
|
return Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
private async post(path: string, body: Record<string, unknown
|
|
196
|
+
private async post(path: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<OpenAiChatCompletionResponse> {
|
|
195
197
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
196
198
|
method: 'POST',
|
|
197
199
|
headers: {
|
|
@@ -199,6 +201,7 @@ export class OpenAIProvider implements LlmProvider {
|
|
|
199
201
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
200
202
|
},
|
|
201
203
|
body: JSON.stringify(body),
|
|
204
|
+
signal,
|
|
202
205
|
});
|
|
203
206
|
|
|
204
207
|
if (res.status === 429) {
|