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
49 changes: 49 additions & 0 deletions .changeset/slimy-bugs-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@voltagent/serverless-hono": patch
---

fix: observability spans terminating prematurely on Vercel Edge and Deno Deploy

## The Problem

Observability spans were being cut short on Vercel Edge and Deno Deploy runtimes because the `toVercelEdge()` and `toDeno()` adapters didn't implement `waitUntil` support. Unlike `toCloudflareWorker()`, which properly extracted and set up `waitUntil` from the execution context, these adapters would terminate async operations (like span exports) as soon as the response was returned.

This caused the observability pipeline's `FetchTraceExporter` and `FetchLogExporter` to have their export promises cancelled mid-flight, resulting in incomplete or missing observability data.

## The Solution

Refactored all serverless adapters to use a new `withWaitUntil()` helper utility that:

- Extracts `waitUntil` from the runtime context (Cloudflare's `executionCtx`, Vercel's `context`, or Deno's `info`)
- Sets it as `globalThis.___voltagent_wait_until` for the observability exporters to use
- Returns a cleanup function that properly restores previous state
- Handles errors gracefully and supports nested calls

Now all three adapters (`toCloudflareWorker`, `toVercelEdge`, `toDeno`) use the same battle-tested pattern:

```ts
const cleanup = withWaitUntil(context);
try {
return await processRequest(request);
} finally {
cleanup();
}
```

## Impact

- ✅ Observability spans now export successfully on Vercel Edge Runtime
- ✅ Observability spans now export successfully on Deno Deploy
- ✅ Consistent `waitUntil` behavior across all serverless platforms
- ✅ DRY principle: eliminated duplicate code across adapters
- ✅ Comprehensive test coverage with 11 unit tests covering edge cases, nested calls, and error scenarios

## Technical Details

The fix introduces:

- `utils/wait-until-wrapper.ts`: Reusable `withWaitUntil()` helper
- `utils/wait-until-wrapper.spec.ts`: Complete test suite (11/11 passing)
- Updated `toCloudflareWorker()`: Simplified using helper
- **Fixed** `toVercelEdge()`: Now properly supports `waitUntil`
- **Fixed** `toDeno()`: Now properly supports `waitUntil`
55 changes: 21 additions & 34 deletions packages/serverless-hono/src/serverless-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import type { Hono } from "hono";
import { createServerlessApp } from "./app-factory";
import type { ServerlessConfig, ServerlessRuntime } from "./types";
import { detectServerlessRuntime } from "./utils/runtime-detection";

type VoltAgentGlobal = typeof globalThis & {
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
};
import { withWaitUntil } from "./utils/wait-until-wrapper";
export class HonoServerlessProvider implements IServerlessProvider {
private readonly deps: ServerProviderDeps;
private readonly config?: ServerlessConfig;
Expand Down Expand Up @@ -45,54 +42,44 @@ export class HonoServerlessProvider implements IServerlessProvider {
env: Record<string, unknown>,
executionCtx: unknown,
): Promise<Response> => {
const waitUntil =
executionCtx && typeof (executionCtx as any)?.waitUntil === "function"
? (executionCtx as any).waitUntil.bind(executionCtx)
: undefined;

const globals = globalThis as VoltAgentGlobal;
const previousWaitUntil = globals.___voltagent_wait_until;

if (waitUntil) {
globals.___voltagent_wait_until = (promise) => {
try {
waitUntil(promise);
} catch {
void promise;
}
};
}
const cleanup = withWaitUntil(executionCtx as any);

try {
await this.ensureEnvironmentTarget(env);
const app = await this.getApp();
return await app.fetch(request, env as Record<string, unknown>, executionCtx as any);
} finally {
if (waitUntil) {
if (previousWaitUntil) {
globals.___voltagent_wait_until = previousWaitUntil;
} else {
globals.___voltagent_wait_until = undefined;
}
}
cleanup();
}
},
};
}

toVercelEdge(): (request: Request, context?: unknown) => Promise<Response> {
return async (request: Request, context?: unknown) => {
await this.ensureEnvironmentTarget(context as Record<string, unknown> | undefined);
const app = await this.getApp();
return app.fetch(request, context as Record<string, unknown> | undefined);
const cleanup = withWaitUntil(context as any);

try {
await this.ensureEnvironmentTarget(context as Record<string, unknown> | undefined);
const app = await this.getApp();
return await app.fetch(request, context as Record<string, unknown> | undefined);
} finally {
cleanup();
}
};
}

