Skip to content
Draft
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
131 changes: 128 additions & 3 deletions src/adapter/cloudflare-worker/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
import { ElysiaAdapter } from '../..'
import { WebStandardAdapter } from '../web-standard/index'
import type { ExecutionContext } from '../types'

/**
* Global variable to store the current ExecutionContext
* This gets set by the Cloudflare Worker runtime for each request
*/
declare global {
var __cloudflareExecutionContext: ExecutionContext | undefined
}

/**
* Creates a setImmediate that automatically detects and uses the current ExecutionContext
* This works with the standard export default app pattern
* @returns setImmediate function that uses the current ExecutionContext if available
*/
export function createAutoDetectingSetImmediate(): (
callback: () => void
) => void {
return (callback: () => void) => {
// Check if we're in a Cloudflare Worker environment and have an ExecutionContext
if (
typeof globalThis.__cloudflareExecutionContext !== 'undefined' &&
globalThis.__cloudflareExecutionContext &&
typeof globalThis.__cloudflareExecutionContext.waitUntil ===
'function'
) {
// Use the current ExecutionContext with proper error handling
globalThis.__cloudflareExecutionContext.waitUntil(
Promise.resolve()
.then(callback)
.catch((error) => {
console.error(
'Error in setImmediate callback (ExecutionContext):',
error
)
})
)
} else {
// Fallback to Promise.resolve with error handling
Promise.resolve()
.then(callback)
.catch((error) => {
console.error(
'Error in setImmediate callback (Promise.resolve):',
error
)
})
}
}
}

