From 139f072bc02c79a9f13cda098c4aae711676abab Mon Sep 17 00:00:00 2001 From: Christian Meunier Date: Wed, 12 Aug 2026 17:07:37 +0800 Subject: [PATCH] feat(self-host): full Docker Compose self-hosting via a Miniflare runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete, working path for running Maple outside Cloudflare — no Tinybird, no managed platform. apps/api, apps/alerting, and apps/electric-sync are Cloudflare Workers with no plain-HTTP entry point. deploy/workerd/ runs their Wrangler bundles under Miniflare in one container, deriving every binding and cron schedule from each worker's own wrangler.jsonc at boot (so it tracks upstream config with no manual mirroring), reaching Postgres through the Hyperdrive binding and running the drizzle migrations (incl. the Electric publication) on startup. docker-compose.selfhost.yml wires the full stack: postgres + electric (control plane / shape sync), clickhouse + ch-migrate + collector (telemetry), the workerd container, the web SPA, and a Caddy reverse proxy giving one origin (/api -> api worker, /sync -> electric-sync, /* -> SPA). docs/self-hosting.md is the guide; .env.selfhost.example lists the config. Also removes the dead apps/api/Dockerfile (its `bun run start` CMD has no matching script; the api is a Worker) and reverts docker-compose.yml to the postgres+electric dev-DB role its header documents. Validated end-to-end from a clean boot: ClickHouse + Postgres migrations, self-hosted login, and the Electric dashboards shape all succeed through the proxy; a smoke trace reaches the collector. Runtime note: Miniflare is the working path today; celld is the eventual target once it can run the api (TCP/Hyperdrive, cron, KV/Queues), per #352. --- .env.selfhost.example | 35 ++++ apps/api/Dockerfile | 30 --- deploy/workerd/Caddyfile | 27 +++ deploy/workerd/Dockerfile | 87 ++++++++ deploy/workerd/bun.lock | 151 ++++++++++++++ deploy/workerd/collector-config.yaml | 76 +++++++ deploy/workerd/package.json | 16 ++ deploy/workerd/runtime.ts | 287 +++++++++++++++++++++++++++ deploy/workerd/tsconfig.json | 14 ++ docker-compose.selfhost.yml | 185 +++++++++++++++++ docker-compose.yml | 94 +-------- docs/self-hosting.md | 118 +++++++++++ 12 files changed, 1001 insertions(+), 119 deletions(-) create mode 100644 .env.selfhost.example delete mode 100644 apps/api/Dockerfile create mode 100644 deploy/workerd/Caddyfile create mode 100644 deploy/workerd/Dockerfile create mode 100644 deploy/workerd/bun.lock create mode 100644 deploy/workerd/collector-config.yaml create mode 100644 deploy/workerd/package.json create mode 100644 deploy/workerd/runtime.ts create mode 100644 deploy/workerd/tsconfig.json create mode 100644 docker-compose.selfhost.yml create mode 100644 docs/self-hosting.md diff --git a/.env.selfhost.example b/.env.selfhost.example new file mode 100644 index 000000000..d9b59b259 --- /dev/null +++ b/.env.selfhost.example @@ -0,0 +1,35 @@ +# Environment for docker-compose.selfhost.yml. Copy to `.env` and fill in. +# +# cp .env.selfhost.example .env +# +# Compose auto-loads `.env` from the project root. + +# --- Secrets (required) ------------------------------------------------------ + +# Root login password for the self-hosted UI (MAPLE_AUTH_MODE=self_hosted). +MAPLE_ROOT_PASSWORD=change-me + +# Postgres password (control plane) — shared by postgres, electric, workerd. +MAPLE_POSTGRES_PASSWORD=change-me + +# ClickHouse `default` password (telemetry) — shared by clickhouse, ch-migrate, +# collector, workerd. +MAPLE_CLICKHOUSE_PASSWORD=change-me + +# Ingest-key crypto. Generate ONCE and keep stable — rotating these makes +# existing ingest keys undecryptable: +# openssl rand -base64 32 # MAPLE_INGEST_KEY_ENCRYPTION_KEY +# openssl rand -base64 32 # MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY +MAPLE_INGEST_KEY_ENCRYPTION_KEY= +MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY= + +# --- Optional ---------------------------------------------------------------- + +# Public origin the UI is served from. Defaults to the local proxy. Changing it +# requires rebuilding the `web` service (VITE vars are baked at build time): +# docker compose -f docker-compose.selfhost.yml up -d --build web +# MAPLE_APP_BASE_URL=http://localhost:3471 + +# Powers chat + AI triage (Workers AI is Cloudflare-only; Maple falls back to +# OpenRouter). Leave unset to run everything except those two features. +# OPENROUTER_API_KEY= diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index 354c80643..000000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# Build context: monorepo root (.) -FROM oven/bun:1.3.11 AS base -WORKDIR /app - -FROM base AS pruner -COPY . . -RUN bunx turbo prune @maple/api --docker --out-dir /app/out - -FROM base AS installer -COPY --from=pruner /app/out/json/ ./ -COPY --from=pruner /app/out/bun.lock ./bun.lock -RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 make g++ && \ - rm -rf /var/lib/apt/lists/* -RUN bun install -COPY --from=pruner /app/out/full/ ./ -RUN bunx turbo typecheck - -FROM oven/bun:1.3.11-slim AS runner -WORKDIR /app - -COPY --from=installer --chown=bun:bun /app /app -USER bun - -ENV NODE_ENV=production -ENV PORT=3472 -EXPOSE 3472 - -WORKDIR /app/apps/api -CMD ["bun", "run", "start"] diff --git a/deploy/workerd/Caddyfile b/deploy/workerd/Caddyfile new file mode 100644 index 000000000..de85494ae --- /dev/null +++ b/deploy/workerd/Caddyfile @@ -0,0 +1,27 @@ +# Single-origin reverse proxy for the self-hosted stack. Fronts the SPA and the +# two HTTP workers on one port so the browser talks to one origin (no CORS). +# +# /api/* -> api worker (workerd:3472) — prefix preserved +# /sync/* -> electric-sync worker (workerd:3476) — /sync stripped +# /* -> SPA (web:80) +# +# The api worker serves its own routes under /api, so /api is passed through +# unchanged. electric-sync serves /api/sync/shape and the SPA reaches it under +# /sync (VITE_ELECTRIC_SYNC_URL=/sync), so /sync is stripped first. +# +# In front of a public domain, terminate TLS here (Caddy does it automatically +# for a real hostname) or at your own ingress. + +:3471 { + handle_path /sync/* { + reverse_proxy workerd:3476 + } + + handle /api/* { + reverse_proxy workerd:3472 + } + + handle { + reverse_proxy web:80 + } +} diff --git a/deploy/workerd/Dockerfile b/deploy/workerd/Dockerfile new file mode 100644 index 000000000..949926a20 --- /dev/null +++ b/deploy/workerd/Dockerfile @@ -0,0 +1,87 @@ +# Build context: monorepo root (.) +# +# Combined workerd container hosting the three Maple workers (api, alerting, +# electric-sync) in a single Miniflare process for self-hosted deploys outside +# Cloudflare. The control-plane database is external Postgres (MAPLE_PG_URL); +# KV / Durable Object / queue / workflow state persists to /data. + +FROM oven/bun:1.3.11 AS base +WORKDIR /app + +# ---- pruner: extract the relevant workspaces from the monorepo ---- +FROM base AS pruner +COPY . . +RUN bunx turbo prune \ + @maple/api \ + @maple/alerting \ + @maple/electric-sync \ + @maple-dev/effect-sdk \ + @maple/clickhouse-cli \ + --docker --out-dir /app/out + +# ---- builder: install + build SDK + bundle each worker ---- +FROM base AS builder +COPY --from=pruner /app/out/json/ ./ +COPY --from=pruner /app/out/bun.lock ./bun.lock +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 make g++ && \ + rm -rf /var/lib/apt/lists/* +RUN bun install +COPY --from=pruner /app/out/full/ ./ + +# These packages only expose pre-built `dist/` via their subpath exports — +# build them first so wrangler can resolve them during the worker bundle +# step (effect-sdk for the api entry, clickhouse-builder for the +# domain/query-engine imports). +RUN bunx turbo build --filter=@maple-dev/effect-sdk --filter=@maple-dev/clickhouse-builder + +# Bundle each worker via wrangler dry-run. Each emits a self-contained +# module bundle at /.js. +RUN cd apps/api && bunx wrangler deploy --dry-run --outdir=/build/api +RUN cd apps/alerting && bunx wrangler deploy --dry-run --outdir=/build/alerting +RUN cd apps/electric-sync && bunx wrangler deploy --dry-run --outdir=/build/electric-sync + +# Bundle the ClickHouse schema CLI from this same source tree. Baking it in +# pins the CLI to whatever Maple commit our fork was synced to, so schema +# applied at boot stays in lockstep with what the API expects (no surprise +# `@latest` pulls at deploy time). +RUN bun build packages/clickhouse-cli/src/cli.ts \ + --target=bun \ + --outfile=/build/clickhouse-cli.js + +# ---- runner: minimal image that runs the Miniflare orchestrator ---- +FROM base AS runner +WORKDIR /app + +# Worker bundles +COPY --from=builder /build/api/ /app/bundles/api/ +COPY --from=builder /build/alerting/ /app/bundles/alerting/ +COPY --from=builder /build/electric-sync/ /app/bundles/electric-sync/ + +# Each worker's wrangler.jsonc — runtime.ts derives its Miniflare bindings and +# cron schedules from these, so the runtime tracks upstream config with no +# manual mirroring. +COPY --from=builder /app/apps/api/wrangler.jsonc /app/wrangler/api.jsonc +COPY --from=builder /app/apps/alerting/wrangler.jsonc /app/wrangler/alerting.jsonc +COPY --from=builder /app/apps/electric-sync/wrangler.jsonc /app/wrangler/electric-sync.jsonc + +# Drizzle migrations (Postgres) applied to MAPLE_PG_URL on boot — includes +# meta/_journal.json, which the drizzle migrator requires. +COPY --from=builder /app/packages/db/drizzle /app/migrations + +# Baked ClickHouse schema CLI — invoked by the `maple-ch-migrate` init +# container (override the default CMD via docker-compose). +COPY --from=builder /build/clickhouse-cli.js /app/clickhouse-cli.js + +# Runtime entry + its deps +COPY deploy/workerd/package.json deploy/workerd/runtime.ts ./ +RUN bun install --production + +ENV NODE_ENV=production +ENV MAPLE_DATA_DIR=/data +ENV API_PORT=3472 +ENV ELECTRIC_SYNC_PORT=3476 + +EXPOSE 3472 3476 + +CMD ["bun", "runtime.ts"] diff --git a/deploy/workerd/bun.lock b/deploy/workerd/bun.lock new file mode 100644 index 000000000..1039b7061 --- /dev/null +++ b/deploy/workerd/bun.lock @@ -0,0 +1,151 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@maple/workerd-runtime", + "dependencies": { + "cron": "^4.3.0", + "drizzle-orm": "^0.45.1", + "jsonc-parser": "^3.3.1", + "miniflare": "5.20260730.0-alpha", + "postgres": "^3.4.9", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260730.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260730.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260730.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260730.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260730.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.23", "", {}, "sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg=="], + + "@types/luxon": ["@types/luxon@3.7.3", "", {}, "sha512-pE7BSbKHiojpl4v7iEzdfFLXXJaH5RPJxI3Wr4x3HU59l83PJfhcUx4mGPWq245+DCAENAzRF/+ZoYr2tJCe/g=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cron": ["cron@4.4.0", "", { "dependencies": { "@types/luxon": "~3.7.0", "luxon": "~3.7.0" } }, "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "miniflare": ["miniflare@5.20260730.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260730.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/deploy/workerd/collector-config.yaml b/deploy/workerd/collector-config.yaml new file mode 100644 index 000000000..ffcfba377 --- /dev/null +++ b/deploy/workerd/collector-config.yaml @@ -0,0 +1,76 @@ +# OpenTelemetry Collector configuration for Maple's prebuilt +# `otel-collector-maple` image. The mapleexporter writes Maple-shaped +# JSONEachRow directly into ClickHouse base tables — no shim required. + +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 1s + send_batch_size: 5000 + send_batch_max_size: 10000 + + memory_limiter: + check_interval: 1s + limit_mib: 512 + spike_limit_mib: 128 + +exporters: + maple: + endpoint: "http://clickhouse:8123" + database: "maple" + username: "default" + password: "${env:MAPLE_CLICKHOUSE_PASSWORD}" + org_id: "default" + timeout: 30s + retry_on_failure: + enabled: true + initial_interval: 1s + max_interval: 30s + max_elapsed_time: 300s + sending_queue: + enabled: true + num_consumers: 8 + queue_size: 10000 + storage: file_storage/queue + +extensions: + file_storage/queue: + directory: /var/lib/otelcol/file_storage + create_directory: true + timeout: 10s + compaction: + on_start: true + on_rebound: true + + health_check: + endpoint: 0.0.0.0:13133 + +service: + extensions: [health_check, file_storage/queue] + + pipelines: + logs: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [maple] + + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [maple] + + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [maple] + + telemetry: + logs: + level: info diff --git a/deploy/workerd/package.json b/deploy/workerd/package.json new file mode 100644 index 000000000..fd6bb9828 --- /dev/null +++ b/deploy/workerd/package.json @@ -0,0 +1,16 @@ +{ + "name": "@maple/workerd-runtime", + "private": true, + "type": "module", + "dependencies": { + "miniflare": "5.20260730.0-alpha", + "drizzle-orm": "^0.45.1", + "postgres": "^3.4.9", + "cron": "^4.3.0", + "jsonc-parser": "^3.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + } +} diff --git a/deploy/workerd/runtime.ts b/deploy/workerd/runtime.ts new file mode 100644 index 000000000..d80d61ad1 --- /dev/null +++ b/deploy/workerd/runtime.ts @@ -0,0 +1,287 @@ +/** + * Self-host Miniflare runtime for the Maple worker triad. + * + * Hosts api, alerting, and electric-sync in one Miniflare process. Each + * worker's bindings and cron schedules are derived from its own + * `wrangler.jsonc` at boot (see `readWrangler`/`toWorkerOptions`), so this + * runtime tracks upstream config changes with no manual mirroring. The + * control-plane database is Postgres (MAPLE_PG_URL), reached through the + * Hyperdrive binding each worker already declares and migrated here on boot + * with drizzle. KV, the SQLite-backed Durable Object, and queue/workflow + * state persist under MAPLE_DATA_DIR (default `/data`). + * + * Exposed: + * - API_PORT (3472) — api worker (Miniflare's primary entrypoint) + * - ELECTRIC_SYNC_PORT (3476) — electric-sync worker, proxied via Node http + * + * Crons fire via the worker proxies' Fetcher RPC `scheduled()`, passing the + * exact cron expression each handler dispatches on. + * + * Bindings intentionally dropped from the wrangler configs (Cloudflare-only + * services with documented fallbacks): + * - `ai` (Workers AI) — the LLM layer falls back to OpenRouter over REST; + * set OPENROUTER_API_KEY to enable chat + AI triage. + * - `send_email` (EMAIL) — sends skip when the binding is missing. + */ + +import { CronJob } from "cron" +import { drizzle } from "drizzle-orm/postgres-js" +import { migrate } from "drizzle-orm/postgres-js/migrator" +import { readFileSync } from "node:fs" +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import { join } from "node:path" +import { parse as parseJsonc } from "jsonc-parser" +import { Log, LogLevel, Miniflare, type WorkerOptions } from "miniflare" +import postgres from "postgres" + +const DATA_DIR = process.env.MAPLE_DATA_DIR ?? "/data" +const API_PORT = Number(process.env.API_PORT ?? 3472) +const ELECTRIC_SYNC_PORT = Number(process.env.ELECTRIC_SYNC_PORT ?? 3476) +const BUNDLES_DIR = process.env.MAPLE_BUNDLES_DIR ?? "/app/bundles" +const WRANGLER_DIR = process.env.MAPLE_WRANGLER_DIR ?? "/app/wrangler" +const MIGRATIONS_DIR = process.env.MAPLE_MIGRATIONS_DIR ?? "/app/migrations" + +// Which worker owns the HTTP entry vs. the side-proxied worker vs. cron-only. +const PRIMARY_WORKER = "api" +const PROXIED_WORKER = "electric-sync" +const WORKER_NAMES = [PRIMARY_WORKER, "alerting", PROXIED_WORKER] as const + +const PG_URL = process.env.MAPLE_PG_URL +if (!PG_URL) throw new Error("MAPLE_PG_URL is required (postgres:// control-plane database URL)") + +// --- wrangler.jsonc → Miniflare WorkerOptions ------------------------------- + +interface WranglerConfig { + compatibility_date?: string + compatibility_flags?: string[] + vars?: Record + hyperdrive?: Array<{ binding: string }> + kv_namespaces?: Array<{ binding: string }> + durable_objects?: { bindings?: Array<{ name: string; class_name: string }> } + migrations?: Array<{ new_sqlite_classes?: string[] }> + workflows?: Array<{ name: string; binding: string; class_name: string }> + queues?: { + producers?: Array<{ binding: string; queue: string }> + consumers?: Array<{ + queue: string + max_batch_size?: number + max_batch_timeout?: number + max_retries?: number + }> + } + ratelimits?: Array<{ + name: string + namespace_id: string + simple: { limit: number; period?: number } + }> + triggers?: { crons?: string[] } +} + +const readWrangler = (name: string): WranglerConfig => + parseJsonc(readFileSync(join(WRANGLER_DIR, `${name}.jsonc`), "utf8")) as WranglerConfig + +const findBundle = (name: string): string => join(BUNDLES_DIR, name, "worker.js") + +const toWorkerOptions = ( + name: string, + cfg: WranglerConfig, + sharedBindings: Record, +): WorkerOptions => { + const opts: Record = { + name, + modules: [{ type: "ESModule", path: findBundle(name) }], + compatibilityDate: cfg.compatibility_date, + compatibilityFlags: cfg.compatibility_flags, + // wrangler `vars` are plain strings; fold them in with the shared env. + bindings: { ...sharedBindings, ...(cfg.vars ?? {}) }, + } + + // Every declared Hyperdrive points at the one control-plane Postgres. + if (cfg.hyperdrive?.length) { + opts.hyperdrives = Object.fromEntries(cfg.hyperdrive.map((h) => [h.binding, PG_URL])) + } + if (cfg.kv_namespaces?.length) { + opts.kvNamespaces = Object.fromEntries(cfg.kv_namespaces.map((k) => [k.binding, k.binding])) + } + if (cfg.durable_objects?.bindings?.length) { + const sqliteClasses = new Set( + (cfg.migrations ?? []).flatMap((m) => m.new_sqlite_classes ?? []), + ) + opts.durableObjects = Object.fromEntries( + cfg.durable_objects.bindings.map((d) => [ + d.name, + { className: d.class_name, useSQLite: sqliteClasses.has(d.class_name) }, + ]), + ) + } + if (cfg.workflows?.length) { + opts.workflows = Object.fromEntries( + cfg.workflows.map((w) => [w.binding, { name: w.name, className: w.class_name }]), + ) + } + if (cfg.queues?.producers?.length) { + opts.queueProducers = Object.fromEntries( + cfg.queues.producers.map((p) => [p.binding, p.queue]), + ) + } + if (cfg.queues?.consumers?.length) { + opts.queueConsumers = Object.fromEntries( + cfg.queues.consumers.map((c) => [ + c.queue, + { + maxBatchSize: c.max_batch_size, + maxBatchTimeout: c.max_batch_timeout, + maxRetries: c.max_retries, + }, + ]), + ) + } + if (cfg.ratelimits?.length) { + opts.ratelimits = Object.fromEntries( + cfg.ratelimits.map((r) => [r.name, { namespace_id: r.namespace_id, simple: r.simple }]), + ) + } + + return opts as WorkerOptions +} + +// --- boot ------------------------------------------------------------------- + +const configs = new Map(WORKER_NAMES.map((name) => [name, readWrangler(name)])) + +// Drizzle migrations against Postgres, before any worker starts. Idempotent — +// tracked in __drizzle_migrations. Retry while Postgres finishes booting. +{ + const sql = postgres(PG_URL, { max: 1, fetch_types: false }) + const db = drizzle(sql) + console.log("[migrate] applying drizzle migrations…") + let lastError: unknown + for (let attempt = 1; attempt <= 10; attempt++) { + try { + await migrate(db, { migrationsFolder: MIGRATIONS_DIR }) + lastError = undefined + break + } catch (err) { + lastError = err + console.warn(`[migrate] attempt ${attempt}/10 failed: ${(err as Error).message}`) + await new Promise((r) => setTimeout(r, 3000)) + } + } + await sql.end() + if (lastError) throw lastError + console.log("[migrate] complete") +} + +// Forward Maple-relevant env vars to all workers as bindings. +const envPrefixRe = + /^(MAPLE_|CLICKHOUSE_|TINYBIRD_|CLERK_|RESEND_|AUTUMN_|SD_|INTERNAL_|ELECTRIC_|OPENROUTER_)/ +const sharedBindings: Record = {} +for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && envPrefixRe.test(k)) sharedBindings[k] = v +} + +const mf = new Miniflare({ + log: new Log(LogLevel.INFO), + host: "0.0.0.0", + port: API_PORT, + + // Single root for KV / DO / queue / workflow state (Miniflare 5 replaced + // the per-plugin *Persist options with this). + resourcePersistencePath: DATA_DIR, + + // The primary worker must be first so it owns the HTTP entry on API_PORT. + workers: WORKER_NAMES.map((name) => toWorkerOptions(name, configs.get(name)!, sharedBindings)), +}) + +await mf.ready +console.log(`[runtime] api listening on :${API_PORT}`) + +// electric-sync proxy on its own port — Miniflare only exposes the first worker. +const electricSync = await mf.getWorker(PROXIED_WORKER) + +const proxyToWorker = async ( + worker: { fetch: (input: string, init: RequestInit) => Promise }, + req: IncomingMessage, + res: ServerResponse, +): Promise => { + try { + const proto = (req.headers["x-forwarded-proto"] as string) ?? "http" + const host = (req.headers["x-forwarded-host"] as string) ?? (req.headers.host ?? "localhost") + const url = `${proto}://${host}${req.url ?? "/"}` + + let body: Buffer | undefined + if (req.method && req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + body = Buffer.concat(chunks) + } + + const upstream = await worker.fetch(url, { + method: req.method, + headers: req.headers as Record, + body, + }) + res.statusCode = upstream.status + upstream.headers.forEach((value, key) => res.setHeader(key, value)) + if (upstream.body) { + const reader = upstream.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + res.write(Buffer.from(value)) + } + } + res.end() + } catch (err) { + console.error("[proxy] error:", err) + res.statusCode = 502 + res.end(`bad gateway: ${(err as Error).message}`) + } +} + +createServer((req, res) => { + proxyToWorker(electricSync as never, req, res).catch((err) => { + console.error("[proxy] unhandled:", err) + if (!res.headersSent) res.statusCode = 500 + res.end() + }) +}).listen(ELECTRIC_SYNC_PORT, "0.0.0.0", () => { + console.log(`[runtime] ${PROXIED_WORKER} listening on :${ELECTRIC_SYNC_PORT}`) +}) + +// Cron triggers. The worker proxy's Fetcher RPC `scheduled()` runs the worker's +// scheduled handler; each handler dispatches on the exact cron expression, so +// it is passed through verbatim. Schedules come from each worker's wrangler.jsonc. +type ScheduledWorker = { scheduled: (opts: { cron?: string }) => Promise<{ outcome: string }> } + +const triggerCron = async ( + workerName: string, + worker: ScheduledWorker, + cron: string, +): Promise => { + try { + const result = await worker.scheduled({ cron }) + if (result.outcome !== "ok") console.error(`[cron] ${workerName} ${cron} → ${result.outcome}`) + } catch (err) { + console.error(`[cron] ${workerName} ${cron} failed:`, err) + } +} + +for (const name of WORKER_NAMES) { + const crons = configs.get(name)?.triggers?.crons ?? [] + if (crons.length === 0) continue + const worker = (await mf.getWorker(name)) as unknown as ScheduledWorker + for (const cron of crons) { + new CronJob(cron, () => triggerCron(name, worker, cron), null, true) + } + console.log(`[runtime] ${name} crons registered: ${crons.join(", ")}`) +} + +// Cleanup +const shutdown = async (signal: string): Promise => { + console.log(`[runtime] received ${signal}, shutting down`) + await mf.dispose() + process.exit(0) +} +process.on("SIGTERM", () => void shutdown("SIGTERM")) +process.on("SIGINT", () => void shutdown("SIGINT")) diff --git a/deploy/workerd/tsconfig.json b/deploy/workerd/tsconfig.json new file mode 100644 index 000000000..619382c29 --- /dev/null +++ b/deploy/workerd/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": ["runtime.ts"] +} diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml new file mode 100644 index 000000000..3ebb31153 --- /dev/null +++ b/docker-compose.selfhost.yml @@ -0,0 +1,185 @@ +# Full self-hosted Maple stack — no Cloudflare, no Tinybird. +# +# Telemetry lands in ClickHouse via Maple's prebuilt OTel collector. The control +# plane (orgs, users, ingest keys, dashboards, alerts) lives in Postgres, with +# ElectricSQL streaming control-plane tables to the web app in real time. The +# three Cloudflare Workers (api, alerting, electric-sync) run under Miniflare in +# the `workerd` container — see deploy/workerd/ and docs/self-hosting.md. +# +# Quick start: +# cp .env.selfhost.example .env # then fill in the secrets it lists +# docker compose -f docker-compose.selfhost.yml up -d --build +# open http://localhost:3471 # log in with MAPLE_ROOT_PASSWORD +# +# Apps send OTLP to http://localhost:4318 (HTTP) or :4317 (gRPC). + +name: maple + +services: + # Control-plane database. wal_level=logical lets Electric tail the WAL for + # shape sync; the publication itself is created by a drizzle migration on + # workerd boot. + postgres: + image: postgres:17-alpine + command: ["postgres", "-c", "wal_level=logical"] + environment: + POSTGRES_USER: maple + POSTGRES_PASSWORD: ${MAPLE_POSTGRES_PASSWORD:?set MAPLE_POSTGRES_PASSWORD in .env} + POSTGRES_DB: maple + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U maple -d maple"] + interval: 10s + timeout: 5s + retries: 5 + + # Serves per-table "shapes" from the Postgres WAL. Internal only — the + # electric-sync worker (in workerd) authenticates and org-scopes every request. + electric: + image: electricsql/electric:latest + environment: + DATABASE_URL: postgresql://maple:${MAPLE_POSTGRES_PASSWORD}@postgres:5432/maple?sslmode=disable + ELECTRIC_INSECURE: "true" + ELECTRIC_MANUAL_TABLE_PUBLISHING: "true" + volumes: + - electric-data:/app/persistent + depends_on: + postgres: + condition: service_healthy + + # Telemetry store. + clickhouse: + image: clickhouse/clickhouse-server:26.2-alpine + environment: + CLICKHOUSE_DB: maple + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: ${MAPLE_CLICKHOUSE_PASSWORD:?set MAPLE_CLICKHOUSE_PASSWORD in .env} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + volumes: + - clickhouse-data:/var/lib/clickhouse/ + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + # 127.0.0.1, not localhost: the container has IPv6 disabled, so `localhost` + # can resolve to ::1 and refuse while ClickHouse is listening on IPv4. + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s + + # One-shot: applies the ClickHouse schema via the CLI baked into the workerd + # image, then exits. Re-running is idempotent (tracked in _maple_schema_migrations). + ch-migrate: + build: + context: . + dockerfile: deploy/workerd/Dockerfile + command: ["bun", "/app/clickhouse-cli.js", "apply"] + environment: + MAPLE_CH_URL: http://clickhouse:8123 + MAPLE_CH_USER: default + MAPLE_CH_PASSWORD: ${MAPLE_CLICKHOUSE_PASSWORD} + MAPLE_CH_DATABASE: maple + depends_on: + clickhouse: + condition: service_healthy + restart: on-failure + + # OTLP receiver → mapleexporter → ClickHouse base tables. + collector: + image: ghcr.io/makisuo/maple/otel-collector-maple:0.1.5 + # The image runs as nonroot (uid 65532), which can't write the durable + # file-storage queue on a fresh root-owned named volume. Run as root so the + # on-disk queue (which survives ClickHouse blips without dropping telemetry) + # is writable. + user: "0:0" + environment: + MAPLE_CLICKHOUSE_PASSWORD: ${MAPLE_CLICKHOUSE_PASSWORD} + volumes: + - ./deploy/workerd/collector-config.yaml:/etc/otel/config.yaml:ro + - otel-queue-data:/var/lib/otelcol/file_storage + ports: + - "4317:4317" + - "4318:4318" + depends_on: + ch-migrate: + condition: service_completed_successfully + + # api + alerting + electric-sync under one Miniflare process. Reaches Postgres + # through a Hyperdrive binding and drizzle-migrates it (incl. the Electric + # publication) on boot. + workerd: + build: + context: . + dockerfile: deploy/workerd/Dockerfile + environment: + MAPLE_PG_URL: postgres://maple:${MAPLE_POSTGRES_PASSWORD}@postgres:5432/maple + MAPLE_AUTH_MODE: self_hosted + MAPLE_ROOT_PASSWORD: ${MAPLE_ROOT_PASSWORD:?set MAPLE_ROOT_PASSWORD in .env} + MAPLE_DEFAULT_ORG_ID: default + MAPLE_INGEST_KEY_ENCRYPTION_KEY: ${MAPLE_INGEST_KEY_ENCRYPTION_KEY:?generate with openssl rand -base64 32} + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: ${MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY:?generate with openssl rand -base64 32} + # Vanilla ClickHouse (not Tinybird's gateway). Only valid in single-org + # self_hosted mode — keeps CLICKHOUSE_PASSWORD on the raw SQL path. + CLICKHOUSE_PROVIDER: clickhouse + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: ${MAPLE_CLICKHOUSE_PASSWORD} + CLICKHOUSE_DATABASE: maple + ELECTRIC_URL: http://electric:3000 + # Chat + AI triage run on OpenRouter (no Workers AI outside Cloudflare). + # Optional: leave unset to run everything except those two features. + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + MAPLE_APP_BASE_URL: ${MAPLE_APP_BASE_URL:-http://localhost:3471} + MAPLE_ENVIRONMENT: production + # Parsed as required by the api's Env schema, but never used with + # CLICKHOUSE_PROVIDER=clickhouse and no per-org BYO row. + TINYBIRD_HOST: http://unused.invalid + TINYBIRD_TOKEN: unused + volumes: + - workerd-data:/data + depends_on: + ch-migrate: + condition: service_completed_successfully + postgres: + condition: service_healthy + electric: + condition: service_started + + # Static SPA. VITE_* are baked at build time, so changing MAPLE_APP_BASE_URL + # (your public origin) needs a `--build` rebuild of this service. + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + VITE_API_BASE_URL: /api + VITE_MAPLE_AUTH_MODE: self_hosted + VITE_ELECTRIC_SYNC_URL: ${MAPLE_APP_BASE_URL:-http://localhost:3471}/sync + depends_on: + workerd: + condition: service_started + + # Single-origin reverse proxy: /api → api worker, /sync → electric-sync + # worker, /* → SPA. This is the one port you open. + proxy: + image: caddy:2-alpine + volumes: + - ./deploy/workerd/Caddyfile:/etc/caddy/Caddyfile:ro + ports: + - "3471:3471" + depends_on: + web: + condition: service_started + workerd: + condition: service_started + +volumes: + postgres-data: + clickhouse-data: + electric-data: + otel-queue-data: + workerd-data: diff --git a/docker-compose.yml b/docker-compose.yml index b82e04fdc..e01963610 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,8 @@ +# Local-dev backing services for `wrangler dev` (postgres + electric). +# +# For a full self-hosted Maple deployment (the workers, collector, ClickHouse, +# web) see docker-compose.selfhost.yml and docs/self-hosting.md. + services: # Dev database for `wrangler dev` (the Hyperdrive localConnectionString in # each worker's wrangler.jsonc points here). Port 5499 dodges any host @@ -40,94 +45,5 @@ services: postgres: condition: service_healthy - api: - build: - context: . - dockerfile: apps/api/Dockerfile - ports: - - "3472:3472" - env_file: .env - environment: - PORT: 3472 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3472/health"] - interval: 10s - timeout: 5s - retries: 3 - - web: - build: - context: . - dockerfile: apps/web/Dockerfile - args: - VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:3472} - VITE_MAPLE_AUTH_MODE: ${MAPLE_AUTH_MODE:-self_hosted} - ports: - - "3471:80" - - ingest: - build: - context: . - dockerfile: apps/ingest/Dockerfile - ports: - - "3474:3474" - env_file: .env - environment: - INGEST_PORT: 3474 - INGEST_FORWARD_OTLP_ENDPOINT: http://otel-collector:4318 - INGEST_FORWARD_TIMEOUT_MS: 10000 - INGEST_MAX_REQUEST_BODY_BYTES: 20971520 - INGEST_REQUIRE_TLS: "false" - depends_on: - otel-collector: - condition: service_started - - alerting: - build: - context: . - dockerfile: apps/alerting/Dockerfile - env_file: .env - environment: - MAPLE_APP_BASE_URL: ${MAPLE_APP_BASE_URL:-http://web} - depends_on: - api: - condition: service_healthy - ingest: - condition: service_started - - scraper: - build: - context: . - dockerfile: apps/scraper/Dockerfile - ports: - - "3475:3475" - env_file: .env - environment: - PORT: 3475 - MAPLE_API_URL: http://api:3472 - MAPLE_INGEST_URL: http://ingest:3474 - SD_INTERNAL_TOKEN: ${SD_INTERNAL_TOKEN:-maple-sd-dev-token} - depends_on: - api: - condition: service_healthy - ingest: - condition: service_started - - otel-collector: - build: - context: . - dockerfile: otel/Dockerfile - ports: - - "4317:4317" - - "4318:4318" - - "13133:13133" - env_file: .env - volumes: - - otel-queue-data:/var/lib/otelcol/file_storage - depends_on: - api: - condition: service_healthy - volumes: - otel-queue-data: postgres-data: diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 000000000..31c692818 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,118 @@ +# Self-hosting Maple (Docker Compose) + +A full Maple deployment outside Cloudflare — no Tinybird, no managed platform. +Telemetry lives in vanilla ClickHouse; the control plane (orgs, users, ingest +keys, dashboards, alerts) lives in Postgres, with ElectricSQL streaming +control-plane tables to the web app. + +The catch is that `apps/api`, `apps/alerting`, and `apps/electric-sync` are +Cloudflare Workers — there is no plain-HTTP entry point. This stack runs their +Wrangler bundles under [Miniflare](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare) +(the same `workerd` runtime Cloudflare runs at the edge) in a single container, +with Miniflare emulating the KV, Durable Object, Queue, Workflow, and Hyperdrive +bindings the workers use. See [`deploy/workerd/`](../deploy/workerd/). + +> **Runtime note.** Miniflare is the pragmatic choice today. Longer term this +> could move to [celld](https://github.com/denoland/celld) for a +> production-tuned Workers/Durable-Objects runtime; at the time of writing celld +> can't yet run the api (no TCP/Hyperdrive→Postgres, no cron/`scheduled`, no +> KV/Queues), so Miniflare is the working path until those land. + +## Architecture + +``` +apps ──OTLP──▶ collector ──▶ ClickHouse (telemetry) + ▲ + │ SELECT (HTTP) +browser ──▶ proxy :3471 ──┬──▶ workerd (Miniflare) /api → api worker :3472 + │ ├─ Hyperdrive ──▶ Postgres (control plane) + │ └─ /sync → electric-sync worker :3476 + │ ▲ + │ Electric │ shapes ◀── Postgres WAL + └──▶ web (SPA) /* +``` + +- **postgres** — control-plane DB. `wal_level=logical` so Electric can tail it. +- **electric** — serves per-table shapes from the WAL; internal only, fronted by + the electric-sync worker which authenticates and org-scopes every request. +- **clickhouse** — telemetry store. +- **ch-migrate** — one-shot; applies the ClickHouse schema (the CLI baked into + the workerd image), then exits. Idempotent. +- **collector** — Maple's prebuilt OTel collector; `mapleexporter` writes + straight into ClickHouse base tables. +- **workerd** — api + alerting + electric-sync under one Miniflare process. + Reaches Postgres via a Hyperdrive binding and runs the drizzle migrations + (including the Electric publication) on boot. +- **web** — the SPA (nginx). +- **proxy** — a single-origin Caddy reverse proxy: `/api` → api worker, + `/sync` → electric-sync worker, `/*` → SPA. The one port you expose. + +## Quick start + +```bash +cp .env.selfhost.example .env +# edit .env: set the passwords and generate the two ingest keys +openssl rand -base64 32 # → MAPLE_INGEST_KEY_ENCRYPTION_KEY +openssl rand -base64 32 # → MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY + +docker compose -f docker-compose.selfhost.yml up -d --build +``` + +Then open and log in with `MAPLE_ROOT_PASSWORD`. + +On first boot `ch-migrate` applies the ClickHouse schema and exits, then +`workerd` runs the Postgres drizzle migrations; the collector and web come up +once migrate completes. + +## Configuration + +See [`.env.selfhost.example`](../.env.selfhost.example). Required: the three +passwords (`MAPLE_ROOT_PASSWORD`, `MAPLE_POSTGRES_PASSWORD`, +`MAPLE_CLICKHOUSE_PASSWORD`) and the two ingest keys. Optional: +`OPENROUTER_API_KEY` (chat + AI triage — Workers AI is Cloudflare-only, so Maple +falls back to OpenRouter; leave unset to run everything else) and +`MAPLE_APP_BASE_URL` (your public origin). + +`CLICKHOUSE_PROVIDER=clickhouse` is set in the compose — the api's `Env` defaults +to `tinybird`, and this is what keeps raw SQL on the vanilla-ClickHouse path. +`TINYBIRD_HOST` / `TINYBIRD_TOKEN` are pinned to dummy values: the `Env` schema +still parses them as required, but nothing reaches Tinybird with +`CLICKHOUSE_PROVIDER=clickhouse` and no per-org BYO row. + +## Sending telemetry + +Point your apps' OTLP exporter at the collector: + +``` +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # or :4317 for gRPC +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +``` + +There is no ingest auth on the collector — keep it on a private network, or put +an authenticating proxy in front if it is internet-facing. + +## Public deployment + +`MAPLE_APP_BASE_URL` is baked into the SPA at build time (Vite), so if you serve +Maple from a real domain, set it and rebuild the web image: + +```bash +MAPLE_APP_BASE_URL=https://maple.example.com \ + docker compose -f docker-compose.selfhost.yml up -d --build web +``` + +The bundled Caddy proxy terminates plain HTTP on `:3471`. Behind a public domain, +either let Caddy manage TLS (give it your hostname) or terminate TLS at your own +ingress and forward to the proxy. + +## Upgrades + +Pull a new revision, rebuild, redeploy: + +```bash +git pull +docker compose -f docker-compose.selfhost.yml up -d --build +``` + +ClickHouse schema upgrades land via `ch-migrate`; Postgres drizzle migrations run +on `workerd` boot. Both are idempotent.