Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-elysia-workers-asynclocalstorage.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 15 additions & 6 deletions packages/evlog/src/elysia/index.ts
Original file line number Diff line number Diff line change
@@ -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<AuditableLogger>()
patchAsyncLocalStorageEnterWith(storage)

const activeLoggers = new WeakSet<AuditableLogger>()

Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Comment thread
HugoRCD marked this conversation as resolved.
requestState.set(request, { finish, skipped, logger })
})
.derive({ as: 'global' }, ({ request }) => {
Expand All @@ -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)
Expand All @@ -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)
})
}
95 changes: 95 additions & 0 deletions packages/evlog/src/shared/asyncStorageScope.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
storage: AsyncLocalStorage<T>,
value: T,
): void {
storage.enterWith(value)
}

/** Clear a value previously bound with {@link bindAsyncLocalStorage}. */
export function clearAsyncLocalStorage<T>(storage: AsyncLocalStorage<T>): 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<T>(
storage: AsyncLocalStorage<T>,
): void {
if (supportsAsyncLocalStorageEnterWith(storage)) return
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<TReturn>(
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
},
})
}
193 changes: 193 additions & 0 deletions packages/evlog/test/shared/asyncStorageScope.test.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(): AsyncLocalStorage<T> {
class LocalAsyncLocalStorage extends AsyncLocalStorage<T> {}
Object.defineProperty(LocalAsyncLocalStorage.prototype, 'enterWith', {
configurable: true,
writable: true,
value: undefined,
})
const storage = new LocalAsyncLocalStorage()
patchAsyncLocalStorageEnterWith(storage)
return storage
}

function createThrowingEnterWithStorage<T = unknown>(): AsyncLocalStorage<T> {
class LocalAsyncLocalStorage extends AsyncLocalStorage<T> {}
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<Response> }, 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<string>())).toBe(true)
})

it('detects throwing enterWith as unsupported', () => {
class LocalAsyncLocalStorage extends AsyncLocalStorage<string> {}
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<string>()
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<string> {}
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<string>()

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<string>()

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<object>()
const activeLoggers = new WeakSet<object>()

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<string, string | undefined> = {
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()
})
})
Loading