From cd5e4f9f9dfec525912802392696b4a78df4ec59 Mon Sep 17 00:00:00 2001 From: Sean LeCheminant Date: Thu, 5 Feb 2026 15:27:28 -0600 Subject: [PATCH 1/3] fix: Extracting waitUntil and calling it directly is losing its this context. --- packages/evlog/src/nitro/plugin.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 18d61a2..f9cfc3a 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -119,11 +119,12 @@ 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 + const cfContext = event.context.cloudflare?.context - if (typeof waitUntil === 'function') { - waitUntil(drainPromise) + if (cfContext && typeof cfContext.waitUntil === 'function') { + cfContext.waitUntil(drainPromise) + } else if (event.context.waitUntil && typeof event.context.waitUntil === 'function') { + event.context.waitUntil(drainPromise) } } From 3f3f88705233e0cd394d1e8f561914c29fa5bc44 Mon Sep 17 00:00:00 2001 From: Sean LeCheminant Date: Thu, 5 Feb 2026 15:39:06 -0600 Subject: [PATCH 2/3] simplified --- packages/evlog/src/nitro/plugin.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index f9cfc3a..75728d3 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -119,12 +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 cfContext = event.context.cloudflare?.context - - if (cfContext && typeof cfContext.waitUntil === 'function') { - cfContext.waitUntil(drainPromise) - } else if (event.context.waitUntil && typeof event.context.waitUntil === 'function') { - event.context.waitUntil(drainPromise) + const waitUntilCtx = event.context.cloudflare?.context ?? event.context + if (typeof waitUntilCtx?.waitUntil === 'function') { + waitUntilCtx.waitUntil(drainPromise) } } From 07edc7669b94f07a8158515cfd2317e3f80f206e Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sat, 7 Feb 2026 17:51:30 +0000 Subject: [PATCH 3/3] fix tests --- packages/evlog/test/nitro-plugin.test.ts | 52 +++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) 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 = {