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.