diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4ae9972..f6923f1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,11 +22,6 @@ on: required: false default: '' type: string - pages_project_name: - description: 'Override Pages project name (optional)' - required: false - default: '' - type: string worker_name: description: 'Override Worker name (optional)' required: false @@ -52,11 +47,6 @@ on: required: false default: 'false' type: string - wait_for_tls: - description: 'Wait until https:// becomes reachable (true/false)' - required: false - default: 'true' - type: string concurrency: group: inkrypt-deploy-${{ github.repository }}-${{ inputs.domain }} @@ -76,14 +66,12 @@ jobs: INKRYPT_RP_NAME: ${{ inputs.rp_name }} INKRYPT_COOKIE_SAMESITE: ${{ inputs.cookie_samesite }} INKRYPT_CORS_ORIGIN: ${{ inputs.cors_origin }} - INKRYPT_PAGES_PROJECT_NAME: ${{ inputs.pages_project_name }} INKRYPT_WORKER_NAME: ${{ inputs.worker_name }} INKRYPT_D1_NAME: ${{ inputs.d1_name }} # Safety toggles FORCE_TAKEOVER_DNS: ${{ inputs.force_takeover_dns }} FORCE_TAKEOVER_ROUTES: ${{ inputs.force_takeover_routes }} - WAIT_FOR_TLS: ${{ inputs.wait_for_tls }} steps: - name: Checkout @@ -110,23 +98,11 @@ jobs: echo "CLOUDFLARE_ACCOUNT_ID=${{ steps.zone.outputs.account_id }}" >> "$GITHUB_ENV" echo "CLOUDFLARE_ZONE_ID=${{ steps.zone.outputs.zone_id }}" >> "$GITHUB_ENV" - - name: Ensure Pages project exists - run: node deploy/cf-api.mjs ensure-pages-project --account-id "${{ steps.zone.outputs.account_id }}" --project-name "${{ steps.cfg.outputs.pages_project_name }}" --production-branch main - - name: Build web run: npm --workspace apps/web run build - - name: Deploy Pages (Direct Upload) - uses: cloudflare/pages-action@v1 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ steps.zone.outputs.account_id }} - projectName: ${{ steps.cfg.outputs.pages_project_name }} - directory: apps/web/dist - branch: main - - - name: Bind custom domain + DNS (Pages) - run: node deploy/cf-api.mjs ensure-pages-domain --domain "${{ steps.cfg.outputs.domain }}" --account-id "${{ steps.zone.outputs.account_id }}" --project-name "${{ steps.cfg.outputs.pages_project_name }}" --zone-id "${{ steps.zone.outputs.zone_id }}" --cname-target "${{ steps.cfg.outputs.cname_target }}" + - name: Ensure DNS record (Worker-only) + run: node deploy/cf-api.mjs ensure-dns-a --zone-id "${{ steps.zone.outputs.zone_id }}" --name "${{ steps.cfg.outputs.domain }}" - name: Ensure D1 exists id: d1 @@ -197,7 +173,7 @@ jobs: run: npx wrangler deploy --name "${{ steps.cfg.outputs.worker_name }}" --cwd apps/worker --config wrangler.toml - name: Ensure Worker routes - run: node deploy/cf-api.mjs ensure-worker-routes --zone-id "${{ steps.zone.outputs.zone_id }}" --worker-name "${{ steps.cfg.outputs.worker_name }}" --route "${{ steps.cfg.outputs.domain }}/api/*,${{ steps.cfg.outputs.domain }}/auth/*,${{ steps.cfg.outputs.domain }}/healthz*" + run: node deploy/cf-api.mjs ensure-worker-routes --zone-id "${{ steps.zone.outputs.zone_id }}" --worker-name "${{ steps.cfg.outputs.worker_name }}" --route "${{ steps.cfg.outputs.domain }}/*" - name: Smoke test shell: bash diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 8fb8ec4..a533a4f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -6,8 +6,8 @@ Inkrypt 是一款基于 Passkey 的端到端加密笔记应用——你的笔记 | 组件 | 技术 | 部署目标 | |------|------|----------| -| 前端 | Vite + React | Cloudflare Pages | -| 后端 | Hono | Cloudflare Workers | +| 前端 | Vite + React | Cloudflare Workers Static Assets | +| 后端 | Hono | Cloudflare Workers(同一个 Worker 内) | | 存储 | D1 | Cloudflare D1 | | 限流 | Durable Objects | Cloudflare DO | @@ -15,25 +15,13 @@ Inkrypt 是一款基于 Passkey 的端到端加密笔记应用——你的笔记 ## 部署前必读 -### 1. 推荐同域部署 - -前端和后端放在同一个域名下是最省心的方式: - -``` -https://notes.example.com/* → Pages(静态资源) -https://notes.example.com/api/* → Worker(API) -https://notes.example.com/auth/* → Worker(认证) -``` - -这样不需要处理跨域,Cookie 和 WebAuthn 都最稳定。 - -### 2. 域名定了就别改 +### 1. 域名定了就别改 后端用 `RP_ID`(域名)和 `ORIGIN`(完整地址)验证 Passkey。上线后改域名会导致已有 Passkey 失效。 -### 3. 不支持跨站部署 +### 2. 不支持跨站部署 -前端在 `*.pages.dev`、后端在 `example.com` 这种跨站组合会被 CSRF 保护拦截,不支持。 +前后端不在同一站点会被 CSRF 保护拦截,不支持。 --- @@ -42,7 +30,7 @@ https://notes.example.com/auth/* → Worker(认证) **Cloudflare 侧**: - Cloudflare 账号 - 一个域名(已托管到 Cloudflare) -- 开通 Workers、Pages、D1、Durable Objects +- 开通 Workers、D1、Durable Objects **本地**: - Node.js 20+ @@ -54,12 +42,10 @@ https://notes.example.com/auth/* → Worker(认证) 本仓库内置 GitHub Actions 工作流:`.github/workflows/deploy.yml`,可在 GitHub 上一键完成: -- Pages 项目创建与部署(Direct Upload) -- Worker 部署(含 D1/DO) +- Worker 部署(包含静态资源 + API,含 D1/DO) - D1 创建与 migrations -- Pages 自定义域名绑定 -- DNS CNAME 自动配置 -- Worker Routes 自动配置(`/api/*`、`/auth/*`、`/healthz*`) +- DNS 记录自动配置(Worker-only) +- Worker Routes 自动配置(`/*`,内部仅 `/api/*`、`/auth/*`、`/healthz*` 走代码) ### 你需要准备 @@ -70,7 +56,7 @@ https://notes.example.com/auth/* → Worker(认证) ### Token 权限建议(最小集) - Zone:`Zone:Read`、`DNS:Edit`、`Workers Routes:Edit` -- Account:`Pages:Edit`、`Workers Scripts:Edit`、`D1:Edit` +- Account:`Workers Scripts:Edit`、`D1:Edit` ### Durable Objects(SQLite 后端) @@ -91,7 +77,6 @@ https://notes.example.com/auth/* → Worker(认证) - `force_takeover_dns=true`:当 `DOMAIN` 已存在 DNS 记录但不匹配时,允许覆盖 - `force_takeover_routes=true`:当目标路由已绑定其他 Worker 时,允许接管 -- `wait_for_tls=false`:不等待证书/HTTPS 可用(默认会等待) --- @@ -155,28 +140,18 @@ npx wrangler deploy ## 步骤 5:部署前端 -1. 打开 Cloudflare Dashboard → Pages → 创建项目 -2. 绑定你的 Git 仓库 -3. 配置构建: - - **Build command**: `npm ci && npm --workspace apps/web run build` - - **Output directory**: `apps/web/dist` - - **Environment**: `NODE_VERSION=22` +前端静态资源会随 Worker 一起部署(Workers Static Assets),无需创建 Pages 项目。 --- ## 步骤 6:配置路由 -### 给 Pages 绑定域名 - -Pages 项目 → 自定义域名 → 添加 `notes.example.com` - ### 给 Worker 添加路由 Worker → Triggers → Routes → 添加: -- `notes.example.com/api/*` -- `notes.example.com/auth/*` +- `notes.example.com/*` -这样 `/api` 和 `/auth` 走 Worker,其他走 Pages。 +这样整个站点都由同一个 Worker 提供:静态资源由 Static Assets 返回,`/api`、`/auth` 等路径由 Hono 代码处理。 --- diff --git a/README.md b/README.md index 40ec433..6fd5dfe 100644 --- a/README.md +++ b/README.md @@ -125,28 +125,26 @@ Token 最小权限建议: - Zone:`Zone:Read`、`DNS:Edit`、`Workers Routes:Edit` -- Account:`Pages:Edit`、`Workers Scripts:Edit`、`D1:Edit` +- Account:`Workers Scripts:Edit`、`D1:Edit` ### 3) 运行部署工作流 进入仓库 → Actions → `Deploy Inkrypt` → Run workflow: - 必填:`domain` -- 选填:`rp_name`、`cors_origin`、`pages_project_name`、`worker_name`、`d1_name`、`d1_location` +- 选填:`rp_name`、`cors_origin`、`worker_name`、`d1_name`、`d1_location` 安全开关(默认谨慎): - `force_takeover_dns=true`:允许覆盖已存在但不匹配的 DNS 记录 - `force_takeover_routes=true`:允许接管已被其他 Worker 占用的 Routes -- `wait_for_tls=false`:不等待 HTTPS 就绪(默认会等待) 该工作流会自动完成: -- Pages 项目创建与部署(Direct Upload) -- Worker 部署(含 D1/DO) +- Worker 部署(包含静态资源 + API,含 D1/DO) - D1 创建与 migrations -- Pages 自定义域名绑定 + DNS CNAME 自动配置 -- Worker Routes 自动配置(`/api/*`、`/auth/*`、`/healthz*`) +- DNS 记录自动配置(Worker-only) +- Worker Routes 自动配置(`/*`,内部仅 `/api/*`、`/auth/*`、`/healthz*` 走代码) - Smoke test:访问 `https:///healthz` ### 4) 部署完成后 diff --git a/apps/worker/wrangler.toml.example b/apps/worker/wrangler.toml.example index 1a6f9ae..85b96f0 100644 --- a/apps/worker/wrangler.toml.example +++ b/apps/worker/wrangler.toml.example @@ -6,6 +6,14 @@ compatibility_flags = ["nodejs_compat"] workers_dev = false preview_urls = false +[assets] +# Serve the Vite build output as Static Assets from the same Worker. +directory = "../web/dist" +# SPA mode: when a request doesn't match an asset, return index.html (for HTML navigations). +not_found_handling = "single-page-application" +# Only these routes should invoke the User Worker (Hono). Everything else is served as static assets. +run_worker_first = ["/api/*", "/auth/*", "/healthz*"] + [vars] RP_NAME = "Inkrypt" RP_ID = "notes.example.com" diff --git a/deploy/cf-api.mjs b/deploy/cf-api.mjs index 0e91c17..9b43ad4 100644 --- a/deploy/cf-api.mjs +++ b/deploy/cf-api.mjs @@ -164,7 +164,7 @@ async function resolveZone({ token, domain }) { throw new Error(`No active Cloudflare zone found for DOMAIN=${normalized}. Is it added to Cloudflare and does the token have Zone:Read?`) } -async function ensureDnsCname({ token, zoneId, recordName, target, proxied, force }) { +async function ensureDnsA({ token, zoneId, recordName, ip, proxied, force }) { const records = await cf(token, { method: 'GET', path: `/zones/${zoneId}/dns_records`, @@ -178,9 +178,9 @@ async function ensureDnsCname({ token, zoneId, recordName, target, proxied, forc } const desired = { - type: 'CNAME', + type: 'A', name: recordName, - content: target, + content: ip, ttl: 1, proxied: proxied ?? true, } @@ -194,14 +194,14 @@ async function ensureDnsCname({ token, zoneId, recordName, target, proxied, forc const matches = record?.type === desired.type && String(record?.name).toLowerCase() === String(desired.name).toLowerCase() && - String(record?.content).toLowerCase() === String(desired.content).toLowerCase() && + String(record?.content) === String(desired.content) && Boolean(record?.proxied) === Boolean(desired.proxied) if (matches) return { action: 'unchanged', id: record.id } if (!force) { throw new Error( - `DNS record for ${recordName} exists but does not match expected CNAME -> ${target}. Set FORCE_TAKEOVER_DNS=true to override.`, + `DNS record for ${recordName} exists but does not match expected A -> ${ip}. Set FORCE_TAKEOVER_DNS=true to override.`, ) } @@ -213,60 +213,6 @@ async function ensureDnsCname({ token, zoneId, recordName, target, proxied, forc return { action: 'updated', id: updated?.id ?? record.id } } -async function ensurePagesDomain({ token, accountId, projectName, domain }) { - const domains = await cf(token, { - method: 'GET', - path: `/accounts/${accountId}/pages/projects/${projectName}/domains`, - }) - - const list = Array.isArray(domains) ? domains : [] - const found = list.find((d) => String(d?.name).toLowerCase() === String(domain).toLowerCase()) - if (found) return { action: 'exists', domain: found } - - const created = await cf(token, { - method: 'POST', - path: `/accounts/${accountId}/pages/projects/${projectName}/domains`, - body: { name: domain }, - }) - return { action: 'created', domain: created } -} - -async function ensurePagesProject({ token, accountId, projectName, productionBranch }) { - try { - const project = await cf(token, { - method: 'GET', - path: `/accounts/${accountId}/pages/projects/${projectName}`, - }) - return { action: 'exists', project } - } catch (err) { - if (err instanceof CloudflareApiError && err.status === 404) { - // Continue to create. - } else { - throw err - } - } - - const project = await cf(token, { - method: 'POST', - path: `/accounts/${accountId}/pages/projects`, - body: { - name: projectName, - production_branch: productionBranch, - }, - }) - - return { action: 'created', project } -} - -async function retryPagesDomainValidation({ token, accountId, projectName, domain }) { - // "Retry the validation status of a single domain." - const updated = await cf(token, { - method: 'PATCH', - path: `/accounts/${accountId}/pages/projects/${projectName}/domains/${domain}`, - }) - return updated -} - async function ensureWorkerRoutes({ token, zoneId, workerName, patterns, force }) { const routes = await cf(token, { method: 'GET', path: `/zones/${zoneId}/workers/routes` }) const list = Array.isArray(routes) ? routes : [] @@ -299,27 +245,6 @@ async function ensureWorkerRoutes({ token, zoneId, workerName, patterns, force } } } -async function waitForHttps({ domain, timeoutSeconds, intervalSeconds }) { - const deadline = Date.now() + timeoutSeconds * 1000 - const url = `https://${domain}/` - let lastError = null - - while (Date.now() < deadline) { - try { - const resp = await fetch(url, { method: 'HEAD', redirect: 'manual' }) - // 522/525/526 are typical "not ready" statuses. Any other response indicates TLS + routing is working. - if (![522, 525, 526].includes(resp.status)) return - lastError = new Error(`HTTP ${resp.status}`) - } catch (err) { - lastError = err - } - - await new Promise((r) => setTimeout(r, intervalSeconds * 1000)) - } - - throw new Error(`Timed out waiting for TLS/HTTPS on ${url}. Last error: ${lastError ? String(lastError) : 'unknown'}`) -} - function parseBool(v) { if (typeof v === 'boolean') return v if (v === undefined) return false @@ -332,8 +257,7 @@ function usage() { return ` Usage: node deploy/cf-api.mjs resolve-zone --domain - node deploy/cf-api.mjs ensure-pages-project --account-id --project-name [--production-branch main] - node deploy/cf-api.mjs ensure-pages-domain --domain --account-id --project-name --zone-id --cname-target [--force-dns] [--wait-tls] + node deploy/cf-api.mjs ensure-dns-a --zone-id --name [--ip 192.0.2.1] [--proxied true|false] [--force] node deploy/cf-api.mjs ensure-worker-routes --zone-id --worker-name --route [--route ...] [--force] Auth: @@ -360,58 +284,18 @@ if (cmd === 'resolve-zone') { process.exit(0) } -if (cmd === 'ensure-pages-project') { - const accountId = String(args['account-id'] ?? '').trim() - const projectName = String(args['project-name'] ?? '').trim() - const productionBranch = String(args['production-branch'] ?? 'main').trim() || 'main' - - if (!accountId) throw new Error('--account-id is required') - if (!projectName) throw new Error('--project-name is required') - - const result = await ensurePagesProject({ token, accountId, projectName, productionBranch }) - process.stdout.write(`Pages project: ${result.action}\n`) - process.exit(0) -} - -if (cmd === 'ensure-pages-domain') { - const domain = normalizeDomain(args.domain ?? process.env.DOMAIN ?? process.env.INKRYPT_DOMAIN) - const accountId = String(args['account-id'] ?? '').trim() - const projectName = String(args['project-name'] ?? '').trim() +if (cmd === 'ensure-dns-a') { const zoneId = String(args['zone-id'] ?? '').trim() - const cnameTarget = String(args['cname-target'] ?? '').trim() + const name = normalizeDomain(args.name ?? args.domain ?? process.env.DOMAIN ?? process.env.INKRYPT_DOMAIN) + const ip = String(args.ip ?? '192.0.2.1').trim() || '192.0.2.1' + const proxied = args.proxied === undefined ? true : parseBool(args.proxied) + const force = parseBool(args.force ?? process.env.FORCE_TAKEOVER_DNS) - if (!accountId) throw new Error('--account-id is required') - if (!projectName) throw new Error('--project-name is required') if (!zoneId) throw new Error('--zone-id is required') - if (!cnameTarget) throw new Error('--cname-target is required') - - const forceDns = parseBool(args['force-dns'] ?? process.env.FORCE_TAKEOVER_DNS) - const waitTls = parseBool(args['wait-tls'] ?? process.env.WAIT_FOR_TLS) - - const pages = await ensurePagesDomain({ token, accountId, projectName, domain }) - process.stdout.write(`Pages domain: ${pages.action}\n`) - - const dns = await ensureDnsCname({ - token, - zoneId, - recordName: domain, - target: cnameTarget, - proxied: true, - force: forceDns, - }) - process.stdout.write(`DNS CNAME ${domain} -> ${cnameTarget}: ${dns.action}\n`) - - await retryPagesDomainValidation({ token, accountId, projectName, domain }) - process.stdout.write(`Pages domain validation: retried\n`) - - if (waitTls) { - const timeoutSeconds = Number(args['tls-timeout'] ?? 600) - const intervalSeconds = Number(args['tls-interval'] ?? 10) - process.stdout.write(`Waiting for HTTPS to become available (timeout=${timeoutSeconds}s)...\n`) - await waitForHttps({ domain, timeoutSeconds, intervalSeconds }) - process.stdout.write(`HTTPS is reachable: https://${domain}\n`) - } + if (!name) throw new Error('--name is required') + const dns = await ensureDnsA({ token, zoneId, recordName: name, ip, proxied, force }) + process.stdout.write(`DNS A ${name} -> ${ip}: ${dns.action}\n`) process.exit(0) } diff --git a/deploy/name.mjs b/deploy/name.mjs index 0382362..5878434 100644 --- a/deploy/name.mjs +++ b/deploy/name.mjs @@ -114,16 +114,12 @@ const corsOrigin = corsOriginRaw || origin const slug = slugifyDomain(domain) const hash = shortHash(domain) -const pagesProjectNameOverride = String(args['pages-project-name'] ?? process.env.INKRYPT_PAGES_PROJECT_NAME ?? '').trim() const workerNameOverride = String(args['worker-name'] ?? process.env.INKRYPT_WORKER_NAME ?? '').trim() const d1NameOverride = String(args['d1-name'] ?? process.env.INKRYPT_D1_NAME ?? '').trim() -const pagesProjectName = pagesProjectNameOverride || buildName('inkrypt', slug, hash) const workerName = workerNameOverride || buildName('inkrypt-api', slug, hash) const d1Name = d1NameOverride || buildName('inkrypt', slug, hash) -const cnameTarget = `${pagesProjectName}.pages.dev` - const outputs = { domain, origin, @@ -131,10 +127,8 @@ const outputs = { rp_name: rpName, cors_origin: corsOrigin, cookie_samesite: cookieSameSite, - pages_project_name: pagesProjectName, worker_name: workerName, d1_name: d1Name, - cname_target: cnameTarget, } for (const [k, v] of Object.entries(outputs)) githubOutput(k, v)