@fluojs/http 1.0.0 → 1.1.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.ko.md +53 -10
- package/README.md +53 -10
- package/dist/context/sse.d.ts +20 -0
- package/dist/context/sse.d.ts.map +1 -1
- package/dist/context/sse.js +19 -0
- package/dist/decorators.d.ts +10 -0
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +20 -3
- package/dist/dispatch/dispatcher.d.ts.map +1 -1
- package/dist/dispatch/dispatcher.js +138 -2
- package/dist/dispatch/fast-path/eligibility-checker.d.ts +22 -0
- package/dist/dispatch/fast-path/eligibility-checker.d.ts.map +1 -1
- package/dist/dispatch/fast-path/eligibility-checker.js +32 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +4 -4
package/README.ko.md
CHANGED
|
@@ -105,16 +105,59 @@ function someDeepHelper() {
|
|
|
105
105
|
### 서버 전송 이벤트
|
|
106
106
|
|
|
107
107
|
```ts
|
|
108
|
-
import {
|
|
108
|
+
import { Controller, Sse, type SseMessage } from '@fluojs/http';
|
|
109
|
+
|
|
110
|
+
@Controller('/orders')
|
|
111
|
+
export class OrdersEventsController {
|
|
112
|
+
@Sse('/events')
|
|
113
|
+
async *stream(): AsyncIterable<SseMessage<{ status: string }> | { heartbeat: true }> {
|
|
114
|
+
yield { data: { status: 'connected' }, event: 'ready', id: 'orders-ready' };
|
|
115
|
+
|
|
116
|
+
while (true) {
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
118
|
+
yield { heartbeat: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`@Sse(path)`는 `GET` 라우트를 등록하고 `text/event-stream` produced media type metadata를 선언합니다. Handler는 수동 stream 제어가 필요하면 `SseResponse`를 반환할 수 있고, managed streaming이 필요하면 `AsyncIterable<SseMessage<T> | T>`를 반환할 수 있습니다. Managed async iterable은 `SseResponse`와 같은 `encodeSseMessage(...)` 동작으로 변환됩니다. 일반 yield 값은 `data:` frame이 되고, `data` 필드가 있는 객체는 `event`, `id`, `retry`도 함께 제공할 수 있습니다. Dispatcher는 `RequestContext.request.signal`이 abort되거나 response stream이 닫히면 source 소비를 중단하고, write가 backpressure를 보고하면 `FrameworkResponseStream.waitForDrain()`을 기다리며, 완료 또는 source error 시 stream을 닫고, source에서 던진 오류는 이미 commit된 SSE response를 닫은 뒤 일반 dispatcher error/observer seam으로 전달합니다. Observable 값은 계속 범위 밖이며 RxJS dependency는 필요하지 않습니다.
|
|
125
|
+
|
|
126
|
+
브라우저 쪽에서는 해당 연결을 소유하는 React effect 안에서 `EventSource`를 만들고 cleanup 함수에서 항상 닫아야 합니다. 그래야 route 변경, Strict Mode remount, component unmount가 중복 stream을 남기지 않습니다.
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
import { useEffect, useState } from 'react';
|
|
130
|
+
|
|
131
|
+
export function OrderEvents({ orderId }: { orderId: string }) {
|
|
132
|
+
const [events, setEvents] = useState<string[]>([]);
|
|
109
133
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
const source = new EventSource(`/orders/events?orderId=${encodeURIComponent(orderId)}`, {
|
|
136
|
+
withCredentials: true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
source.addEventListener('ready', (event) => {
|
|
140
|
+
setEvents((current) => [...current, event.data]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
source.onerror = () => {
|
|
144
|
+
// 서버가 terminal status로 닫지 않는 한 브라우저가 자동으로 재연결합니다.
|
|
145
|
+
console.warn('Order event stream disconnected; waiting for browser retry.');
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
source.close();
|
|
150
|
+
};
|
|
151
|
+
}, [orderId]);
|
|
152
|
+
|
|
153
|
+
return <output>{events.join('\n')}</output>;
|
|
115
154
|
}
|
|
116
155
|
```
|
|
117
156
|
|
|
157
|
+
브라우저 `EventSource`는 호출자가 임의의 `Authorization` 헤더를 붙일 수 없습니다. SSE 엔드포인트는 same-origin cookie, `withCredentials`와 명시적인 CORS credentials 정책, 또는 guard가 검증하는 짧은 수명의 signed URL/query token으로 인증하세요. 내장 `EventSource` API가 아니라 fetch 기반 custom SSE client를 쓰는 경우가 아니라면 bearer header 브라우저 예제를 문서화하지 마세요.
|
|
158
|
+
|
|
159
|
+
운영 환경에서는 SSE 연결을 buffering 없이 오래 유지해야 합니다. 신뢰한 origin에 대해서만 CORS credentials를 허용하고, proxy buffering과 response transform을 비활성화하며(`SseResponse`는 `Cache-Control: no-cache, no-transform` 및 `X-Accel-Buffering: no`를 설정합니다), `text/event-stream`을 buffering하는 compression middleware를 피하고, load balancer 또는 platform idle timeout을 heartbeat interval보다 길게 두고, `sse.comment('heartbeat')` 같은 comment heartbeat를 보내며, 클라이언트가 재연결 후 replay가 필요할 때 `Last-Event-ID`를 처리할 수 있도록 충분한 event history를 보존하세요.
|
|
160
|
+
|
|
118
161
|
### Versioning
|
|
119
162
|
|
|
120
163
|
`createHandlerMapping(...)`은 `VersioningType`과 `versioning` option을 통해 URI, header, media-type, custom versioning strategy를 지원합니다. Route registration은 exact/static match를 fallback보다 앞에 두고, 동등하게 정규화된 route는 registration order를 보존합니다.
|
|
@@ -141,14 +184,14 @@ Fluo의 HTTP 데코레이터는 TC39 표준 데코레이터이며, runtime 또
|
|
|
141
184
|
|
|
142
185
|
## 공개 API
|
|
143
186
|
|
|
144
|
-
- **라우팅 데코레이터**: `Controller`, `Get`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head`
|
|
187
|
+
- **라우팅 데코레이터**: `Controller`, `Get`, `Sse`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head`
|
|
145
188
|
- **바인딩 데코레이터**: `FromBody`, `FromQuery`, `FromPath`, `FromHeader`, `FromCookie`, `RequestDto`, `Optional`, `Convert`
|
|
146
189
|
- **실행 데코레이터**: `UseGuards`, `UseInterceptors`, `HttpCode`, `Version`, `Header`, `Redirect`, `Produces`
|
|
147
|
-
- **핵심 런타임 타입**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse`
|
|
190
|
+
- **핵심 런타임 타입**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse`, `SseMessage`, `Middleware`, `MiddlewareContext`, `MiddlewareRouteConfig`, `Next`, `Guard`, `GuardContext`, `Interceptor`, `InterceptorContext`, `CallHandler`, `RequestObserver`, `DispatcherLogger`
|
|
148
191
|
- **Adapter API**: `HttpApplicationAdapter`, `createNoopHttpApplicationAdapter`, `createServerBackedHttpAdapterRealtimeCapability`, `createUnsupportedHttpAdapterRealtimeCapability`, `createFetchStyleHttpAdapterRealtimeCapability`
|
|
149
192
|
- **예외와 오류**: `HttpException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `NotAcceptableException`, `TooManyRequestsException`, `InternalServerErrorException`, `PayloadTooLargeException`, `createErrorResponse`, `RouteConflictError`, `InvalidRoutePathError`, `HandlerNotFoundError`, `RequestAbortedError`
|
|
150
|
-
- **헬퍼**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage`
|
|
151
|
-
- **Option type**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `SecurityHeadersOptions`, `SseSendOptions`
|
|
193
|
+
- **헬퍼**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createMemoryRateLimitStore`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage`, `isSseMessage`
|
|
194
|
+
- **Option 및 store type**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `RateLimitStoreEntry`, `SecurityHeadersOptions`, `SseSendOptions`
|
|
152
195
|
|
|
153
196
|
## 내부 서브경로 (`@fluojs/http/internal`)
|
|
154
197
|
|
package/README.md
CHANGED
|
@@ -107,16 +107,59 @@ function someDeepHelper() {
|
|
|
107
107
|
### Server-sent events
|
|
108
108
|
|
|
109
109
|
```ts
|
|
110
|
-
import {
|
|
110
|
+
import { Controller, Sse, type SseMessage } from '@fluojs/http';
|
|
111
|
+
|
|
112
|
+
@Controller('/orders')
|
|
113
|
+
export class OrdersEventsController {
|
|
114
|
+
@Sse('/events')
|
|
115
|
+
async *stream(): AsyncIterable<SseMessage<{ status: string }> | { heartbeat: true }> {
|
|
116
|
+
yield { data: { status: 'connected' }, event: 'ready', id: 'orders-ready' };
|
|
117
|
+
|
|
118
|
+
while (true) {
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
120
|
+
yield { heartbeat: true };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`@Sse(path)` registers a `GET` route and declares `text/event-stream` produced media type metadata. Handlers may either return `SseResponse` for manual stream control or return `AsyncIterable<SseMessage<T> | T>` for managed streaming. Managed async iterables are converted with the same `encodeSseMessage(...)` behavior as `SseResponse`: plain yielded values become `data:` frames, while yielded objects with a `data` field may also provide `event`, `id`, and `retry`. The dispatcher stops consuming the source when `RequestContext.request.signal` aborts or the response stream closes, calls `FrameworkResponseStream.waitForDrain()` when a write reports backpressure, closes the stream on completion or source errors, and routes thrown source errors through the normal dispatcher error/observer seam after the already-committed SSE response is closed. Observable values remain out of scope and no RxJS dependency is required.
|
|
127
|
+
|
|
128
|
+
On the browser side, create the `EventSource` inside the React effect that owns it and always close it from the cleanup function so route changes, Strict Mode remounts, and component unmounts do not leave duplicate streams open:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { useEffect, useState } from 'react';
|
|
132
|
+
|
|
133
|
+
export function OrderEvents({ orderId }: { orderId: string }) {
|
|
134
|
+
const [events, setEvents] = useState<string[]>([]);
|
|
111
135
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const source = new EventSource(`/orders/events?orderId=${encodeURIComponent(orderId)}`, {
|
|
138
|
+
withCredentials: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
source.addEventListener('ready', (event) => {
|
|
142
|
+
setEvents((current) => [...current, event.data]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
source.onerror = () => {
|
|
146
|
+
// Browsers reconnect automatically unless the server closes with a terminal status.
|
|
147
|
+
console.warn('Order event stream disconnected; waiting for browser retry.');
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
source.close();
|
|
152
|
+
};
|
|
153
|
+
}, [orderId]);
|
|
154
|
+
|
|
155
|
+
return <output>{events.join('\n')}</output>;
|
|
117
156
|
}
|
|
118
157
|
```
|
|
119
158
|
|
|
159
|
+
Browser `EventSource` does not let callers attach arbitrary `Authorization` headers. Authenticate SSE endpoints with same-origin cookies, `withCredentials` plus explicit CORS credentials policy, or a short-lived signed URL/query token that your guard validates. Do not document a bearer-header browser example unless you are using a custom fetch-based SSE client instead of the built-in `EventSource` API.
|
|
160
|
+
|
|
161
|
+
Operationally, keep SSE connections unbuffered and long-lived: allow credentials in CORS only for trusted origins, disable proxy buffering and response transforms (`SseResponse` sets `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no`), avoid compression middleware that buffers `text/event-stream`, set load balancer or platform idle timeouts above your heartbeat interval, send comment heartbeats such as `sse.comment('heartbeat')`, and persist enough event history to honor `Last-Event-ID` when clients reconnect and need replay.
|
|
162
|
+
|
|
120
163
|
### Versioning
|
|
121
164
|
|
|
122
165
|
`createHandlerMapping(...)` supports URI, header, media-type, and custom versioning strategies through `VersioningType` and the `versioning` option. Route registration keeps exact/static matches ahead of fallbacks while preserving registration order for equivalent normalized routes.
|
|
@@ -143,14 +186,14 @@ Adapters should pass an `AbortSignal` on `FrameworkRequest.signal` when the plat
|
|
|
143
186
|
|
|
144
187
|
## Public API
|
|
145
188
|
|
|
146
|
-
- **Routing decorators**: `Controller`, `Get`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head`
|
|
189
|
+
- **Routing decorators**: `Controller`, `Get`, `Sse`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head`
|
|
147
190
|
- **Binding decorators**: `FromBody`, `FromQuery`, `FromPath`, `FromHeader`, `FromCookie`, `RequestDto`, `Optional`, `Convert`
|
|
148
191
|
- **Execution decorators**: `UseGuards`, `UseInterceptors`, `HttpCode`, `Version`, `Header`, `Redirect`, `Produces`
|
|
149
|
-
- **Core runtime types**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse`
|
|
192
|
+
- **Core runtime types**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse`, `SseMessage`, `Middleware`, `MiddlewareContext`, `MiddlewareRouteConfig`, `Next`, `Guard`, `GuardContext`, `Interceptor`, `InterceptorContext`, `CallHandler`, `RequestObserver`, `DispatcherLogger`
|
|
150
193
|
- **Adapter API**: `HttpApplicationAdapter`, `createNoopHttpApplicationAdapter`, `createServerBackedHttpAdapterRealtimeCapability`, `createUnsupportedHttpAdapterRealtimeCapability`, `createFetchStyleHttpAdapterRealtimeCapability`
|
|
151
194
|
- **Exceptions and errors**: `HttpException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `NotAcceptableException`, `TooManyRequestsException`, `InternalServerErrorException`, `PayloadTooLargeException`, `createErrorResponse`, `RouteConflictError`, `InvalidRoutePathError`, `HandlerNotFoundError`, `RequestAbortedError`
|
|
152
|
-
- **Helpers**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage`
|
|
153
|
-
- **Option types**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `SecurityHeadersOptions`, `SseSendOptions`
|
|
195
|
+
- **Helpers**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createMemoryRateLimitStore`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage`, `isSseMessage`
|
|
196
|
+
- **Option and store types**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `RateLimitStoreEntry`, `SecurityHeadersOptions`, `SseSendOptions`
|
|
154
197
|
|
|
155
198
|
## Internal Subpath (`@fluojs/http/internal`)
|
|
156
199
|
|
package/dist/context/sse.d.ts
CHANGED
|
@@ -8,6 +8,26 @@ export interface SseSendOptions {
|
|
|
8
8
|
/** Optional client retry delay in milliseconds. Non-finite or negative values are ignored. */
|
|
9
9
|
retry?: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Structured message emitted by a managed `@Sse()` async iterable handler.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Returning `AsyncIterable<SseMessage<T> | T>` from an `@Sse()` route lets the
|
|
16
|
+
* dispatcher write each item as an SSE frame. Plain values become `data:`
|
|
17
|
+
* frames. Objects with a `data` field plus optional `event`, `id`, or `retry`
|
|
18
|
+
* fields use those SSE metadata fields for the frame.
|
|
19
|
+
*/
|
|
20
|
+
export interface SseMessage<T = unknown> extends SseSendOptions {
|
|
21
|
+
/** Payload encoded into the event frame's `data:` lines. */
|
|
22
|
+
data: T;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Runtime guard for structured managed SSE messages.
|
|
26
|
+
*
|
|
27
|
+
* @param value Candidate value yielded by an async iterable SSE source.
|
|
28
|
+
* @returns `true` when the value follows the public `SseMessage<T>` shape.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isSseMessage<T = unknown>(value: unknown): value is SseMessage<T>;
|
|
11
31
|
/**
|
|
12
32
|
* Encodes a comment as a canonical server-sent event comment frame.
|
|
13
33
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/context/sse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA8C,cAAc,EAAE,MAAM,aAAa,CAAC;AAE9F,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,8FAA8F;IAC9F,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAoCD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKxD;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,MAAM,CAoBpF;AAED;;;;;GAKG;AACH,qBAAa,WAAW;IASV,OAAO,CAAC,QAAQ,CAAC,OAAO;IARpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,mBAAmB,CAAC,CAAa;IAEzC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAEtB;gBAE2B,OAAO,EAAE,cAAc;IAmCpD;;;;;;OAMG;IACH,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO;IAI1D;;;;;OAKG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAIjC,uFAAuF;IACvF,KAAK,IAAI,IAAI;IAiBb,OAAO,CAAC,UAAU;CAYnB"}
|
|
1
|
+
{"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/context/sse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA8C,cAAc,EAAE,MAAM,aAAa,CAAC;AAE9F,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,8FAA8F;IAC9F,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,cAAc;IAC7D,4DAA4D;IAC5D,IAAI,EAAE,CAAC,CAAC;CACT;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAAC,CAAC,CAAC,CAEhF;AAoCD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKxD;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,MAAM,CAoBpF;AAED;;;;;GAKG;AACH,qBAAa,WAAW;IASV,OAAO,CAAC,QAAQ,CAAC,OAAO;IARpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,mBAAmB,CAAC,CAAa;IAEzC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAEtB;gBAE2B,OAAO,EAAE,cAAc;IAmCpD;;;;;;OAMG;IACH,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO;IAI1D;;;;;OAKG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAIjC,uFAAuF;IACvF,KAAK,IAAI,IAAI;IAiBb,OAAO,CAAC,UAAU;CAYnB"}
|
package/dist/context/sse.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
/** Options that customize the fields emitted for one server-sent event frame. */
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Structured message emitted by a managed `@Sse()` async iterable handler.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Returning `AsyncIterable<SseMessage<T> | T>` from an `@Sse()` route lets the
|
|
8
|
+
* dispatcher write each item as an SSE frame. Plain values become `data:`
|
|
9
|
+
* frames. Objects with a `data` field plus optional `event`, `id`, or `retry`
|
|
10
|
+
* fields use those SSE metadata fields for the frame.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runtime guard for structured managed SSE messages.
|
|
15
|
+
*
|
|
16
|
+
* @param value Candidate value yielded by an async iterable SSE source.
|
|
17
|
+
* @returns `true` when the value follows the public `SseMessage<T>` shape.
|
|
18
|
+
*/
|
|
19
|
+
export function isSseMessage(value) {
|
|
20
|
+
return typeof value === 'object' && value !== null && 'data' in value;
|
|
21
|
+
}
|
|
3
22
|
function sanitizeSseField(value) {
|
|
4
23
|
return value.replace(/\r/g, '').replace(/\n/g, '');
|
|
5
24
|
}
|
package/dist/decorators.d.ts
CHANGED
|
@@ -28,6 +28,16 @@ export declare function Version(version: string): ClassOrMethodDecoratorLike;
|
|
|
28
28
|
* @returns A method decorator that registers a `GET` handler mapping.
|
|
29
29
|
*/
|
|
30
30
|
export declare const Get: (path: string) => MethodDecoratorLike;
|
|
31
|
+
/**
|
|
32
|
+
* Registers a server-sent events route handler as `GET` with `text/event-stream` produces metadata.
|
|
33
|
+
*
|
|
34
|
+
* @param path Route path relative to the controller base path.
|
|
35
|
+
* @returns A method decorator that registers a `GET` SSE handler mapping.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Handlers may return `SseResponse` for manual control or `AsyncIterable<SseMessage<T> | T>` for managed dispatcher streaming.
|
|
39
|
+
*/
|
|
40
|
+
export declare const Sse: (path: string) => MethodDecoratorLike;
|
|
31
41
|
/**
|
|
32
42
|
* Registers a `POST` route handler.
|
|
33
43
|
*
|
package/dist/decorators.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,mBAAmB,EAEzB,MAAM,cAAc,CAAC;AAgBtB,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAc,eAAe,EAAE,MAAM,YAAY,CAAC;AAGxF,KAAK,wBAAwB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAC1F,KAAK,yBAAyB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,2BAA2B,KAAK,IAAI,CAAC;AACjG,KAAK,wBAAwB,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,0BAA0B,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,IAAI,CAAC;AAI1H,KAAK,kBAAkB,GAAG,wBAAwB,CAAC;AACnD,KAAK,mBAAmB,GAAG,yBAAyB,CAAC;AACrD,KAAK,0BAA0B,GAAG,wBAAwB,GAAG,yBAAyB,CAAC;AACvF,KAAK,kBAAkB,GAAG,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"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"}
|
package/dist/decorators.js
CHANGED
|
@@ -184,7 +184,7 @@ function mergeStandardDtoBinding(metadata, propertyKey, partial) {
|
|
|
184
184
|
...partial
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
|
-
function createRouteDecorator(method) {
|
|
187
|
+
function createRouteDecorator(method, produces) {
|
|
188
188
|
return path => {
|
|
189
189
|
validateRoutePath(path, `@${method}() path`);
|
|
190
190
|
const decorator = (valueOrTarget, contextOrPropertyKey) => {
|
|
@@ -192,13 +192,20 @@ function createRouteDecorator(method) {
|
|
|
192
192
|
const route = getStandardRouteRecord(contextOrPropertyKey.metadata, contextOrPropertyKey.name);
|
|
193
193
|
route.method = method;
|
|
194
194
|
route.path = path;
|
|
195
|
+
if (produces) {
|
|
196
|
+
route.produces = normalizeProducesMediaTypes(produces);
|
|
197
|
+
}
|
|
195
198
|
return;
|
|
196
199
|
}
|
|
197
200
|
if (isMetadataPropertyKey(contextOrPropertyKey)) {
|
|
198
|
-
|
|
201
|
+
const routeMetadata = {
|
|
199
202
|
method,
|
|
200
203
|
path
|
|
201
|
-
}
|
|
204
|
+
};
|
|
205
|
+
if (produces) {
|
|
206
|
+
routeMetadata.produces = normalizeProducesMediaTypes(produces);
|
|
207
|
+
}
|
|
208
|
+
mergeLegacyRouteMetadata(valueOrTarget, contextOrPropertyKey, routeMetadata);
|
|
202
209
|
}
|
|
203
210
|
};
|
|
204
211
|
return decorator;
|
|
@@ -299,6 +306,16 @@ export function Version(version) {
|
|
|
299
306
|
* @returns A method decorator that registers a `GET` handler mapping.
|
|
300
307
|
*/
|
|
301
308
|
export const Get = createRouteDecorator('GET');
|
|
309
|
+
/**
|
|
310
|
+
* Registers a server-sent events route handler as `GET` with `text/event-stream` produces metadata.
|
|
311
|
+
*
|
|
312
|
+
* @param path Route path relative to the controller base path.
|
|
313
|
+
* @returns A method decorator that registers a `GET` SSE handler mapping.
|
|
314
|
+
*
|
|
315
|
+
* @remarks
|
|
316
|
+
* Handlers may return `SseResponse` for manual control or `AsyncIterable<SseMessage<T> | T>` for managed dispatcher streaming.
|
|
317
|
+
*/
|
|
318
|
+
export const Sse = createRouteDecorator('GET', ['text/event-stream']);
|
|
302
319
|
/**
|
|
303
320
|
* Registers a `POST` route handler.
|
|
304
321
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/dispatch/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,YAAY,CAAC;AAQnE,OAAO,KAAK,EACV,MAAM,EACN,yBAAyB,EACzB,aAAa,EACb,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,
|
|
1
|
+
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/dispatch/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,YAAY,CAAC;AAQnE,OAAO,KAAK,EACV,MAAM,EACN,yBAAyB,EACzB,aAAa,EACb,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EAKjB,cAAc,EAEd,eAAe,EAEf,cAAc,EAId,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAKrB,OAAO,EAKL,KAAK,aAAa,EAOnB,MAAM,sBAAsB,CAAC;AAE9B,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC/E,OAAO,EAAE,4BAA4B,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAE5F,gEAAgE;AAChE,MAAM,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,iBAAiB,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,OAAO,GAAG,IAAI,CAAC;AAEpK,uDAAuD;AACvD,MAAM,WAAW,uBAAuB;IACtC,iDAAiD;IACjD,aAAa,CAAC,EAAE,cAAc,EAAE,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,kBAAkB,CAAC,EAAE,yBAAyB,CAAC;IAC/C,sDAAsD;IACtD,cAAc,EAAE,cAAc,CAAC;IAC/B,2DAA2D;IAC3D,YAAY,CAAC,EAAE,eAAe,EAAE,CAAC;IACjC,0DAA0D;IAC1D,SAAS,CAAC,EAAE,mBAAmB,EAAE,CAAC;IAClC,+DAA+D;IAC/D,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,qCAAqC;IACrC,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,sEAAsE;IACtE,YAAY,CAAC,EAAE;QACb,wDAAwD;QACxD,oBAAoB,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;KACjD,CAAC;IACF,qDAAqD;IACrD,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,qDAAqD;IACrD,aAAa,EAAE,SAAS,CAAC;IACzB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAy8BD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,UAAU,CAuF7E;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,UAAU,GAAG,aAAa,GAAG,SAAS,CAE5F;AAED,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getCompiledDtoBindingPlan } from '../adapters/dto-binding-plan.js';
|
|
2
2
|
import { createRequestContext, runWithRequestContext } from '../context/request-context.js';
|
|
3
|
-
import { SseResponse } from '../context/sse.js';
|
|
3
|
+
import { SseResponse, isSseMessage } from '../context/sse.js';
|
|
4
4
|
import { RequestAbortedError } from '../errors.js';
|
|
5
5
|
import { runGuardChain } from '../guards.js';
|
|
6
6
|
import { runInterceptorChain } from '../interceptors.js';
|
|
@@ -255,6 +255,140 @@ function ensureRequestNotAborted(request) {
|
|
|
255
255
|
function isRequestAborted(request) {
|
|
256
256
|
return request.isAborted?.() ?? request.signal?.aborted === true;
|
|
257
257
|
}
|
|
258
|
+
function isSseRoute(handler) {
|
|
259
|
+
return handler.route.produces?.some(mediaType => mediaType.toLowerCase().startsWith('text/event-stream')) === true;
|
|
260
|
+
}
|
|
261
|
+
function isAsyncIterable(value) {
|
|
262
|
+
return typeof value === 'object' && value !== null && Symbol.asyncIterator in value && typeof value[Symbol.asyncIterator] === 'function';
|
|
263
|
+
}
|
|
264
|
+
function createAbortPromise(request) {
|
|
265
|
+
if (!request.signal) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
if (request.signal.aborted) {
|
|
269
|
+
return {
|
|
270
|
+
cleanup: () => undefined,
|
|
271
|
+
promise: Promise.resolve('aborted')
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
let listener;
|
|
275
|
+
const promise = new Promise(resolve => {
|
|
276
|
+
listener = () => resolve('aborted');
|
|
277
|
+
request.signal?.addEventListener('abort', listener, {
|
|
278
|
+
once: true
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
cleanup: () => {
|
|
283
|
+
if (listener) {
|
|
284
|
+
request.signal?.removeEventListener('abort', listener);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
promise
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function createStreamClosePromise(stream) {
|
|
291
|
+
if (stream.closed) {
|
|
292
|
+
return {
|
|
293
|
+
cleanup: () => undefined,
|
|
294
|
+
promise: Promise.resolve('aborted')
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (!stream.onClose) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
let cleanup;
|
|
301
|
+
const promise = new Promise(resolve => {
|
|
302
|
+
cleanup = stream.onClose?.(() => resolve('aborted')) ?? undefined;
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
cleanup: () => {
|
|
306
|
+
cleanup?.();
|
|
307
|
+
},
|
|
308
|
+
promise
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function createManagedSseStopPromise(request, stream) {
|
|
312
|
+
const stops = [createAbortPromise(request), createStreamClosePromise(stream)].filter(entry => entry !== undefined);
|
|
313
|
+
if (stops.length === 0) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
cleanup: () => {
|
|
318
|
+
for (const stop of stops) {
|
|
319
|
+
stop.cleanup();
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
promise: Promise.race(stops.map(stop => stop.promise))
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function resolveManagedSseFrame(value) {
|
|
326
|
+
if (isSseMessage(value)) {
|
|
327
|
+
const {
|
|
328
|
+
data,
|
|
329
|
+
event,
|
|
330
|
+
id,
|
|
331
|
+
retry
|
|
332
|
+
} = value;
|
|
333
|
+
return {
|
|
334
|
+
data,
|
|
335
|
+
options: {
|
|
336
|
+
event,
|
|
337
|
+
id,
|
|
338
|
+
retry
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
data: value,
|
|
344
|
+
options: {}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function closeAsyncIteratorEventually(iterator) {
|
|
348
|
+
void iterator.return?.().catch(() => undefined);
|
|
349
|
+
}
|
|
350
|
+
async function readManagedSseNext(request, stream, iterator) {
|
|
351
|
+
const abort = createManagedSseStopPromise(request, stream);
|
|
352
|
+
if (!abort) {
|
|
353
|
+
return iterator.next();
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return await Promise.race([iterator.next(), abort.promise]);
|
|
357
|
+
} finally {
|
|
358
|
+
abort.cleanup();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function writeManagedSseIterable(handler, requestContext, source) {
|
|
362
|
+
if (!isSseRoute(handler)) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
const sse = new SseResponse(requestContext);
|
|
366
|
+
const stream = requestContext.response.stream;
|
|
367
|
+
if (!stream) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
371
|
+
try {
|
|
372
|
+
while (!isRequestAborted(requestContext.request)) {
|
|
373
|
+
const next = await readManagedSseNext(requestContext.request, stream, iterator);
|
|
374
|
+
if (next === 'aborted') {
|
|
375
|
+
closeAsyncIteratorEventually(iterator);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
if (next.done === true) {
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
const frame = resolveManagedSseFrame(next.value);
|
|
382
|
+
const accepted = sse.send(frame.data, frame.options);
|
|
383
|
+
if (!accepted) {
|
|
384
|
+
await requestContext.response.stream?.waitForDrain?.();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} finally {
|
|
388
|
+
sse.close();
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
258
392
|
function resolveFastPathHandlerRuntimeCache(handler, cache) {
|
|
259
393
|
const cached = cache.get(handler);
|
|
260
394
|
if (cached) {
|
|
@@ -335,7 +469,9 @@ async function dispatchMatchedHandler(handler, executionPlan, requestContext, co
|
|
|
335
469
|
requestContext
|
|
336
470
|
}, async () => invokeControllerHandler(handler, requestContext, binder, controllerContainer));
|
|
337
471
|
ensureRequestNotAborted(requestContext.request);
|
|
338
|
-
if (
|
|
472
|
+
if (isAsyncIterable(result) && (await writeManagedSseIterable(handler, requestContext, result))) {
|
|
473
|
+
// Managed SSE streams are already committed and closed by writeManagedSseIterable.
|
|
474
|
+
} else if (!(result instanceof SseResponse) && !requestContext.response.committed) {
|
|
339
475
|
await writeSuccessResponse(handler, requestContext.request, requestContext.response, result, contentNegotiation);
|
|
340
476
|
}
|
|
341
477
|
await notifyObserversSafely(observers, requestContext, async (observer, context) => {
|
|
@@ -6,13 +6,35 @@ interface CompiledEligibilityPlan {
|
|
|
6
6
|
eligibility: FastPathEligibility;
|
|
7
7
|
isEligible: boolean;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Compiles the conservative fast-path eligibility decision for one handler.
|
|
11
|
+
*
|
|
12
|
+
* @param handler Handler descriptor being analyzed.
|
|
13
|
+
* @param options Dispatcher options that can introduce full-path requirements.
|
|
14
|
+
* @param adapter Human-readable adapter label used in observability metadata.
|
|
15
|
+
* @returns The compiled eligibility metadata and boolean eligibility flag.
|
|
16
|
+
*/
|
|
9
17
|
export declare function compileFastPathEligibility(handler: HandlerDescriptor, options: CreateDispatcherOptions, adapter: string): CompiledEligibilityPlan;
|
|
18
|
+
/**
|
|
19
|
+
* Reads fast-path eligibility metadata attached to a handler descriptor.
|
|
20
|
+
*
|
|
21
|
+
* @param handler Handler descriptor previously analyzed by the dispatcher.
|
|
22
|
+
* @returns The attached eligibility metadata, when present.
|
|
23
|
+
*/
|
|
10
24
|
export declare function getHandlerFastPathEligibility(handler: HandlerDescriptor): FastPathEligibility | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Attaches fast-path eligibility metadata to a handler descriptor.
|
|
27
|
+
*
|
|
28
|
+
* @param handler Handler descriptor to annotate.
|
|
29
|
+
* @param eligibility Eligibility metadata to expose through dispatcher observability.
|
|
30
|
+
*/
|
|
11
31
|
export declare function setHandlerFastPathEligibility(handler: HandlerDescriptor, eligibility: FastPathEligibility): void;
|
|
32
|
+
/** Options shared by fast-path executor helpers. */
|
|
12
33
|
export interface FastPathExecutorOptions {
|
|
13
34
|
binder?: Binder;
|
|
14
35
|
rootContainer: Container;
|
|
15
36
|
}
|
|
37
|
+
/** Result returned after attempting fast-path handler execution. */
|
|
16
38
|
export interface FastPathExecutionResult {
|
|
17
39
|
executed: boolean;
|
|
18
40
|
result?: unknown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"eligibility-checker.d.ts","sourceRoot":"","sources":["../../../src/dispatch/fast-path/eligibility-checker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,OAAO,KAAK,EACV,MAAM,EACN,iBAAiB,EAElB,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,KAAK,mBAAmB,EAAgC,MAAM,kBAAkB,CAAC;AAM1F,UAAU,uBAAuB;IAC/B,WAAW,EAAE,mBAAmB,CAAC;IACjC,UAAU,EAAE,OAAO,CAAC;CACrB;AA0DD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,uBAAuB,EAChC,OAAO,EAAE,MAAM,GACd,uBAAuB,
|
|
1
|
+
{"version":3,"file":"eligibility-checker.d.ts","sourceRoot":"","sources":["../../../src/dispatch/fast-path/eligibility-checker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,OAAO,KAAK,EACV,MAAM,EACN,iBAAiB,EAElB,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,KAAK,mBAAmB,EAAgC,MAAM,kBAAkB,CAAC;AAM1F,UAAU,uBAAuB;IAC/B,WAAW,EAAE,mBAAmB,CAAC;IACjC,UAAU,EAAE,OAAO,CAAC;CACrB;AA0DD;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,uBAAuB,EAChC,OAAO,EAAE,MAAM,GACd,uBAAuB,CAiEzB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,iBAAiB,GACzB,mBAAmB,GAAG,SAAS,CAIjC;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,mBAAmB,GAC/B,IAAI,CAGN;AAED,oDAAoD;AACpD,MAAM,WAAW,uBAAuB;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,SAAS,CAAC;CAC1B;AAED,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB"}
|
|
@@ -41,6 +41,15 @@ function determineMiddlewareRequirement(handler, appMiddleware) {
|
|
|
41
41
|
const moduleMiddleware = handler.metadata.moduleMiddleware;
|
|
42
42
|
return moduleMiddleware !== undefined && moduleMiddleware.length > 0;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compiles the conservative fast-path eligibility decision for one handler.
|
|
47
|
+
*
|
|
48
|
+
* @param handler Handler descriptor being analyzed.
|
|
49
|
+
* @param options Dispatcher options that can introduce full-path requirements.
|
|
50
|
+
* @param adapter Human-readable adapter label used in observability metadata.
|
|
51
|
+
* @returns The compiled eligibility metadata and boolean eligibility flag.
|
|
52
|
+
*/
|
|
44
53
|
export function compileFastPathEligibility(handler, options, adapter) {
|
|
45
54
|
const routeId = `${handler.route.method}:${handler.route.path}`;
|
|
46
55
|
const hasGuard = (handler.route.guards?.length ?? 0) > 0;
|
|
@@ -49,6 +58,7 @@ export function compileFastPathEligibility(handler, options, adapter) {
|
|
|
49
58
|
const hasRequestScopedDI = determineRequestScopeRequirement(handler, options);
|
|
50
59
|
const hasMiddleware = determineMiddlewareRequirement(handler, options.appMiddleware ?? []);
|
|
51
60
|
const hasContentNegotiation = options.contentNegotiation?.formatters !== undefined && options.contentNegotiation.formatters.length > 0;
|
|
61
|
+
const isSseRoute = handler.route.produces?.some(mediaType => mediaType.toLowerCase().startsWith('text/event-stream')) === true;
|
|
52
62
|
const eligibility = {
|
|
53
63
|
adapter,
|
|
54
64
|
executionPath: 'full',
|
|
@@ -88,6 +98,9 @@ export function compileFastPathEligibility(handler, options, adapter) {
|
|
|
88
98
|
if (hasContentNegotiation) {
|
|
89
99
|
blockingReasons.push('content negotiation');
|
|
90
100
|
}
|
|
101
|
+
if (isSseRoute) {
|
|
102
|
+
blockingReasons.push('SSE streaming');
|
|
103
|
+
}
|
|
91
104
|
const isEligible = blockingReasons.length === 0;
|
|
92
105
|
if (!isEligible) {
|
|
93
106
|
eligibility.fallbackReason = `Full path required due to: ${blockingReasons.join(', ')}`;
|
|
@@ -99,9 +112,27 @@ export function compileFastPathEligibility(handler, options, adapter) {
|
|
|
99
112
|
isEligible
|
|
100
113
|
};
|
|
101
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reads fast-path eligibility metadata attached to a handler descriptor.
|
|
118
|
+
*
|
|
119
|
+
* @param handler Handler descriptor previously analyzed by the dispatcher.
|
|
120
|
+
* @returns The attached eligibility metadata, when present.
|
|
121
|
+
*/
|
|
102
122
|
export function getHandlerFastPathEligibility(handler) {
|
|
103
123
|
return handler[FAST_PATH_ELIGIBILITY_SYMBOL];
|
|
104
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Attaches fast-path eligibility metadata to a handler descriptor.
|
|
128
|
+
*
|
|
129
|
+
* @param handler Handler descriptor to annotate.
|
|
130
|
+
* @param eligibility Eligibility metadata to expose through dispatcher observability.
|
|
131
|
+
*/
|
|
105
132
|
export function setHandlerFastPathEligibility(handler, eligibility) {
|
|
106
133
|
handler[FAST_PATH_ELIGIBILITY_SYMBOL] = eligibility;
|
|
107
|
-
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Options shared by fast-path executor helpers. */
|
|
137
|
+
|
|
138
|
+
/** Result returned after attempting fast-path handler execution. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from './adapter.js';
|
|
2
2
|
export * from './middleware/correlation.js';
|
|
3
3
|
export * from './middleware/cors.js';
|
|
4
|
-
export { All, Controller, Convert, Delete, FromBody, FromCookie, FromHeader, FromPath, FromQuery, Get, Head, Header, HttpCode, Optional, Options, Patch, Post, Produces, Put, Redirect, RequestDto, UseGuards, UseInterceptors, Version, } from './decorators.js';
|
|
4
|
+
export { All, Controller, Convert, Delete, FromBody, FromCookie, FromHeader, FromPath, FromQuery, Get, Head, Header, HttpCode, Optional, Options, Patch, Post, Produces, Put, Redirect, RequestDto, Sse, UseGuards, UseInterceptors, Version, } from './decorators.js';
|
|
5
5
|
export * from './dispatch/dispatcher.js';
|
|
6
6
|
export type { FastPathEligibility, FastPathStats } from './dispatch/fast-path/index.js';
|
|
7
7
|
export { FAST_PATH_ELIGIBILITY_SYMBOL, FAST_PATH_STATS_SYMBOL, formatFastPathStats, getDispatcherFastPathStats, } from './dispatch/dispatcher.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,sBAAsB,CAAC;AACrC,OAAO,EACL,GAAG,EACH,UAAU,EACV,OAAO,EACP,MAAM,EACN,QAAQ,EACR,UAAU,EACV,UAAU,EACV,QAAQ,EACR,SAAS,EACT,GAAG,EACH,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,KAAK,EACL,IAAI,EACJ,QAAQ,EACR,GAAG,EACH,QAAQ,EACR,UAAU,EACV,SAAS,EACT,eAAe,EACf,OAAO,GACR,MAAM,iBAAiB,CAAC;AACzB,cAAc,0BAA0B,CAAC;AACzC,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACxF,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,EACtB,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,0BAA0B,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,cAAc,CAAC;AAC7B,OAAO,EACL,SAAS,EACT,uBAAuB,EACvB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,4BAA4B,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,kCAAkC,CAAC;AACjD,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,sBAAsB,CAAC;AACrC,OAAO,EACL,GAAG,EACH,UAAU,EACV,OAAO,EACP,MAAM,EACN,QAAQ,EACR,UAAU,EACV,UAAU,EACV,QAAQ,EACR,SAAS,EACT,GAAG,EACH,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,KAAK,EACL,IAAI,EACJ,QAAQ,EACR,GAAG,EACH,QAAQ,EACR,UAAU,EACV,GAAG,EACH,SAAS,EACT,eAAe,EACf,OAAO,GACR,MAAM,iBAAiB,CAAC;AACzB,cAAc,0BAA0B,CAAC;AACzC,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACxF,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,EACtB,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,0BAA0B,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,cAAc,CAAC;AAC7B,OAAO,EACL,SAAS,EACT,uBAAuB,EACvB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,4BAA4B,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,kCAAkC,CAAC;AACjD,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from './adapter.js';
|
|
2
2
|
export * from './middleware/correlation.js';
|
|
3
3
|
export * from './middleware/cors.js';
|
|
4
|
-
export { All, Controller, Convert, Delete, FromBody, FromCookie, FromHeader, FromPath, FromQuery, Get, Head, Header, HttpCode, Optional, Options, Patch, Post, Produces, Put, Redirect, RequestDto, UseGuards, UseInterceptors, Version } from './decorators.js';
|
|
4
|
+
export { All, Controller, Convert, Delete, FromBody, FromCookie, FromHeader, FromPath, FromQuery, Get, Head, Header, HttpCode, Optional, Options, Patch, Post, Produces, Put, Redirect, RequestDto, Sse, UseGuards, UseInterceptors, Version } from './decorators.js';
|
|
5
5
|
export * from './dispatch/dispatcher.js';
|
|
6
6
|
export { FAST_PATH_ELIGIBILITY_SYMBOL, FAST_PATH_STATS_SYMBOL, formatFastPathStats, getDispatcherFastPathStats } from './dispatch/dispatcher.js';
|
|
7
7
|
export * from './errors.js';
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"controller",
|
|
11
11
|
"rest"
|
|
12
12
|
],
|
|
13
|
-
"version": "1.
|
|
13
|
+
"version": "1.1.0",
|
|
14
14
|
"private": false,
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"repository": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"dist"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@fluojs/core": "^1.0.
|
|
45
|
-
"@fluojs/validation": "^1.0.
|
|
46
|
-
"@fluojs/di": "^1.0.
|
|
44
|
+
"@fluojs/core": "^1.0.3",
|
|
45
|
+
"@fluojs/validation": "^1.0.4",
|
|
46
|
+
"@fluojs/di": "^1.0.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"vitest": "^3.2.4"
|