Skip to content

Testing & Debugging — Functions

Aerostack functions run on Cloudflare Workers, which means you get a full local development environment powered by the local runtime (Miniflare). All six platform bindings work locally — including Database, Cache, Queue, AI, Vector Search, and Storage.


Terminal window
aerostack dev

Starts a local development server with hot reload. All bindings are simulated locally. The function is accessible at http://localhost:8787 by default.

BindingLocal behavior
env.DBLocal SQLite file (persistent between restarts)
env.CACHELocal cache store (persistent between restarts)
env.QUEUELocal queue simulation (messages delivered immediately)
env.AIRequires --remote flag or mocked (see below)
env.VECTORIZERequires --remote flag or mocked
env.STORAGELocal storage simulation (files stored in .aerostack/state/)

To test AI and Vector Search locally against Cloudflare’s real infrastructure:

Terminal window
aerostack dev --remote

This runs your function locally but connects to production bindings. Use with caution — writes to the database, cache, and storage will affect real data.

Local data is stored in .aerostack/state/ inside your project directory:

.aerostack/
state/
v3/
d1/ # Local database files
kv/ # Local cache data
r2/ # Local storage objects

To reset local state and start fresh:

Terminal window
rm -rf .aerostack/state
aerostack dev

The simplest and most effective debugging tool. Logs appear in the terminal where aerostack dev is running.

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
console.log(`[${request.method}] ${url.pathname}`)
const start = Date.now()
const { results } = await env.DB.prepare('SELECT * FROM users').all()
console.log(`DB query took ${Date.now() - start}ms, returned ${results.length} rows`)
return Response.json(results)
}
}

For deployed functions, use the Aerostack CLI to stream live logs:

Terminal window
aerostack logs my-function-name

This streams all console.log, console.error, and exception output from your production function in real-time. Filter by status:

Terminal window
# Only errors
aerostack logs my-function-name --status error
# Only specific paths
aerostack logs my-function-name --search "/api/users"

Wrap your handler in a try-catch to ensure errors are always logged and returned as structured JSON:

export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
return await handleRequest(request, env)
} catch (err) {
console.error('Unhandled error:', err)
const message = err instanceof Error ? err.message : 'Unknown error'
const stack = err instanceof Error ? err.stack : undefined
return Response.json({
error: message,
...(env.ENVIRONMENT === 'development' ? { stack } : {})
}, { status: 500 })
}
}
}
async function handleRequest(request: Request, env: Env): Promise<Response> {
// Your actual logic here — errors bubble up to the catch block
}

The fastest way to test during development:

Terminal window
# GET request
curl http://localhost:8787/api/users
# POST with JSON body
curl -X POST http://localhost:8787/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
# With headers
curl http://localhost:8787/api/users \
-H "Authorization: Bearer test-token" \
-H "Accept: application/json"
# See response headers
curl -i http://localhost:8787/api/users

Use Vitest with @cloudflare/vitest-pool-workers for unit tests that run against real Cloudflare bindings locally.

Install the test dependencies:

Terminal window
npm install -D vitest @cloudflare/vitest-pool-workers

Create vitest.config.ts:

import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './aerostack.toml' }
}
}
}
})

Write a test in test/index.test.ts:

import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
import { describe, it, expect, beforeAll } from 'vitest'
import worker from '../src/index'
describe('Bookmarks API', () => {
beforeAll(async () => {
// Set up test data
await env.DB.exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`)
})
it('creates a bookmark', async () => {
const request = new Request('http://localhost/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', title: 'Example' })
})
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
await waitOnExecutionContext(ctx)
expect(response.status).toBe(201)
const body = await response.json()
expect(body.url).toBe('https://example.com')
expect(body.title).toBe('Example')
expect(body.id).toBeDefined()
})
it('lists bookmarks', async () => {
const request = new Request('http://localhost/bookmarks')
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
await waitOnExecutionContext(ctx)
expect(response.status).toBe(200)
const body = await response.json()
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
})
it('returns 400 for missing fields', async () => {
const request = new Request('http://localhost/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }) // missing title
})
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
await waitOnExecutionContext(ctx)
expect(response.status).toBe(400)
})
})

Run tests:

Terminal window
npx vitest run

For functions that interact with multiple bindings, test the full flow:

it('caches DB results', async () => {
// First request — cache miss
const res1 = await worker.fetch(new Request('http://localhost/bookmarks'), env, createExecutionContext())
expect(res1.headers.get('X-Cache')).toBe('MISS')
// Second request — cache hit
const res2 = await worker.fetch(new Request('http://localhost/bookmarks'), env, createExecutionContext())
expect(res2.headers.get('X-Cache')).toBe('HIT')
// Verify same data
const data1 = await res1.json()
const data2 = await res2.json()
expect(data1).toEqual(data2)
})

Your database schema has not been applied locally. Run your migration:

Terminal window
aerostack db execute --local --file=schema.sql

AI binding returns “AI is not defined”

Section titled “AI binding returns “AI is not defined””

The AI binding requires either:

  • --remote flag when running aerostack dev
  • The [ai] section in your aerostack.toml
[ai]
binding = "AI"

Ensure your aerostack.toml has both a producer and consumer configuration:

[[queues.producers]]
binding = "QUEUE"
queue = "my-queue"
[[queues.consumers]]
queue = "my-queue"

And that your function exports a queue() handler.

Cache returns null for a key that was just written

Section titled “Cache returns null for a key that was just written”

The cache is eventually consistent. In production, writes can take up to 60 seconds to propagate globally. For data that must be immediately consistent, use the database instead of the cache.

Locally, the cache is immediately consistent — this issue only appears in production.

Function works locally but fails when deployed

Section titled “Function works locally but fails when deployed”

Common causes:

  1. Missing secrets — set them with aerostack secrets set KEY
  2. Schema not applied — run migrations on the remote database
  3. Binding mismatch — ensure aerostack.toml binding names match what your code uses (env.DB, env.CACHE, etc.)
  4. Compatibility date — if using newer APIs, update compatibility_date in aerostack.toml