Skip to content

Latest commit

 

History

History
451 lines (347 loc) · 13.9 KB

AGENTS.md

File metadata and controls

451 lines (347 loc) · 13.9 KB

AI Agent Instructions for Cloudflare Web App Template

This document contains all design decisions and instructions for AI agents working on this project. Follow these guidelines to ensure consistency.


Project Overview

A full-stack web application template for Cloudflare Workers with type-safe RPC, authentication, and modern tooling.


Technology Stack

Layer Technology Notes
Frontend Runtime Bun Tooling, dev server, builds
Frontend Framework TanStack Router + React File-based routing
Styling Tailwind CSS
State Management TanStack Query
Backend Hono on Cloudflare Workers
RPC hono/client Type-safe API calls
Database Cloudflare D1 OR PlanetScale User choice at project creation
ORM Drizzle + Drizzle Kit
Validation Zod
Auth Better Auth Google OAuth
Session Storage Cookies
Logging Cloudflare Workers Logs
AI (optional) Vercel AI SDK
File Storage Cloudflare R2 Signed URLs for private access

Critical Rules

1. Database Choice - Ask at Project Start

At the start of a new project, always ask the user which database they want to use:

  • Cloudflare D1 - SQLite-based, serverless, integrated with Cloudflare
  • PlanetScale - MySQL-compatible, uses Hyperdrive for connection pooling

Run bun run setup to configure the project based on this choice.

2. Run Linting and Type Checking After Changes

Always run bun run lint and bun run typecheck after making code changes. Oxlint checks code quality. The type check ensures type safety across the RPC boundary. The hono/client provides end-to-end type safety, but only if TypeScript passes.

3. Extract Shared Functions

Rule: When adding new functionality, check if it exists in another page file. If so, extract to backend/functions/ and update both pages to use it.

Never duplicate logic across page files.

4. Follow the Pages Pattern

Each frontend page has a corresponding file in backend/pages/ containing all routes used by that page.

5. Do Not Use useEffect by Default

Do not use useEffect in frontend code. Read docs/you-might-not-need-an-effect.md and use a direct React or TanStack Query pattern.

If an Effect is required to synchronize with an external system, add a reason and a narrow oxlint-disable-next-line eslint/no-restricted-imports comment to the import. Do not disable the rule for a complete file.

6. Commit and Test Every Database Migration

Every change to backend/db/schema.ts must include a generated SQL migration under backend/drizzle/migrations/.

After each schema change:

  1. Run bun run db:generate.
  2. Review and commit the generated SQL and Drizzle metadata.
  3. Run bun run db:migrate:local against a fresh local D1 database.
  4. Run the affected API or application flow against that migrated database.

Do not use db:push, request-time CREATE TABLE statements, or runtime schema initialization as a substitute for a committed migration. The canonical migration directory is backend/drizzle/migrations; do not create backend/migrations.


Project Structure

