Files
gh-jezweb-claude-skills-ski…/references/architecture.md
2025-11-30 08:24:36 +08:00

11 KiB

Architecture Deep Dive

Last Updated: 2025-10-20

This document explains the architectural patterns used in Cloudflare Workers with Hono, Vite, and Static Assets.


Table of Contents

  1. Export Patterns
  2. Routing Architecture
  3. Static Assets Integration
  4. Bindings and Type Safety
  5. Development vs Production

Export Patterns

The Correct Pattern (ES Module Format)

import { Hono } from 'hono'

const app = new Hono()

// Define routes...

// ✅ CORRECT: Export the Hono app directly
export default app

Why this works:

  • Hono's app object already implements the fetch handler
  • When Cloudflare calls your Worker, it automatically invokes app.fetch()
  • This is the ES Module Worker format (modern, recommended)

The Incorrect Pattern (Causes Errors)

// ❌ WRONG: This causes "Cannot read properties of undefined (reading 'map')" error
export default {
  fetch: app.fetch
}

Why this fails:

  • When using Vite's build tools with Hono, the app.fetch binding is lost
  • The Vite bundler transforms the code in a way that breaks the this context
  • Source: honojs/hono #3955

Module Worker Format (When You Need Multiple Handlers)

import { Hono } from 'hono'

const app = new Hono()

// Define routes...

// ✅ CORRECT: Use Module Worker format for scheduled/tail handlers
export default {
  fetch: app.fetch,

  scheduled: async (event, env, ctx) => {
    // Cron job logic
    console.log('Cron triggered:', event.cron)
  },

  tail: async (events, env, ctx) => {
    // Tail handler logic
    console.log('Tail events:', events)
  }
}

When to use this:

  • You need scheduled (cron) handlers
  • You need tail handlers for log consumption
  • You need queue consumers
  • You need durable object handlers

Important: This is still ES Module format, not the deprecated Service Worker format.

Deprecated Service Worker Format (Never Use)

// ❌ DEPRECATED: Never use this format
addEventListener('fetch', (event) => {
  event.respondWith(handleRequest(event.request))
})

Why never use this:

  • Deprecated since Cloudflare Workers v2
  • Doesn't support modern features (D1, Vectorize, etc.)
  • Not compatible with TypeScript types
  • Not supported by Vite plugin

Routing Architecture

Request Flow

