diff --git a/changelog.d/fixes/15395-share-request-context-symbol-for.md b/changelog.d/fixes/15395-share-request-context-symbol-for.md new file mode 100644 index 000000000000..7e4f52963b27 --- /dev/null +++ b/changelog.d/fixes/15395-share-request-context-symbol-for.md @@ -0,0 +1 @@ +- **fix(shared):** share request context stores across duplicated modules ([#15395](https://github.com/diegosouzapw/OmniRoute/pull/15395)) — thanks @maxmad64bis diff --git a/src/shared/middleware/correlationId.ts b/src/shared/middleware/correlationId.ts index a97f15bb6c13..792a60d30c25 100644 --- a/src/shared/middleware/correlationId.ts +++ b/src/shared/middleware/correlationId.ts @@ -11,7 +11,15 @@ import { AsyncLocalStorage } from "node:async_hooks"; import crypto from "crypto"; -const correlationStore = new AsyncLocalStorage(); +const CORRELATION_STORE_KEY = Symbol.for("omniroute.correlationId.store"); +type GlobalWithCorrelationStore = typeof globalThis & { + [CORRELATION_STORE_KEY]?: AsyncLocalStorage; +}; + +function getCorrelationStore(): AsyncLocalStorage { + return ((globalThis as GlobalWithCorrelationStore)[CORRELATION_STORE_KEY] ??= + new AsyncLocalStorage()); +} /** * Generate a unique correlation ID. @@ -26,7 +34,7 @@ function generateCorrelationId() { * @returns {string|undefined} */ export function getCorrelationId() { - return correlationStore.getStore(); + return getCorrelationStore().getStore(); } /** @@ -39,5 +47,5 @@ export function getCorrelationId() { */ export function runWithCorrelation(correlationId, fn) { const id = correlationId || generateCorrelationId(); - return correlationStore.run(id, fn); + return getCorrelationStore().run(id, fn); } diff --git a/src/shared/utils/requestId.ts b/src/shared/utils/requestId.ts index 27a19caa5dc5..7882b930229f 100644 --- a/src/shared/utils/requestId.ts +++ b/src/shared/utils/requestId.ts @@ -21,7 +21,15 @@ type RequestLike = { headers?: HeaderReader | null; } | null; -const requestIdStore = new AsyncLocalStorage(); +const REQUEST_ID_STORE_KEY = Symbol.for("omniroute.requestId.store"); +type GlobalWithRequestIdStore = typeof globalThis & { + [REQUEST_ID_STORE_KEY]?: AsyncLocalStorage; +}; + +function getRequestIdStore(): AsyncLocalStorage { + return ((globalThis as GlobalWithRequestIdStore)[REQUEST_ID_STORE_KEY] ??= + new AsyncLocalStorage()); +} function getHeaderValue(request: RequestLike, name: string): string | null { const value = request?.headers?.get?.(name); @@ -35,7 +43,7 @@ function getHeaderValue(request: RequestLike, name: string): string | null { * @returns {string | null} */ export function getRequestId() { - return requestIdStore.getStore() || null; + return getRequestIdStore().getStore() || null; } /** @@ -54,7 +62,7 @@ export async function withRequestId( ): Promise { const existingId = getHeaderValue(request, "x-request-id"); const requestId = existingId || randomUUID(); - return requestIdStore.run(requestId, handler); + return getRequestIdStore().run(requestId, handler); } /** diff --git a/tests/unit/share-request-context-symbol-for.test.ts b/tests/unit/share-request-context-symbol-for.test.ts new file mode 100644 index 000000000000..7457f91707fb --- /dev/null +++ b/tests/unit/share-request-context-symbol-for.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Two module copies share the request contexts: a read from copy A must see the +// context opened by copy B. This mirrors production, where the module is bundled +// once per server chunk, so middleware opening the context and the route reading +// it may run on different copies. Single-copy coverage alone cannot catch this: +// it passes before and after. + +const CORRELATION_PATH = "../../src/shared/middleware/correlationId.ts"; +const REQUEST_ID_PATH = "../../src/shared/utils/requestId.ts"; + +test("a read from copy A sees the correlation opened by copy B", async () => { + const copyA = await import(`${CORRELATION_PATH}?graph=a`); + const copyB = await import(`${CORRELATION_PATH}?graph=b`); + const id = "corr-shared-1"; + const seen = copyB.runWithCorrelation(id, () => copyA.getCorrelationId()); + assert.equal(seen, id); +}); + +test("a read from copy A sees the request id opened by copy B", async () => { + const copyA = await import(`${REQUEST_ID_PATH}?graph=a`); + const copyB = await import(`${REQUEST_ID_PATH}?graph=b`); + const id = "req-shared-1"; + const seen = await copyB.withRequestId({ headers: { get: () => id } }, () => + copyA.getRequestId() + ); + assert.equal(seen, id); +}); + +test("sequential contexts keep their own id, outside yields the empty value", async () => { + const corrA = await import(`${CORRELATION_PATH}?graph=c`); + const corrB = await import(`${CORRELATION_PATH}?graph=d`); + assert.equal( + corrB.runWithCorrelation("corr-first", () => corrA.getCorrelationId()), + "corr-first" + ); + assert.equal( + corrB.runWithCorrelation("corr-second", () => corrA.getCorrelationId()), + "corr-second" + ); + assert.equal(corrA.getCorrelationId(), undefined); + + const reqA = await import(`${REQUEST_ID_PATH}?graph=c`); + const reqB = await import(`${REQUEST_ID_PATH}?graph=d`); + const first = await reqB.withRequestId({ headers: { get: () => "req-first" } }, () => + reqA.getRequestId() + ); + assert.equal(first, "req-first"); + const second = await reqB.withRequestId({ headers: { get: () => "req-second" } }, () => + reqA.getRequestId() + ); + assert.equal(second, "req-second"); + assert.equal(reqA.getRequestId(), null); +});