-
Notifications
You must be signed in to change notification settings - Fork 47
fix(elysia): polyfill enterWith for Cloudflare Workers #395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
HugoRCD
wants to merge
6
commits into
main
Choose a base branch
from
fix/elysia-workers-asynclocalstorage
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d49c16a
fix(elysia): polyfill enterWith for Cloudflare Workers
HugoRCD 9b2bd11
fix(elysia): isolate enterWith polyfill tests from global ALS
HugoRCD 700415c
fix(elysia): tighten asyncStorageScope polyfill typings
HugoRCD bfc29f1
refactor(elysia): simplify enterWith polyfill and address review
HugoRCD efb8eed
fix(elysia): patch only the evlog AsyncLocalStorage instance
HugoRCD 8ce0538
fix(elysia): probe enterWith support instead of typeof check
HugoRCD File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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 | ||
| }, | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.