export function isCloudflareWorker() {
try {
Expand Down Expand Up @@ -42,6 +92,83 @@ export function isCloudflareWorker() {
export const CloudflareAdapter: ElysiaAdapter = {
...WebStandardAdapter,
name: 'cloudflare-worker',
async stop(app, closeActiveConnections) {
// Call onStop lifecycle hooks for Cloudflare Workers
if (app.event.stop) {
try {
for (let i = 0; i < app.event.stop.length; i++)
await app.event.stop[i].fn(app)
} catch (error) {
console.error('Error in Cloudflare Worker stop hooks:', error)
}
}
},
beforeCompile(app) {
// Polyfill setImmediate for Cloudflare Workers - use auto-detecting version
// This will automatically use ExecutionContext when available
if (typeof globalThis.setImmediate === 'undefined') {
Object.defineProperty(globalThis, 'setImmediate', {
value: createAutoDetectingSetImmediate(),
writable: true,
enumerable: true,
configurable: true
})
}

// Also set it on the global object for compatibility
if (
typeof global !== 'undefined' &&
typeof global.setImmediate === 'undefined'
) {
Object.defineProperty(global, 'setImmediate', {
value: globalThis.setImmediate,
writable: true,
enumerable: true,
configurable: true
})
}

// Override app.fetch to accept ExecutionContext and set it globally
const originalFetch = app.fetch.bind(app)
app.fetch = function (
request: Request,
env?: any,
ctx?: ExecutionContext
) {
try {
if (ctx) {
globalThis.__cloudflareExecutionContext = ctx
}
const result = originalFetch(request)
// Clean up context after request to prevent memory leaks and context bleeding
globalThis.__cloudflareExecutionContext = undefined
return result
} catch (error) {
Comment on lines +131 to +146
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Keep ExecutionContext alive until the request truly finishes

We set globalThis.__cloudflareExecutionContext = ctx, call originalFetch(request), and then immediately clear the global before the returned promise settles. All downstream async work (handlers, finally blocks, afterResponse, etc.) therefore run with the context wiped out, so waitUntil is never available and the feature still falls back to the Promise branch. The same happens on errors—cleanup runs before hooks fire.

Wrap the originalFetch call so the context remains set for the entire lifetime of the request and only restore/clear it in a finally after the promise resolves, e.g.:

-		const result = originalFetch(request)
-		// Clean up context after request to prevent memory leaks and context bleeding
-		globalThis.__cloudflareExecutionContext = undefined
-		return result
+		const previous = globalThis.__cloudflareExecutionContext
+		if (ctx) globalThis.__cloudflareExecutionContext = ctx
+
+		const result = originalFetch(request)
+
+		const cleanup = () => {
+			if (previous === undefined) {
+				delete globalThis.__cloudflareExecutionContext
+			} else {
+				globalThis.__cloudflareExecutionContext = previous
+			}
+		}
+
+		return result instanceof Promise ? result.finally(cleanup) : (cleanup(), result)

This keeps waitUntil reachable for afterResponse while still preventing leaks and restores any prior context to avoid cross-request bleeding.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Override app.fetch to accept ExecutionContext and set it globally
const originalFetch = app.fetch.bind(app)
app.fetch = function (
request: Request,
env?: any,
ctx?: ExecutionContext
) {
try {
if (ctx) {
globalThis.__cloudflareExecutionContext = ctx
}
const result = originalFetch(request)
// Clean up context after request to prevent memory leaks and context bleeding
globalThis.__cloudflareExecutionContext = undefined
return result
} catch (error) {
// Override app.fetch to accept ExecutionContext and set it globally
const originalFetch = app.fetch.bind(app)
app.fetch = function (
request: Request,
env?: any,
ctx?: ExecutionContext
) {
try {
if (ctx) {
globalThis.__cloudflareExecutionContext = ctx
}
const previous = globalThis.__cloudflareExecutionContext
if (ctx) globalThis.__cloudflareExecutionContext = ctx
const result = originalFetch(request)
const cleanup = () => {
if (previous === undefined) {
delete globalThis.__cloudflareExecutionContext
} else {
globalThis.__cloudflareExecutionContext = previous
}
}
return result instanceof Promise
? result.finally(cleanup)
: (cleanup(), result)
} catch (error) {
// existing error handling...
}
}
🤖 Prompt for AI Agents
In src/adapter/cloudflare-worker/index.ts around lines 131 to 146, the code sets
globalThis.__cloudflareExecutionContext before calling originalFetch(request)
but clears it immediately after the call, which tears down the context before
the returned promise settles; change this so the context remains set for the
full lifetime of the request by saving the previous context, assigning
globalThis.__cloudflareExecutionContext = ctx, calling originalFetch(request)
and then attaching a finally handler (or awaiting the promise) that restores the
previous context (or sets undefined if none) only after the promise resolves or
rejects; ensure errors are re-thrown/passed through so behavior is unchanged and
memory/context leaks are prevented.

console.error(
'Error in Cloudflare Worker fetch override:',
error
)
throw error
}
}

for (const route of app.routes) route.compile()

// Call onStart lifecycle hooks for Cloudflare Workers
// since they use the compile pattern instead of listen
if (app.event.start)
for (let i = 0; i < app.event.start.length; i++)
app.event.start[i].fn(app)
},
composeHandler: {
...WebStandardAdapter.composeHandler,
inject: {
...WebStandardAdapter.composeHandler.inject,
// Provide setImmediate for composed handlers in Workers
// This uses the auto-detecting version that will use ExecutionContext when available
setImmediate: createAutoDetectingSetImmediate()
}
},
composeGeneralHandler: {
...WebStandardAdapter.composeGeneralHandler,
error404(hasEventHook, hasErrorHook, afterHandle) {
Expand All @@ -61,9 +188,7 @@ export const CloudflareAdapter: ElysiaAdapter = {
}
}
},
beforeCompile(app) {
for (const route of app.routes) route.compile()
},

listen(app) {
return (options, callback) => {
console.warn(
Expand Down
16 changes: 16 additions & 0 deletions src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ import type { Sucrose } from '../sucrose'
import type { Prettify, AnyLocalHook, MaybePromise } from '../types'
import type { AnyWSLocalHook } from '../ws/types'

/**
* ExecutionContext interface for Cloudflare Workers
*/
export interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void
passThroughOnException(): void
readonly props: Props
}

/**
* Request context for adapters that need additional runtime information
*/
export interface RequestContext {
executionContext?: ExecutionContext
}

export interface ElysiaAdapter {
name: string
listen(
Expand Down
38 changes: 34 additions & 4 deletions src/dynamic-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TypeCheck } from './type-system'

import type { Context } from './context'
import type { ElysiaTypeCheck } from './schema'
import type { ExecutionContext, RequestContext } from './adapter/types'

import {
ElysiaCustomStatusResponse,
Expand All @@ -21,6 +22,14 @@ import { parseCookie } from './cookies'

import type { Handler, LifeCycleStore, SchemaValidator } from './types'

/**
* Global variable to store the current ExecutionContext
* This gets set by the Cloudflare Worker runtime for each request
*/
declare global {
var __cloudflareExecutionContext: ExecutionContext | undefined
}

// JIT Handler
export type DynamicHandler = {
handle: unknown | Handler<any, any>
Expand Down Expand Up @@ -54,7 +63,10 @@ export const createDynamicHandler = (app: AnyElysia) => {
// @ts-ignore
const defaultHeader = app.setHeaders

return async (request: Request): Promise<Response> => {
return async (
request: Request,
requestContext?: RequestContext
): Promise<Response> => {
const url = request.url,
s = url.indexOf('/', 11),
qi = url.indexOf('?', s + 1),
Expand Down Expand Up @@ -663,11 +675,29 @@ export const createDynamicHandler = (app: AnyElysia) => {
// @ts-expect-error private
return app.handleError(context, reportedError)
} finally {
if (app.event.afterResponse)
setImmediate(async () => {
if (app.event.afterResponse) {
// Use a polyfill that works in Cloudflare Workers
const runAfterResponse = async () => {
for (const afterResponse of app.event.afterResponse!)
await afterResponse.fn(context as any)
})
}

// Get the best available async execution method
const waitUntil =
requestContext?.executionContext?.waitUntil ||
globalThis.__cloudflareExecutionContext?.waitUntil

if (waitUntil) {
// Use ExecutionContext.waitUntil for Cloudflare Workers
waitUntil(Promise.resolve().then(runAfterResponse))
} else if (typeof setImmediate !== 'undefined') {
// Use setImmediate (may be polyfilled by adapter)
setImmediate(runAfterResponse)
} else {
// Fallback to Promise.resolve for other environments
Promise.resolve().then(runAfterResponse)
}
}
}
}
}
Expand Down