Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/docs/content/3.adapters/1.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions packages/evlog/src/nitro/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
11 changes: 10 additions & 1 deletion packages/evlog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) => void
}
}
/** Vercel Edge context (available when deployed to Vercel Edge) */
waitUntil?: (promise: Promise<unknown>) => void
}
node?: { res?: { statusCode?: number } }
response?: Response
}
Expand Down
177 changes: 176 additions & 1 deletion packages/evlog/test/nitro-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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<void> } },
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()
})
})
Loading