toDeno(): (request: Request, info?: unknown) => Promise<Response> {
return async (request: Request, info?: unknown) => {
await this.ensureEnvironmentTarget(info as Record<string, unknown> | undefined);
const app = await this.getApp();
return app.fetch(request, info as Record<string, unknown> | undefined);
const cleanup = withWaitUntil(info as any);

try {
await this.ensureEnvironmentTarget(info as Record<string, unknown> | undefined);
const app = await this.getApp();
return await app.fetch(request, info as Record<string, unknown> | undefined);
} finally {
cleanup();
}
};
}

Expand Down
180 changes: 180 additions & 0 deletions packages/serverless-hono/src/utils/wait-until-wrapper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WaitUntilContext } from "./wait-until-wrapper";
import { withWaitUntil } from "./wait-until-wrapper";

type VoltAgentGlobal = typeof globalThis & {
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
};

describe("withWaitUntil", () => {
let originalWaitUntil: ((promise: Promise<unknown>) => void) | undefined;

beforeEach(() => {
const globals = globalThis as VoltAgentGlobal;
originalWaitUntil = globals.___voltagent_wait_until;
});

afterEach(() => {
const globals = globalThis as VoltAgentGlobal;
globals.___voltagent_wait_until = originalWaitUntil;
});

it("should set global waitUntil when context has waitUntil", () => {
const mockWaitUntil = vi.fn();
const context: WaitUntilContext = { waitUntil: mockWaitUntil };

withWaitUntil(context);

const globals = globalThis as VoltAgentGlobal;
expect(globals.___voltagent_wait_until).toBeDefined();
});

it("should not set global waitUntil when context is undefined", () => {
withWaitUntil(undefined);

const globals = globalThis as VoltAgentGlobal;
expect(globals.___voltagent_wait_until).toBeUndefined();
});

it("should not set global waitUntil when context is null", () => {
withWaitUntil(null);

const globals = globalThis as VoltAgentGlobal;
expect(globals.___voltagent_wait_until).toBeUndefined();
});

it("should not set global waitUntil when context has no waitUntil", () => {
const context = {};

withWaitUntil(context);

const globals = globalThis as VoltAgentGlobal;
expect(globals.___voltagent_wait_until).toBeUndefined();
});

it("should call context.waitUntil when global waitUntil is invoked", () => {
const mockWaitUntil = vi.fn();
const context: WaitUntilContext = { waitUntil: mockWaitUntil };

withWaitUntil(context);

const globals = globalThis as VoltAgentGlobal;
const promise = Promise.resolve();
globals.___voltagent_wait_until?.(promise);

expect(mockWaitUntil).toHaveBeenCalledWith(promise);
});

it("should handle errors from context.waitUntil gracefully", () => {
const mockWaitUntil = vi.fn(() => {
throw new Error("waitUntil error");
});
const context: WaitUntilContext = { waitUntil: mockWaitUntil };

withWaitUntil(context);

const globals = globalThis as VoltAgentGlobal;
const promise = Promise.resolve();

// Should not throw
expect(() => globals.___voltagent_wait_until?.(promise)).not.toThrow();
expect(mockWaitUntil).toHaveBeenCalled();
});

it("should restore previous waitUntil after cleanup", () => {
const previousWaitUntil = vi.fn();
const mockWaitUntil = vi.fn();
const context: WaitUntilContext = { waitUntil: mockWaitUntil };

const globals = globalThis as VoltAgentGlobal;
globals.___voltagent_wait_until = previousWaitUntil;

const cleanup = withWaitUntil(context);

expect(globals.___voltagent_wait_until).not.toBe(previousWaitUntil);

cleanup();

expect(globals.___voltagent_wait_until).toBe(previousWaitUntil);
});

it("should clear global waitUntil after cleanup when no previous value existed", () => {
const mockWaitUntil = vi.fn();
const context: WaitUntilContext = { waitUntil: mockWaitUntil };

const globals = globalThis as VoltAgentGlobal;
globals.___voltagent_wait_until = undefined;

const cleanup = withWaitUntil(context);

expect(globals.___voltagent_wait_until).toBeDefined();

cleanup();

expect(globals.___voltagent_wait_until).toBeUndefined();
});

it("should handle nested calls with proper state restoration", () => {
const outerWaitUntil = vi.fn();
const innerWaitUntil = vi.fn();

const outerContext: WaitUntilContext = { waitUntil: outerWaitUntil };
const innerContext: WaitUntilContext = { waitUntil: innerWaitUntil };

const globals = globalThis as VoltAgentGlobal;

const outerCleanup = withWaitUntil(outerContext);
const outerGlobalWaitUntil = globals.___voltagent_wait_until;

const innerCleanup = withWaitUntil(innerContext);
const innerGlobalWaitUntil = globals.___voltagent_wait_until;

expect(outerGlobalWaitUntil).toBeDefined();
expect(innerGlobalWaitUntil).toBeDefined();
expect(innerGlobalWaitUntil).not.toBe(outerGlobalWaitUntil);

// Call inner global waitUntil
const promise1 = Promise.resolve();
globals.___voltagent_wait_until?.(promise1);
expect(innerWaitUntil).toHaveBeenCalledWith(promise1);
expect(outerWaitUntil).not.toHaveBeenCalled();

// Cleanup inner
innerCleanup();
expect(globals.___voltagent_wait_until).toBe(outerGlobalWaitUntil);

// Call outer global waitUntil
const promise2 = Promise.resolve();
globals.___voltagent_wait_until?.(promise2);
expect(outerWaitUntil).toHaveBeenCalledWith(promise2);

// Cleanup outer
outerCleanup();
expect(globals.___voltagent_wait_until).toBeUndefined();
});

it("should not affect global state when context has no waitUntil", () => {
const globals = globalThis as VoltAgentGlobal;
const previousWaitUntil = vi.fn();
globals.___voltagent_wait_until = previousWaitUntil;

const cleanup = withWaitUntil({});

expect(globals.___voltagent_wait_until).toBe(previousWaitUntil);

cleanup();

expect(globals.___voltagent_wait_until).toBe(previousWaitUntil);
});

it("should handle context with non-function waitUntil", () => {
const context = { waitUntil: "not a function" as any };

const cleanup = withWaitUntil(context);

const globals = globalThis as VoltAgentGlobal;
expect(globals.___voltagent_wait_until).toBeUndefined();

cleanup();
});
});
56 changes: 56 additions & 0 deletions packages/serverless-hono/src/utils/wait-until-wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type VoltAgentGlobal = typeof globalThis & {
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
};

