diff --git a/apps/docs/content/3.adapters/1.overview.md b/apps/docs/content/3.adapters/1.overview.md index e22b3c9f..c05cccb3 100644 --- a/apps/docs/content/3.adapters/1.overview.md +++ b/apps/docs/content/3.adapters/1.overview.md @@ -27,6 +27,10 @@ export default defineNitroPlugin((nitroApp) => { }) ``` +::callout{icon="i-lucide-cloud" color="info"} +**Serverless Support:** On Cloudflare Workers and Vercel Edge, evlog automatically uses `waitUntil()` to ensure drains complete before the runtime terminates. No additional configuration needed. +:: + ## Available Adapters ::card-group diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index f405cd2b..5bddd227 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -73,14 +73,23 @@ function getResponseStatus(event: ServerEvent): number { } function callDrainHook(nitroApp: NitroApp, emittedEvent: WideEvent | null, event: ServerEvent): void { - if (emittedEvent) { - nitroApp.hooks.callHook('evlog:drain', { - event: emittedEvent, - request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, - headers: getSafeHeaders(event), - }).catch((err) => { - console.error('[evlog] drain failed:', err) - }) + if (!emittedEvent) return + + const drainPromise = nitroApp.hooks.callHook('evlog:drain', { + event: emittedEvent, + request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, + headers: getSafeHeaders(event), + }).catch((err) => { + console.error('[evlog] drain failed:', err) + }) + + // 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) } } diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index 72de2f6f..ea812b40 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -354,7 +354,16 @@ export interface H3EventContext { export interface ServerEvent { method: string path: string - context: H3EventContext + context: H3EventContext & { + /** Cloudflare Workers context (available when deployed to CF Workers) */ + cloudflare?: { + context: { + waitUntil: (promise: Promise) => void + } + } + /** Vercel Edge context (available when deployed to Vercel Edge) */ + waitUntil?: (promise: Promise) => void + } node?: { res?: { statusCode?: number } } response?: Response } diff --git a/packages/evlog/test/nitro-plugin.test.ts b/packages/evlog/test/nitro-plugin.test.ts index af2b4c01..531b374f 100644 --- a/packages/evlog/test/nitro-plugin.test.ts +++ b/packages/evlog/test/nitro-plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getHeaders } from 'h3' -import type { DrainContext } from '../src/types' +import type { DrainContext, ServerEvent, WideEvent } from '../src/types' // Mock h3's getHeaders vi.mock('h3', () => ({ @@ -297,3 +297,178 @@ describe('nitro plugin - drain hook headers', () => { expect(drainContext!.headers).toHaveProperty('content-type', 'application/json') }) }) + +describe('nitro plugin - waitUntil support', () => { + /** Simulate the callDrainHook function from the plugin */ + function callDrainHook( + nitroApp: { hooks: { callHook: (name: string, ctx: DrainContext) => Promise } }, + emittedEvent: WideEvent | null, + event: ServerEvent, + ): void { + if (!emittedEvent) return + + const drainPromise = nitroApp.hooks.callHook('evlog:drain', { + event: emittedEvent, + request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, + headers: {}, + }).catch((err) => { + console.error('[evlog] drain failed:', err) + }) + + // Use waitUntil if available (Cloudflare Workers, Vercel Edge) + const waitUntil = event.context.cloudflare?.context?.waitUntil + ?? event.context.waitUntil + + if (typeof waitUntil === 'function') { + waitUntil(drainPromise) + } + } + + it('calls waitUntil with Cloudflare Workers context', () => { + const mockWaitUntil = vi.fn() + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/test', + context: { + cloudflare: { + context: { + waitUntil: mockWaitUntil, + }, + }, + }, + } + + const mockEmittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'production', + } + + callDrainHook({ hooks: mockHooks }, mockEmittedEvent, mockEvent) + + // Verify waitUntil was called with a promise + expect(mockWaitUntil).toHaveBeenCalledTimes(1) + expect(mockWaitUntil).toHaveBeenCalledWith(expect.any(Promise)) + }) + + it('calls waitUntil with Vercel Edge context', () => { + const mockWaitUntil = vi.fn() + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/users', + context: { + waitUntil: mockWaitUntil, + }, + } + + const mockEmittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'production', + } + + callDrainHook({ hooks: mockHooks }, mockEmittedEvent, mockEvent) + + // Verify waitUntil was called with a promise + expect(mockWaitUntil).toHaveBeenCalledTimes(1) + expect(mockWaitUntil).toHaveBeenCalledWith(expect.any(Promise)) + }) + + it('prefers Cloudflare waitUntil over Vercel when both are present', () => { + const mockCfWaitUntil = vi.fn() + const mockVercelWaitUntil = vi.fn() + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/checkout', + context: { + cloudflare: { + context: { + waitUntil: mockCfWaitUntil, + }, + }, + waitUntil: mockVercelWaitUntil, + }, + } + + const mockEmittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'production', + } + + callDrainHook({ hooks: mockHooks }, mockEmittedEvent, mockEvent) + + // Cloudflare should be preferred + expect(mockCfWaitUntil).toHaveBeenCalledTimes(1) + expect(mockVercelWaitUntil).not.toHaveBeenCalled() + }) + + it('works without waitUntil (traditional Node.js server)', () => { + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/health', + context: { + // No cloudflare or waitUntil context + }, + } + + const mockEmittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'development', + } + + // Should not throw + expect(() => { + callDrainHook({ hooks: mockHooks }, mockEmittedEvent, mockEvent) + }).not.toThrow() + + // Drain hook should still be called + expect(mockHooks.callHook).toHaveBeenCalledWith('evlog:drain', expect.any(Object)) + }) + + it('does not call waitUntil when emittedEvent is null', () => { + const mockWaitUntil = vi.fn() + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/test', + context: { + cloudflare: { + context: { + waitUntil: mockWaitUntil, + }, + }, + }, + } + + callDrainHook({ hooks: mockHooks }, null, mockEvent) + + // Neither should be called when event is null + expect(mockWaitUntil).not.toHaveBeenCalled() + expect(mockHooks.callHook).not.toHaveBeenCalled() + }) +})