From 3b80eff4133bb723873d879346b0a3cd7acf3a90 Mon Sep 17 00:00:00 2001 From: saltbo Date: Sun, 12 Apr 2026 10:40:15 -0400 Subject: [PATCH] feat: migrate from CF Pages to Workers with one-click deploy button - Replace Pages Functions with Workers entry (`workers/bootstrap.ts`) - Add Deploy to Cloudflare button in README - Integrate `@cloudflare/vite-plugin` for CF dev with HMR - Integrate `@hono/vite-dev-server` for Node dev with HMR - `npm run dev` now defaults to CF Workers mode - Add `run_worker_first = ["/api/*"]` so static assets stay free - Extract shared Node bootstrap (`server/bootstrap.ts`) for reuse - Update all docs from Pages to Workers references Co-Authored-By: Claude Opus 4.6 (1M context) --- .dev.vars.example | 2 +- CHANGELOG.md | 2 +- CLAUDE.md | 4 +- CONTRIBUTING.md | 27 +++++------- README.md | 6 +-- V2_ROADMAP.md | 2 +- biome.json | 2 +- docs/architecture.md | 25 ++++++----- docs/roadmap/v2.0.md | 2 +- functions/api/[[route]].ts | 24 ----------- functions/tsconfig.json | 3 -- package-lock.json | 83 ++++++++++++++++++++++++++++++++++++ package.json | 10 +++-- scripts/db-reset.ts | 6 +-- server/bootstrap.ts | 16 +++++++ server/entry-node.ts | 27 +++--------- tsconfig.server.json | 4 +- vite.config.ts | 12 +++--- vitest.cloudflare.config.ts | 2 +- workers/bootstrap.cf-test.ts | 27 ++++++++++++ workers/bootstrap.ts | 26 +++++++++++ wrangler.toml | 26 ++++++----- 22 files changed, 222 insertions(+), 116 deletions(-) delete mode 100644 functions/api/[[route]].ts delete mode 100644 functions/tsconfig.json create mode 100644 server/bootstrap.ts create mode 100644 workers/bootstrap.cf-test.ts create mode 100644 workers/bootstrap.ts diff --git a/.dev.vars.example b/.dev.vars.example index e167ca1c..7ffad75c 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,4 +1,4 @@ -# Local development environment variables for `wrangler pages dev`. +# Local development environment variables for `wrangler dev`. # Copy this file to `.dev.vars` (gitignored) and fill in real values. # # For the Node entry (`npm run dev` / Docker), these same variables must be diff --git a/CHANGELOG.md b/CHANGELOG.md index a8159c3c..4c3edb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Complete rewrite from Go to TypeScript. ### Changed - New tech stack: Hono + Drizzle + Better Auth -- Dual deployment: Cloudflare Pages and Docker +- Dual deployment: Cloudflare Workers and Docker - R2 as default storage backend - Better Auth replaces custom JWT implementation diff --git a/CLAUDE.md b/CLAUDE.md index 5062599f..cb37220e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,14 +2,14 @@ ## Project Overview -ZPan v2 is an open-source, S3-native file hosting platform written in TypeScript. Cloudflare Pages is the primary deployment target, Node.js (Docker) is backup. +ZPan v2 is an open-source, S3-native file hosting platform written in TypeScript. Cloudflare Workers is the primary deployment target, Node.js (Docker) is backup. Core architecture: clients upload directly to S3-compatible storage via presigned URLs, bypassing server bandwidth. ## Key Context - Single package: `server/` (Hono API), `src/` (React SPA), `shared/` (types/schemas) -- CF Pages Functions: `functions/api/[[route]].ts` (CF Pages + D1) and `server/entry-node.ts` (Node + SQLite) +- CF Workers: `workers/bootstrap.ts` (CF Workers + D1) and `server/entry-node.ts` (Node + SQLite) - Tests are co-located: `*.test.ts` (Node), `*.cf-test.ts` (CF Workers) - Migrations: drizzle-kit generates SQL → wrangler manages D1 state diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 050469cb..6db56f1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ npm install ```sh npm run dev # Start server + web dev servers (reads .dev.vars) -npm run dev:pages # Build & run as Cloudflare Pages locally +npm run dev:cf # Build & run as Cloudflare Workers locally ``` ## Quality Gates @@ -43,9 +43,9 @@ npm run e2e # Playwright E2E tests ## Preview Verification -Every PR that touches UI or API behavior **must** be verified in the Cloudflare Pages preview environment before merging. The verification report **must** be posted as a PR comment — a PR without a verification comment cannot be merged. +Every PR that touches UI or API behavior **must** be verified in the Cloudflare Workers preview environment before merging. The verification report **must** be posted as a PR comment — a PR without a verification comment cannot be merged. -Cloudflare Pages automatically deploys each PR to a preview URL (posted as a PR comment, e.g. `https://.zpan.pages.dev`). +Cloudflare Workers automatically deploys each PR to a preview URL (posted as a PR comment). Before merging, the reviewer **must** verify in the preview environment and post a PR comment with: - **Screenshots** proving the feature works (golden path + edge cases) @@ -68,33 +68,28 @@ Schema is defined in `server/db/schema.ts` and `server/db/auth-schema.ts`. ```sh npm run db:generate # Generate migration SQL after schema changes npm run db:migrate # Apply migrations (Node/SQLite) -wrangler d1 migrations apply zpan-db --local # Apply migrations (CF Pages local) -wrangler d1 migrations apply zpan-db --remote # Apply migrations (CF Pages production) +wrangler d1 migrations apply zpan-db --local # Apply migrations (D1 local) +wrangler d1 migrations apply zpan-db --remote # Apply migrations (D1 production) ``` To reset local databases with seed data (admin user + dev storage): ```sh npm run db:reset # Reset Node database (zpan.db) -npm run db:reset:pages # Reset D1 local database (.wrangler) +npm run db:reset:d1 # Reset D1 local database (.wrangler) ``` Migration files live in `migrations/` at project root. Always commit them. ## Deployment -Primary target is Cloudflare Pages. Node.js (Docker) is the backup runtime. +Primary target is Cloudflare Workers. Node.js (Docker) is the backup runtime. ```sh -npm run build:pages # Build frontend to dist/ -npm run deploy # Build + deploy to Cloudflare Pages +npm run build # Build frontend to dist/ +npm run deploy # Build + deploy to Cloudflare Workers ``` -The wrangler config (`wrangler.toml`) uses: -- `main` → worker entry point (bundled by wrangler) -- `[assets]` → static frontend with SPA fallback -- `run_worker_first = ["/api/*"]` → only API routes hit the worker - ## Project Structure ``` @@ -102,9 +97,9 @@ zpan/ ├── server/ # Hono API (routes, middleware, auth, platform abstraction) ├── src/ # React frontend (TanStack Router, shadcn/ui) ├── shared/ # Shared types, Zod schemas, constants -├── functions/ # CF Pages Functions entry +├── workers/ # Cloudflare Workers entry ├── migrations/ # D1/SQLite migrations (drizzle-kit generated, wrangler managed) -├── wrangler.toml # Cloudflare Pages + Workers config +├── wrangler.toml # Cloudflare Workers config └── biome.json # Lint + format config ``` diff --git a/README.md b/README.md index b902b335..765a6d40 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ ZPan is a lightweight file hosting platform built on top of S3-compatible storag ## Deploy -### Cloudflare Pages (Recommended) +### Cloudflare Workers (Recommended) One-click deploy with zero server management. Free tier covers personal use. - +[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/saltbo/zpan) ### Docker @@ -38,7 +38,7 @@ docker compose up -d ## Why ZPan? - **Zero bandwidth bottleneck** — files transfer directly between client and S3 -- **Zero ops option** — deploy to Cloudflare Pages for free, no server needed +- **Zero ops option** — deploy to Cloudflare Workers for free, no server needed - **Self-host friendly** — Docker deployment with any S3-compatible storage - **Tool ecosystem** — works with PicGo, ShareX, Flameshot out of the box - **Open source** — free forever for self-hosted users diff --git a/V2_ROADMAP.md b/V2_ROADMAP.md index c0794685..a32c450e 100644 --- a/V2_ROADMAP.md +++ b/V2_ROADMAP.md @@ -40,7 +40,7 @@ Each version ships 1–2 major features. Ship small, ship often. ## Deployment Options -- **Cloudflare Pages** — Zero-ops, free tier covers personal use, one-click deploy +- **Cloudflare Workers** — Zero-ops, free tier covers personal use, one-click deploy - **Docker** — Self-hosted, bring your own S3, full control Same codebase, same features, two runtimes. diff --git a/biome.json b/biome.json index 51369f85..fd972bde 100644 --- a/biome.json +++ b/biome.json @@ -11,7 +11,7 @@ "shared/**/*.ts", "src/**/*.ts", "src/**/*.tsx", - "functions/**/*.ts", + "workers/**/*.ts", "e2e/**/*.ts", "!src/routeTree.gen.ts" ], diff --git a/docs/architecture.md b/docs/architecture.md index d3f81cbd..937bbf46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,12 +5,11 @@ Technical reference for ZPan v2 development. For product roadmap, see [V2_ROADMA ## Global Architecture ``` -CF Pages (wrangler) Docker (Node.js) +CF Workers (wrangler) Docker (Node.js) ┌─────────────────────┐ -│ [assets] → web/dist │ -│ run_worker_first = │ entry-node.ts -│ ["/api/*"] │ (serves both static + API) -│ main: entry-cf.ts │ │ +│ [assets] → dist/ │ +│ SPA fallback │ entry-node.ts +│ main: workers/ │ (serves both static + API) │ exports { fetch } │ │ └──────────┬──────────┘ │ │ Single deployment │ @@ -35,8 +34,8 @@ CF Pages (wrangler) Docker (Node.js) | Layer | Choice | Alternatives Considered | Why This One | |-------|--------|------------------------|--------------| -| Web Framework | **Hono** | Express, Fastify, ElysiaJS | Native CF Pages Functions + Node.js dual runtime. Lightweight, fast, web-standard Request/Response API | -| ORM | **Drizzle** | Prisma, Kysely | First-class D1 support, type-safe, SQL-like syntax, lightweight bundle for CF Pages Functions | +| Web Framework | **Hono** | Express, Fastify, ElysiaJS | Native CF Workers + Node.js dual runtime. Lightweight, fast, web-standard Request/Response API | +| ORM | **Drizzle** | Prisma, Kysely | First-class D1 support, type-safe, SQL-like syntax, lightweight bundle for CF Workers | | Auth | **Better Auth** | Lucia, Auth.js, custom JWT | Drizzle adapter, D1 support, built-in social login / OIDC / organization plugin, active development | | S3 SDK | **@aws-sdk/client-s3** | minio-js, custom fetch | Industry standard, works with every S3-compatible provider including R2 | | Package Manager | **npm** | pnpm, yarn, bun | Standard, no extra tooling, Volta manages Node version | @@ -92,11 +91,11 @@ zpan/ │ ├── lib/ # API client (Hono RPC), utils │ └── i18n/ # Translations (en.json, zh.json) ├── shared/ # Shared types, Zod schemas, constants -├── functions/ # CF Pages Functions entry -│ └── api/[[route]].ts # CF entry point +├── workers/ # CF Workers entry +│ └── app.ts # CF entry point ├── migrations/ # D1/SQLite migrations (drizzle-kit generated) ├── e2e/ # Playwright E2E tests -├── wrangler.toml # Cloudflare Pages config +├── wrangler.toml # Cloudflare Workers config ├── biome.json # Lint + format config └── package.json # Single package, npm ``` @@ -145,7 +144,7 @@ GitHub Actions: - `npm e2e` — Playwright tests **On merge to master:** -- Build + deploy to CF Pages (preview / production) +- Build + deploy to CF Workers (preview / production) - Build Docker image + push to Docker Hub (planned) ## Platform Abstraction @@ -172,8 +171,8 @@ Note: `s3` and `cron` will be added in later versions. ### Entry Points -- `entry-cloudflare.ts` — exports `default { fetch }`, wrangler bundles it directly. Static frontend served by wrangler's `[assets]` config with SPA fallback. `run_worker_first = ["/api/*"]` routes only API requests to the worker. -- `entry-node.ts` — starts Hono via `@hono/node-server`, serves both API and static frontend from `../../dist` on the same port. +- `workers/bootstrap.ts` — exports `default { fetch }`, wrangler bundles it directly. Static frontend served by wrangler's `[assets]` config with SPA fallback. +- `server/entry-node.ts` — starts Hono via `@hono/node-server`, serves both API and static frontend from `./dist` on the same port. ## Per-Version Technical Decisions diff --git a/docs/roadmap/v2.0.md b/docs/roadmap/v2.0.md index f7fa1e47..01261ffa 100644 --- a/docs/roadmap/v2.0.md +++ b/docs/roadmap/v2.0.md @@ -30,7 +30,7 @@ The minimum viable ZPan v2. Get the new stack running, ship it, validate it. - Per-user storage quotas set by admin - Site name and description configurable via admin panel - Environment variable based configuration -- Cloudflare Pages one-click deploy +- Cloudflare Workers one-click deploy - Docker image + docker-compose ### Web UI diff --git a/functions/api/[[route]].ts b/functions/api/[[route]].ts deleted file mode 100644 index ed336310..00000000 --- a/functions/api/[[route]].ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createApp } from '../../server/app' -import { createAuth } from '../../server/auth' -import { createCloudflarePlatform } from '../../server/platform/cloudflare' - -interface Env { - DB: D1Database - BETTER_AUTH_SECRET: string - BETTER_AUTH_URL?: string - TRUSTED_ORIGINS?: string - [key: string]: unknown -} - -export const onRequest: PagesFunction = async (context) => { - const { BETTER_AUTH_SECRET, BETTER_AUTH_URL, TRUSTED_ORIGINS } = context.env - if (!BETTER_AUTH_SECRET) { - throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.') - } - const platform = createCloudflarePlatform(context.env) - const trustedOrigins = TRUSTED_ORIGINS?.split(',') - .map((o) => o.trim()) - .filter(Boolean) - const auth = createAuth(platform.db, BETTER_AUTH_SECRET, BETTER_AUTH_URL, trustedOrigins) - return createApp(platform, auth).fetch(context.request) -} diff --git a/functions/tsconfig.json b/functions/tsconfig.json deleted file mode 100644 index 0336dc6a..00000000 --- a/functions/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../tsconfig.server.json" -} diff --git a/package-lock.json b/package-lock.json index 00f215e4..be9ec7a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,8 +56,10 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@cloudflare/vite-plugin": "^1.31.2", "@cloudflare/vitest-pool-workers": "^0.14.0", "@cloudflare/workers-types": "^4.20260401.1", + "@hono/vite-dev-server": "^0.25.1", "@playwright/test": "1.58.2", "@tailwindcss/vite": "^4.2.2", "@tanstack/router-plugin": "^1.167.12", @@ -1596,6 +1598,24 @@ } } }, + "node_modules/@cloudflare/vite-plugin": { + "version": "1.31.2", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.31.2.tgz", + "integrity": "sha512-6RyoPhqmpuHPB+Zudt7mOUdGzB1+DQtJtPdAxUajhlS2ZUU0+bCn9Cj4g6Z2EvajBrkBTw1yVLqtt4bsUnp1Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.16.0", + "miniflare": "4.20260409.0", + "unenv": "2.0.0-rc.24", + "wrangler": "4.81.1", + "ws": "8.18.0" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^4.81.1" + } + }, "node_modules/@cloudflare/vitest-pool-workers": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.14.3.tgz", @@ -2730,6 +2750,36 @@ "hono": "^4" } }, + "node_modules/@hono/vite-dev-server": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@hono/vite-dev-server/-/vite-dev-server-0.25.1.tgz", + "integrity": "sha512-H6EqoPtCV+uH1fu8nKyRy5Wh3LU012QxloEYMlUcyMjjEfVFiRXPjHx/0TQiiRi140Vu8San5A4XQWyElKFKaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.11", + "minimatch": "^9.0.3" + }, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "*", + "miniflare": "*", + "wrangler": "*" + }, + "peerDependenciesMeta": { + "hono": { + "optional": false + }, + "miniflare": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, "node_modules/@hono/zod-validator": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.7.6.tgz", @@ -9641,6 +9691,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9869,6 +9926,16 @@ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -13413,6 +13480,22 @@ "node": ">=18.0.0" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", diff --git a/package.json b/package.json index a082734d..5239fb18 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,15 @@ "private": true, "type": "module", "scripts": { - "dev": "concurrently \"tsx watch --env-file=.dev.vars server/entry-node.ts\" \"vite\"", - "dev:pages": "vite build && wrangler pages dev dist", + "dev": "vite dev", + "dev:node": "node --env-file=.dev.vars node_modules/vite/bin/vite.js dev --mode node", "build": "vite build", "build:server": "tsup server/entry-node.ts --format esm --outDir dist-server", - "deploy": "vite build && wrangler pages deploy dist", + "deploy": "vite build && wrangler deploy", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset": "tsx --env-file=.dev.vars scripts/db-reset.ts", - "db:reset:pages": "tsx --env-file=.dev.vars scripts/db-reset.ts --pages", + "db:reset:d1": "tsx --env-file=.dev.vars scripts/db-reset.ts --d1", "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.web.json", "test": "vitest run", "test:cf": "vitest run --config vitest.cloudflare.config.ts", @@ -75,8 +75,10 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@cloudflare/vite-plugin": "^1.31.2", "@cloudflare/vitest-pool-workers": "^0.14.0", "@cloudflare/workers-types": "^4.20260401.1", + "@hono/vite-dev-server": "^0.25.1", "@playwright/test": "1.58.2", "@tailwindcss/vite": "^4.2.2", "@tanstack/router-plugin": "^1.167.12", diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts index 13471322..68ee8bdf 100644 --- a/scripts/db-reset.ts +++ b/scripts/db-reset.ts @@ -10,7 +10,7 @@ import * as authSchema from '../server/db/auth-schema' import * as schema from '../server/db/schema' import type { Platform } from '../server/platform/interface' -const isPages = process.argv.includes('--pages') +const isD1 = process.argv.includes('--d1') const NODE_DB_PATH = process.env.DATABASE_URL || './zpan.db' const D1_STATE_DIR = '.wrangler/state/v3/d1' @@ -34,7 +34,7 @@ const storageConfig = { } // ── 1. reset database ── -const platform = isPages ? resetPages() : resetNode() +const platform = isD1 ? resetD1() : resetNode() // ── 2. seed ── const secret = process.env.BETTER_AUTH_SECRET || 'dev-secret-for-seed' @@ -80,7 +80,7 @@ function resetNode(): Platform { return { db, getEnv: (key) => process.env[key] } } -function resetPages(): Platform { +function resetD1(): Platform { // wipe D1 local state if (fs.existsSync(D1_STATE_DIR)) { fs.rmSync(D1_STATE_DIR, { recursive: true }) diff --git a/server/bootstrap.ts b/server/bootstrap.ts new file mode 100644 index 00000000..d8023c5e --- /dev/null +++ b/server/bootstrap.ts @@ -0,0 +1,16 @@ +import { createApp } from './app' +import { createAuth } from './auth' +import { createNodePlatform } from './platform/node' + +const platform = createNodePlatform() +const secret = process.env.BETTER_AUTH_SECRET +if (!secret) { + throw new Error('BETTER_AUTH_SECRET is required. Set it in the environment before starting the server.') +} +const baseURL = process.env.BETTER_AUTH_URL || 'http://localhost:5173' +const trustedOrigins = process.env.TRUSTED_ORIGINS?.split(',') + .map((o) => o.trim()) + .filter(Boolean) || ['http://localhost:5173'] +const auth = createAuth(platform.db, secret, baseURL, trustedOrigins) + +export default createApp(platform, auth) diff --git a/server/entry-node.ts b/server/entry-node.ts index 21f68c11..7cf0f7b5 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -1,28 +1,13 @@ import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' -import { createApp } from './app' -import { createAuth } from './auth' -import { createNodePlatform } from './platform/node' +import app from './bootstrap' -const platform = createNodePlatform() -const secret = process.env.BETTER_AUTH_SECRET -if (!secret) { - throw new Error('BETTER_AUTH_SECRET is required. Set it in the environment before starting the server.') -} -const baseURL = process.env.BETTER_AUTH_URL || 'http://localhost:8222' -const trustedOrigins = process.env.TRUSTED_ORIGINS?.split(',') - .map((o) => o.trim()) - .filter(Boolean) || ['http://localhost:5173'] -const auth = createAuth(platform.db, secret, baseURL, trustedOrigins) - -const api = createApp(platform, auth) - -const app = new Hono() -app.route('/', api) -app.use('/*', serveStatic({ root: './dist' })) -app.get('/*', serveStatic({ root: './dist', path: 'index.html' })) +const server = new Hono() +server.route('/', app) +server.use('/*', serveStatic({ root: './dist' })) +server.get('/*', serveStatic({ root: './dist', path: 'index.html' })) const port = Number(process.env.PORT) || 8222 console.log(`ZPan server running on http://localhost:${port}`) -serve({ fetch: app.fetch, port }) +serve({ fetch: server.fetch, port }) diff --git a/tsconfig.server.json b/tsconfig.server.json index 42ff4e50..4ec86800 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -5,6 +5,6 @@ "declaration": false, "declarationMap": false }, - "include": ["server", "shared", "functions"], - "exclude": ["server/**/*.cf-test.ts", "server/test/apply-migrations.ts"] + "include": ["server", "shared", "workers"], + "exclude": ["server/**/*.cf-test.ts", "workers/**/*.cf-test.ts", "server/test/apply-migrations.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 692c008f..de2b7742 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,12 @@ import path from 'node:path' +import { cloudflare } from '@cloudflare/vite-plugin' import tailwindcss from '@tailwindcss/vite' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' import react from '@vitejs/plugin-react-swc' +import devServer from '@hono/vite-dev-server' import { defineConfig } from 'vite' -export default defineConfig({ +export default defineConfig(({ mode }) => ({ build: { outDir: 'dist', emptyOutDir: true, @@ -16,6 +18,9 @@ export default defineConfig({ }), react(), tailwindcss(), + ...(mode === 'node' + ? [devServer({ entry: './server/bootstrap.ts', injectClientScript: false, exclude: [/^(?!\/api\/).*/] })] + : [cloudflare()]), ], resolve: { alias: { @@ -26,8 +31,5 @@ export default defineConfig({ }, server: { port: 5173, - proxy: { - '/api': 'http://localhost:8222', - }, }, -}) +})) diff --git a/vitest.cloudflare.config.ts b/vitest.cloudflare.config.ts index d2472516..24210784 100644 --- a/vitest.cloudflare.config.ts +++ b/vitest.cloudflare.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ test: { globals: true, testTimeout: 15000, - include: ['server/**/*.cf-test.ts'], + include: ['server/**/*.cf-test.ts', 'workers/**/*.cf-test.ts'], setupFiles: ['./server/test/apply-migrations.ts'], }, }) diff --git a/workers/bootstrap.cf-test.ts b/workers/bootstrap.cf-test.ts new file mode 100644 index 00000000..958584d4 --- /dev/null +++ b/workers/bootstrap.cf-test.ts @@ -0,0 +1,27 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it } from 'vitest' +import worker from './bootstrap' + +describe('[CF] Worker fetch handler', () => { + it('throws when BETTER_AUTH_SECRET is missing', async () => { + const request = new Request('http://localhost/api/health') + const envWithoutSecret = { ...env, BETTER_AUTH_SECRET: '' } + await expect(worker.fetch(request, envWithoutSecret)).rejects.toThrow( + 'BETTER_AUTH_SECRET is not configured for this deployment.', + ) + }) + + it('returns a response for a valid request', async () => { + const request = new Request('http://localhost/api/health') + const res = await worker.fetch(request, env) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'ok' }) + }) + + it('splits and trims TRUSTED_ORIGINS when provided', async () => { + const request = new Request('http://localhost/api/health') + const envWithOrigins = { ...env, TRUSTED_ORIGINS: ' https://a.example.com , https://b.example.com ' } + const res = await worker.fetch(request, envWithOrigins) + expect(res.status).toBe(200) + }) +}) diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts new file mode 100644 index 00000000..1b3db1ee --- /dev/null +++ b/workers/bootstrap.ts @@ -0,0 +1,26 @@ +import { createApp } from '../server/app' +import { createAuth } from '../server/auth' +import { createCloudflarePlatform } from '../server/platform/cloudflare' + +interface Env { + DB: D1Database + BETTER_AUTH_SECRET: string + BETTER_AUTH_URL?: string + TRUSTED_ORIGINS?: string + [key: string]: unknown +} + +export default { + async fetch(request: Request, env: Env): Promise { + const { BETTER_AUTH_SECRET, BETTER_AUTH_URL, TRUSTED_ORIGINS } = env + if (!BETTER_AUTH_SECRET) { + throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.') + } + const platform = createCloudflarePlatform(env) + const trustedOrigins = TRUSTED_ORIGINS?.split(',') + .map((o) => o.trim()) + .filter(Boolean) + const auth = createAuth(platform.db, BETTER_AUTH_SECRET, BETTER_AUTH_URL, trustedOrigins) + return createApp(platform, auth).fetch(request) + }, +} diff --git a/wrangler.toml b/wrangler.toml index 0451565b..1b5fae0d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,10 +1,13 @@ name = "zpan" +main = "./workers/bootstrap.ts" compatibility_date = "2024-12-01" compatibility_flags = ["nodejs_compat"] -pages_build_output_dir = "./dist" +[assets] +not_found_handling = "single-page-application" +run_worker_first = ["/api/*"] -# Local development D1 binding (used by `wrangler pages dev`) +# Local development D1 binding (used by `wrangler dev`) [[d1_databases]] binding = "DB" database_name = "zpan-db-local" @@ -14,18 +17,16 @@ migrations_dir = "./migrations" # ---------------------------------------------------------------------------- # Environment-specific config. # -# CF Pages has exactly two environments: production and preview. Top-level -# bindings in wrangler.toml apply to BOTH of them unless an [env.] block -# overrides — so to isolate preview from production we must declare both. +# Workers has two environments: production and preview. Top-level bindings +# apply to BOTH unless an [env.] block overrides. # # Secrets (BETTER_AUTH_SECRET etc.) cannot live in this file. Set them via: -# wrangler pages secret put BETTER_AUTH_SECRET --project-name=zpan -# For preview, the secret is managed directly via the Cloudflare API (there -# is no per-env flag on `wrangler pages secret put` for Pages). See -# scripts/setup-preview.sh (or the project runbook) for the PATCH command. +# wrangler secret put BETTER_AUTH_SECRET # ---------------------------------------------------------------------------- # --- production --- +# TODO: Update these URLs after creating the Workers project in CF Dashboard. +# Workers subdomain will be https://zpan..workers.dev or your custom domain. [env.production.vars] BETTER_AUTH_URL = "https://zpan.pages.dev" TRUSTED_ORIGINS = "https://zpan.pages.dev" @@ -48,8 +49,5 @@ database_name = "zpan-db-preview" database_id = "a9197d7a-6524-42f0-b646-e714dcb30b3b" migrations_dir = "./migrations" -# Observability note: Pages Functions do not support [observability] in -# wrangler.toml or via the deployment_configs API. Logs are real-time only: -# npx wrangler pages deployment tail --project-name=zpan -# For persistent Workers Logs, the project would need to migrate from -# Pages Functions to a standalone Worker. +[observability] +enabled = true