/
├── frontend/
│   ├── src/
│   │   ├── routes/              # TanStack Router file-based routes
│   │   │   ├── __root.tsx       # Root layout
│   │   │   ├── index.tsx        # Landing page (public)
│   │   │   ├── login.tsx        # Login page
│   │   │   ├── logout.tsx       # Logout page
│   │   │   ├── auth/
│   │   │   │   └── callback.tsx # OAuth callback
│   │   │   └── app/
│   │   │       └── index.tsx    # Default signed-in page
│   │   ├── components/
│   │   │   ├── PageLoader.tsx   # Full page loading state
│   │   │   └── ErrorBoundary.tsx # Global error boundary
│   │   ├── lib/
│   │   │   ├── api.ts           # Hono RPC client
│   │   │   └── query.ts         # TanStack Query client
│   │   ├── main.tsx
│   │   └── routeTree.gen.ts     # Auto-generated by TanStack Router
│   ├── index.html
│   ├── package.json
│   ├── tailwind.config.ts
│   ├── tsconfig.json
│   └── vite.config.ts
│
├── backend/
│   ├── pages/                   # Route handlers organized by frontend page
│   │   ├── index.ts             # Landing page routes
│   │   ├── login.ts             # Login routes
│   │   ├── logout.ts            # Logout routes
│   │   └── app/
│   │       └── index.ts         # Signed-in page routes
│   ├── functions/               # Shared business logic
│   │   ├── github-issues.ts     # Deduplicated server-error issues
│   │   └── user.ts              # Example: user-related functions
│   ├── db/
│   │   ├── schema.ts            # Drizzle schema definitions
│   │   └── index.ts             # Database client export
│   ├── lib/
│   │   ├── auth.ts              # Better Auth configuration
│   │   ├── errors.ts            # Error response helpers
│   │   └── r2.ts                # R2 signed URL helpers
│   ├── middleware/
│   │   ├── auth.ts              # Auth middleware
│   │   ├── csp.ts               # CSP headers
│   │   └── logging.ts           # Structured Workers Logs middleware
│   ├── drizzle/
│   │   └── migrations/          # Drizzle Kit migrations
│   ├── index.ts                 # Hono app entry point
│   ├── package.json
│   ├── tsconfig.json
│   └── wrangler.toml
│
├── scripts/
│   └── setup.ts                 # Initial project setup script
├── package.json                 # Root workspace scripts
├── env.example                  # Example environment variables
├── .dev.vars                    # Local environment variables (gitignored)
├── .gitignore
├── AGENTS.md                    # This file
└── README.md

Backend Architecture

Pages Pattern

Each frontend page has a corresponding file in backend/pages/ containing all routes used by that page.

Naming Convention:

  • GET /api/pages/{page}/data - Load data for a page (onLoad)
  • POST /api/pages/{page}/{action} - Handle form submissions (onSubmit)

Note: Actions use flat paths (e.g., /update-profile) not nested paths (e.g., /actions/update-profile) for better Hono RPC type inference.

Example: backend/pages/app/index.ts

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { apiSuccess } from '../lib/errors'
import type { Env, Variables } from '../types'

const app = new Hono<{ Bindings: Env; Variables: Variables }>()
  // Load page data (onLoad)
  .get('/data', async (c) => {
    const user = c.get('user')
    return c.json(apiSuccess({ user, items: [] }))
  })
  // Handle form submission (onSubmit)
  .post('/update-profile',
    zValidator('json', z.object({
      name: z.string().min(1).max(100),
    })),
    async (c) => {
      const body = c.req.valid('json')
      return c.json(apiSuccess({ updated: true, name: body.name }))
    }
  )

export default app

Functions Pattern

Shared logic between multiple pages goes in backend/functions/.

Example: backend/functions/user.ts

import { db } from '../db'
import { users } from '../db/schema'
import { eq } from 'drizzle-orm'

export async function getUserById(id: string) {
  return db.query.users.findFirst({
    where: eq(users.id, id)
  })
}

export async function updateUserProfile(id: string, data: { name: string }) {
  return db.update(users)
    .set(data)
    .where(eq(users.id, id))
    .returning()
}

API Response Format

All API responses must follow this standardized format:

// Success response
type ApiSuccess<T> = {
  success: true
  data: T
}

// Error response
type ApiError = {
  success: false
  error: {
    code: string                         // e.g., "VALIDATION_ERROR", "NOT_FOUND", "UNAUTHORIZED"
    message: string                      // Human-readable message
    fieldErrors?: Record<string, string> // Per-field validation errors
    formErrors?: string[]                // Form-level errors
  }
}

Helper functions in backend/lib/errors.ts:

export function apiSuccess<T>(data: T): ApiSuccess<T> {
  return { success: true, data }
}

export function apiError(
  code: string,
  message: string,
  fieldErrors?: Record<string, string>,
  formErrors?: string[]
): ApiError {
  return {
    success: false,
    error: { code, message, fieldErrors, formErrors }
  }
}

