-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(nextjs): Add cloudflare waitUntil detection
#18336
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import type { Span } from '@sentry/core'; | ||
| import { debug, fill, flush, setHttpStatus } from '@sentry/core'; | ||
| import { debug, fill, flush, GLOBAL_OBJ, setHttpStatus, vercelWaitUntil } from '@sentry/core'; | ||
| import type { ServerResponse } from 'http'; | ||
| import { DEBUG_BUILD } from '../debug-build'; | ||
| import type { ResponseEndMethod, WrappedResponseEndMethod } from '../types'; | ||
|
|
@@ -54,3 +54,53 @@ export async function flushSafelyWithTimeout(): Promise<void> { | |
| DEBUG_BUILD && debug.log('Error while flushing events:\n', e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Uses platform-specific waitUntil function to wait for the provided task to complete without blocking. | ||
| */ | ||
| export function waitUntil(task: Promise<unknown>): void { | ||
| // If deployed on Cloudflare, use the Cloudflare waitUntil function to flush the events | ||
| if (isCloudflareWaitUntilAvailable()) { | ||
| cloudflareWaitUntil(task); | ||
| return; | ||
| } | ||
|
|
||
| // otherwise, use vercel's | ||
| vercelWaitUntil(task); | ||
| } | ||
|
|
||
| type MinimalCloudflareContext = { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| waitUntil(promise: Promise<any>): void; | ||
| }; | ||
|
|
||
| /** | ||
| * Gets the Cloudflare context from the global object. | ||
| * Relevant to opennext | ||
| * https://github.com/opennextjs/opennextjs-cloudflare/blob/b53a046bd5c30e94a42e36b67747cefbf7785f9a/packages/cloudflare/src/cli/templates/init.ts#L17 | ||
| */ | ||
| function _getCloudflareContext(): MinimalCloudflareContext | undefined { | ||
| const cfContextSymbol = Symbol.for('__cloudflare-context__'); | ||
|
||
|
|
||
| return ( | ||
| GLOBAL_OBJ as typeof GLOBAL_OBJ & { | ||
| [cfContextSymbol]?: { | ||
| ctx: MinimalCloudflareContext; | ||
| }; | ||
| } | ||
| )[cfContextSymbol]?.ctx; | ||
| } | ||
|
|
||
| /** | ||
| * Function that delays closing of a Cloudflare lambda until the provided promise is resolved. | ||
| */ | ||
| export function cloudflareWaitUntil(task: Promise<unknown>): void { | ||
| _getCloudflareContext()?.waitUntil(task); | ||
| } | ||
|
|
||
| /** | ||
| * Checks if the Cloudflare waitUntil function is available globally. | ||
| */ | ||
| export function isCloudflareWaitUntilAvailable(): boolean { | ||
| return typeof _getCloudflareContext()?.waitUntil === 'function'; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { GLOBAL_OBJ } from '@sentry/core'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { waitUntil } from '../../../src/common/utils/responseEnd'; | ||
|
|
||
| vi.mock('@sentry/core', async () => { | ||
| const actual = await vi.importActual('@sentry/core'); | ||
| return { | ||
| ...actual, | ||
| debug: { | ||
| log: vi.fn(), | ||
| }, | ||
| flush: vi.fn(), | ||
| vercelWaitUntil: vi.fn(), | ||
| }; | ||
| }); | ||
|
|
||
| describe('responseEnd utils', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| // Clear Cloudflare context | ||
| const cfContextSymbol = Symbol.for('__cloudflare-context__'); | ||
| (GLOBAL_OBJ as any)[cfContextSymbol] = undefined; | ||
| // Clear Vercel context | ||
| const vercelContextSymbol = Symbol.for('@vercel/request-context'); | ||
| (GLOBAL_OBJ as any)[vercelContextSymbol] = undefined; | ||
| }); | ||
|
|
||
| describe('waitUntil', () => { | ||
| it('should use cloudflareWaitUntil when Cloudflare context is available', async () => { | ||
| const cfContextSymbol = Symbol.for('__cloudflare-context__'); | ||
| const cfWaitUntilMock = vi.fn(); | ||
| (GLOBAL_OBJ as any)[cfContextSymbol] = { | ||
| ctx: { | ||
| waitUntil: cfWaitUntilMock, | ||
| }, | ||
| }; | ||
|
|
||
| const testTask = Promise.resolve('test'); | ||
| waitUntil(testTask); | ||
|
|
||
| expect(cfWaitUntilMock).toHaveBeenCalledWith(testTask); | ||
| expect(cfWaitUntilMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| // Should not call vercelWaitUntil when Cloudflare is available | ||
| const { vercelWaitUntil } = await import('@sentry/core'); | ||
| expect(vercelWaitUntil).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should use vercelWaitUntil when Cloudflare context is not available', async () => { | ||
| const { vercelWaitUntil } = await import('@sentry/core'); | ||
| const testTask = Promise.resolve('test'); | ||
|
|
||
| waitUntil(testTask); | ||
|
|
||
| expect(vercelWaitUntil).toHaveBeenCalledWith(testTask); | ||
| expect(vercelWaitUntil).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should prefer Cloudflare over Vercel when both are available', async () => { | ||
| // Set up Cloudflare context | ||
| const cfContextSymbol = Symbol.for('__cloudflare-context__'); | ||
| const cfWaitUntilMock = vi.fn(); | ||
| (GLOBAL_OBJ as any)[cfContextSymbol] = { | ||
| ctx: { | ||
| waitUntil: cfWaitUntilMock, | ||
| }, | ||
| }; | ||
|
|
||
| // Set up Vercel context | ||
| const vercelWaitUntilMock = vi.fn(); | ||
| (GLOBAL_OBJ as any)[Symbol.for('@vercel/request-context')] = { | ||
| get: () => ({ waitUntil: vercelWaitUntilMock }), | ||
| }; | ||
|
|
||
| const testTask = Promise.resolve('test'); | ||
| waitUntil(testTask); | ||
|
|
||
| // Should use Cloudflare | ||
| expect(cfWaitUntilMock).toHaveBeenCalledWith(testTask); | ||
| expect(cfWaitUntilMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| // Should not use Vercel | ||
| const { vercelWaitUntil } = await import('@sentry/core'); | ||
| expect(vercelWaitUntil).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should handle errors gracefully when waitUntil is called with a rejected promise', async () => { | ||
| const { vercelWaitUntil } = await import('@sentry/core'); | ||
| const testTask = Promise.reject(new Error('test error')); | ||
|
|
||
| // Should not throw synchronously | ||
| expect(() => waitUntil(testTask)).not.toThrow(); | ||
| expect(vercelWaitUntil).toHaveBeenCalledWith(testTask); | ||
|
|
||
| // Prevent unhandled rejection in test | ||
| testTask.catch(() => {}); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super-l: Since this is Opennext specific we could theoretically rename it to make it more obvious.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I though the comments would suffice, but you are right, it should be clearer with the naming. I will do that 👍