Incoming Request
    ↓
    ├─→ Worker checks run_worker_first patterns
    │   └─→ Matches /api/* → Worker handles it → Returns JSON
    │
    └─→ No match → Static Assets handler
        ├─→ File exists → Returns file
        └─→ File not found → SPA fallback → Returns index.html

Configuration Required

In wrangler.jsonc:

{
  "assets": {
    "directory": "./public/",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*"]
  }
}

Critical: Without run_worker_first, the SPA fallback intercepts ALL requests, including API routes.

Route Priority

  1. Worker routes (if matched by run_worker_first)

    app.get('/api/hello', (c) => c.json({ ... }))
    
  2. Static files (if file exists in public/)

    public/styles.css → Served as-is
    public/logo.png → Served as-is
    
  3. SPA fallback (if file doesn't exist)

    /unknown-route → Returns public/index.html
    

Advanced Routing Patterns

Wildcard Routes

// Match all API versions
app.get('/api/:version/users', (c) => {
  const version = c.req.param('version')
  return c.json({ version })
})

// Match nested routes
app.get('/api/users/:id/posts/:postId', (c) => {
  const { id, postId } = c.req.param()
  return c.json({ userId: id, postId })
})

Regex Routes

// Match numeric IDs only
app.get('/api/users/:id{[0-9]+}', (c) => {
  const id = c.req.param('id')
  return c.json({ id: parseInt(id) })
})

Route Groups

const api = new Hono()

api.get('/users', (c) => c.json({ users: [] }))
api.get('/posts', (c) => c.json({ posts: [] }))

app.route('/api', api)  // Mount at /api

Static Assets Integration

How Static Assets Work

  1. Upload: When you deploy, Wrangler uploads public/ to Cloudflare's asset store
  2. Binding: Your Worker receives an ASSETS Fetcher binding
  3. Request: Your Worker can forward requests to ASSETS.fetch()
  4. Cache: Assets are cached at the edge automatically

The Fallback Pattern

// Handle all unmatched routes
app.all('*', (c) => {
  // Forward to Static Assets
  return c.env.ASSETS.fetch(c.req.raw)
})

What this does:

  • Forwards request to Static Assets handler
  • Static Assets checks if file exists
  • If yes: Returns file
  • If no: Returns index.html (SPA fallback)

Custom 404 Handling

app.all('*', async (c) => {
  const response = await c.env.ASSETS.fetch(c.req.raw)

  // If Static Assets returns 404, customize response
  if (response.status === 404) {
    return c.json({ error: 'Not Found' }, 404)
  }

  return response
})

Asset Preprocessing

app.all('*', async (c) => {
  const url = new URL(c.req.url)

  // Rewrite /old-path to /new-path
  if (url.pathname === '/old-path') {
    url.pathname = '/new-path'
  }

  // Create new request with modified URL
  const modifiedRequest = new Request(url, c.req.raw)
  return c.env.ASSETS.fetch(modifiedRequest)
})

Bindings and Type Safety

Defining Bindings

type Bindings = {
  ASSETS: Fetcher               // Static Assets (always present)
  MY_KV: KVNamespace            // KV namespace
  DB: D1Database                // D1 database
  MY_BUCKET: R2Bucket           // R2 bucket
  MY_VAR: string                // Environment variable
}

const app = new Hono<{ Bindings: Bindings }>()

Accessing Bindings

app.get('/api/data', async (c) => {
  // Type-safe access to bindings
  const value = await c.env.MY_KV.get('key')
  const result = await c.env.DB.prepare('SELECT * FROM users').all()
  const object = await c.env.MY_BUCKET.get('file.txt')
  const variable = c.env.MY_VAR

  return c.json({ value, result, object, variable })
})

Auto-Generated Types

Run wrangler types to generate worker-configuration.d.ts:

// Auto-generated by Wrangler
interface Env {
  ASSETS: Fetcher
  MY_KV: KVNamespace
  DB: D1Database
  MY_BUCKET: R2Bucket
  MY_VAR: string
}

Then use:

const app = new Hono<{ Bindings: Env }>()

Development vs Production

Local Development (wrangler dev)

npm run dev

What happens:

  • Miniflare simulates Cloudflare's runtime locally
  • Bindings are emulated (KV, D1, R2)
  • HMR enabled via Vite plugin
  • Runs on http://localhost:8787

Configuration:

// vite.config.ts
export default defineConfig({
  plugins: [
    cloudflare({
      persist: true,  // Persist data between restarts
    }),
  ],
})

Production Deployment (wrangler deploy)

npm run deploy

What happens:

  • Vite builds your code
  • Wrangler uploads to Cloudflare
  • Static Assets uploaded separately
  • Worker deployed to edge network

Build Output:

dist/
├── index.js        # Your Worker code (bundled)
└── ...             # Other build artifacts

Environment-Specific Configuration

// wrangler.jsonc
{
  "name": "my-worker",
  "env": {
    "staging": {
      "name": "my-worker-staging",
      "vars": { "ENV": "staging" },
      "kv_namespaces": [
        { "binding": "MY_KV", "id": "staging-kv-id" }
      ]
    },
    "production": {
      "name": "my-worker-production",
      "vars": { "ENV": "production" },
      "kv_namespaces": [
        { "binding": "MY_KV", "id": "production-kv-id" }
      ]
    }
  }
}

Deploy to specific environment:

wrangler deploy --env staging
wrangler deploy --env production

Environment Detection in Code

app.get('/api/info', (c) => {
  const isDev = c.req.url.includes('localhost')
  const env = c.env.ENV || 'development'

  return c.json({ isDev, env })
})

Performance Considerations

Cold Starts

Cloudflare Workers have extremely fast cold starts (~5ms):

  • Code is distributed globally
  • No containers to spin up
  • Minimal initialization overhead

Keep your bundle small:

  • Avoid large dependencies
  • Use tree-shaking (Vite does this automatically)
  • Lazy-load heavy modules

CPU Time Limits

  • Free Plan: 10ms CPU time per request
  • Paid Plan: 50ms CPU time per request

Tip: Use asynchronous operations (KV, D1, R2) to avoid blocking CPU time.

Memory Limits

  • 128 MB per Worker instance

Tip: Avoid loading large files into memory. Stream data when possible.

Request Size Limits

  • Request Body: 100 MB
  • Response Body: No limit (can stream)

Best Practices

1. Use Middleware for Common Logic

import { logger } from 'hono/logger'
import { cors } from 'hono/cors'

app.use('*', logger())
app.use('/api/*', cors())

2. Separate API and Static Routes

const api = new Hono()

api.get('/users', ...)
api.get('/posts', ...)

app.route('/api', api)
app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw))

3. Handle Errors Gracefully

app.onError((err, c) => {
  console.error(err)
  return c.json({ error: 'Internal Server Error' }, 500)
})

4. Use TypeScript

// Define types for request/response
type User = {
  id: number
  name: string
}

app.get('/api/users/:id', async (c) => {
  const id = parseInt(c.req.param('id'))
  const user: User = { id, name: 'Alice' }
  return c.json(user)
})

5. Validate Input

import { z } from 'zod'

const schema = z.object({
  name: z.string(),
  email: z.string().email(),
})

app.post('/api/users', async (c) => {
  const body = await c.req.json()
  const validated = schema.parse(body)
  return c.json({ success: true, data: validated })
})

Troubleshooting

Issue: API routes return HTML

Cause: Missing run_worker_first configuration

Fix: Add to wrangler.jsonc:

{
  "assets": {
    "run_worker_first": ["/api/*"]
  }
}

Issue: HMR crashes with "A hanging Promise was canceled"

Cause: Race condition in older Vite plugin versions

Fix: Update to latest:

npm install -D @cloudflare/vite-plugin@1.13.13

Issue: Deployment fails with "Cannot read properties of undefined"

Cause: Incorrect export pattern

Fix: Use export default app (not { fetch: app.fetch })


For more troubleshooting, see common-issues.md.