Error Codes:

  • VALIDATION_ERROR - Input validation failed
  • NOT_FOUND - Resource not found
  • UNAUTHORIZED - Authentication required
  • FORBIDDEN - Insufficient permissions
  • INTERNAL_ERROR - Server error

Authentication

Better Auth Setup

  • Uses Google OAuth for authentication
  • Sessions stored in cookies
  • Cookie caching enabled (5 minutes)

Auth Middleware

Use requireAuth middleware for protected routes:

import { requireAuth } from '../middleware/auth'

const app = new Hono()
  .use('*', requireAuth)
  .get('/data', async (c) => {
    const user = c.get('user')
    // user is guaranteed to exist
  })

Security

CSP Headers

Applied via middleware:

  • Content-Security-Policy: Restrictive default policy
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: strict-origin-when-cross-origin

Input Validation

  • All input validated with Zod at API boundary via @hono/zod-validator
  • Drizzle provides SQL injection protection via parameterized queries

CSRF Protection

Handled automatically by Better Auth using the double-submit cookie pattern.


File Storage (R2)

Use signed URLs for all file operations. The bucket should be private by default.

import { getDownloadUrl, getUploadUrl } from '../lib/r2'

// Get a signed URL for downloading
const downloadUrl = await getDownloadUrl(env, 'files/document.pdf')

// Get a signed URL for uploading
const uploadUrl = await getUploadUrl(env, 'files/new-document.pdf')

Database

D1 (SQLite)

// backend/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(),
})

PlanetScale (MySQL)

// backend/db/schema.ts
import { mysqlTable, varchar, int, timestamp } from 'drizzle-orm/mysql-core'

export const users = mysqlTable('users', {
  id: varchar('id', { length: 255 }).primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 255 }),
  createdAt: timestamp('created_at').notNull().defaultNow(),
})

Migration Commands

# Generate migration from schema changes
bun run db:generate

# Push to database (development)
bun run db:push

# Apply committed migrations to local D1
bun run db:migrate:local

# Apply migrations (production)
bun run db:migrate

Logging

All requests emit structured events to Cloudflare Workers Logs. Use console.log, console.warn, or console.error with structured objects for additional events.

When all three GITHUB_ISSUES_* environment variables are configured, server errors create GitHub issues and mention the configured user. The reporter uses a stable fingerprint and does not create a duplicate while a matching issue is open.


Development Commands

Command Description
bun run dev Start both frontend and backend in development
bun run dev:frontend Start only frontend dev server
bun run dev:backend Start only backend (wrangler dev)
bun run build Build both frontend and backend
bun run lint Run Oxlint
bun run typecheck Run TypeScript type checking
bun run setup Run initial project setup
bun run db:generate Generate Drizzle migrations
bun run db:push Push schema to database
bun run db:migrate:local Apply committed migrations to local D1
bun run db:migrate Run migrations

Default Pages

Page Route Description
Landing / Public marketing/landing page
Login /login Google OAuth login button
Logout /logout Logout confirmation and action
Auth Callback /auth/callback OAuth callback handler
App Dashboard /app Default signed-in page (protected)

Environment Variables

Copy env.example to .dev.vars for local development. For production, use Cloudflare secrets:

wrangler secret put DATABASE_URL
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put BETTER_AUTH_URL  # Your production URL, e.g., https://your-app.workers.dev
wrangler secret put GITHUB_ISSUES_TOKEN
wrangler secret put GITHUB_ISSUES_REPOSITORY
wrangler secret put GITHUB_ISSUES_NOTIFY_USER
wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEY

Checklist for AI Agents

When working on this project:

  1. Confirm database choice (D1 or PlanetScale) if not already configured
  2. Follow the pages pattern for backend routes
  3. Use the standardized API response format
  4. Run bun run lint and bun run typecheck after code changes
  5. Extract shared logic to backend/functions/
  6. Use Zod for all input validation
  7. Use signed URLs for R2 file access
  8. Use requireAuth middleware for protected routes
  9. Do not use frontend useEffect without a documented lint exception