diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cb7d6fb9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +.github +.codex-tmp +.edgeever-data +.env* +.idea +.wrangler* +node_modules +release +target +test-results +playwright-report +videos +apps/desktop/** +!apps/desktop/package.json +apps/extension/** +!apps/extension/package.json +apps/ios +apps/mobile/** +!apps/mobile/package.json +apps/site/** +!apps/site/package.json +**/*.test.* +**/.DS_Store +*.log diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 00000000..74ccf5b0 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,210 @@ +name: Build and publish Docker image + +on: + pull_request: + paths: + - "apps/api/**" + - "apps/web/**" + - "packages/**" + - "docs/**" + - "scripts/self-hosted-*.mjs" + - "migrations/**" + - "patches/**" + - "Dockerfile" + - ".dockerignore" + - "compose.yaml" + - "bunfig.toml" + - "package.json" + - "bun.lock" + - "tailwind.config.ts" + - "tsconfig.json" + - ".github/workflows/docker-image.yml" + push: + branches: + - main + paths: + - "apps/api/**" + - "apps/web/**" + - "packages/**" + - "docs/**" + - "scripts/self-hosted-*.mjs" + - "migrations/**" + - "patches/**" + - "Dockerfile" + - ".dockerignore" + - "compose.yaml" + - "bunfig.toml" + - "package.json" + - "bun.lock" + - "tailwind.config.ts" + - "tsconfig.json" + - ".github/workflows/docker-image.yml" + workflow_dispatch: + inputs: + release_tag: + description: "Draft release tag to publish (for example v1.33.0); leave empty for main" + required: false + type: string + release: + types: + - published + +permissions: + contents: read + +env: + IMAGE_NAME: ghcr.io/tianma-if/edgeever + +jobs: + container-test: + if: github.repository == 'tianma-if/edgeever' && github.event_name != 'release' + name: Build and verify container + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Build local image + run: docker build --tag edgeever:ci . + + - name: Start container + run: | + docker run --detach --name edgeever-ci \ + --env EDGE_EVER_AUTH_PASSWORD=container-ci-password \ + --publish 127.0.0.1:8787:8787 \ + edgeever:ci + + - name: Verify shared API and persistent storage + run: | + for attempt in $(seq 1 60); do + if health="$(curl --fail --silent --show-error http://127.0.0.1:8787/api/health)"; then + break + fi + sleep 1 + done + echo "${health}" | jq -e '.ok == true and .runtime == "self-hosted-bun" and .authMode == "required"' + docker exec edgeever-ci test -f /data/edgeever.sqlite + docker exec edgeever-ci test -d /data/resources + + - name: Stop container gracefully + run: | + docker stop --timeout 30 edgeever-ci + docker logs edgeever-ci | grep -F '[self-hosted] shutdown complete' + + - name: Show container logs on failure + if: failure() + run: docker logs edgeever-ci || true + + publish: + if: >- + github.repository == 'tianma-if/edgeever' && + github.event_name != 'pull_request' && + github.event_name != 'release' + needs: container-test + name: Publish official multi-platform image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Resolve image tags + id: image + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + if [[ -n "${RELEASE_TAG}" ]]; then + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release_tag must match vX.Y.Z" >&2 + exit 1 + fi + release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")" + if [[ "$(jq -r '.draft' <<<"${release_json}")" != "true" ]] || [[ "$(jq -r '.prerelease' <<<"${release_json}")" != "false" ]]; then + echo "${RELEASE_TAG} must identify a non-prerelease Draft release" >&2 + exit 1 + fi + version="${RELEASE_TAG#v}" + expected_version="$(jq -r '.version' package.json)" + target_commitish="$(jq -r '.target_commitish' <<<"${release_json}")" + if [[ "${version}" != "${expected_version}" ]] || [[ "${target_commitish}" != "${GITHUB_SHA}" ]]; then + echo "Draft tag, package version, target commit, and workflow commit must match" >&2 + exit 1 + fi + { + echo "tags<> "${GITHUB_OUTPUT}" + else + short_sha="${GITHUB_SHA:0:12}" + { + echo "tags<> "${GITHUB_OUTPUT}" + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Sign in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.image.outputs.tags }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.image.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Verify anonymous image access + run: | + docker logout ghcr.io + docker buildx imagetools inspect "${IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" + + release-audit: + if: github.repository == 'tianma-if/edgeever' && github.event_name == 'release' + name: Audit published Docker image + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Verify prepared public image tags + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + release_inspect="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}")" + latest_inspect="$(docker buildx imagetools inspect "${IMAGE_NAME}:latest")" + release_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${release_inspect}" | head -n 1)" + latest_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${latest_inspect}" | head -n 1)" + test -n "${release_digest}" + test "${release_digest}" = "${latest_digest}" + docker buildx imagetools inspect --raw "${IMAGE_NAME}:${RELEASE_TAG}" | + jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..29534122 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1.7 +FROM oven/bun:1.3.14-alpine AS manifests +WORKDIR /app + +COPY package.json bun.lock bunfig.toml ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/desktop/package.json apps/desktop/package.json +COPY apps/extension/package.json apps/extension/package.json +COPY apps/mobile/package.json apps/mobile/package.json +COPY apps/site/package.json apps/site/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/client/package.json packages/client/package.json +COPY packages/plugin-api/package.json packages/plugin-api/package.json +COPY packages/shared/package.json packages/shared/package.json +COPY patches patches + +FROM manifests AS dependencies +RUN bun install --frozen-lockfile \ + --filter edgeever \ + --filter @edgeever/api \ + --filter @edgeever/web + +FROM manifests AS production-dependencies +RUN bun install --frozen-lockfile --production --filter edgeever + +FROM dependencies AS build +COPY apps/api apps/api +COPY apps/web apps/web +COPY packages packages +COPY docs docs +COPY tsconfig.json tailwind.config.ts ./ +RUN bun run build:web + +FROM oven/bun:1.3.14-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + EDGE_EVER_DATA_DIR=/data \ + EDGE_EVER_WEB_DIR=/app/apps/web/dist \ + PORT=8787 + +COPY --from=production-dependencies /app/node_modules ./node_modules +COPY --from=build /app/apps/api ./apps/api +COPY --from=build /app/apps/web/dist ./apps/web/dist +COPY --from=build /app/packages ./packages +COPY --from=build /app/docs/openapi.json ./docs/openapi.json +COPY migrations ./migrations +COPY scripts/self-hosted-config.mjs scripts/self-hosted-secrets.mjs scripts/self-hosted-server.mjs ./scripts/ +COPY package.json ./package.json + +RUN mkdir -p /data && chown -R bun:bun /data +USER bun +VOLUME ["/data"] +EXPOSE 8787 +STOPSIGNAL SIGTERM +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD bun -e "const r=await fetch('http://127.0.0.1:8787/api/health');if(!r.ok)process.exit(1)" +CMD ["bun", "scripts/self-hosted-server.mjs"] diff --git a/README.md b/README.md index 00eb5556..6671bf38 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ [简体中文](README.zh-CN.md) | English -> **EdgeEver: A serverless, 100% free, open-source, and AI-native self-hosted Evernote alternative.** +> **EdgeEver: An open-source, AI-native, and portable self-hosted Evernote alternative.** EdgeEver is a modern, open-source notes workspace built for effortless knowledge management. It revives the beloved Evernote-style three-pane layout while offering an open data architecture and seamless AI Agent integration for complete ownership and smart productivity. > 💡 **Serverless & 100% Free Forever** -> EdgeEver uses a pure Serverless architecture. **No server purchase or VPS rental is required**, and there is no need to configure Docker or SSL certificates. By running within Cloudflare's free quotas, personal use is **100% free with zero maintenance**. +> EdgeEver can run within Cloudflare's free quotas with no server purchase or VPS maintenance. Users who prefer a VPS, NAS, or home server can deploy the same application with Docker. > ⭐ If EdgeEver is useful to you, consider giving it a Star. Your support helps more people discover the project. @@ -35,7 +35,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s ## Features -- **Zero Server, Zero Ops, Truly Free**: Powered by Cloudflare Serverless. No cloud servers to rent or maintain. Free tiers easily accommodate up to 150k notes and 50k images with blazing-fast global edge delivery. +- **Deploy Your Way**: Run the same application on Cloudflare's free serverless platform or with Docker on a VPS, NAS, or home server—without forking the product code. - **Open Data, No Vendor Lock-in**: Built on standard SQLite with complete REST API, MCP, and CLI access. Your knowledge is stored transparently and accessible anytime without being locked to a single app. - **Lossless ZIP Backup & Portability**: Export your complete library as a clean archive containing Markdown, Front Matter, nested folders, relative attachment links, and version histories for instant restoration anywhere. - **Native AI Agent Synergy**: Deep integration with Model Context Protocol (MCP) allows AI tools like Claude Code, Codex, and Antigravity to read, organize, and summarize your notes, or sync seamlessly with Notion and Feishu Bitable. @@ -59,9 +59,9 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s ## Deployment -EdgeEver uses a pure Serverless architecture that runs entirely within Cloudflare's free tiers. **No VPS or server rental is required, and there is no need to configure Docker or SSL certificates.** +Cloudflare is the recommended zero-server deployment. Docker is available for users who prefer a VPS, NAS, or home server; both runtimes share the same application and migrations. -You can deploy online using either of the following two options: +For Cloudflare, choose either of the following online deployment options: ### Option A: Deploy with an AI Agent (Recommended) @@ -92,6 +92,16 @@ Complete setup in 5 simple web steps: > 📖 For full step-by-step instructions and configuration details, see the [Online Deployment Guide](docs/deploy-cloudflare-button.md). +### Option C: Docker on a VPS or NAS + +```sh +export EDGE_EVER_VERSION=vX.Y.Z +export EDGE_EVER_AUTH_PASSWORD='replace-with-a-long-random-password' +docker compose up -d +``` + +Docker persists SQLite and local attachments in one `/data` volume and also supports S3-compatible attachment storage. See the [Docker deployment guide](docs/deploy-docker.md) for HTTPS, secrets, NAS permissions, backup, and upgrade instructions. + --- > 💡 **Deployment Tip (Cloudflare R2 Billing)**: Although Cloudflare R2 offers a generous [free storage allowance](https://developers.cloudflare.com/r2/pricing/#free-tier) that note-taking workloads are unlikely to ever exceed, you must first activate an R2 subscription and add a payment method. Cloudflare [officially supports](https://developers.cloudflare.com/billing/get-started/update-billing-info/#supported-payment-methods) UnionPay, Visa, Mastercard, and other cards, as well as PayPal, Apple Pay, Google Pay, and other payment methods. @@ -148,7 +158,7 @@ Welcome to the EdgeEver AI community group, home to many Vibe Coding builders an - iOS app: Native SwiftUI in `apps/ios` (iOS 17+), with a packaged TipTap EditorBundle, GRDB local mirror/outbox, and Android-aligned shell chrome. - Native desktop app: Electron + Rust sidecar combines a consistent cross-platform experience with high-performance local data services; SQLite enables offline editing, incremental sync when back online, and local backups. - Web clipper: Manifest V3, Mozilla Readability, and Turndown for Chrome, Microsoft Edge, and Firefox. -- Backend: Cloudflare Workers, Hono, Zod, D1, and R2, with REST API, OpenAPI, and Remote MCP. +- Backend: one Hono/Zod business application with REST API, OpenAPI, and Remote MCP; Cloudflare uses Workers/D1/R2, while Docker uses Bun/SQLite/local files or S3. ## Quick Start @@ -172,7 +182,7 @@ packages/shared Shared types, Zod schemas, TipTap / Markdown conversion crates/desktop-sidecar Rust sidecar for local SQLite, offline data, backups, and resources scripts Wrangler wrapper, password hash, CLI, MCP stdio bridge, Evernote ENEX import -migrations D1 database migrations +migrations Shared append-only D1/SQLite database migrations docs OpenAPI schema, architecture, migration, and deployment docs .github/workflows CI for web, mobile, iOS, desktop packaging, deployment, and releases wrangler.toml Cloudflare Workers, Assets, D1, R2 configuration @@ -218,7 +228,7 @@ Open **Profile → AI Integrations** to add one or more OpenAI-compatible, Anthr Note AI includes six focused built-in prompts across Web, Android, and iOS: summarize, translate, polish, make concise, convert to Xiaohongshu style, and convert to X (Twitter) style. You can add custom prompts for more specialized workflows. Editors on all three platforms can also run AI directly on selected text and replace only that selection. Results stream into a reviewable draft that you can retry, refine with a follow-up instruction, append, or explicitly accept as a replacement. Translation uses a language picker whose default follows the interface language: Chinese defaults to English, while English defaults to Simplified Chinese. -AI requests are sent by the EdgeEver server rather than directly by the browser or native client. Model credentials are isolated by personal workspace and encrypted before being stored. Standard deployments automatically derive an AI-specific encryption key from the existing instance authentication secret, so no additional deployment variable is required. The same AI business code runs in Cloudflare Workers and the planned Docker/Bun runtime. +AI requests are sent by the EdgeEver server rather than directly by the browser or native client. Model credentials are isolated by personal workspace and encrypted before being stored. Standard deployments automatically derive an AI-specific encryption key from the existing instance authentication secret, so no additional deployment variable is required. The same AI business code runs in Cloudflare Workers and the Docker/Bun runtime. ## Image Compression @@ -241,9 +251,9 @@ If you want to migrate notes from other platforms to EdgeEver, please refer to t - **Memos Migration**: Please refer to [docs/memos-migration-guide.md](docs/memos-migration-guide.md) - **Notion Migration**: Please refer to [docs/notion-migration-guide.md](docs/notion-migration-guide.md) -## Docker Deployment Roadmap +## Docker Deployment -> 🐳 Docker-based self-hosting for VPS, NAS, and home servers is planned but is not yet a supported release. An experimental Bun runtime is already available for adapter development, with SQLite + local files or S3-compatible storage; PostgreSQL remains reserved at the contract level. See [Self-hosting and Docker architecture](docs/self-hosting-architecture.md). +Docker runs the same frontend, API routes, services, authentication, MCP implementation, and migrations as Cloudflare. The container uses SQLite with local files or S3-compatible attachment storage and supports `amd64` and `arm64`. See [Deploy EdgeEver with Docker](docs/deploy-docker.md) and [Self-hosting and Docker architecture](docs/self-hosting-architecture.md). ## Acknowledgements diff --git a/README.zh-CN.md b/README.zh-CN.md index 2cf72d9c..690d20f6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,12 +5,12 @@ 简体中文 | [English](README.md) -> **EdgeEver:无需服务器、0 费用、开源且原生支持 AI 的自托管「印象笔记」替代方案。** +> **EdgeEver:开源、原生支持 AI、可自由部署的自托管「印象笔记」替代方案。** EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印象笔记的三栏高效体验,同时具备完全开放的数据架构与原生 AI Agent 联动能力,让个人知识沉淀更轻量、更自由。 > 💡 **终身免服务器,100% 免费** -> EdgeEver 采用纯 Serverless(无服务器)架构。自部署时**无需购买云服务器**,也**无需配置复杂的 Docker 或 SSL 证书**。免费运行于 Cloudflare 配额之内,个人日常使用 **100% 免费,零费用、零运维**。 +> EdgeEver 可以免费运行在 Cloudflare 配额内,无需购买或维护服务器;希望使用 VPS、NAS 或家庭服务器的用户,也可以通过 Docker 部署同一套应用。 > ⭐ 如果 EdgeEver 对你有帮助,欢迎点个 Star。你的支持会帮助更多人发现这个项目。 @@ -35,7 +35,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印 ## 功能 -- **零服务器、零运维、真正免费**:基于 Cloudflare Serverless 架构,彻底告别服务器租用与运维烦恼。免费配额可轻松容纳 15 万条笔记与 5 万张图片,全球节点带来秒开体验。 +- **自由选择部署方式**:同一套应用既可免费运行于 Cloudflare Serverless,也可通过 Docker 部署到 VPS、NAS 或家庭服务器,无需维护产品代码分支。 - **数据开放,不设围墙**:基于标准 SQLite 存储,提供 REST API、MCP 与 CLI 接口。数据随时可读可导,不再担心被任何特定平台绑定。 - **无损 ZIP 打包与无缝迁移**:一键打包导出包含 Markdown、Front Matter、嵌套目录及附件的完整档案,同时保留历史版本与结构化数据,方便在不同实例间完整还原。 - **原生 AI Agent 智脑联动**:内置 MCP(Model Context Protocol)协议,支持 Claude Code、Codex、Antigravity 等 AI 助手直接读取与整理笔记,也可与 Notion Database、飞书多维表格轻松打通。 @@ -59,9 +59,9 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印 ## 部署 -EdgeEver 采用纯 Serverless 架构,完全运行在 Cloudflare 免费配额内,**无需购买服务器/VPS,也无需配置 Docker 或 SSL 证书**。 +Cloudflare 是推荐的零服务器部署方式;希望使用 VPS、NAS 或家庭服务器的用户也可以选择 Docker,两种运行时共用同一套应用和 migration。 -您可以选择以下两种方式之一在线部署: +Cloudflare 在线部署可以选择以下两种方式之一: ### 方案一:AI Agent 一键部署(推荐) @@ -92,6 +92,16 @@ EdgeEver 采用纯 Serverless 架构,完全运行在 Cloudflare 免费配额 > 📖 包含具体参数与构建命令的详细步骤,请查看 [在线部署完整文档](docs/deploy-cloudflare-button.zh-CN.md)。 +### 方案三:在 VPS 或 NAS 上使用 Docker + +```sh +export EDGE_EVER_VERSION=vX.Y.Z +export EDGE_EVER_AUTH_PASSWORD='请替换为足够长的随机密码' +docker compose up -d +``` + +Docker 将 SQLite 与本地附件统一持久化到 `/data` 卷,也支持 S3 兼容附件存储。HTTPS、Secret、NAS 权限、备份与升级说明请查看 [Docker 部署文档](docs/deploy-docker.zh-CN.md)。 + --- > 💡 **部署提示(Cloudflare R2 付款方式)**:虽然 Cloudflare R2 存储提供了足够慷慨、在笔记场景中几乎永远不会超量的[免费存储额度](https://developers.cloudflare.com/r2/pricing/#free-tier),但需先开通 R2 subscription 并绑定付款方式。Cloudflare [官方支持](https://developers.cloudflare.com/billing/get-started/update-billing-info/#supported-payment-methods) 银联(UnionPay)、Visa、Mastercard 等银行卡,以及 PayPal、Apple Pay、Google Pay 等付款方式。 @@ -148,7 +158,7 @@ macOS App 可从 [GitHub Releases](https://github.com/tianma-if/edgeever/release - iOS App:`apps/ios` 中的原生 SwiftUI(iOS 17+),内置 TipTap EditorBundle、GRDB 本地镜像/outbox,界面与 Android 壳层对齐。 - 原生桌面端:Electron + Rust sidecar,兼顾跨平台一致体验与高性能本地数据服务;基于 SQLite 支持离线编辑、联网后增量同步与本地备份。 - 网页裁剪:Manifest V3、Mozilla Readability、Turndown,支持 Chrome、Microsoft Edge 与 Firefox。 -- 后端:Cloudflare Workers、Hono、Zod、D1、R2,提供 REST API、OpenAPI 与 Remote MCP。 +- 后端:一套基于 Hono/Zod 的业务应用,提供 REST API、OpenAPI 与 Remote MCP;Cloudflare 使用 Workers/D1/R2,Docker 使用 Bun/SQLite/本地文件或 S3。 ## 快速开始 @@ -172,7 +182,7 @@ packages/shared 共享类型、Zod schema、TipTap / Markdown 内容转换 crates/desktop-sidecar Rust sidecar,负责本地 SQLite、离线数据、备份与资源服务 scripts Wrangler 封装、密码 hash、CLI、MCP stdio bridge、Evernote ENEX 导入 -migrations D1 数据库迁移 +migrations D1/SQLite 共用、只增不改的数据库 migration docs OpenAPI schema、架构、迁移与部署文档 .github/workflows Web、移动端、iOS、桌面端打包、部署与 Release 的 CI wrangler.toml Cloudflare Workers、Assets、D1、R2 配置 @@ -242,9 +252,9 @@ Cloudflare Worker 侧执行图片处理会消耗计算/图片处理额度,因 - **Memos 笔记的迁入**:请参考 [docs/memos-migration-guide.md](docs/memos-migration-guide.md) - **Notion 笔记的迁入**:请参考 [docs/notion-migration-guide.md](docs/notion-migration-guide.md) -## Docker 部署规划 +## Docker 部署 -> 🐳 面向 VPS、NAS 和家庭服务器的 Docker 私有化部署已纳入规划,但当前还不是正式支持的发行方式。现在已经提供用于适配器开发的实验性 Bun 运行入口,支持 SQLite + 本地文件或 S3 兼容存储;PostgreSQL 目前仍只预留契约。详见[自托管与 Docker 架构预留](docs/self-hosting-architecture.zh-CN.md)。 +Docker 与 Cloudflare 共用同一套前端、API 路由、业务服务、鉴权、MCP 实现和 migration。容器使用 SQLite,并支持本地文件或 S3 兼容附件存储,提供 `amd64` 与 `arm64` 镜像。详见[使用 Docker 部署 EdgeEver](docs/deploy-docker.zh-CN.md)和[自托管与 Docker 架构](docs/self-hosting-architecture.zh-CN.md)。 ## 致谢 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2008df38..e2f82d97 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -594,18 +594,29 @@ app.post("/api/v1/demo/reset", async (c) => { }); }); -const worker = { - async fetch(request: Request, env: WorkerBindings, ctx: ExecutionContext) { - const runtimeEnv = { - ...env, - storage: createCloudflareStorageAdapter(env), - } as Bindings; - +/** + * Executes the platform-neutral EdgeEver application with an injected storage + * adapter. Runtime entrypoints must remain thin and call this function rather + * than introducing platform-specific route or service implementations. + */ +export const fetchEdgeEverApp = async ( + request: Request, + runtimeEnv: Bindings, + ctx: ExecutionContext, +) => { if (isLocalDemoSeedEnabled(runtimeEnv)) { await ensureLocalDemoSeed(runtimeEnv); } return app.fetch(request, runtimeEnv, ctx); +}; + +const worker = { + async fetch(request: Request, env: WorkerBindings, ctx: ExecutionContext) { + return fetchEdgeEverApp(request, { + ...env, + storage: createCloudflareStorageAdapter(env), + }, ctx); }, async scheduled(controller: ScheduledController, env: WorkerBindings, ctx: ExecutionContext) { const runtimeEnv = { diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..aa1a4d6d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,20 @@ +services: + edgeever: + image: ghcr.io/tianma-if/edgeever:${EDGE_EVER_VERSION:-latest} + restart: unless-stopped + init: true + ports: + - "${EDGE_EVER_PORT:-8787}:8787" + environment: + EDGE_EVER_AUTH_USERNAME: "${EDGE_EVER_AUTH_USERNAME:-admin}" + EDGE_EVER_AUTH_PASSWORD: "${EDGE_EVER_AUTH_PASSWORD:?Set EDGE_EVER_AUTH_PASSWORD before starting EdgeEver}" + volumes: + - edgeever-data:/data + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 30s + +volumes: + edgeever-data: diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md new file mode 100644 index 00000000..55874f3e --- /dev/null +++ b/docs/deploy-docker.md @@ -0,0 +1,142 @@ +# Deploy EdgeEver with Docker + +EdgeEver uses the same web application, Hono routes, services, authentication, +OpenAPI document, MCP implementation, and append-only migrations on Cloudflare +and Docker. Only the thin runtime and infrastructure adapters differ: Docker +uses Bun with SQLite and local files (or S3-compatible object storage), while +Cloudflare uses Workers with D1 and R2. + +## Requirements + +- Docker Engine 24 or later with Docker Compose v2. +- An `amd64` or `arm64` Linux host. +- A reverse proxy with HTTPS when the instance is reachable outside a trusted + local network. + +## Start with Compose + +Download `compose.yaml`, choose the release you want to run, and provide a +strong instance password: + +```sh +export EDGE_EVER_VERSION=vX.Y.Z +export EDGE_EVER_AUTH_PASSWORD='replace-with-a-long-random-password' +docker compose up -d +docker compose ps +``` + +Open `http://localhost:8787`. The container reports healthy only after the +shared `/api/health` endpoint confirms that authentication, SQLite, and object +storage are ready. + +Compose creates one named volume. Everything that must survive a container +replacement is under `/data`: + +```text +/data/edgeever.sqlite SQLite database +/data/resources/ local images and attachments +``` + +The image runs as the non-root `bun` user (UID/GID `1000`). If a NAS requires +a host bind mount instead of the named volume, create the directory first and +grant UID/GID `1000` read/write access. + +## Configuration + +Common environment variables: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `EDGE_EVER_AUTH_USERNAME` | `admin` | Initial administrator username | +| `EDGE_EVER_AUTH_PASSWORD` | none | Initial password; required for a new database | +| `EDGE_EVER_AUTH_PASSWORD_HASH` | none | PBKDF2 hash alternative to the plaintext bootstrap password | +| `EDGE_EVER_SESSION_TTL_DAYS` | `400` | Login session lifetime | +| `EDGE_EVER_IDLE_TIMEOUT_SECONDS` | `120` | Bun streaming idle timeout, from 10 to 255 seconds | +| `EDGE_EVER_STORAGE_ENCRYPTION_KEY` | none | Encrypts saved external object-storage credentials | +| `EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` | derived | Optional independent AI credential encryption key | + +For secrets, append `_FILE` to a supported variable and point it at a Docker +secret, for example `EDGE_EVER_AUTH_PASSWORD_FILE=/run/secrets/auth_password`. +The password/hash, storage encryption keys, and S3 access credentials support +this form. Do not set both the direct variable and its `_FILE` counterpart. + +`EDGE_EVER_ALLOW_UNAUTHENTICATED=true` is available for isolated development +only. Do not expose an unauthenticated instance to a network. + +## S3-compatible attachment storage + +SQLite remains in `/data`, while new attachments can be stored in MinIO, AWS +S3, Alibaba Cloud OSS, Tencent COS, R2, or another compatible service: + +```yaml +environment: + EDGE_EVER_STORAGE_BACKEND: s3 + EDGE_EVER_S3_ENDPOINT: https://s3.example.com + EDGE_EVER_S3_REGION: us-east-1 + EDGE_EVER_S3_BUCKET: edgeever + EDGE_EVER_S3_ACCESS_KEY_ID_FILE: /run/secrets/s3_access_key + EDGE_EVER_S3_SECRET_ACCESS_KEY_FILE: /run/secrets/s3_secret_key + EDGE_EVER_S3_FORCE_PATH_STYLE: "true" +``` + +Changing the default backend does not migrate historical attachments. Keep +the old backend available until its resources have been exported or migrated. + +## HTTPS and network exposure + +The container serves HTTP on port `8787`. Terminate HTTPS in a maintained +reverse proxy such as Caddy, Traefik, or Nginx and forward the original host +and client address headers. Do not publish SQLite, `/data`, or an object-store +administration port. + +## Backup and restore + +Use EdgeEver's ZIP export for portable content backups. Also make a cold copy +of the `/data` volume for complete instance recovery: + +1. Run `docker compose stop edgeever` and wait for the shutdown-complete log. + EdgeEver checkpoints SQLite's WAL during graceful shutdown. +2. Copy or snapshot the entire named volume, including the SQLite file and + `resources` directory. +3. Start the service with `docker compose start edgeever`. + +Keep `EDGE_EVER_STORAGE_ENCRYPTION_KEY` and any explicitly configured +`EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` in a separate secret backup. A volume +backup cannot decrypt stored credentials without those keys. When S3 storage +is enabled, back up the bucket separately. + +Restore only while EdgeEver is stopped, into an empty volume, and restore the +matching secret keys at the same time. Test backups periodically on a separate +instance. + +## Upgrade and rollback + +Use immutable release tags instead of `latest` for production: + +```sh +export EDGE_EVER_VERSION=vX.Y.Z +docker compose pull +docker compose up -d +docker compose ps +``` + +The container applies the same append-only `migrations/*.sql` files used by D1 +before it starts accepting traffic. Take a backup first. Application rollback +does not reverse a database migration; restore the pre-upgrade volume backup +when a data rollback is required. + +To move between Cloudflare and Docker, use EdgeEver's full backup/export and +restore flow. Do not copy a live D1 database file or rewrite migration history. + +## Build from source + +```sh +docker build --tag edgeever:local . +docker run --rm -p 8787:8787 \ + -e EDGE_EVER_AUTH_PASSWORD='replace-with-a-long-random-password' \ + -v edgeever-data:/data \ + edgeever:local +``` + +PostgreSQL is not supported in the first Docker release. It remains a future +storage adapter and will not introduce a separate business-code branch. diff --git a/docs/deploy-docker.zh-CN.md b/docs/deploy-docker.zh-CN.md new file mode 100644 index 00000000..c035d6e9 --- /dev/null +++ b/docs/deploy-docker.zh-CN.md @@ -0,0 +1,130 @@ +# 使用 Docker 部署 EdgeEver + +EdgeEver 在 Cloudflare 与 Docker 中共用同一套 Web 应用、Hono 路由、业务服务、 +鉴权、OpenAPI、MCP 实现和只增不改的 migration。两种部署仅有薄运行入口和基础 +设施适配器不同:Docker 使用 Bun + SQLite + 本地文件(或 S3 兼容对象存储), +Cloudflare 使用 Workers + D1 + R2。 + +## 环境要求 + +- Docker Engine 24 或更高版本,包含 Docker Compose v2。 +- `amd64` 或 `arm64` Linux 主机。 +- 实例离开可信局域网时,必须使用带 HTTPS 的反向代理。 + +## 使用 Compose 启动 + +下载 `compose.yaml`,选择要运行的正式版本,并设置高强度实例密码: + +```sh +export EDGE_EVER_VERSION=vX.Y.Z +export EDGE_EVER_AUTH_PASSWORD='请替换为足够长的随机密码' +docker compose up -d +docker compose ps +``` + +打开 `http://localhost:8787`。只有共享的 `/api/health` 确认鉴权、SQLite 和对象 +存储均已就绪后,容器才会进入 healthy 状态。 + +Compose 会创建一个命名卷。所有需要在容器替换后保留的数据都位于 `/data`: + +```text +/data/edgeever.sqlite SQLite 数据库 +/data/resources/ 本地图片与附件 +``` + +镜像以非 root 的 `bun` 用户运行(UID/GID 均为 `1000`)。如果 NAS 必须使用主机 +目录绑定而不是命名卷,请先创建目录,并为 UID/GID `1000` 授予读写权限。 + +## 配置 + +常用环境变量: + +| 变量 | 默认值 | 用途 | +| --- | --- | --- | +| `EDGE_EVER_AUTH_USERNAME` | `admin` | 初始管理员用户名 | +| `EDGE_EVER_AUTH_PASSWORD` | 无 | 初始密码;新数据库必须提供 | +| `EDGE_EVER_AUTH_PASSWORD_HASH` | 无 | 可替代明文引导密码的 PBKDF2 hash | +| `EDGE_EVER_SESSION_TTL_DAYS` | `400` | 登录会话有效期 | +| `EDGE_EVER_IDLE_TIMEOUT_SECONDS` | `120` | Bun 流式响应空闲超时,可设为 10 到 255 秒 | +| `EDGE_EVER_STORAGE_ENCRYPTION_KEY` | 无 | 加密保存的外部对象存储凭据 | +| `EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` | 自动派生 | 可选的独立 AI 凭据加密密钥 | + +Secret 可在受支持的变量名后追加 `_FILE`,并指向 Docker secret,例如 +`EDGE_EVER_AUTH_PASSWORD_FILE=/run/secrets/auth_password`。密码/hash、存储加密 +密钥和 S3 访问凭据均支持这种形式。同一个 Secret 不得同时设置直接变量与 +对应的 `_FILE` 变量。 + +`EDGE_EVER_ALLOW_UNAUTHENTICATED=true` 仅用于隔离的开发环境,严禁将未鉴权 +实例暴露到网络。 + +## 使用 S3 兼容附件存储 + +SQLite 仍保存在 `/data`,新附件可以写入 MinIO、AWS S3、阿里云 OSS、腾讯云 +COS、R2 或其他兼容服务: + +```yaml +environment: + EDGE_EVER_STORAGE_BACKEND: s3 + EDGE_EVER_S3_ENDPOINT: https://s3.example.com + EDGE_EVER_S3_REGION: us-east-1 + EDGE_EVER_S3_BUCKET: edgeever + EDGE_EVER_S3_ACCESS_KEY_ID_FILE: /run/secrets/s3_access_key + EDGE_EVER_S3_SECRET_ACCESS_KEY_FILE: /run/secrets/s3_secret_key + EDGE_EVER_S3_FORCE_PATH_STYLE: "true" +``` + +切换默认后端不会迁移历史附件。在完成导出或迁移前,必须保持旧后端可用。 + +## HTTPS 与网络暴露 + +容器在 `8787` 端口提供 HTTP。请使用维护活跃的 Caddy、Traefik 或 Nginx 终止 +HTTPS,并转发原始 Host 和客户端地址。严禁公开 SQLite、`/data` 或对象存储的 +管理端口。 + +## 备份与恢复 + +可使用 EdgeEver ZIP 导出制作跨环境内容备份,同时应冷备份 `/data` 卷以支持 +完整实例恢复: + +1. 执行 `docker compose stop edgeever`,等待日志出现 shutdown complete。 + EdgeEver 会在优雅停机时 checkpoint SQLite WAL。 +2. 完整复制或快照命名卷,包括 SQLite 文件与 `resources` 目录。 +3. 执行 `docker compose start edgeever` 恢复服务。 + +请单独备份 `EDGE_EVER_STORAGE_ENCRYPTION_KEY`,以及显式配置的 +`EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY`。缺少这些密钥时,卷备份无法解密已保存 +的凭据。使用 S3 时还需独立备份存储桶。 + +只能在 EdgeEver 停止时恢复到空卷,并同时恢复匹配的 Secret。应定期在独立 +实例中验证备份。 + +## 升级与回滚 + +生产环境应使用不可变的正式版本标签,不要依赖 `latest`: + +```sh +export EDGE_EVER_VERSION=vX.Y.Z +docker compose pull +docker compose up -d +docker compose ps +``` + +容器会在接收流量前应用与 D1 共用的 `migrations/*.sql`。升级前必须先备份。 +回退应用镜像不会逆向撤销数据库 migration;需要回退数据时,应恢复升级前的 +卷备份。 + +在 Cloudflare 与 Docker 之间迁移时,请使用 EdgeEver 的完整备份/导出与恢复 +流程。不要复制在线 D1 数据库文件,也不要改写 migration 历史。 + +## 从源码构建 + +```sh +docker build --tag edgeever:local . +docker run --rm -p 8787:8787 \ + -e EDGE_EVER_AUTH_PASSWORD='请替换为足够长的随机密码' \ + -v edgeever-data:/data \ + edgeever:local +``` + +Docker 首发版本不支持 PostgreSQL。它仍是未来的存储适配器,不会形成另一套 +业务代码分支。 diff --git a/docs/promotion/slogan.md b/docs/promotion/slogan.md index 8d77b277..440317a4 100644 --- a/docs/promotion/slogan.md +++ b/docs/promotion/slogan.md @@ -32,13 +32,13 @@ ### 🇨🇳 中文 ```markdown > 💡 **终身免服务器,100% 免费** -> EdgeEver 采用纯 Serverless(无服务器)架构。自部署时**你不需要购买任何云服务器**,也**不需要折腾复杂的 Docker 或 SSL 证书**。直接运行在 Cloudflare 的免费额度内,个人日常使用 **完全免费,0 费用,0 运维**。 +> EdgeEver 可以直接运行在 Cloudflare 免费额度内,无需购买服务器,个人日常使用 **0 费用、0 运维**;也可以把同一套应用通过 Docker 部署到 VPS、NAS 或家庭服务器,数据与运行环境始终由你掌控。 ``` ### 🇺🇸 英文 ```markdown > 💡 **Serverless & 100% Free Forever** -> EdgeEver uses a pure Serverless architecture. **No server purchase or VPS rental is required**, and there is no need to configure Docker or SSL certificates. By running within Cloudflare's free quotas, personal use is **100% free with zero maintenance**. +> EdgeEver can run within Cloudflare's free quotas with **no server purchase and zero maintenance**, or you can deploy the same application with Docker on a VPS, NAS, or home server while keeping full control of your data and runtime. ``` --- diff --git a/docs/release.md b/docs/release.md index b1e88dd8..ecfda28b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -51,17 +51,18 @@ installed and launched after the Release. Release asset rather than the overall GitHub tag. This prevents a Web-only or API-only Release from prompting an unnecessary native update. - The script creates the tracking Issue and Draft Release, validates or reuses - native assets, publishes, closes the Issue, and installs the matching DMG. + native assets, prepares the multi-platform Docker image, publishes, closes + the Issue, and installs the matching DMG. Demo deployment continues independently after its Actions URL is printed. - Mobile store delivery is not part of this command. See [Mobile Store Delivery](store-delivery.md). ## Failure and Resume -- Validation or Draft asset failures leave the Release unpublished. +- Validation, Draft asset, or Docker image failures leave the Release unpublished. - Rerunning the same command resumes a matching Draft created by an interrupted run instead of creating another Issue, commit, or Release. -- A failed post-publication native audit attempts to return the Release to +- A failed post-publication native or Docker audit attempts to return the Release to Draft and leaves the Issue open. - If application replacement fails, the script restores the previous app from its macOS Trash backup when possible. diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md index a58e049d..dce92c9d 100644 --- a/docs/release.zh-CN.md +++ b/docs/release.zh-CN.md @@ -44,17 +44,17 @@ bun run release -- \ Android arm64 APK。未变化的原生资产沿用原文件名、版本和校验和。 - 桌面端和 Android 更新检查使用对应 Release 资产中记录的版本,而不是整体 GitHub Tag,避免仅涉及 Web 或 API 的 Release 触发无效原生更新。 -- 脚本负责创建跟踪 Issue 和 Draft Release、验证或复用原生资产、正式发布、 - 关闭 Issue,并安装与当前 Mac 架构匹配的 DMG。输出 Actions 链接后,Demo - 部署会独立继续执行。 +- 脚本负责创建跟踪 Issue 和 Draft Release、验证或复用原生资产、准备多架构 + Docker 镜像、正式发布、关闭 Issue,并安装与当前 Mac 架构匹配的 DMG。 + 输出 Actions 链接后,Demo 部署会独立继续执行。 - 此命令不执行移动端商店交付,详见 [移动端商店交付](store-delivery.zh-CN.md)。 ## 失败与续跑 -- 本地验证或 Draft 资产失败时,Release 保持未发布状态。 +- 本地验证、Draft 资产或 Docker 镜像失败时,Release 保持未发布状态。 - 中断后重新执行相同命令,会续跑匹配的 Draft,不会重复创建 Issue、提交或 Release。 -- 发布后的原生资产审计失败时,脚本会尝试将 Release 恢复为 Draft,并保留 - Issue。 +- 发布后的原生资产或 Docker 镜像审计失败时,脚本会尝试将 Release 恢复为 + Draft,并保留 Issue。 - 替换应用失败时,脚本会尽可能从 macOS 废纸篓备份恢复上一版应用。 diff --git a/docs/self-hosting-architecture.md b/docs/self-hosting-architecture.md index a6440e84..874718f8 100644 --- a/docs/self-hosting-architecture.md +++ b/docs/self-hosting-architecture.md @@ -1,7 +1,8 @@ # Self-hosting and Docker architecture -Docker-based self-hosting is planned for VPS, NAS, and home servers. It is not -available as a supported release yet. +Docker-based self-hosting supports VPS, NAS, and home servers without creating +a second product implementation. Cloudflare and Docker execute the same Hono +application and differ only at thin runtime and infrastructure adapter seams. ## Current boundary @@ -13,11 +14,11 @@ current concrete implementation lives in - `DatabaseAdapter`: SQL statements and batches. - `BlobStoreAdapter`: attachment `get`, `put`, and `delete` operations. -Cloudflare D1 and R2 remain the production adapter today. This keeps the -existing Worker deployment unchanged while reserving a stable replacement -point for a self-hosted adapter. +Cloudflare injects the D1/R2 adapter into `fetchEdgeEverApp`; the Bun entrypoint +injects the SQLite/filesystem or SQLite/S3 adapter into that same function. +Neither entrypoint owns route or business decisions. -The shared self-hosted configuration shape is also defined as +The shared self-hosted configuration shape is defined as `SelfHostedStorageConfig`, with one application data directory, one SQLite database file, and one attachment directory. @@ -27,10 +28,10 @@ contracts. It is intentionally not implemented yet. SQLite remains the default self-hosted database; PostgreSQL will be useful for larger teams, higher write concurrency, and external database operations. -## Intended Docker shape +## Docker shape -The first supported container deployment should be a single application -container with two persistent mounts: +The supported container deployment is a single application container with one +persistent `/data` mount: ```text EdgeEver container @@ -38,10 +39,10 @@ EdgeEver container └── attachment store -> /data/resources ``` -The initial self-hosted adapter preserves the existing SQLite schema and +The self-hosted adapter preserves the existing SQLite schema and `migrations/*.sql` files. Attachments are addressed by the same opaque object -keys currently stored in `resources.object_key`; the experimental runtime -already supports both a local filesystem backend and an S3-compatible backend. +keys stored in `resources.object_key`; the runtime supports both a local +filesystem backend and an S3-compatible backend. When PostgreSQL is implemented, it must introduce an explicit SQL dialect and migration set for PostgreSQL-specific full-text search and transaction @@ -70,11 +71,10 @@ ambiguous. - Expose a health check that distinguishes process availability from database readiness and attachment-store readiness. -This document is an architecture reservation, not a Docker deployment guide. -Until a self-hosted adapter and backup/upgrade procedure are tested, the -Cloudflare deployment remains the only supported production deployment. +Operational instructions, including secrets, HTTPS, backups, upgrades, and NAS +permissions, live in [Deploy EdgeEver with Docker](deploy-docker.md). -An experimental local runtime is available for adapter development: +The Docker image and local development use the same Bun runtime: ```sh bun run build:web @@ -82,7 +82,7 @@ EDGE_EVER_AUTH_PASSWORD='' bun run start:self-hosted ``` Set `EDGE_EVER_DATA_DIR` to the directory that should be persisted by Docker -or a NAS volume. This runtime is not yet a supported release artifact. +or a NAS volume. Long-running streaming responses use a 120-second idle timeout by default. Set `EDGE_EVER_IDLE_TIMEOUT_SECONDS` to a value from 10 to 255 to override it. diff --git a/docs/self-hosting-architecture.zh-CN.md b/docs/self-hosting-architecture.zh-CN.md index eb32ddd9..234e5f7e 100644 --- a/docs/self-hosting-architecture.zh-CN.md +++ b/docs/self-hosting-architecture.zh-CN.md @@ -1,7 +1,8 @@ # 自托管与 Docker 架构预留 -EdgeEver 计划支持 VPS、NAS 和家庭服务器上的 Docker 自托管,但当前版本 -还没有提供受支持的 Docker 发行版。 +EdgeEver 通过 Docker 支持 VPS、NAS 和家庭服务器自托管,同时不会形成第二套 +产品实现。Cloudflare 与 Docker 执行同一个 Hono 应用,仅在薄运行入口和基础 +设施适配器边界存在差异。 ## 当前边界 @@ -13,10 +14,10 @@ Cloudflare SDK 类型。目前的具体实现位于 - `DatabaseAdapter`:SQL 语句与批处理。 - `BlobStoreAdapter`:附件的 `get`、`put` 和 `delete` 操作。 -目前生产环境仍使用 Cloudflare D1 和 R2,现有 Worker 部署方式不变;未来 -自托管适配器可以在这个边界上替换,而不需要整体重写业务层。 +Cloudflare 将 D1/R2 适配器注入 `fetchEdgeEverApp`;Bun 入口则向同一个函数 +注入 SQLite/文件系统或 SQLite/S3 适配器。两个入口均不得包含路由或业务判断。 -共享的自托管配置结构也已定义为 `SelfHostedStorageConfig`,包含统一的应用 +共享的自托管配置结构定义为 `SelfHostedStorageConfig`,包含统一的应用 数据目录、SQLite 数据库文件和附件目录。 PostgreSQL 已通过与驱动无关的 `RelationalDatabaseAdapter` 和 @@ -24,9 +25,9 @@ PostgreSQL 已通过与驱动无关的 `RelationalDatabaseAdapter` 和 SQLite 仍是自托管默认数据库;未来 PostgreSQL 更适合较大团队、更高写入并发 以及独立数据库运维场景。 -## 计划中的 Docker 形态 +## Docker 形态 -第一版正式支持的容器部署应为单容器应用,并挂载两个持久化目录: +正式支持的容器部署为单应用容器,只需挂载一个持久化 `/data` 目录: ```text EdgeEver 容器 @@ -35,8 +36,8 @@ EdgeEver 容器 ``` 自托管适配器继续复用现有 SQLite 结构和 `migrations/*.sql`;附件继续使用 -`resources.object_key` 中保存的不透明对象键。目前的实验性运行入口已经支持 -本地文件系统和 S3 兼容对象存储两种后端。 +`resources.object_key` 中保存的不透明对象键。运行入口支持本地文件系统和 +S3 兼容对象存储两种后端。 未来实现 PostgreSQL 时,必须明确处理 SQL 方言以及 PostgreSQL 专用的全文搜索 和事务行为,并提供独立迁移策略,不能让现有 SQLite/D1 migration 文件产生歧义。 @@ -53,18 +54,17 @@ EdgeEver 容器 Rate Limiting 和 WAF 只能作为部署层的可选增强,不能作为运行前提。 - 健康检查应区分进程可用、数据库就绪和附件存储就绪。 -本文档用于预留架构边界,不是 Docker 部署指南。在自托管适配器以及备份、 -升级流程经过实际验证前,Cloudflare 部署仍是唯一受支持的生产部署方式。 +Secret、HTTPS、备份、升级与 NAS 权限等运维说明请查看 +[使用 Docker 部署 EdgeEver](deploy-docker.zh-CN.md)。 -当前已提供用于开发适配器的实验性本地运行入口: +Docker 镜像与本地开发共用同一个 Bun 运行入口: ```sh bun run build:web EDGE_EVER_AUTH_PASSWORD='<强密码>' bun run start:self-hosted ``` -可通过 `EDGE_EVER_DATA_DIR` 指定需要由 Docker 或 NAS 卷持久化的目录。该入口 -目前仍不是正式支持的发行物。 +可通过 `EDGE_EVER_DATA_DIR` 指定需要由 Docker 或 NAS 卷持久化的目录。 长时间流式响应默认使用 120 秒空闲超时。可将 `EDGE_EVER_IDLE_TIMEOUT_SECONDS` 设置为 10 到 255 之间的值进行覆盖。 diff --git a/package.json b/package.json index 7689f47a..dd8b89c1 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dev:api:demo": "bun scripts/local-dev.mjs api demo", "dev:self-hosted": "bun --watch scripts/self-hosted-server.mjs", "start:self-hosted": "bun scripts/self-hosted-server.mjs", + "test:docker-release": "bun test scripts/docker-release.test.mjs scripts/build-cloudflare-worker.test.mjs tests/storage-adapter.test.ts", "dev:remote": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:remote\" \"bun run dev:web\"", "dev:api:remote": "bun scripts/run-wrangler.mjs dev --remote --port 8787", "dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173", diff --git a/scripts/docker-release.test.mjs b/scripts/docker-release.test.mjs new file mode 100644 index 00000000..82a9c0be --- /dev/null +++ b/scripts/docker-release.test.mjs @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { resolveSelfHostedConfig } from "./self-hosted-config.mjs"; +import { loadSelfHostedEnvironment } from "./self-hosted-secrets.mjs"; + +const readProjectFile = (path) => readFileSync(resolve(import.meta.dir, "..", path), "utf8"); + +describe("self-hosted runtime configuration", () => { + test("uses the portable single-volume defaults", () => { + const config = resolveSelfHostedConfig({}, "/opt/edgeever"); + expect(config.dataDirectory).toBe("/opt/edgeever/.edgeever-data"); + expect(config.databaseFile).toBe("/opt/edgeever/.edgeever-data/edgeever.sqlite"); + expect(config.resourcesDirectory).toBe("/opt/edgeever/.edgeever-data/resources"); + expect(config.port).toBe(8787); + expect(config.storageBackend).toBe("local"); + }); + + test("loads Docker secrets without retaining the file indirection", async () => { + const directory = await mkdtemp(`${tmpdir()}/edgeever-docker-secret-`); + const secretPath = resolve(directory, "password"); + try { + await writeFile(secretPath, "secret-from-file\n"); + const environment = await loadSelfHostedEnvironment({ EDGE_EVER_AUTH_PASSWORD_FILE: secretPath }); + expect(environment.EDGE_EVER_AUTH_PASSWORD).toBe("secret-from-file"); + expect(environment.EDGE_EVER_AUTH_PASSWORD_FILE).toBeUndefined(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("rejects invalid ports and storage backends", () => { + expect(() => resolveSelfHostedConfig({ EDGE_EVER_PORT: "0" })).toThrow("EDGE_EVER_PORT"); + expect(() => resolveSelfHostedConfig({ EDGE_EVER_STORAGE_BACKEND: "r2" })).toThrow( + "either local or s3", + ); + expect(() => resolveSelfHostedConfig({ EDGE_EVER_STORAGE_BACKEND: "s3" })).toThrow( + "EDGE_EVER_S3_BUCKET", + ); + }); +}); + +describe("Docker release contract", () => { + test("runs the production image as a non-root user with one persistent volume", () => { + const dockerfile = readProjectFile("Dockerfile"); + expect(dockerfile).toContain("FROM oven/bun:1.3.14-alpine AS runtime"); + expect(dockerfile).toContain("COPY patches patches"); + expect(dockerfile).toContain("COPY docs docs"); + expect(dockerfile).toContain("--filter @edgeever/web"); + expect(dockerfile).toContain("--production --filter edgeever"); + expect(dockerfile).toContain("USER bun"); + expect(dockerfile).toContain('VOLUME ["/data"]'); + expect(dockerfile).toContain("HEALTHCHECK"); + }); + + test("keeps authentication explicit in Compose", () => { + const compose = readProjectFile("compose.yaml"); + expect(compose).toContain("EDGE_EVER_AUTH_PASSWORD: \"${EDGE_EVER_AUTH_PASSWORD:?"); + expect(compose).toContain("edgeever-data:/data"); + expect(compose).toContain("no-new-privileges:true"); + }); + + test("keeps both runtime entrypoints on the shared application", () => { + const application = readProjectFile("apps/api/src/index.ts"); + const selfHosted = readProjectFile("scripts/self-hosted-server.mjs"); + expect(application).toContain("export const fetchEdgeEverApp"); + expect(application).toContain("storage: createCloudflareStorageAdapter(env)"); + expect(selfHosted).toContain("fetchEdgeEverApp(request, env, executionContext)"); + expect(selfHosted).not.toContain("worker.fetch("); + }); + + test("gates official image publishing and release auditing", () => { + const workflow = readProjectFile(".github/workflows/docker-image.yml"); + expect(workflow.match(/github\.repository == 'tianma-if\/edgeever'/g)?.length).toBeGreaterThanOrEqual(2); + expect(workflow).toContain("release_tag"); + expect(workflow).toContain("docker logout ghcr.io"); + expect(workflow).toContain("docker buildx imagetools inspect"); + }); +}); diff --git a/scripts/release.mjs b/scripts/release.mjs index b4c7ad74..726671f8 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -21,9 +21,10 @@ const DEFAULT_REPOSITORY = "tianma-if/edgeever"; const VERSION_BUMPS = new Set(["patch", "minor", "major"]); const POLL_INTERVAL_MS = 10_000; const RUN_DISCOVERY_TIMEOUT_MS = 60_000; -const RELEASE_WORKFLOWS = { +export const RELEASE_WORKFLOWS = { desktop: "desktop-build.yml", mobile: "mobile-build.yml", + docker: "docker-image.yml", demo: "deploy-demo.yml", }; @@ -947,7 +948,7 @@ const releaseMain = async (options) => { console.log(`[release] Draft created: ${draftUrl}`); } - const [desktopRunId, mobileRunId] = await Promise.all([ + const [desktopRunId, mobileRunId, dockerRunId] = await Promise.all([ dispatchReleaseWorkflow({ repository: options.repository, workflow: RELEASE_WORKFLOWS.desktop, @@ -960,6 +961,12 @@ const releaseMain = async (options) => { tag, headSha: releaseSha, }), + dispatchReleaseWorkflow({ + repository: options.repository, + workflow: RELEASE_WORKFLOWS.docker, + tag, + headSha: releaseSha, + }), ]); await Promise.all([ waitForRun({ @@ -972,6 +979,11 @@ const releaseMain = async (options) => { runId: mobileRunId, label: "Draft Android assets", }), + waitForRun({ + repository: options.repository, + runId: dockerRunId, + label: "Draft Docker image", + }), ]); const draft = ghJson([ @@ -1010,7 +1022,7 @@ const releaseMain = async (options) => { ], { capture: true }); console.log(`[release] published: ${releaseUrl}`); - const [desktopAudit, mobileAudit] = await Promise.all([ + const [desktopAudit, mobileAudit, dockerAudit] = await Promise.all([ findReleaseRun({ repository: options.repository, workflow: RELEASE_WORKFLOWS.desktop, @@ -1025,6 +1037,13 @@ const releaseMain = async (options) => { headSha: releaseSha, publishedAfter: publishedAt, }), + findReleaseRun({ + repository: options.repository, + workflow: RELEASE_WORKFLOWS.docker, + tag, + headSha: releaseSha, + publishedAfter: publishedAt, + }), ]); const demoRuns = listWorkflowRuns({ repository: options.repository, @@ -1052,6 +1071,11 @@ const releaseMain = async (options) => { runId: mobileAudit.databaseId, label: "Published Android asset audit", }), + waitForRun({ + repository: options.repository, + runId: dockerAudit.databaseId, + label: "Published Docker image audit", + }), ]); } catch (error) { run("gh", [ @@ -1072,7 +1096,7 @@ const releaseMain = async (options) => { "--repo", options.repository, "--body", - `Released in [${tag}](${releaseUrl}).\n\nRequired local validations, Draft asset preparation, and post-publication native asset audits passed.`, + `Released in [${tag}](${releaseUrl}).\n\nRequired local validations, Draft asset and image preparation, and post-publication audits passed.`, ]); run("gh", [ "issue", diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 1d7756a3..d5d3e16c 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -6,6 +6,7 @@ import { buildReleaseTitle, nextVersion, parseReleaseArgs, + RELEASE_WORKFLOWS, RELEASE_VALIDATIONS, resolveReleaseVersion, reusedAssetMatches, @@ -13,6 +14,10 @@ import { } from "./release.mjs"; describe("release automation", () => { + test("prepares and audits the official Docker image with every formal release", () => { + expect(RELEASE_WORKFLOWS.docker).toBe("docker-image.yml"); + }); + test("runs the complete project regression suite before release", () => { expect(RELEASE_VALIDATIONS).toContainEqual({ label: "Project regression tests", diff --git a/scripts/self-hosted-config.mjs b/scripts/self-hosted-config.mjs new file mode 100644 index 00000000..d46a0bbd --- /dev/null +++ b/scripts/self-hosted-config.mjs @@ -0,0 +1,43 @@ +import { join, resolve } from "node:path"; + +const SUPPORTED_STORAGE_BACKENDS = new Set(["local", "s3"]); + +const parseInteger = (value, fallback, name, minimum, maximum) => { + const normalized = value?.trim(); + if (!normalized) return fallback; + + const parsed = Number(normalized); + if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`); + } + return parsed; +}; + +export const resolveSelfHostedConfig = (environment = process.env, projectRoot = process.cwd()) => { + const dataDirectory = resolve(environment.EDGE_EVER_DATA_DIR ?? join(projectRoot, ".edgeever-data")); + const storageBackend = (environment.EDGE_EVER_STORAGE_BACKEND ?? "local").trim().toLowerCase(); + + if (!SUPPORTED_STORAGE_BACKENDS.has(storageBackend)) { + throw new Error("EDGE_EVER_STORAGE_BACKEND must be either local or s3"); + } + if (storageBackend === "s3" && !environment.EDGE_EVER_S3_BUCKET?.trim()) { + throw new Error("EDGE_EVER_S3_BUCKET is required when EDGE_EVER_STORAGE_BACKEND=s3"); + } + + return { + dataDirectory, + databaseFile: resolve(environment.EDGE_EVER_SQLITE_FILE ?? join(dataDirectory, "edgeever.sqlite")), + resourcesDirectory: resolve(environment.EDGE_EVER_RESOURCES_DIR ?? join(dataDirectory, "resources")), + webDirectory: resolve(environment.EDGE_EVER_WEB_DIR ?? join(projectRoot, "apps/web/dist")), + hostname: environment.EDGE_EVER_HOST?.trim() || "0.0.0.0", + port: parseInteger(environment.PORT ?? environment.EDGE_EVER_PORT, 8787, "EDGE_EVER_PORT", 1, 65_535), + idleTimeout: parseInteger( + environment.EDGE_EVER_IDLE_TIMEOUT_SECONDS, + 120, + "EDGE_EVER_IDLE_TIMEOUT_SECONDS", + 10, + 255, + ), + storageBackend, + }; +}; diff --git a/scripts/self-hosted-secrets.mjs b/scripts/self-hosted-secrets.mjs new file mode 100644 index 00000000..dd15e542 --- /dev/null +++ b/scripts/self-hosted-secrets.mjs @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; + +const FILE_SECRET_NAMES = [ + "EDGE_EVER_AUTH_PASSWORD", + "EDGE_EVER_AUTH_PASSWORD_HASH", + "EDGE_EVER_STORAGE_ENCRYPTION_KEY", + "EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY", + "EDGE_EVER_S3_ACCESS_KEY_ID", + "EDGE_EVER_S3_SECRET_ACCESS_KEY", +]; + +export const loadSelfHostedEnvironment = async (environment = process.env) => { + const resolved = { ...environment }; + + for (const name of FILE_SECRET_NAMES) { + const fileName = `${name}_FILE`; + const filePath = environment[fileName]?.trim(); + if (!filePath) continue; + if (environment[name] !== undefined) { + throw new Error(`Set either ${name} or ${fileName}, not both`); + } + + resolved[name] = (await readFile(filePath, "utf8")).replace(/[\r\n]+$/, ""); + delete resolved[fileName]; + } + + return resolved; +}; diff --git a/scripts/self-hosted-server.mjs b/scripts/self-hosted-server.mjs index aa6ec663..860d7ed9 100644 --- a/scripts/self-hosted-server.mjs +++ b/scripts/self-hosted-server.mjs @@ -2,25 +2,23 @@ import { Database } from "bun:sqlite"; import { mkdir, readdir, readFile } from "node:fs/promises"; import { dirname, extname, join, normalize, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import worker from "../apps/api/src/index.ts"; +import { hasBootstrapCredential } from "../apps/api/src/auth-bootstrap.ts"; +import { isUnauthenticatedAccessEnabled } from "../apps/api/src/auth-state.ts"; +import { fetchEdgeEverApp } from "../apps/api/src/index.ts"; import { createSelfHostedStorageAdapter } from "../apps/api/src/self-hosted-storage-adapter.ts"; import { createS3CompatibleStorageAdapter } from "../apps/api/src/s3-compatible-storage-adapter.ts"; +import { resolveSelfHostedConfig } from "./self-hosted-config.mjs"; +import { loadSelfHostedEnvironment } from "./self-hosted-secrets.mjs"; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const dataDirectory = resolve(process.env.EDGE_EVER_DATA_DIR ?? join(projectRoot, ".edgeever-data")); -const databaseFile = resolve(process.env.EDGE_EVER_SQLITE_FILE ?? join(dataDirectory, "edgeever.sqlite")); -const resourcesDirectory = resolve(process.env.EDGE_EVER_RESOURCES_DIR ?? join(dataDirectory, "resources")); -const webDirectory = resolve(process.env.EDGE_EVER_WEB_DIR ?? join(projectRoot, "apps/web/dist")); -const port = Number(process.env.PORT ?? process.env.EDGE_EVER_PORT ?? 8787); -const configuredIdleTimeout = Number(process.env.EDGE_EVER_IDLE_TIMEOUT_SECONDS ?? 120); -const idleTimeout = Number.isFinite(configuredIdleTimeout) - ? Math.min(255, Math.max(10, configuredIdleTimeout)) - : 120; +const runtimeEnvironment = await loadSelfHostedEnvironment(process.env); +const config = resolveSelfHostedConfig(runtimeEnvironment, projectRoot); +const { dataDirectory, databaseFile, resourcesDirectory, webDirectory } = config; await mkdir(dataDirectory, { recursive: true }); await mkdir(resourcesDirectory, { recursive: true }); const sqlite = new Database(databaseFile, { create: true }); -sqlite.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;"); +sqlite.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000;"); const migrationFiles = (await readdir(join(projectRoot, "migrations"))) .filter((name) => name.endsWith(".sql")) @@ -50,40 +48,47 @@ for (const name of migrationFiles) { console.log(`[self-hosted] applied migration ${name}`); } -const storageBackend = process.env.EDGE_EVER_STORAGE_BACKEND ?? "local"; -const storage = storageBackend === "s3" +const existingUser = sqlite.query("SELECT id FROM users WHERE is_disabled = 0 LIMIT 1").get(); +if ( + !existingUser + && !hasBootstrapCredential(runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD, runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD_HASH) + && !isUnauthenticatedAccessEnabled(runtimeEnvironment.EDGE_EVER_ALLOW_UNAUTHENTICATED) +) { + sqlite.close(); + throw new Error( + "Authentication is not configured. Set EDGE_EVER_AUTH_PASSWORD (recommended), " + + "EDGE_EVER_AUTH_PASSWORD_HASH, or explicitly set EDGE_EVER_ALLOW_UNAUTHENTICATED=true.", + ); +} + +const storage = config.storageBackend === "s3" ? createS3CompatibleStorageAdapter(sqlite, { - bucket: process.env.EDGE_EVER_S3_BUCKET ?? "", - region: process.env.EDGE_EVER_S3_REGION, - endpoint: process.env.EDGE_EVER_S3_ENDPOINT, - accessKeyId: process.env.EDGE_EVER_S3_ACCESS_KEY_ID, - secretAccessKey: process.env.EDGE_EVER_S3_SECRET_ACCESS_KEY, - forcePathStyle: process.env.EDGE_EVER_S3_FORCE_PATH_STYLE - ? process.env.EDGE_EVER_S3_FORCE_PATH_STYLE === "true" + bucket: runtimeEnvironment.EDGE_EVER_S3_BUCKET ?? "", + region: runtimeEnvironment.EDGE_EVER_S3_REGION, + endpoint: runtimeEnvironment.EDGE_EVER_S3_ENDPOINT, + accessKeyId: runtimeEnvironment.EDGE_EVER_S3_ACCESS_KEY_ID, + secretAccessKey: runtimeEnvironment.EDGE_EVER_S3_SECRET_ACCESS_KEY, + forcePathStyle: runtimeEnvironment.EDGE_EVER_S3_FORCE_PATH_STYLE + ? runtimeEnvironment.EDGE_EVER_S3_FORCE_PATH_STYLE === "true" : undefined, }) : createSelfHostedStorageAdapter(sqlite, resourcesDirectory); - -if (storageBackend === "s3" && !process.env.EDGE_EVER_S3_BUCKET) { - throw new Error("EDGE_EVER_S3_BUCKET is required when EDGE_EVER_STORAGE_BACKEND=s3"); -} const env = { - DB: storage.db, - RESOURCES: storage.resources, - EDGE_EVER_AUTH_USERNAME: process.env.EDGE_EVER_AUTH_USERNAME ?? "admin", + storage, + EDGE_EVER_AUTH_USERNAME: runtimeEnvironment.EDGE_EVER_AUTH_USERNAME ?? "admin", EDGE_EVER_RUNTIME: "self-hosted-bun", - EDGE_EVER_AUTH_PASSWORD: process.env.EDGE_EVER_AUTH_PASSWORD, - EDGE_EVER_AUTH_PASSWORD_HASH: process.env.EDGE_EVER_AUTH_PASSWORD_HASH, - EDGE_EVER_SESSION_TTL_DAYS: process.env.EDGE_EVER_SESSION_TTL_DAYS ?? "400", - EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS, - EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS: process.env.EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS, - EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS, - EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS: process.env.EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS, - EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS, - EDGE_EVER_STORAGE_ENCRYPTION_KEY: process.env.EDGE_EVER_STORAGE_ENCRYPTION_KEY, - EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: process.env.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY, - EDGE_EVER_DEMO_MODE: process.env.EDGE_EVER_DEMO_MODE, - EDGE_EVER_ALLOW_UNAUTHENTICATED: process.env.EDGE_EVER_ALLOW_UNAUTHENTICATED, + EDGE_EVER_AUTH_PASSWORD: runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD, + EDGE_EVER_AUTH_PASSWORD_HASH: runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD_HASH, + EDGE_EVER_SESSION_TTL_DAYS: runtimeEnvironment.EDGE_EVER_SESSION_TTL_DAYS ?? "400", + EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS: runtimeEnvironment.EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS, + EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS: runtimeEnvironment.EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS, + EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS: runtimeEnvironment.EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS, + EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS: runtimeEnvironment.EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS, + EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS: runtimeEnvironment.EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS, + EDGE_EVER_STORAGE_ENCRYPTION_KEY: runtimeEnvironment.EDGE_EVER_STORAGE_ENCRYPTION_KEY, + EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: runtimeEnvironment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY, + EDGE_EVER_DEMO_MODE: runtimeEnvironment.EDGE_EVER_DEMO_MODE, + EDGE_EVER_ALLOW_UNAUTHENTICATED: runtimeEnvironment.EDGE_EVER_ALLOW_UNAUTHENTICATED, }; const executionContext = { @@ -126,14 +131,15 @@ const serveStatic = async (request) => { }; const server = Bun.serve({ - port, + hostname: config.hostname, + port: config.port, // Model providers may take longer than Bun's 10-second default to emit the // first streaming token. Keep the connection alive within Bun's supported range. - idleTimeout, + idleTimeout: config.idleTimeout, async fetch(request) { const pathname = new URL(request.url).pathname; if (pathname.startsWith("/api/") || pathname === "/mcp" || pathname.startsWith("/mcp/")) { - return worker.fetch(request, env, executionContext); + return fetchEdgeEverApp(request, env, executionContext); } return serveStatic(request); }, @@ -141,5 +147,25 @@ const server = Bun.serve({ console.log(`[self-hosted] listening on ${server.url}`); console.log(`[self-hosted] data directory: ${dataDirectory}`); -console.log(`[self-hosted] storage backend: ${storageBackend}`); -console.log(`[self-hosted] idle timeout: ${idleTimeout}s`); +console.log(`[self-hosted] storage backend: ${config.storageBackend}`); +console.log(`[self-hosted] idle timeout: ${config.idleTimeout}s`); + +let shuttingDown = false; +const shutdown = async (signal) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[self-hosted] received ${signal}; shutting down`); + + try { + await server.stop(); + sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + sqlite.close(); + console.log("[self-hosted] shutdown complete"); + } catch (error) { + console.error("[self-hosted] shutdown failed", error); + process.exitCode = 1; + } +}; + +process.once("SIGTERM", () => void shutdown("SIGTERM")); +process.once("SIGINT", () => void shutdown("SIGINT"));