@fluojs/http
Advanced tools
@@ -5,5 +5,7 @@ import type { ContextKey, RequestContext } from '../types.js'; | ||
| * | ||
| * Hosts with `AsyncLocalStorage` preserve the context across awaited work. Hosts without an async | ||
| * context primitive use a stack fallback that keeps the context only for the synchronous callback | ||
| * frame and clears it before awaited continuations resume. | ||
| * Hosts with `AsyncLocalStorage` preserve the context across awaited work. During lazy | ||
| * `AsyncLocalStorage` resolution, promise continuations registered before the store resolves keep | ||
| * their request context until the returned promise settles. Hosts without an async context primitive | ||
| * use a stack fallback that keeps the context only for the synchronous callback frame and clears it | ||
| * before awaited continuations resume. | ||
| * | ||
@@ -10,0 +12,0 @@ * @param context Request context snapshot to bind to the current async execution chain. |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"request-context.d.ts","sourceRoot":"","sources":["../../src/context/request-context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAc9D;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,CAgBtF;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,cAAc,GAAG,SAAS,CAErE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,cAAc,CAUrD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAK5E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,CAKtE;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,CAE7F;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI,CAE9F"} | ||
| {"version":3,"file":"request-context.d.ts","sourceRoot":"","sources":["../../src/context/request-context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAoB9D;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,CAgBtF;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,cAAc,GAAG,SAAS,CAErE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,cAAc,CAUrD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAK5E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,CAKtE;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,CAE7F;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI,CAE9F"} |
@@ -7,10 +7,13 @@ import { FluoError } from '@fluojs/core'; | ||
| let fallbackRequestContextStore; | ||
| let originalPromiseThen; | ||
| let promiseThenPatchDepth = 0; | ||
| const dynamicResolutionFallbackStack = []; | ||
| /** | ||
| * Runs a callback inside the request-scoped async context. | ||
| * | ||
| * Hosts with `AsyncLocalStorage` preserve the context across awaited work. Hosts without an async | ||
| * context primitive use a stack fallback that keeps the context only for the synchronous callback | ||
| * frame and clears it before awaited continuations resume. | ||
| * Hosts with `AsyncLocalStorage` preserve the context across awaited work. During lazy | ||
| * `AsyncLocalStorage` resolution, promise continuations registered before the store resolves keep | ||
| * their request context until the returned promise settles. Hosts without an async context primitive | ||
| * use a stack fallback that keeps the context only for the synchronous callback frame and clears it | ||
| * before awaited continuations resume. | ||
| * | ||
@@ -147,14 +150,24 @@ * @param context Request context snapshot to bind to the current async execution chain. | ||
| dynamicResolutionFallbackStack.push(context); | ||
| installPromiseThenContextBridge(); | ||
| let cleanedUp = false; | ||
| const cleanup = () => { | ||
| if (cleanedUp) { | ||
| return; | ||
| } | ||
| cleanedUp = true; | ||
| removeDynamicResolutionFallbackContext(context); | ||
| restorePromiseThenContextBridge(); | ||
| void resolveRequestContextStore(); | ||
| }; | ||
| try { | ||
| const result = callback(); | ||
| void resolveRequestContextStore(); | ||
| if (isPromiseLike(result)) { | ||
| return result.finally(() => { | ||
| removeDynamicResolutionFallbackContext(context); | ||
| }); | ||
| if (isPromise(result)) { | ||
| const then = originalPromiseThen ?? Promise.prototype.then; | ||
| void then.call(result, cleanup, cleanup); | ||
| return result; | ||
| } | ||
| removeDynamicResolutionFallbackContext(context); | ||
| cleanup(); | ||
| return result; | ||
| } catch (error) { | ||
| removeDynamicResolutionFallbackContext(context); | ||
| cleanup(); | ||
| throw error; | ||
@@ -164,6 +177,3 @@ } | ||
| function getDynamicResolutionFallbackContext() { | ||
| if (dynamicResolutionFallbackStack.length !== 1) { | ||
| return undefined; | ||
| } | ||
| return dynamicResolutionFallbackStack[0]; | ||
| return dynamicResolutionFallbackStack.at(-1); | ||
| } | ||
@@ -176,7 +186,41 @@ function removeDynamicResolutionFallbackContext(context) { | ||
| } | ||
| function isPromiseLike(value) { | ||
| return typeof value === 'object' && value !== null && 'then' in value && 'finally' in value; | ||
| function installPromiseThenContextBridge() { | ||
| if (promiseThenPatchDepth === 0) { | ||
| originalPromiseThen = Promise.prototype.then; | ||
| Object.defineProperty(Promise.prototype, 'then', { | ||
| configurable: true, | ||
| value: createContextBridgePromiseThen(originalPromiseThen), | ||
| writable: true | ||
| }); | ||
| } | ||
| promiseThenPatchDepth += 1; | ||
| } | ||
| function restorePromiseThenContextBridge() { | ||
| promiseThenPatchDepth -= 1; | ||
| if (promiseThenPatchDepth === 0 && originalPromiseThen) { | ||
| Object.defineProperty(Promise.prototype, 'then', { | ||
| configurable: true, | ||
| value: originalPromiseThen, | ||
| writable: true | ||
| }); | ||
| originalPromiseThen = undefined; | ||
| } | ||
| } | ||
| function createContextBridgePromiseThen(originalThen) { | ||
| return function contextBridgePromiseThen(onfulfilled, onrejected) { | ||
| const context = getDynamicResolutionFallbackContext(); | ||
| return originalThen.call(this, wrapPromiseCallback(context, onfulfilled), wrapPromiseCallback(context, onrejected)); | ||
| }; | ||
| } | ||
| function wrapPromiseCallback(context, callback) { | ||
| if (!context || typeof callback !== 'function') { | ||
| return callback; | ||
| } | ||
| return value => runWithDynamicResolutionFallbackContext(context, () => callback(value)); | ||
| } | ||
| function isPromise(value) { | ||
| return value instanceof Promise; | ||
| } | ||
| function isAsyncCallback(callback) { | ||
| return callback.constructor.name === 'AsyncFunction'; | ||
| } |
@@ -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,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"} | ||
| {"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;AA08BD;;;;;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"} |
@@ -48,2 +48,3 @@ import { getCompiledDtoBindingPlan } from '../adapters/dto-binding-plan.js'; | ||
| requestId: request.requestId, | ||
| isAborted: request.isAborted, | ||
| signal: request.signal, | ||
@@ -50,0 +51,0 @@ url: request.url |
+1
-1
@@ -13,3 +13,3 @@ { | ||
| ], | ||
| "version": "1.1.1", | ||
| "version": "1.1.2", | ||
| "private": false, | ||
@@ -16,0 +16,0 @@ "license": "MIT", |
+10
-4
@@ -44,2 +44,7 @@ # @fluojs/http | ||
| class FindUserParamsDto { | ||
| @FromPath('id') | ||
| id!: string; | ||
| } | ||
| @Controller('/users') | ||
@@ -54,4 +59,5 @@ export class UserController { | ||
| @Get('/:id') | ||
| getById(@FromPath('id') id: string) { | ||
| return { id, name: 'John Doe' }; | ||
| @RequestDto(FindUserParamsDto) | ||
| getById(input: FindUserParamsDto) { | ||
| return { id: input.id, name: 'John Doe' }; | ||
| } | ||
@@ -101,3 +107,3 @@ } | ||
| `runWithRequestContext(...)`는 호스트가 `globalThis.AsyncLocalStorage` 또는 Node 내장 `node:async_hooks` 모듈로 `AsyncLocalStorage`를 제공할 때 활성 컨텍스트를 `await` 이후까지 보존합니다. 루트 `@fluojs/http` import는 async-context storage를 probe하거나 instantiate하지 않습니다. Helper가 처음 사용될 때 storage를 lazy하게 해석하고, `process.getBuiltinModule(...)` 실패를 guard하며, 동기 probe를 노출하지 않는 Node host에서는 계속 `node:async_hooks`를 동적으로 해석할 수 있습니다. 비동기 컨텍스트 primitive가 없는 비 Node 호스트에서는 동기 stack fallback을 사용하며, 겹치는 비동기 요청이 서로의 컨텍스트를 관찰하지 않도록 awaited continuation이 재개되기 전에 컨텍스트를 비웁니다. | ||
| `runWithRequestContext(...)`는 호스트가 `globalThis.AsyncLocalStorage` 또는 Node 내장 `node:async_hooks` 모듈로 `AsyncLocalStorage`를 제공할 때 활성 컨텍스트를 `await` 이후까지 보존합니다. 루트 `@fluojs/http` import는 async-context storage를 probe하거나 instantiate하지 않습니다. Helper가 처음 사용될 때 storage를 lazy하게 해석하고, `process.getBuiltinModule(...)` 실패를 guard하며, Node async storage 해석이 끝나기 전에 등록된 promise continuation도 격리하면서 첫 호출의 동기 callback 반환 및 throw 동작은 그대로 유지합니다. 비동기 컨텍스트 primitive가 없는 비 Node 호스트에서는 동기 stack fallback을 사용하며, 겹치는 비동기 요청이 서로의 컨텍스트를 관찰하지 않도록 awaited continuation이 재개되기 전에 컨텍스트를 비웁니다. | ||
@@ -186,3 +192,3 @@ ### 프록시 뒤의 속도 제한 | ||
| 어댑터는 플랫폼이 제공한다면 `FrameworkRequest.signal`에 `AbortSignal`을 전달해야 합니다. SSE에서는 가능하면 `FrameworkResponse.stream.onClose(...)`도 노출해야 합니다. `SseResponse`는 request abort와 raw stream close를 모두 구독하고, 멱등하게 닫히며, 어느 쪽이 먼저 종료되더라도 등록한 listener를 제거합니다. | ||
| 어댑터는 플랫폼이 제공한다면 `FrameworkRequest.signal`에 `AbortSignal`을 전달하고, signal allocation이 실용적이지 않다면 `isAborted()` probe를 제공해야 합니다. Dispatcher는 per-dispatch request clone에 두 abort surface를 모두 보존하고 handler 작업 전후에 검사하므로 `AbortSignal`이 없는 어댑터도 abandon된 요청을 중단할 수 있습니다. SSE에서는 가능하면 `FrameworkResponse.stream.onClose(...)`도 노출해야 합니다. `SseResponse`는 request abort와 raw stream close를 모두 구독하고, 멱등하게 닫히며, 어느 쪽이 먼저 종료되더라도 등록한 listener를 제거합니다. | ||
@@ -189,0 +195,0 @@ ## 공개 API |
+10
-4
@@ -46,2 +46,7 @@ # @fluojs/http | ||
| class FindUserParamsDto { | ||
| @FromPath('id') | ||
| id!: string; | ||
| } | ||
| @Controller('/users') | ||
@@ -56,4 +61,5 @@ export class UserController { | ||
| @Get('/:id') | ||
| getById(@FromPath('id') id: string) { | ||
| return { id, name: 'John Doe' }; | ||
| @RequestDto(FindUserParamsDto) | ||
| getById(input: FindUserParamsDto) { | ||
| return { id: input.id, name: 'John Doe' }; | ||
| } | ||
@@ -103,3 +109,3 @@ } | ||
| `runWithRequestContext(...)` preserves the active context across awaited work when the host provides `AsyncLocalStorage` through `globalThis.AsyncLocalStorage` or Node's built-in `node:async_hooks` module. The root `@fluojs/http` import does not probe or instantiate async-context storage; helpers resolve storage lazily on first use, guard `process.getBuiltinModule(...)` failures, and can still resolve `node:async_hooks` dynamically for Node hosts that do not expose the synchronous probe. Non-Node hosts without an async-context primitive use a synchronous stack fallback that clears the context before awaited continuations resume, avoiding cross-request leaks instead of pretending to isolate overlapping async work. | ||
| `runWithRequestContext(...)` preserves the active context across awaited work when the host provides `AsyncLocalStorage` through `globalThis.AsyncLocalStorage` or Node's built-in `node:async_hooks` module. The root `@fluojs/http` import does not probe or instantiate async-context storage; helpers resolve storage lazily on first use, guard `process.getBuiltinModule(...)` failures, and keep the first-call synchronous callback return and throw behavior unchanged while isolating promise continuations registered before Node async storage finishes resolving. Non-Node hosts without an async-context primitive use a synchronous stack fallback that clears the context before awaited continuations resume, avoiding cross-request leaks instead of pretending to isolate overlapping async work. | ||
@@ -188,3 +194,3 @@ ### Rate limiting behind proxies | ||
| Adapters should pass an `AbortSignal` on `FrameworkRequest.signal` when the platform exposes one. For SSE, adapters should also expose `FrameworkResponse.stream.onClose(...)` when possible; `SseResponse` listens to both request abort and raw stream close, closes idempotently, and removes registered listeners when either side terminates first. | ||
| Adapters should pass an `AbortSignal` on `FrameworkRequest.signal` when the platform exposes one, or an `isAborted()` probe when allocating a signal is not practical. The dispatcher preserves both abort surfaces on its per-dispatch request clone and checks them before and after handler work so adapters without `AbortSignal` can still stop abandoned requests. For SSE, adapters should also expose `FrameworkResponse.stream.onClose(...)` when possible; `SseResponse` listens to both request abort and raw stream close, closes idempotently, and removes registered listeners when either side terminates first. | ||
@@ -191,0 +197,0 @@ ## Public API |
288461
0.9%5827
0.83%228
2.7%