From aca3934af5e203b5c823b46eaf174335fb5ef748 Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 15:12:38 -0300 Subject: [PATCH 1/7] feat(cloudflare): add lifecycle hooks and setimmediate polyfill for workers --- src/adapter/cloudflare-worker/index.ts | 81 +++++++++- src/dynamic-handle.ts | 26 +++- test/cloudflare/lifecycle-hooks.test.ts | 187 ++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 test/cloudflare/lifecycle-hooks.test.ts diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index 8ce597d6..d1693564 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -42,6 +42,78 @@ export function isCloudflareWorker() { export const CloudflareAdapter: ElysiaAdapter = { ...WebStandardAdapter, name: 'cloudflare-worker', + async stop(app, closeActiveConnections) { + if (app.server) { + // Call onStop lifecycle hooks for Cloudflare Workers + if (app.event.stop) + for (let i = 0; i < app.event.stop.length; i++) + app.event.stop[i].fn(app) + } else { + console.log( + "Elysia isn't running. Call `app.listen` to start the server.", + new Error().stack + ) + } + }, + beforeCompile(app) { + // Polyfill setImmediate for Cloudflare Workers - set it globally + if (typeof globalThis.setImmediate === 'undefined') { + // @ts-ignore - Polyfill for Cloudflare Workers + globalThis.setImmediate = (callback: () => void) => { + // Check if we're in a Cloudflare Worker environment with ctx.waitUntil + if (typeof globalThis !== 'undefined' && + // @ts-ignore - Check for Cloudflare Workers context + globalThis.ctx && + // @ts-ignore + typeof globalThis.ctx.waitUntil === 'function') { + + // Use ctx.waitUntil to ensure the callback completes + // @ts-ignore + globalThis.ctx.waitUntil(Promise.resolve().then(callback)) + } else { + // Fallback to Promise.resolve for non-Cloudflare environments + Promise.resolve().then(callback) + } + } + } + + // Also set it on the global object for compatibility + if (typeof global !== 'undefined' && typeof global.setImmediate === 'undefined') { + // @ts-ignore + global.setImmediate = globalThis.setImmediate + } + + 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 + setImmediate: (callback: () => void) => { + // Same logic as above - use ctx.waitUntil if available + if (typeof globalThis !== 'undefined' && + // @ts-ignore - Checking for Worker context + globalThis.ctx && + // @ts-ignore + typeof globalThis.ctx.waitUntil === 'function') { + + // Let the Worker runtime handle the async work + // @ts-ignore + globalThis.ctx.waitUntil(Promise.resolve().then(callback)) + } else { + // Standard promise-based fallback + Promise.resolve().then(callback) + } + } + } + }, composeGeneralHandler: { ...WebStandardAdapter.composeGeneralHandler, error404(hasEventHook, hasErrorHook, afterHandle) { @@ -61,9 +133,7 @@ export const CloudflareAdapter: ElysiaAdapter = { } } }, - beforeCompile(app) { - for (const route of app.routes) route.compile() - }, + listen(app) { return (options, callback) => { console.warn( @@ -71,6 +141,11 @@ export const CloudflareAdapter: ElysiaAdapter = { ) app.compile() + + // Call onStart lifecycle hooks + if (app.event.start) + for (let i = 0; i < app.event.start.length; i++) + app.event.start[i].fn(app) } } } diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index 6b9cacb0..3c21618f 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -663,11 +663,31 @@ 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) - }) + } + + // Check if we're in a Cloudflare Worker environment with ctx.waitUntil + if (typeof globalThis !== 'undefined' && + // @ts-ignore - Check for Cloudflare Workers context + globalThis.ctx && + // @ts-ignore + typeof globalThis.ctx.waitUntil === 'function') { + + // Use ctx.waitUntil to ensure the hooks complete in Cloudflare Workers + // @ts-ignore + globalThis.ctx.waitUntil(runAfterResponse()) + } else if (typeof setImmediate !== 'undefined') { + // Use setImmediate for Node.js environments + setImmediate(runAfterResponse) + } else { + // Fallback to Promise.resolve for other environments + Promise.resolve().then(runAfterResponse) + } + } } } } diff --git a/test/cloudflare/lifecycle-hooks.test.ts b/test/cloudflare/lifecycle-hooks.test.ts new file mode 100644 index 00000000..c40797dc --- /dev/null +++ b/test/cloudflare/lifecycle-hooks.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, spyOn, beforeEach, afterEach } from 'bun:test' +import { Elysia } from '../../src' +import { CloudflareAdapter } from '../../src/adapter/cloudflare-worker' + +describe('Cloudflare Worker Adapter Lifecycle Hooks', () => { + let consoleSpy: ReturnType + + beforeEach(() => { + consoleSpy = spyOn(console, 'log') + }) + + afterEach(() => { + consoleSpy.mockRestore() + }) + + describe('Server-level hooks', () => { + it('should call onStart lifecycle hooks when app is compiled', () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onStart(() => { + console.log('🚀 Server started!') + }) + .get('/', () => 'Hello World') + .compile() + + expect(consoleSpy).toHaveBeenCalledWith('🚀 Server started!') + }) + + it('should call multiple onStart hooks in order', () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onStart(() => { + console.log('First onStart') + }) + .onStart(() => { + console.log('Second onStart') + }) + .get('/', () => 'Hello World') + .compile() + + expect(consoleSpy).toHaveBeenCalledWith('First onStart') + expect(consoleSpy).toHaveBeenCalledWith('Second onStart') + }) + + it('should not call onStart hooks if none are registered', () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .get('/', () => 'Hello World') + .compile() + + expect(consoleSpy).not.toHaveBeenCalled() + }) + + it('should call onStop hooks when stop is called', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onStop(() => { + console.log('🛑 Server stopped!') + }) + .get('/', () => 'Hello World') + .compile() + + // Call stop to trigger onStop hooks + await app.stop() + + expect(consoleSpy).toHaveBeenCalledWith('🛑 Server stopped!') + }) + }) + + describe('Request-level hooks', () => { + it('should call onRequest hooks during request processing', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onRequest(() => { + console.log('📨 Request received!') + }) + .get('/', () => 'Hello World') + .compile() + + const response = await app.fetch(new Request('http://localhost/')) + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith('📨 Request received!') + }) + + it('should call onParse hooks during request parsing', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onParse(() => { + console.log('🔍 Parsing request!') + }) + .post('/', () => 'Hello World') + .compile() + + const response = await app.fetch(new Request('http://localhost/', { + method: 'POST', + body: JSON.stringify({ test: 'data' }), + headers: { 'Content-Type': 'application/json' } + })) + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith('🔍 Parsing request!') + }) + + it('should call onTransform hooks during context transformation', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onTransform(() => { + console.log('🔄 Transforming context!') + }) + .get('/', () => 'Hello World') + .compile() + + const response = await app.fetch(new Request('http://localhost/')) + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith('🔄 Transforming context!') + }) + + it('should call onBeforeHandle hooks before route handler', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onBeforeHandle(() => { + console.log('⚡ Before handle!') + }) + .get('/', () => 'Hello World') + .compile() + + const response = await app.fetch(new Request('http://localhost/')) + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith('⚡ Before handle!') + }) + + it('should call onAfterHandle hooks after route handler', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onAfterHandle(() => { + console.log('✅ After handle!') + }) + .get('/', () => 'Hello World') + .compile() + + const response = await app.fetch(new Request('http://localhost/')) + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith('✅ After handle!') + }) + + it('should call onAfterResponse hooks after response is sent', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onAfterResponse(() => { + console.log('📤 After response!') + }) + .get('/', () => 'Hello World') + .compile() + + const response = await app.fetch(new Request('http://localhost/')) + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith('📤 After response!') + }) + + it('should call onError hooks when an error occurs', async () => { + const app = new Elysia({ + adapter: CloudflareAdapter + }) + .onError(() => { + console.log('❌ Error occurred!') + return 'Error handled' + }) + .get('/', () => { + throw new Error('Test error') + }) + .compile() + + const response = await app.fetch(new Request('http://localhost/')) + expect(response.status).toBe(500) + expect(consoleSpy).toHaveBeenCalledWith('❌ Error occurred!') + }) + }) +}) From 91b6246f0eea19bec075f027b11ab3e7b2f193ed Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 15:22:42 -0300 Subject: [PATCH 2/7] chore: remove uncessary test file --- test/cloudflare/lifecycle-hooks.test.ts | 187 ------------------------ 1 file changed, 187 deletions(-) delete mode 100644 test/cloudflare/lifecycle-hooks.test.ts diff --git a/test/cloudflare/lifecycle-hooks.test.ts b/test/cloudflare/lifecycle-hooks.test.ts deleted file mode 100644 index c40797dc..00000000 --- a/test/cloudflare/lifecycle-hooks.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it, spyOn, beforeEach, afterEach } from 'bun:test' -import { Elysia } from '../../src' -import { CloudflareAdapter } from '../../src/adapter/cloudflare-worker' - -describe('Cloudflare Worker Adapter Lifecycle Hooks', () => { - let consoleSpy: ReturnType - - beforeEach(() => { - consoleSpy = spyOn(console, 'log') - }) - - afterEach(() => { - consoleSpy.mockRestore() - }) - - describe('Server-level hooks', () => { - it('should call onStart lifecycle hooks when app is compiled', () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onStart(() => { - console.log('🚀 Server started!') - }) - .get('/', () => 'Hello World') - .compile() - - expect(consoleSpy).toHaveBeenCalledWith('🚀 Server started!') - }) - - it('should call multiple onStart hooks in order', () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onStart(() => { - console.log('First onStart') - }) - .onStart(() => { - console.log('Second onStart') - }) - .get('/', () => 'Hello World') - .compile() - - expect(consoleSpy).toHaveBeenCalledWith('First onStart') - expect(consoleSpy).toHaveBeenCalledWith('Second onStart') - }) - - it('should not call onStart hooks if none are registered', () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .get('/', () => 'Hello World') - .compile() - - expect(consoleSpy).not.toHaveBeenCalled() - }) - - it('should call onStop hooks when stop is called', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onStop(() => { - console.log('🛑 Server stopped!') - }) - .get('/', () => 'Hello World') - .compile() - - // Call stop to trigger onStop hooks - await app.stop() - - expect(consoleSpy).toHaveBeenCalledWith('🛑 Server stopped!') - }) - }) - - describe('Request-level hooks', () => { - it('should call onRequest hooks during request processing', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onRequest(() => { - console.log('📨 Request received!') - }) - .get('/', () => 'Hello World') - .compile() - - const response = await app.fetch(new Request('http://localhost/')) - expect(response.status).toBe(200) - expect(consoleSpy).toHaveBeenCalledWith('📨 Request received!') - }) - - it('should call onParse hooks during request parsing', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onParse(() => { - console.log('🔍 Parsing request!') - }) - .post('/', () => 'Hello World') - .compile() - - const response = await app.fetch(new Request('http://localhost/', { - method: 'POST', - body: JSON.stringify({ test: 'data' }), - headers: { 'Content-Type': 'application/json' } - })) - expect(response.status).toBe(200) - expect(consoleSpy).toHaveBeenCalledWith('🔍 Parsing request!') - }) - - it('should call onTransform hooks during context transformation', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onTransform(() => { - console.log('🔄 Transforming context!') - }) - .get('/', () => 'Hello World') - .compile() - - const response = await app.fetch(new Request('http://localhost/')) - expect(response.status).toBe(200) - expect(consoleSpy).toHaveBeenCalledWith('🔄 Transforming context!') - }) - - it('should call onBeforeHandle hooks before route handler', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onBeforeHandle(() => { - console.log('⚡ Before handle!') - }) - .get('/', () => 'Hello World') - .compile() - - const response = await app.fetch(new Request('http://localhost/')) - expect(response.status).toBe(200) - expect(consoleSpy).toHaveBeenCalledWith('⚡ Before handle!') - }) - - it('should call onAfterHandle hooks after route handler', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onAfterHandle(() => { - console.log('✅ After handle!') - }) - .get('/', () => 'Hello World') - .compile() - - const response = await app.fetch(new Request('http://localhost/')) - expect(response.status).toBe(200) - expect(consoleSpy).toHaveBeenCalledWith('✅ After handle!') - }) - - it('should call onAfterResponse hooks after response is sent', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onAfterResponse(() => { - console.log('📤 After response!') - }) - .get('/', () => 'Hello World') - .compile() - - const response = await app.fetch(new Request('http://localhost/')) - expect(response.status).toBe(200) - expect(consoleSpy).toHaveBeenCalledWith('📤 After response!') - }) - - it('should call onError hooks when an error occurs', async () => { - const app = new Elysia({ - adapter: CloudflareAdapter - }) - .onError(() => { - console.log('❌ Error occurred!') - return 'Error handled' - }) - .get('/', () => { - throw new Error('Test error') - }) - .compile() - - const response = await app.fetch(new Request('http://localhost/')) - expect(response.status).toBe(500) - expect(consoleSpy).toHaveBeenCalledWith('❌ Error occurred!') - }) - }) -}) From cdd9b107158115a9a9535f42ebba1cd7f9d04615 Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 15:51:13 -0300 Subject: [PATCH 3/7] wip: fix some pr comments --- src/adapter/cloudflare-worker/index.ts | 84 ++++++++++++-------------- src/dynamic-handle.ts | 18 ++---- 2 files changed, 45 insertions(+), 57 deletions(-) diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index d1693564..165013d9 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -1,6 +1,40 @@ import { ElysiaAdapter } from '../..' import { WebStandardAdapter } from '../web-standard/index' +/** + * 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 { // Check for the presence of caches.default, which is a global in Workers @@ -43,38 +77,17 @@ export const CloudflareAdapter: ElysiaAdapter = { ...WebStandardAdapter, name: 'cloudflare-worker', async stop(app, closeActiveConnections) { - if (app.server) { // Call onStop lifecycle hooks for Cloudflare Workers if (app.event.stop) for (let i = 0; i < app.event.stop.length; i++) app.event.stop[i].fn(app) - } else { - console.log( - "Elysia isn't running. Call `app.listen` to start the server.", - new Error().stack - ) - } }, beforeCompile(app) { - // Polyfill setImmediate for Cloudflare Workers - set it globally + // Polyfill setImmediate for Cloudflare Workers - use auto-detecting version + // This will automatically use ExecutionContext when available if (typeof globalThis.setImmediate === 'undefined') { // @ts-ignore - Polyfill for Cloudflare Workers - globalThis.setImmediate = (callback: () => void) => { - // Check if we're in a Cloudflare Worker environment with ctx.waitUntil - if (typeof globalThis !== 'undefined' && - // @ts-ignore - Check for Cloudflare Workers context - globalThis.ctx && - // @ts-ignore - typeof globalThis.ctx.waitUntil === 'function') { - - // Use ctx.waitUntil to ensure the callback completes - // @ts-ignore - globalThis.ctx.waitUntil(Promise.resolve().then(callback)) - } else { - // Fallback to Promise.resolve for non-Cloudflare environments - Promise.resolve().then(callback) - } - } + globalThis.setImmediate = createAutoDetectingSetImmediate() } // Also set it on the global object for compatibility @@ -96,22 +109,8 @@ export const CloudflareAdapter: ElysiaAdapter = { inject: { ...WebStandardAdapter.composeHandler.inject, // Provide setImmediate for composed handlers in Workers - setImmediate: (callback: () => void) => { - // Same logic as above - use ctx.waitUntil if available - if (typeof globalThis !== 'undefined' && - // @ts-ignore - Checking for Worker context - globalThis.ctx && - // @ts-ignore - typeof globalThis.ctx.waitUntil === 'function') { - - // Let the Worker runtime handle the async work - // @ts-ignore - globalThis.ctx.waitUntil(Promise.resolve().then(callback)) - } else { - // Standard promise-based fallback - Promise.resolve().then(callback) - } - } + // This uses the auto-detecting version that will use ExecutionContext when available + setImmediate: createAutoDetectingSetImmediate() } }, composeGeneralHandler: { @@ -141,11 +140,6 @@ export const CloudflareAdapter: ElysiaAdapter = { ) app.compile() - - // Call onStart lifecycle hooks - if (app.event.start) - for (let i = 0; i < app.event.start.length; i++) - app.event.start[i].fn(app) } } } diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index 3c21618f..00dc9e67 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -54,7 +54,7 @@ export const createDynamicHandler = (app: AnyElysia) => { // @ts-ignore const defaultHeader = app.setHeaders - return async (request: Request): Promise => { + return async (request: Request, requestContext?: { executionContext?: ExecutionContext }): Promise => { const url = request.url, s = url.indexOf('/', 11), qi = url.indexOf('?', s + 1), @@ -670,18 +670,12 @@ export const createDynamicHandler = (app: AnyElysia) => { await afterResponse.fn(context as any) } - // Check if we're in a Cloudflare Worker environment with ctx.waitUntil - if (typeof globalThis !== 'undefined' && - // @ts-ignore - Check for Cloudflare Workers context - globalThis.ctx && - // @ts-ignore - typeof globalThis.ctx.waitUntil === 'function') { - - // Use ctx.waitUntil to ensure the hooks complete in Cloudflare Workers - // @ts-ignore - globalThis.ctx.waitUntil(runAfterResponse()) + // Use ExecutionContext-aware setImmediate if available (Cloudflare Workers) + if (requestContext?.executionContext && typeof requestContext.executionContext.waitUntil === 'function') { + // Use ctx.waitUntil to ensure the callback completes in Cloudflare Workers + requestContext.executionContext.waitUntil(Promise.resolve().then(runAfterResponse)) } else if (typeof setImmediate !== 'undefined') { - // Use setImmediate for Node.js environments + // Use setImmediate (which may be polyfilled by the adapter to auto-detect ExecutionContext) setImmediate(runAfterResponse) } else { // Fallback to Promise.resolve for other environments From 4f33c60bb9bfceda016b0ba5a9e6cf458ca1fefd Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 20:12:13 -0300 Subject: [PATCH 4/7] wip(cloudflare): improve execution context handling for workers --- src/adapter/cloudflare-worker/index.ts | 68 ++++++++++++++++++++------ src/dynamic-handle.ts | 35 +++++++++++-- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index 165013d9..9baa1958 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -1,6 +1,15 @@ import { ElysiaAdapter } from '../..' import { WebStandardAdapter } from '../web-standard/index' +/** + * ExecutionContext interface for Cloudflare Workers + */ +interface ExecutionContext { + waitUntil(promise: Promise): void + passThroughOnException(): void + readonly props: Props +} + /** * Global variable to store the current ExecutionContext * This gets set by the Cloudflare Worker runtime for each request @@ -14,23 +23,38 @@ declare global { * 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 { +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') { + 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) - }) + 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) - }) + Promise.resolve() + .then(callback) + .catch((error) => { + console.error( + 'Error in setImmediate callback (Promise.resolve):', + error + ) + }) } } } @@ -89,15 +113,31 @@ export const CloudflareAdapter: ElysiaAdapter = { // @ts-ignore - Polyfill for Cloudflare Workers globalThis.setImmediate = createAutoDetectingSetImmediate() } - + // Also set it on the global object for compatibility - if (typeof global !== 'undefined' && typeof global.setImmediate === 'undefined') { + if ( + typeof global !== 'undefined' && + typeof global.setImmediate === 'undefined' + ) { // @ts-ignore global.setImmediate = globalThis.setImmediate } - + + // 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 + ) { + if (ctx) { + globalThis.__cloudflareExecutionContext = ctx + } + return originalFetch(request) + } as any + 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) diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index 00dc9e67..0197815d 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -6,6 +6,15 @@ import { TypeCheck } from './type-system' import type { Context } from './context' import type { ElysiaTypeCheck } from './schema' +/** + * ExecutionContext interface for Cloudflare Workers + */ +interface ExecutionContext { + waitUntil(promise: Promise): void + passThroughOnException(): void + readonly props: Props +} + import { ElysiaCustomStatusResponse, ElysiaErrors, @@ -54,7 +63,10 @@ export const createDynamicHandler = (app: AnyElysia) => { // @ts-ignore const defaultHeader = app.setHeaders - return async (request: Request, requestContext?: { executionContext?: ExecutionContext }): Promise => { + return async ( + request: Request, + requestContext?: { executionContext?: ExecutionContext } + ): Promise => { const url = request.url, s = url.indexOf('/', 11), qi = url.indexOf('?', s + 1), @@ -669,11 +681,26 @@ export const createDynamicHandler = (app: AnyElysia) => { for (const afterResponse of app.event.afterResponse!) await afterResponse.fn(context as any) } - + // Use ExecutionContext-aware setImmediate if available (Cloudflare Workers) - if (requestContext?.executionContext && typeof requestContext.executionContext.waitUntil === 'function') { + if ( + requestContext?.executionContext && + typeof requestContext.executionContext.waitUntil === + 'function' + ) { // Use ctx.waitUntil to ensure the callback completes in Cloudflare Workers - requestContext.executionContext.waitUntil(Promise.resolve().then(runAfterResponse)) + requestContext.executionContext.waitUntil( + Promise.resolve().then(runAfterResponse) + ) + } else if ( + globalThis.__cloudflareExecutionContext && + typeof globalThis.__cloudflareExecutionContext.waitUntil === + 'function' + ) { + // Fallback to global ExecutionContext set by Cloudflare adapter + globalThis.__cloudflareExecutionContext.waitUntil( + Promise.resolve().then(runAfterResponse) + ) } else if (typeof setImmediate !== 'undefined') { // Use setImmediate (which may be polyfilled by the adapter to auto-detect ExecutionContext) setImmediate(runAfterResponse) From 69292bd1d2606e457d2f900d20c9c00c2a660a4d Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 20:17:36 -0300 Subject: [PATCH 5/7] fix(cloudflare-worker): improve error handling in stop hooks and fetch override --- src/adapter/cloudflare-worker/index.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index 9baa1958..975b4c19 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -102,9 +102,14 @@ export const CloudflareAdapter: ElysiaAdapter = { name: 'cloudflare-worker', async stop(app, closeActiveConnections) { // Call onStop lifecycle hooks for Cloudflare Workers - if (app.event.stop) - for (let i = 0; i < app.event.stop.length; i++) - app.event.stop[i].fn(app) + 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 @@ -130,10 +135,18 @@ export const CloudflareAdapter: ElysiaAdapter = { env?: any, ctx?: ExecutionContext ) { - if (ctx) { - globalThis.__cloudflareExecutionContext = ctx + try { + if (ctx) { + globalThis.__cloudflareExecutionContext = ctx + } + return originalFetch(request) + } catch (error) { + console.error( + 'Error in Cloudflare Worker fetch override:', + error + ) + throw error } - return originalFetch(request) } as any for (const route of app.routes) route.compile() From 8fe655d5abcf8e09937562858c0bbdd6244e9f8e Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 20:18:29 -0300 Subject: [PATCH 6/7] chore --- src/adapter/cloudflare-worker/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index 975b4c19..5ed716a4 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -1,9 +1,6 @@ import { ElysiaAdapter } from '../..' import { WebStandardAdapter } from '../web-standard/index' -/** - * ExecutionContext interface for Cloudflare Workers - */ interface ExecutionContext { waitUntil(promise: Promise): void passThroughOnException(): void From 2db8cb7088111232264b6489adaccfce5886106f Mon Sep 17 00:00:00 2001 From: Vicentesan Date: Mon, 29 Sep 2025 21:19:18 -0300 Subject: [PATCH 7/7] wip(cloudflare): improve execution context handling and memory management --- src/adapter/cloudflare-worker/index.ts | 30 +++++++++------- src/adapter/types.ts | 16 +++++++++ src/dynamic-handle.ts | 49 ++++++++++---------------- 3 files changed, 53 insertions(+), 42 deletions(-) diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index 5ed716a4..cb39fd31 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -1,11 +1,6 @@ import { ElysiaAdapter } from '../..' import { WebStandardAdapter } from '../web-standard/index' - -interface ExecutionContext { - waitUntil(promise: Promise): void - passThroughOnException(): void - readonly props: Props -} +import type { ExecutionContext } from '../types' /** * Global variable to store the current ExecutionContext @@ -112,8 +107,12 @@ export const CloudflareAdapter: ElysiaAdapter = { // Polyfill setImmediate for Cloudflare Workers - use auto-detecting version // This will automatically use ExecutionContext when available if (typeof globalThis.setImmediate === 'undefined') { - // @ts-ignore - Polyfill for Cloudflare Workers - globalThis.setImmediate = createAutoDetectingSetImmediate() + Object.defineProperty(globalThis, 'setImmediate', { + value: createAutoDetectingSetImmediate(), + writable: true, + enumerable: true, + configurable: true + }) } // Also set it on the global object for compatibility @@ -121,8 +120,12 @@ export const CloudflareAdapter: ElysiaAdapter = { typeof global !== 'undefined' && typeof global.setImmediate === 'undefined' ) { - // @ts-ignore - global.setImmediate = globalThis.setImmediate + Object.defineProperty(global, 'setImmediate', { + value: globalThis.setImmediate, + writable: true, + enumerable: true, + configurable: true + }) } // Override app.fetch to accept ExecutionContext and set it globally @@ -136,7 +139,10 @@ export const CloudflareAdapter: ElysiaAdapter = { if (ctx) { globalThis.__cloudflareExecutionContext = ctx } - return originalFetch(request) + const result = originalFetch(request) + // Clean up context after request to prevent memory leaks and context bleeding + globalThis.__cloudflareExecutionContext = undefined + return result } catch (error) { console.error( 'Error in Cloudflare Worker fetch override:', @@ -144,7 +150,7 @@ export const CloudflareAdapter: ElysiaAdapter = { ) throw error } - } as any + } for (const route of app.routes) route.compile() diff --git a/src/adapter/types.ts b/src/adapter/types.ts index 364eb5a3..704e6a2e 100644 --- a/src/adapter/types.ts +++ b/src/adapter/types.ts @@ -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 { + waitUntil(promise: Promise): 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( diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index 0197815d..e136dcf9 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -5,15 +5,7 @@ import { TypeCheck } from './type-system' import type { Context } from './context' import type { ElysiaTypeCheck } from './schema' - -/** - * ExecutionContext interface for Cloudflare Workers - */ -interface ExecutionContext { - waitUntil(promise: Promise): void - passThroughOnException(): void - readonly props: Props -} +import type { ExecutionContext, RequestContext } from './adapter/types' import { ElysiaCustomStatusResponse, @@ -30,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 @@ -65,7 +65,7 @@ export const createDynamicHandler = (app: AnyElysia) => { return async ( request: Request, - requestContext?: { executionContext?: ExecutionContext } + requestContext?: RequestContext ): Promise => { const url = request.url, s = url.indexOf('/', 11), @@ -682,27 +682,16 @@ export const createDynamicHandler = (app: AnyElysia) => { await afterResponse.fn(context as any) } - // Use ExecutionContext-aware setImmediate if available (Cloudflare Workers) - if ( - requestContext?.executionContext && - typeof requestContext.executionContext.waitUntil === - 'function' - ) { - // Use ctx.waitUntil to ensure the callback completes in Cloudflare Workers - requestContext.executionContext.waitUntil( - Promise.resolve().then(runAfterResponse) - ) - } else if ( - globalThis.__cloudflareExecutionContext && - typeof globalThis.__cloudflareExecutionContext.waitUntil === - 'function' - ) { - // Fallback to global ExecutionContext set by Cloudflare adapter - globalThis.__cloudflareExecutionContext.waitUntil( - Promise.resolve().then(runAfterResponse) - ) + // 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 (which may be polyfilled by the adapter to auto-detect ExecutionContext) + // Use setImmediate (may be polyfilled by adapter) setImmediate(runAfterResponse) } else { // Fallback to Promise.resolve for other environments