@fluojs/http
Advanced tools
@@ -12,2 +12,22 @@ import type { RequestContext } from '../types.js'; | ||
| /** | ||
| * Structured message emitted by a managed `@Sse()` async iterable handler. | ||
| * | ||
| * @remarks | ||
| * Returning `AsyncIterable<SseMessage<T> | T>` from an `@Sse()` route lets the | ||
| * dispatcher write each item as an SSE frame. Plain values become `data:` | ||
| * frames. Objects with a `data` field plus optional `event`, `id`, or `retry` | ||
| * fields use those SSE metadata fields for the frame. | ||
| */ | ||
| export interface SseMessage<T = unknown> extends SseSendOptions { | ||
| /** Payload encoded into the event frame's `data:` lines. */ | ||
| data: T; | ||
| } | ||
| /** | ||
| * Runtime guard for structured managed SSE messages. | ||
| * | ||
| * @param value Candidate value yielded by an async iterable SSE source. | ||
| * @returns `true` when the value follows the public `SseMessage<T>` shape. | ||
| */ | ||
| export declare function isSseMessage<T = unknown>(value: unknown): value is SseMessage<T>; | ||
| /** | ||
| * Encodes a comment as a canonical server-sent event comment frame. | ||
@@ -14,0 +34,0 @@ * |
@@ -1,1 +0,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"} | ||
| {"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"} |
+19
-0
| /** Options that customize the fields emitted for one server-sent event frame. */ | ||
| /** | ||
| * Structured message emitted by a managed `@Sse()` async iterable handler. | ||
| * | ||
| * @remarks | ||
| * Returning `AsyncIterable<SseMessage<T> | T>` from an `@Sse()` route lets the | ||
| * dispatcher write each item as an SSE frame. Plain values become `data:` | ||
| * frames. Objects with a `data` field plus optional `event`, `id`, or `retry` | ||
| * fields use those SSE metadata fields for the frame. | ||
| */ | ||
| /** | ||
| * Runtime guard for structured managed SSE messages. | ||
| * | ||
| * @param value Candidate value yielded by an async iterable SSE source. | ||
| * @returns `true` when the value follows the public `SseMessage<T>` shape. | ||
| */ | ||
| export function isSseMessage(value) { | ||
| return typeof value === 'object' && value !== null && 'data' in value; | ||
| } | ||
| function sanitizeSseField(value) { | ||
@@ -4,0 +23,0 @@ return value.replace(/\r/g, '').replace(/\n/g, ''); |
+10
-0
@@ -32,2 +32,12 @@ import { type Constructor, type MetadataPropertyKey } from '@fluojs/core'; | ||
| /** | ||
| * Registers a server-sent events route handler as `GET` with `text/event-stream` produces metadata. | ||
| * | ||
| * @param path Route path relative to the controller base path. | ||
| * @returns A method decorator that registers a `GET` SSE handler mapping. | ||
| * | ||
| * @remarks | ||
| * Handlers may return `SseResponse` for manual control or `AsyncIterable<SseMessage<T> | T>` for managed dispatcher streaming. | ||
| */ | ||
| export declare const Sse: (path: string) => MethodDecoratorLike; | ||
| /** | ||
| * Registers a `POST` route handler. | ||
@@ -34,0 +44,0 @@ * |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"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"} | ||
| {"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"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"} |
+20
-3
@@ -187,3 +187,3 @@ import { defineControllerMetadata, defineDtoFieldBindingMetadata, defineRouteMetadata, ensureMetadataSymbol, getControllerMetadata, getDtoFieldBindingMetadata, getRouteMetadata, getStandardMetadataBag as readStandardMetadataBag } from '@fluojs/core/internal'; | ||
| } | ||
| function createRouteDecorator(method) { | ||
| function createRouteDecorator(method, produces) { | ||
| return path => { | ||
@@ -196,9 +196,16 @@ validateRoutePath(path, `@${method}() path`); | ||
| route.path = path; | ||
| if (produces) { | ||
| route.produces = normalizeProducesMediaTypes(produces); | ||
| } | ||
| return; | ||
| } | ||
| if (isMetadataPropertyKey(contextOrPropertyKey)) { | ||
| mergeLegacyRouteMetadata(valueOrTarget, contextOrPropertyKey, { | ||
| const routeMetadata = { | ||
| method, | ||
| path | ||
| }); | ||
| }; | ||
| if (produces) { | ||
| routeMetadata.produces = normalizeProducesMediaTypes(produces); | ||
| } | ||
| mergeLegacyRouteMetadata(valueOrTarget, contextOrPropertyKey, routeMetadata); | ||
| } | ||
@@ -305,2 +312,12 @@ }; | ||
| /** | ||
| * Registers a server-sent events route handler as `GET` with `text/event-stream` produces metadata. | ||
| * | ||
| * @param path Route path relative to the controller base path. | ||
| * @returns A method decorator that registers a `GET` SSE handler mapping. | ||
| * | ||
| * @remarks | ||
| * Handlers may return `SseResponse` for manual control or `AsyncIterable<SseMessage<T> | T>` for managed dispatcher streaming. | ||
| */ | ||
| export const Sse = createRouteDecorator('GET', ['text/event-stream']); | ||
| /** | ||
| * Registers a `POST` route handler. | ||
@@ -307,0 +324,0 @@ * |
@@ -1,1 +0,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,EAIjB,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;AAyyBD;;;;;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"} | ||
| {"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"} |
| import { getCompiledDtoBindingPlan } from '../adapters/dto-binding-plan.js'; | ||
| import { createRequestContext, runWithRequestContext } from '../context/request-context.js'; | ||
| import { SseResponse } from '../context/sse.js'; | ||
| import { SseResponse, isSseMessage } from '../context/sse.js'; | ||
| import { RequestAbortedError } from '../errors.js'; | ||
@@ -258,2 +258,136 @@ import { runGuardChain } from '../guards.js'; | ||
| } | ||
| function isSseRoute(handler) { | ||
| return handler.route.produces?.some(mediaType => mediaType.toLowerCase().startsWith('text/event-stream')) === true; | ||
| } | ||
| function isAsyncIterable(value) { | ||
| return typeof value === 'object' && value !== null && Symbol.asyncIterator in value && typeof value[Symbol.asyncIterator] === 'function'; | ||
| } | ||
| function createAbortPromise(request) { | ||
| if (!request.signal) { | ||
| return undefined; | ||
| } | ||
| if (request.signal.aborted) { | ||
| return { | ||
| cleanup: () => undefined, | ||
| promise: Promise.resolve('aborted') | ||
| }; | ||
| } | ||
| let listener; | ||
| const promise = new Promise(resolve => { | ||
| listener = () => resolve('aborted'); | ||
| request.signal?.addEventListener('abort', listener, { | ||
| once: true | ||
| }); | ||
| }); | ||
| return { | ||
| cleanup: () => { | ||
| if (listener) { | ||
| request.signal?.removeEventListener('abort', listener); | ||
| } | ||
| }, | ||
| promise | ||
| }; | ||
| } | ||
| function createStreamClosePromise(stream) { | ||
| if (stream.closed) { | ||
| return { | ||
| cleanup: () => undefined, | ||
| promise: Promise.resolve('aborted') | ||
| }; | ||
| } | ||
| if (!stream.onClose) { | ||
| return undefined; | ||
| } | ||
| let cleanup; | ||
| const promise = new Promise(resolve => { | ||
| cleanup = stream.onClose?.(() => resolve('aborted')) ?? undefined; | ||
| }); | ||
| return { | ||
| cleanup: () => { | ||
| cleanup?.(); | ||
| }, | ||
| promise | ||
| }; | ||
| } | ||
| function createManagedSseStopPromise(request, stream) { | ||
| const stops = [createAbortPromise(request), createStreamClosePromise(stream)].filter(entry => entry !== undefined); | ||
| if (stops.length === 0) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| cleanup: () => { | ||
| for (const stop of stops) { | ||
| stop.cleanup(); | ||
| } | ||
| }, | ||
| promise: Promise.race(stops.map(stop => stop.promise)) | ||
| }; | ||
| } | ||
| function resolveManagedSseFrame(value) { | ||
| if (isSseMessage(value)) { | ||
| const { | ||
| data, | ||
| event, | ||
| id, | ||
| retry | ||
| } = value; | ||
| return { | ||
| data, | ||
| options: { | ||
| event, | ||
| id, | ||
| retry | ||
| } | ||
| }; | ||
| } | ||
| return { | ||
| data: value, | ||
| options: {} | ||
| }; | ||
| } | ||
| function closeAsyncIteratorEventually(iterator) { | ||
| void iterator.return?.().catch(() => undefined); | ||
| } | ||
| async function readManagedSseNext(request, stream, iterator) { | ||
| const abort = createManagedSseStopPromise(request, stream); | ||
| if (!abort) { | ||
| return iterator.next(); | ||
| } | ||
| try { | ||
| return await Promise.race([iterator.next(), abort.promise]); | ||
| } finally { | ||
| abort.cleanup(); | ||
| } | ||
| } | ||
| async function writeManagedSseIterable(handler, requestContext, source) { | ||
| if (!isSseRoute(handler)) { | ||
| return false; | ||
| } | ||
| const sse = new SseResponse(requestContext); | ||
| const stream = requestContext.response.stream; | ||
| if (!stream) { | ||
| return true; | ||
| } | ||
| const iterator = source[Symbol.asyncIterator](); | ||
| try { | ||
| while (!isRequestAborted(requestContext.request)) { | ||
| const next = await readManagedSseNext(requestContext.request, stream, iterator); | ||
| if (next === 'aborted') { | ||
| closeAsyncIteratorEventually(iterator); | ||
| break; | ||
| } | ||
| if (next.done === true) { | ||
| break; | ||
| } | ||
| const frame = resolveManagedSseFrame(next.value); | ||
| const accepted = sse.send(frame.data, frame.options); | ||
| if (!accepted) { | ||
| await requestContext.response.stream?.waitForDrain?.(); | ||
| } | ||
| } | ||
| } finally { | ||
| sse.close(); | ||
| } | ||
| return true; | ||
| } | ||
| function resolveFastPathHandlerRuntimeCache(handler, cache) { | ||
@@ -339,3 +473,5 @@ const cached = cache.get(handler); | ||
| ensureRequestNotAborted(requestContext.request); | ||
| if (!(result instanceof SseResponse) && !requestContext.response.committed) { | ||
| if (isAsyncIterable(result) && (await writeManagedSseIterable(handler, requestContext, result))) { | ||
| // Managed SSE streams are already committed and closed by writeManagedSseIterable. | ||
| } else if (!(result instanceof SseResponse) && !requestContext.response.committed) { | ||
| await writeSuccessResponse(handler, requestContext.request, requestContext.response, result, contentNegotiation); | ||
@@ -342,0 +478,0 @@ } |
@@ -9,5 +9,26 @@ import type { Container } from '@fluojs/di'; | ||
| } | ||
| /** | ||
| * Compiles the conservative fast-path eligibility decision for one handler. | ||
| * | ||
| * @param handler Handler descriptor being analyzed. | ||
| * @param options Dispatcher options that can introduce full-path requirements. | ||
| * @param adapter Human-readable adapter label used in observability metadata. | ||
| * @returns The compiled eligibility metadata and boolean eligibility flag. | ||
| */ | ||
| export declare function compileFastPathEligibility(handler: HandlerDescriptor, options: CreateDispatcherOptions, adapter: string): CompiledEligibilityPlan; | ||
| /** | ||
| * Reads fast-path eligibility metadata attached to a handler descriptor. | ||
| * | ||
| * @param handler Handler descriptor previously analyzed by the dispatcher. | ||
| * @returns The attached eligibility metadata, when present. | ||
| */ | ||
| export declare function getHandlerFastPathEligibility(handler: HandlerDescriptor): FastPathEligibility | undefined; | ||
| /** | ||
| * Attaches fast-path eligibility metadata to a handler descriptor. | ||
| * | ||
| * @param handler Handler descriptor to annotate. | ||
| * @param eligibility Eligibility metadata to expose through dispatcher observability. | ||
| */ | ||
| export declare function setHandlerFastPathEligibility(handler: HandlerDescriptor, eligibility: FastPathEligibility): void; | ||
| /** Options shared by fast-path executor helpers. */ | ||
| export interface FastPathExecutorOptions { | ||
@@ -17,2 +38,3 @@ binder?: Binder; | ||
| } | ||
| /** Result returned after attempting fast-path handler execution. */ | ||
| export interface FastPathExecutionResult { | ||
@@ -19,0 +41,0 @@ executed: boolean; |
@@ -1,1 +0,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,CA6DzB;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,iBAAiB,GACzB,mBAAmB,GAAG,SAAS,CAIjC;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,mBAAmB,GAC/B,IAAI,CAGN;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,SAAS,CAAC;CAC1B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB"} | ||
| {"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"} |
@@ -44,2 +44,11 @@ import { getCompiledDtoBindingPlan } from '../../adapters/dto-binding-plan.js'; | ||
| } | ||
| /** | ||
| * Compiles the conservative fast-path eligibility decision for one handler. | ||
| * | ||
| * @param handler Handler descriptor being analyzed. | ||
| * @param options Dispatcher options that can introduce full-path requirements. | ||
| * @param adapter Human-readable adapter label used in observability metadata. | ||
| * @returns The compiled eligibility metadata and boolean eligibility flag. | ||
| */ | ||
| export function compileFastPathEligibility(handler, options, adapter) { | ||
@@ -53,2 +62,3 @@ const routeId = `${handler.route.method}:${handler.route.path}`; | ||
| const hasContentNegotiation = options.contentNegotiation?.formatters !== undefined && options.contentNegotiation.formatters.length > 0; | ||
| const isSseRoute = handler.route.produces?.some(mediaType => mediaType.toLowerCase().startsWith('text/event-stream')) === true; | ||
| const eligibility = { | ||
@@ -93,2 +103,5 @@ adapter, | ||
| } | ||
| if (isSseRoute) { | ||
| blockingReasons.push('SSE streaming'); | ||
| } | ||
| const isEligible = blockingReasons.length === 0; | ||
@@ -105,7 +118,25 @@ if (!isEligible) { | ||
| } | ||
| /** | ||
| * Reads fast-path eligibility metadata attached to a handler descriptor. | ||
| * | ||
| * @param handler Handler descriptor previously analyzed by the dispatcher. | ||
| * @returns The attached eligibility metadata, when present. | ||
| */ | ||
| export function getHandlerFastPathEligibility(handler) { | ||
| return handler[FAST_PATH_ELIGIBILITY_SYMBOL]; | ||
| } | ||
| /** | ||
| * Attaches fast-path eligibility metadata to a handler descriptor. | ||
| * | ||
| * @param handler Handler descriptor to annotate. | ||
| * @param eligibility Eligibility metadata to expose through dispatcher observability. | ||
| */ | ||
| export function setHandlerFastPathEligibility(handler, eligibility) { | ||
| handler[FAST_PATH_ELIGIBILITY_SYMBOL] = eligibility; | ||
| } | ||
| } | ||
| /** Options shared by fast-path executor helpers. */ | ||
| /** Result returned after attempting fast-path handler execution. */ |
+1
-1
| export * from './adapter.js'; | ||
| export * from './middleware/correlation.js'; | ||
| export * from './middleware/cors.js'; | ||
| 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'; | ||
| 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'; | ||
| export * from './dispatch/dispatcher.js'; | ||
@@ -6,0 +6,0 @@ export type { FastPathEligibility, FastPathStats } from './dispatch/fast-path/index.js'; |
@@ -1,1 +0,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"} | ||
| {"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"} |
+1
-1
| export * from './adapter.js'; | ||
| export * from './middleware/correlation.js'; | ||
| export * from './middleware/cors.js'; | ||
| 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'; | ||
| 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'; | ||
| export * from './dispatch/dispatcher.js'; | ||
@@ -6,0 +6,0 @@ export { FAST_PATH_ELIGIBILITY_SYMBOL, FAST_PATH_STATS_SYMBOL, formatFastPathStats, getDispatcherFastPathStats } from './dispatch/dispatcher.js'; |
+4
-4
@@ -13,3 +13,3 @@ { | ||
| ], | ||
| "version": "1.0.0", | ||
| "version": "1.1.0", | ||
| "private": false, | ||
@@ -45,5 +45,5 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@fluojs/core": "^1.0.0", | ||
| "@fluojs/validation": "^1.0.0", | ||
| "@fluojs/di": "^1.0.0" | ||
| "@fluojs/core": "^1.0.3", | ||
| "@fluojs/validation": "^1.0.4", | ||
| "@fluojs/di": "^1.0.3" | ||
| }, | ||
@@ -50,0 +50,0 @@ "devDependencies": { |
+53
-10
@@ -108,12 +108,55 @@ # @fluojs/http | ||
| ```ts | ||
| import { Get, SseResponse, type RequestContext } from '@fluojs/http'; | ||
| import { Controller, Sse, type SseMessage } from '@fluojs/http'; | ||
| @Get('/events') | ||
| stream(_input: undefined, ctx: RequestContext) { | ||
| const sse = new SseResponse(ctx); | ||
| sse.send({ message: 'hello' }); | ||
| return sse; | ||
| @Controller('/orders') | ||
| export class OrdersEventsController { | ||
| @Sse('/events') | ||
| async *stream(): AsyncIterable<SseMessage<{ status: string }> | { heartbeat: true }> { | ||
| yield { data: { status: 'connected' }, event: 'ready', id: 'orders-ready' }; | ||
| while (true) { | ||
| await new Promise((resolve) => setTimeout(resolve, 15_000)); | ||
| yield { heartbeat: true }; | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| `@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는 필요하지 않습니다. | ||
| 브라우저 쪽에서는 해당 연결을 소유하는 React effect 안에서 `EventSource`를 만들고 cleanup 함수에서 항상 닫아야 합니다. 그래야 route 변경, Strict Mode remount, component unmount가 중복 stream을 남기지 않습니다. | ||
| ```tsx | ||
| import { useEffect, useState } from 'react'; | ||
| export function OrderEvents({ orderId }: { orderId: string }) { | ||
| const [events, setEvents] = useState<string[]>([]); | ||
| useEffect(() => { | ||
| const source = new EventSource(`/orders/events?orderId=${encodeURIComponent(orderId)}`, { | ||
| withCredentials: true, | ||
| }); | ||
| source.addEventListener('ready', (event) => { | ||
| setEvents((current) => [...current, event.data]); | ||
| }); | ||
| source.onerror = () => { | ||
| // 서버가 terminal status로 닫지 않는 한 브라우저가 자동으로 재연결합니다. | ||
| console.warn('Order event stream disconnected; waiting for browser retry.'); | ||
| }; | ||
| return () => { | ||
| source.close(); | ||
| }; | ||
| }, [orderId]); | ||
| return <output>{events.join('\n')}</output>; | ||
| } | ||
| ``` | ||
| 브라우저 `EventSource`는 호출자가 임의의 `Authorization` 헤더를 붙일 수 없습니다. SSE 엔드포인트는 same-origin cookie, `withCredentials`와 명시적인 CORS credentials 정책, 또는 guard가 검증하는 짧은 수명의 signed URL/query token으로 인증하세요. 내장 `EventSource` API가 아니라 fetch 기반 custom SSE client를 쓰는 경우가 아니라면 bearer header 브라우저 예제를 문서화하지 마세요. | ||
| 운영 환경에서는 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를 보존하세요. | ||
| ### Versioning | ||
@@ -145,10 +188,10 @@ | ||
| - **라우팅 데코레이터**: `Controller`, `Get`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head` | ||
| - **라우팅 데코레이터**: `Controller`, `Get`, `Sse`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head` | ||
| - **바인딩 데코레이터**: `FromBody`, `FromQuery`, `FromPath`, `FromHeader`, `FromCookie`, `RequestDto`, `Optional`, `Convert` | ||
| - **실행 데코레이터**: `UseGuards`, `UseInterceptors`, `HttpCode`, `Version`, `Header`, `Redirect`, `Produces` | ||
| - **핵심 런타임 타입**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse` | ||
| - **핵심 런타임 타입**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse`, `SseMessage`, `Middleware`, `MiddlewareContext`, `MiddlewareRouteConfig`, `Next`, `Guard`, `GuardContext`, `Interceptor`, `InterceptorContext`, `CallHandler`, `RequestObserver`, `DispatcherLogger` | ||
| - **Adapter API**: `HttpApplicationAdapter`, `createNoopHttpApplicationAdapter`, `createServerBackedHttpAdapterRealtimeCapability`, `createUnsupportedHttpAdapterRealtimeCapability`, `createFetchStyleHttpAdapterRealtimeCapability` | ||
| - **예외와 오류**: `HttpException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `NotAcceptableException`, `TooManyRequestsException`, `InternalServerErrorException`, `PayloadTooLargeException`, `createErrorResponse`, `RouteConflictError`, `InvalidRoutePathError`, `HandlerNotFoundError`, `RequestAbortedError` | ||
| - **헬퍼**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage` | ||
| - **Option type**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `SecurityHeadersOptions`, `SseSendOptions` | ||
| - **헬퍼**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createMemoryRateLimitStore`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage`, `isSseMessage` | ||
| - **Option 및 store type**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `RateLimitStoreEntry`, `SecurityHeadersOptions`, `SseSendOptions` | ||
@@ -155,0 +198,0 @@ ## 내부 서브경로 (`@fluojs/http/internal`) |
+53
-10
@@ -110,12 +110,55 @@ # @fluojs/http | ||
| ```ts | ||
| import { Get, SseResponse, type RequestContext } from '@fluojs/http'; | ||
| import { Controller, Sse, type SseMessage } from '@fluojs/http'; | ||
| @Get('/events') | ||
| stream(_input: undefined, ctx: RequestContext) { | ||
| const sse = new SseResponse(ctx); | ||
| sse.send({ message: 'hello' }); | ||
| return sse; | ||
| @Controller('/orders') | ||
| export class OrdersEventsController { | ||
| @Sse('/events') | ||
| async *stream(): AsyncIterable<SseMessage<{ status: string }> | { heartbeat: true }> { | ||
| yield { data: { status: 'connected' }, event: 'ready', id: 'orders-ready' }; | ||
| while (true) { | ||
| await new Promise((resolve) => setTimeout(resolve, 15_000)); | ||
| yield { heartbeat: true }; | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| `@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. | ||
| 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: | ||
| ```tsx | ||
| import { useEffect, useState } from 'react'; | ||
| export function OrderEvents({ orderId }: { orderId: string }) { | ||
| const [events, setEvents] = useState<string[]>([]); | ||
| useEffect(() => { | ||
| const source = new EventSource(`/orders/events?orderId=${encodeURIComponent(orderId)}`, { | ||
| withCredentials: true, | ||
| }); | ||
| source.addEventListener('ready', (event) => { | ||
| setEvents((current) => [...current, event.data]); | ||
| }); | ||
| source.onerror = () => { | ||
| // Browsers reconnect automatically unless the server closes with a terminal status. | ||
| console.warn('Order event stream disconnected; waiting for browser retry.'); | ||
| }; | ||
| return () => { | ||
| source.close(); | ||
| }; | ||
| }, [orderId]); | ||
| return <output>{events.join('\n')}</output>; | ||
| } | ||
| ``` | ||
| 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. | ||
| 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. | ||
| ### Versioning | ||
@@ -147,10 +190,10 @@ | ||
| - **Routing decorators**: `Controller`, `Get`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head` | ||
| - **Routing decorators**: `Controller`, `Get`, `Sse`, `Post`, `Put`, `Patch`, `Delete`, `All`, `Options`, `Head` | ||
| - **Binding decorators**: `FromBody`, `FromQuery`, `FromPath`, `FromHeader`, `FromCookie`, `RequestDto`, `Optional`, `Convert` | ||
| - **Execution decorators**: `UseGuards`, `UseInterceptors`, `HttpCode`, `Version`, `Header`, `Redirect`, `Produces` | ||
| - **Core runtime types**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse` | ||
| - **Core runtime types**: `RequestContext`, `FrameworkRequest`, `FrameworkResponse`, `SseResponse`, `SseMessage`, `Middleware`, `MiddlewareContext`, `MiddlewareRouteConfig`, `Next`, `Guard`, `GuardContext`, `Interceptor`, `InterceptorContext`, `CallHandler`, `RequestObserver`, `DispatcherLogger` | ||
| - **Adapter API**: `HttpApplicationAdapter`, `createNoopHttpApplicationAdapter`, `createServerBackedHttpAdapterRealtimeCapability`, `createUnsupportedHttpAdapterRealtimeCapability`, `createFetchStyleHttpAdapterRealtimeCapability` | ||
| - **Exceptions and errors**: `HttpException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `NotAcceptableException`, `TooManyRequestsException`, `InternalServerErrorException`, `PayloadTooLargeException`, `createErrorResponse`, `RouteConflictError`, `InvalidRoutePathError`, `HandlerNotFoundError`, `RequestAbortedError` | ||
| - **Helpers**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage` | ||
| - **Option types**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `SecurityHeadersOptions`, `SseSendOptions` | ||
| - **Helpers**: `createHandlerMapping`, `createDispatcher`, `forRoutes`, `normalizeRoutePattern`, `matchRoutePattern`, `isMiddlewareRouteConfig`, `createCorrelationMiddleware`, `createCorsMiddleware`, `createRateLimitMiddleware`, `createMemoryRateLimitStore`, `createSecurityHeadersMiddleware`, `runWithRequestContext`, `getCurrentRequestContext`, `assertRequestContext`, `createRequestContext`, `createContextKey`, `getContextValue`, `setContextValue`, `encodeSseComment`, `encodeSseMessage`, `isSseMessage` | ||
| - **Option and store types**: `CorsOptions`, `RateLimitOptions`, `RateLimitStore`, `RateLimitStoreEntry`, `SecurityHeadersOptions`, `SseSendOptions` | ||
@@ -157,0 +200,0 @@ ## Internal Subpath (`@fluojs/http/internal`) |
280771
5.98%5658
4.6%222
24.02%Updated
Updated
Updated