/**
* Context that may contain a waitUntil function
*/
export interface WaitUntilContext {
waitUntil?: (promise: Promise<unknown>) => void;
}

/**
* Extracts waitUntil from context and sets it as global for observability
* Returns a cleanup function to restore previous state
*
* @param context - Context object that may contain waitUntil
* @returns Cleanup function to restore previous state
*
* @example
* ```ts
* const cleanup = withWaitUntil(executionCtx);
* try {
* return await processRequest(request);
* } finally {
* cleanup();
* }
* ```
*/
export function withWaitUntil(context?: WaitUntilContext | null): () => void {
const globals = globalThis as VoltAgentGlobal;
const previousWaitUntil = globals.___voltagent_wait_until;

const waitUntil = context?.waitUntil;

if (waitUntil && typeof waitUntil === "function") {
globals.___voltagent_wait_until = (promise) => {
try {
waitUntil(promise);
} catch {
// Silently fail if waitUntil throws
void promise;
}
};
}

// Return cleanup function
return () => {
if (waitUntil) {
if (previousWaitUntil) {
globals.___voltagent_wait_until = previousWaitUntil;
} else {
globals.___voltagent_wait_until = undefined;
}
}
};
}
21 changes: 21 additions & 0 deletions packages/serverless-hono/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["**/*.spec.ts"],
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/**/index.ts"],
},
typecheck: {
include: ["**/**/*.spec-d.ts"],
exclude: ["**/**/*.spec.ts"],
},
globals: true,
testTimeout: 10000,
hookTimeout: 10000,
},
});