diff --git a/.changeset/fix-elysia-workers-asynclocalstorage.md b/.changeset/fix-elysia-workers-asynclocalstorage.md new file mode 100644 index 00000000..2fef6f7e --- /dev/null +++ b/.changeset/fix-elysia-workers-asynclocalstorage.md @@ -0,0 +1,9 @@ +--- +"evlog": patch +--- + +# fix(elysia): support Cloudflare Workers without AsyncLocalStorage.enterWith + +Cloudflare Workers omit native `AsyncLocalStorage.enterWith()`. The Elysia integration now installs a small polyfill on load so `useLogger()` keeps working in typical `wrangler dev` flows. `{ log }` from derive remains the safest option when multiple requests may interleave in the same isolate. + +Closes #394 diff --git a/packages/evlog/src/elysia/index.ts b/packages/evlog/src/elysia/index.ts index 5008e776..2202319a 100644 --- a/packages/evlog/src/elysia/index.ts +++ b/packages/evlog/src/elysia/index.ts @@ -1,11 +1,17 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { Elysia } from 'elysia' import type { AuditableLogger } from '../audit' +import { + bindAsyncLocalStorage, + clearAsyncLocalStorage, + patchAsyncLocalStorageEnterWith, +} from '../shared/asyncStorageScope' import { defineFrameworkIntegration } from '../shared/integration' import type { BaseEvlogOptions } from '../shared/middleware' import { attachForkToLogger } from '../shared/fork' const storage = new AsyncLocalStorage() +patchAsyncLocalStorageEnterWith(storage) const activeLoggers = new WeakSet() @@ -15,9 +21,10 @@ export type EvlogElysiaOptions = BaseEvlogOptions * Get the request-scoped logger from anywhere in the call stack. * Must be called inside a request handled by the `evlog()` plugin. * - * Unlike other frameworks, Elysia uses `storage.enterWith()` which persists - * beyond the request lifecycle. This accessor additionally checks `activeLoggers` - * to ensure the logger belongs to an in-flight request. + * Elysia binds the logger with `enterWith()` because its lifecycle hooks are + * separate from the route handler. On Cloudflare Workers, a small polyfill + * provides `enterWith()` when the runtime omits it. Prefer `{ log }` from + * derive when multiple requests may interleave in the same isolate. * * @example * ```ts @@ -110,7 +117,9 @@ export function evlog(options: EvlogElysiaOptions = {}) { const headers = (request.headers).toJSON?.() ?? Object.fromEntries(request.headers.entries()) const ctx: ElysiaContext = { request, path: url.pathname, headers } const { logger, finish, skipped } = integration.start(ctx, options) - storage.enterWith(logger) + if (!skipped) { + bindAsyncLocalStorage(storage, logger) + } requestState.set(request, { finish, skipped, logger }) }) .derive({ as: 'global' }, ({ request }) => { @@ -122,7 +131,7 @@ export function evlog(options: EvlogElysiaOptions = {}) { emitted.add(request) await state.finish({ status: set.status as number || 200 }) activeLoggers.delete(state.logger) - storage.enterWith(undefined as unknown as AuditableLogger) + clearAsyncLocalStorage(storage) }) .onError({ as: 'global' }, async ({ request, error }) => { const state = requestState.get(request) @@ -132,6 +141,6 @@ export function evlog(options: EvlogElysiaOptions = {}) { state.logger.error(err) await state.finish({ error: err }) activeLoggers.delete(state.logger) - storage.enterWith(undefined as unknown as AuditableLogger) + clearAsyncLocalStorage(storage) }) } diff --git a/packages/evlog/src/shared/asyncStorageScope.ts b/packages/evlog/src/shared/asyncStorageScope.ts new file mode 100644 index 00000000..2e80d336 --- /dev/null +++ b/packages/evlog/src/shared/asyncStorageScope.ts @@ -0,0 +1,95 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +/** + * Whether this runtime provides a working native `AsyncLocalStorage.enterWith()`. + * + * Cloudflare Workers expose `enterWith` on the prototype but throw when it is + * called, so a `typeof` check alone is not enough — we probe with a call. + */ +export function supportsAsyncLocalStorageEnterWith( + storage: { enterWith?: unknown }, +): boolean { + if (typeof storage.enterWith !== 'function') return false + try { + storage.enterWith(undefined) + return true + } catch { + return false + } +} + +/** + * Bind `value` to `storage` for the current async execution context. + * Uses native `enterWith()` when available; otherwise relies on + * {@link patchAsyncLocalStorageEnterWith}. + */ +export function bindAsyncLocalStorage( + storage: AsyncLocalStorage, + value: T, +): void { + storage.enterWith(value) +} + +/** Clear a value previously bound with {@link bindAsyncLocalStorage}. */ +export function clearAsyncLocalStorage(storage: AsyncLocalStorage): void { + storage.enterWith(undefined as unknown as T) +} + +/** + * Polyfill `enterWith()` on a single `AsyncLocalStorage` instance. + * + * Elysia's lifecycle is split across hooks (`onRequest` → handler → `onAfterResponse`). + * Unlike Express or Fastify, there is no single `next()` boundary to wrap in + * `storage.run()`, so the integration binds the logger with `enterWith()`. + * + * The polyfill stores the value on the ALS instance when no native `run()` frame + * is active. That matches single-request `wrangler dev` flows and async work + * spawned from a handler, but it does **not** replicate native per-async-context + * isolation when multiple requests interleave in the same isolate. Prefer `{ log }` + * from derive for concurrent Workers handlers. + */ +export function patchAsyncLocalStorageEnterWith( + storage: AsyncLocalStorage, +): void { + if (supportsAsyncLocalStorageEnterWith(storage)) return + + let fallbackStore: T | undefined + let runDepth = 0 + const originalGetStore = storage.getStore.bind(storage) + const originalRun = storage.run.bind(storage) + + Object.defineProperty(storage, 'enterWith', { + configurable: true, + writable: true, + value(store: T): void { + fallbackStore = store + }, + }) + + Object.defineProperty(storage, 'run', { + configurable: true, + writable: true, + value( + store: T, + callback: (...args: unknown[]) => TReturn, + ...args: unknown[] + ): TReturn { + runDepth++ + try { + return originalRun(store, callback, ...args) + } finally { + runDepth-- + } + }, + }) + + Object.defineProperty(storage, 'getStore', { + configurable: true, + writable: true, + value(): T | undefined { + if (runDepth > 0) return originalGetStore() + const active = originalGetStore() + return active !== undefined ? active : fallbackStore + }, + }) +} diff --git a/packages/evlog/test/shared/asyncStorageScope.test.ts b/packages/evlog/test/shared/asyncStorageScope.test.ts new file mode 100644 index 00000000..68f40155 --- /dev/null +++ b/packages/evlog/test/shared/asyncStorageScope.test.ts @@ -0,0 +1,193 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { beforeEach, describe, expect, it } from 'vitest' +import { Elysia } from 'elysia' +import { initLogger } from '../../src/logger' +import { + patchAsyncLocalStorageEnterWith, + supportsAsyncLocalStorageEnterWith, +} from '../../src/shared/asyncStorageScope' +import { evlog } from '../../src/elysia/index' +import { + createPipelineSpies, + findEventViaDrain, + waitForDrainCalls, +} from '../helpers/framework' + +function createWorkersLikeStorage(): AsyncLocalStorage { + class LocalAsyncLocalStorage extends AsyncLocalStorage {} + Object.defineProperty(LocalAsyncLocalStorage.prototype, 'enterWith', { + configurable: true, + writable: true, + value: undefined, + }) + const storage = new LocalAsyncLocalStorage() + patchAsyncLocalStorageEnterWith(storage) + return storage +} + +function createThrowingEnterWithStorage(): AsyncLocalStorage { + class LocalAsyncLocalStorage extends AsyncLocalStorage {} + Object.defineProperty(LocalAsyncLocalStorage.prototype, 'enterWith', { + configurable: true, + writable: true, + value() { + throw new Error('asyncLocalStorage.enterWith() is not implemented') + }, + }) + const storage = new LocalAsyncLocalStorage() + patchAsyncLocalStorageEnterWith(storage) + return storage +} + +function delay(ms = 1) { + return new Promise((resolve) => { + setImmediate(resolve) + }) +} + +async function request(app: { handle: (req: Request) => Promise }, path: string) { + const response = await app.handle(new Request(`http://localhost${path}`)) + await delay() + return response +} + +describe('asyncStorageScope', () => { + it('detects native enterWith support', () => { + expect(supportsAsyncLocalStorageEnterWith(new AsyncLocalStorage())).toBe(true) + }) + + it('detects throwing enterWith as unsupported', () => { + class LocalAsyncLocalStorage extends AsyncLocalStorage {} + Object.defineProperty(LocalAsyncLocalStorage.prototype, 'enterWith', { + configurable: true, + writable: true, + value() { + throw new Error('asyncLocalStorage.enterWith() is not implemented') + }, + }) + + expect(supportsAsyncLocalStorageEnterWith(new LocalAsyncLocalStorage())).toBe(false) + }) + + it('is a no-op when enterWith already exists', () => { + const storage = new AsyncLocalStorage() + const beforeGetStore = AsyncLocalStorage.prototype.getStore + + patchAsyncLocalStorageEnterWith(storage) + + storage.enterWith('request-logger') + expect(storage.getStore()).toBe('request-logger') + expect(AsyncLocalStorage.prototype.getStore).toBe(beforeGetStore) + }) + + it('patches only the provided storage instance', () => { + class LocalAsyncLocalStorage extends AsyncLocalStorage {} + Object.defineProperty(LocalAsyncLocalStorage.prototype, 'enterWith', { + configurable: true, + writable: true, + value: undefined, + }) + + const patched = new LocalAsyncLocalStorage() + const untouched = new LocalAsyncLocalStorage() + + patchAsyncLocalStorageEnterWith(patched) + + patched.enterWith('evlog') + expect(patched.getStore()).toBe('evlog') + expect(untouched.enterWith).toBeUndefined() + }) + + it('polyfills enterWith for runtimes that omit it', async () => { + const storage = createWorkersLikeStorage() + + storage.enterWith('request-logger') + expect(storage.getStore()).toBe('request-logger') + + await Promise.resolve() + expect(storage.getStore()).toBe('request-logger') + + storage.enterWith(undefined as unknown as string) + expect(storage.getStore()).toBeUndefined() + }) + + it('polyfills when enterWith exists but throws at runtime', async () => { + const storage = createThrowingEnterWithStorage() + + storage.enterWith('request-logger') + expect(storage.getStore()).toBe('request-logger') + + await Promise.resolve() + expect(storage.getStore()).toBe('request-logger') + }) + + it('supports elysia-style request scope usage (#394)', async () => { + const storage = createWorkersLikeStorage() + const activeLoggers = new WeakSet() + + function bindRequestLogger(logger: object) { + storage.enterWith(logger) + activeLoggers.add(logger) + } + + function useLogger() { + const logger = storage.getStore() + if (!logger || !activeLoggers.has(logger)) { + throw new Error('[evlog] useLogger() was called outside of an evlog plugin context.') + } + return logger + } + + const requestLogger = { id: 'request-logger' } + bindRequestLogger(requestLogger) + expect(useLogger()).toBe(requestLogger) + await Promise.resolve() + expect(useLogger()).toBe(requestLogger) + + storage.enterWith(undefined as unknown as object) + activeLoggers.delete(requestLogger) + expect(() => useLogger()).toThrow('[evlog] useLogger()') + }) +}) + +describe('evlog/elysia workers concurrency', () => { + beforeEach(() => { + initLogger({ + env: { service: 'elysia-test' }, + pretty: false, + }) + }) + + it('keeps derive log isolated across interleaved requests without enterWith (#394)', async () => { + const { drain } = createPipelineSpies() + const contexts: Record = { + a: undefined, + b: undefined, + } + + const app = new Elysia() + .use(evlog({ drain })) + .get('/api/a', async ({ log }) => { + log.set({ route: 'a' }) + await Promise.resolve() + contexts.a = log.getContext().route as string | undefined + return { ok: true } + }) + .get('/api/b', ({ log }) => { + log.set({ route: 'b' }) + contexts.b = log.getContext().route as string | undefined + return { ok: true } + }) + + await Promise.all([ + request(app, '/api/a'), + request(app, '/api/b'), + ]) + await waitForDrainCalls(drain, 2) + + expect(contexts.a).toBe('a') + expect(contexts.b).toBe('b') + expect(findEventViaDrain(drain, event => event.route === 'a')).toBeDefined() + expect(findEventViaDrain(drain, event => event.route === 'b')).toBeDefined() + }) +})