diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 8693387..7c6521a 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -119,11 +119,9 @@ function callDrainHook(nitroApp: NitroApp, emittedEvent: WideEvent | null, event // Use waitUntil if available (Cloudflare Workers, Vercel Edge) // This ensures drains complete before the runtime terminates - const waitUntil = event.context.cloudflare?.context?.waitUntil - ?? event.context.waitUntil - - if (typeof waitUntil === 'function') { - waitUntil(drainPromise) + const waitUntilCtx = event.context.cloudflare?.context ?? event.context + if (typeof waitUntilCtx?.waitUntil === 'function') { + waitUntilCtx.waitUntil(drainPromise) } } diff --git a/packages/evlog/test/nitro-plugin.test.ts b/packages/evlog/test/nitro-plugin.test.ts index 92854de..5a3332f 100644 --- a/packages/evlog/test/nitro-plugin.test.ts +++ b/packages/evlog/test/nitro-plugin.test.ts @@ -317,11 +317,10 @@ describe('nitro plugin - waitUntil support', () => { }) // Use waitUntil if available (Cloudflare Workers, Vercel Edge) - const waitUntil = event.context.cloudflare?.context?.waitUntil - ?? event.context.waitUntil - - if (typeof waitUntil === 'function') { - waitUntil(drainPromise) + // Call as a method on the context object to preserve `this` binding + const waitUntilCtx = event.context.cloudflare?.context ?? event.context + if (typeof waitUntilCtx?.waitUntil === 'function') { + waitUntilCtx.waitUntil(drainPromise) } } @@ -448,6 +447,49 @@ describe('nitro plugin - waitUntil support', () => { expect(mockHooks.callHook).toHaveBeenCalledWith('evlog:drain', expect.any(Object)) }) + it('preserves this binding when calling waitUntil (prevents Illegal invocation)', () => { + // Simulate a real waitUntil that requires correct `this` binding, + // like Cloudflare's ExecutionContext which throws "Illegal invocation" + // when `waitUntil` is called without proper `this` context + const executionContext = { + _promises: [] as Promise[], + waitUntil(promise: Promise) { + if (this !== executionContext) { + throw new TypeError('Illegal invocation') + } + this._promises.push(promise) + }, + } + + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/test', + context: { + cloudflare: { + context: executionContext, + }, + }, + } + + const mockEmittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'production', + } + + // Should NOT throw "Illegal invocation" because waitUntil is called as a method + expect(() => { + callDrainHook({ hooks: mockHooks }, mockEmittedEvent, mockEvent) + }).not.toThrow() + + expect(executionContext._promises).toHaveLength(1) + }) + it('does not call waitUntil when emittedEvent is null', () => { const mockWaitUntil = vi.fn() const mockHooks = {