pdf.gevell.com β Technical Architecture Overview
High-level shape
There are three runtime tiers plus managed backing services. The design
principle throughout: the edge and Lambda handle everything that is light and
stateless; anything that needs Chromium, WeasyPrint, pdfjs-dist, or long
wall-clock time is pushed to the self-hosted Node box (or the spot fleet as
backup).
ββββββββββββββββββββββββββββββββ β Browser β β pdf.gevell.com β βββββββββββββββββ¬βββββββββββββββ β βΌ ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Cloudflare Worker: accessible-pdf-web-production β β β’ serves Next.js static export (apps/web/out) β β β’ bound to pdf. + admin.gevell.com β β β’ proxies API calls to API_ORIGIN β βββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ β API_ORIGIN = https://api.theaccessible.org βΌ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β API layer β ONE Hono app, THREE compositions β β β β api.theaccessible.org β CF Tunnel β Node box (server.ts) PRIMARYβ β api-pdf.gevell.com β Lambda (index-aws.ts) SERVERLESS β β worker.theaccessible.org β CF Worker (index.ts) LEGACY / webhooks β βββββββββββββββββ¬ββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββ β heavy jobs β light: auth, CRUD, β (proxy or enqueue) β billing, job create βΌ βΌ ββββββββββββββββββββββ (returns immediately) β SQS PipelineQueue β β vis 900s, 3βDLQ β βββββββββββ¬βββββββββββ β drains to βββββββββββ΄ββββββββββββββββββββββββββββββββββββ β β βΌ (primary) βΌ (backup, age-scaled)βββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββ Node box 10.1.1.4 β β EC2 Spot Fleet ASG ββ β’ server.ts (full API) β β min=0 max=1|4 100% spot ββ β’ ChunkScheduler (ONLY here) β β β’ workers/batch SQS consumer ββ β’ SQS consumer β β β’ SpotMonitor (2-min notice) ββ β’ Chromium β β β’ same ChunkScheduler/exec ββ β’ WeasyPrint sidecar (HTTP) β β scales on SQS oldest-msg age ββ β’ flowchart-extract sidecar β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β βΌ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Backing services β β β’ Supabase Postgres β system of record (jobs, billing, β β entitlements, cost_ledger, teams, app_logs, CRM) β β β’ Cloudflare R2 β file bytes (uploads + outputs) β β β’ Cloudflare KV β sessions β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ1. What runs at Cloudflare
A. The web frontend β apps/web, worker accessible-pdf-web-production
(apps/web/wrangler.toml):
- Next.js static export (
./out) served via the Workers[assets]binding,run_worker_first = true. - Bound to
pdf.gevell.comandadmin.gevell.com(same worker; redirects bare root to/admin). - The worker is a thin shell: serves static assets, exposes a
/__versionprobe, does fingerprint logging, and proxies API calls toAPI_ORIGIN = https://api.theaccessible.org.
B. The legacy API worker β workers/api, index.ts, deployed as
worker.gevell.com:
- The CF Worker composition of the Hono API (
main = src/index.tsinworkers/api/wrangler.toml). - Largely legacy/dead for conversion, but still the live path for Stripe +
workshop checkout webhooks. Deploy is manual (
cd workers/api && npm run deploy), not CI. - Holds the R2 bucket bindings (
accessible-pdf-files,accessible-photos,accessible-forms,org-chart-files,accessible-uploads) andKV_SESSIONS.
C. Edge-native services: R2 (object storage for uploads/outputs) and KV (session store).
What Cloudflare fundamentally cannot do here: any PDF parsing or rendering.
The node-proxy middleware is explicit β if all Node backends are unavailable,
βOn Cloudflare Workers, PDF parsing canβt run at all, so return 503.β No
Chromium, no WeasyPrint, no pdfjs-dist heavy paths on the edge.
2. What Lambda does
Deployed via CDK (infra/cdk/lib/stacks/api-stack.ts), packaged from
workers/api/dist-lambda/http (zip asset, not a container), entry
index-aws.ts, fronted by API Gateway as api-pdf.gevell.com:
- Main API Lambda:
memorySize: 2048,timeout: 300s. - Second Lambda for
POST /ai-fix:memorySize: 1024,timeout: 300sβ split out because that route βroutinely runs longer than API Gatewayβs hard 29s timeout.β
Lambda is the stateless API brain: auth, CRUD, billing, entitlements, job creation, presigned R2/S3, AI-fix calls, and enqueuing heavy work to SQS. It is always the API, never the heavy worker β it accepts a conversion request and either proxies it to the Node box or drops it on SQS for async processing.
Hard limits that shape the design:
- API Gateway caps synchronous responses at 29 seconds β the true ceiling regardless of the 300s Lambda timeout. Anything slower must go async (SQS) or stream.
- Lambda cannot run Chromium or WeasyPrint. This is why the chunked conversion + accessible-PDF export path lives off-Lambda.
- Consequence: Lambdaβs
shouldProxy()heavy POST paths (/api/convert/:fileId,/api/gateway/convert,/api/music/:id/convert, etc.) either proxy to Node or, if Node is down, fall through to an SQS enqueue for async completion.
3. What the Node box does (10.1.1.4)
The self-hosted Node server (server.ts) reached via Cloudflare Tunnel at
api.gevell.com is the primary heavy-lift tier and the default
backend the CF worker proxies to. It runs:
- The full Hono API (same routes as Lambda, plus the heavy handlers that only work here).
- Chromium (Playwright/Puppeteer) and the WeasyPrint sidecar β WeasyPrint
is called over HTTP via
weasyprintUrl(accessible-pdf-export-executor.tsβgenerateAccessiblePdfWithWeasyPrint), i.e. a separate Docker service, not in-process. flowchart-extractsidecar (separate Docker service; rebuilt withdocker compose build flowchart-extract).- The ChunkScheduler (
workers/api/src/scheduler/chunk-scheduler.ts) β drives the chunked async pipeline, orphan recovery, and the stuck-assemblingsweep. The scheduler runs ONLY on the Node box. - The SQS consumer loop that pulls
PipelineQueueand executes conversions.
Operational gotchas:
- Node does not auto-deploy on push. Manual
npm run rebuildon10.1.1.4(zero-downtime rolling restart, run from the Mac). - A rebuild orphans in-flight jobs β the rolling restart strands jobs at
status
assembling, and orphan recovery only re-drivespending/processing. In-processform_conversionshave no orphan recovery at all and are stranded atrefiningforever. Always check for active jobs before rebuilding. - New
@scope/sharedworkspace deps require Dockerfile edits or the Node api-nodes crash-loop (Lambda bundles, so itβs unaffected). - Non-additive migrations must wait for the rebuild, because Node ships old code until then.
4. What SQS does
Defined in infra/cdk/lib/stacks/queue-stack.ts. Two queue pairs, each with a
dead-letter queue:
| Queue | Visibility timeout | Retention | DLQ after |
|---|---|---|---|
| PipelineQueue (conversions) | 900s (15 min) | 7 days | 3 receives β PipelineDlq (14-day retention) |
| S3IngestQueue (S3/SharePoint ingestion) | 900s | 7 days | 3 receives β S3IngestDlq (14-day retention) |
SQS is the async buffer and load-shedder. When a conversion canβt complete
synchronously (too slow for the 29s API Gateway ceiling, or Node is busy/down),
the job is enqueued. The Node SQS consumer normally drains it fast; if Node is
backlogged or down, the spot fleet scales up as backup (Β§6).
maxReceiveCount: 3 means a poison message gets three tries before parking in
the DLQ. The 15-min visibility timeout is sized to cover a single full pipeline
run.
5. Whatβs kept in Postgres (Supabase)
Postgres is the system of record for everything except file bytes (those live in R2/S3):
- Jobs & pipeline:
large_conversion_jobs/ chunk-job state,updated_attriggers + the assembly idempotency RPC (migrations 225/226/237),form_conversions. - Billing & entitlements (two systems that can drift): legacy
credit_balanceswallet andaccount_packagesentitlements; pluscost_ledgerfor per-operation cost tracking (CHECK constraint onoperation_typeis the source of truth). - Teams:
teams, team members/roles/invites, credit transfer/fund, usage reports, alert settings (workers/api/src/routes/teams.ts). - Auth/session: Supabase Auth + email-OTP;
lti_keysfor LTI 1.3. - CRM/admin: deals pipeline,
crm_tasks/crm_notes,crm_attachments, admin issue tracker, spend-alert settings. - Logging: unified
app_logs(Lambda + Node + EC2 all write here; viewer at/admin/logs).
β οΈ Migration drift is a known hazard β prod Supabase does not have every repo migration applied; thereβs no
db push, migrations are applied individually. Verify a table/RPC exists in prod before deploying code that depends on it.
6. Sizing limits β the βcanβt run whereβ map
| Workload | CF Worker | Lambda | Node box / Spot |
|---|---|---|---|
| Serve static frontend | β | β | β |
| Auth, CRUD, billing, job creation | β (legacy) | β | β |
| Stripe / workshop webhooks | β (live here) | β | β |
pdfjs-dist full pipeline (/api/gateway/convert) | β | β | β |
| Chromium (screenshots, browser WCAG checks) | β | β | β |
| WeasyPrint accessible-PDF export | β | β | β (HTTP sidecar) |
| Long jobs (>29s) | β (29s GW cap) | β synchronously | β async via SQS |
flowchart-extract, Audiveris (music) | β | β | β (Docker sidecars) |
The two binding constraints:
- API Gatewayβs 29-second synchronous ceiling β not the 300s Lambda timeout β is what forces async for any non-trivial conversion.
- Chromium + WeasyPrint + heavy
pdfjs-distonly run on the Node/EC2 tier. This is the single most-repeated architectural fact and the reason the Node box exists at all.
7. How the spot fleet could be enabled
The fleet already exists in CDK (infra/cdk/lib/stacks/compute-stack.ts) and is
the designed backup for the Node box β itβs just idle by design:
- Auto Scaling Group:
minCapacity: 0,maxCapacity: config.maxWorkerInstances(1 in the default env, 4 in the larger env βenv-config.ts). - 100% spot,
CAPACITY_OPTIMIZED, acrossc6a.large/c6i.large/m6a.large(2 vCPU, 4 GB each). - Each instance runs
workers/batch(index.ts): loads API keys from SSM Parameter Store, starts the SpotMonitor (polls instance metadata every 5s for the 2-min interruption notice, abandons work if <90s left), and runs the sameChunkScheduler+ SQS consumer as the Node box. - Scaling signal is SQS
ApproximateAgeOfOldestMessage(Maximum), not queue depth:- age < 60s β scale to 0 (the
-maxWorkerInstancesstep) - age β₯ 300s β +1
- age β₯ 900s β +2
- age β₯ 1800s β jump to max
- 300s cooldown between actions.
- age < 60s β scale to 0 (the
βEnablingβ it means one of:
- Do nothing / automatic β if the Node box goes down or falls behind, oldest-message age climbs past 300s and the ASG spins EC2 up on its own, then terminates back to 0 when age drops under 60s. This is the intended failover.
- Force capacity manually β raise the ASG desired/min capacity (or
maxWorkerInstancesinenv-config.tsand redeploy the CDK compute stack) to keep warm workers running instead of relying on Node. - Permanent shift off the Node box β bump
maxWorkerInstancesand let the age-based policy carry steady-state load; the batch worker is functionally equivalent to the Node consumer (same scheduler/executors). Tradeoffs: spot interruption handling (already built) and the WeasyPrint/flowchart sidecars must be reachable from the EC2 workers.
Caveat: changes to
workers/batch/donβt take effect on the running self-hosted worker untilnpm run rebuildon10.1.1.4; changes to the EC2 launch path take effect on the next instance launch (new spot instances pull the current image from ECR).
8. Data lifecycle β where the file bytes live
All document bytes live in one Cloudflare R2 bucket (accessible-pdf-files,
accessed over the S3-compatible API). The original upload is never moved β
the converted HTML and tagged PDF are written alongside it under a separate
output/ prefix keyed by the same fileId. Small per-file metadata and share
tokens live in Cloudflare KV; job/billing/log records live in Postgres.
Browser β 1. POST /api/files β API mints a presigned R2 PUT URL (valid 10 min) β and writes file metadata to KV βΌ 2. Browser uploads the PDF DIRECTLY to R2 (bypasses the API Gateway 10MB cap) β βΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Cloudflare R2 β bucket: accessible-pdf-files (originals NOT moved) ββ ββ users/{userId}/uploads/{fileId} β 3. original PDF ββ β ββ conversion pipeline β (Node box / spot fleet reads original, writes out)ββ βΌ ββ users/{userId}/output/{fileId}/result.html β 4. HTML output ββ users/{userId}/output/{fileId}/accessible.pdf β 5. tagged PDF output ββ users/{userId}/output/{fileId}/β¦acrβ¦ β ACR report (if any) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β βββ Cloudflare KV ββ file metadata (r2 keys, status, pages), β share tokens `share:{token}`, sessions β βββ Supabase Postgres ββ conversion-job records, billing/entitlements, cost_ledger, app_logs (pointers, not bytes)Retention β how long, in each location
| Data | Location | How long it stays | Auto-expiry? |
|---|---|---|---|
| Original PDF | R2 uploads/{fileId} | While associated with the account | β none β persists until deleted |
| HTML output | R2 output/{fileId}/result.html | While associated with the account | β none |
| Tagged PDF output | R2 output/{fileId}/accessible.pdf | While associated with the account | β none |
| File metadata | Cloudflare KV | Lives with the file | Deleted on file/account delete |
| Share-link tokens | Cloudflare KV | Until the share linkβs TTL expires | β TTL |
| Sessions | Cloudflare KV | Up to 30 days from last use | β expiry / sign-out |
| Conversion job metadata | Postgres | 13 months | β rolling by age |
| Request / usage logs | Postgres (app_logs) | 90 days | β rolling by age |
| AI prompt/response logs | Postgres | 30 days | β rolling by age |
| Billing records / invoices | Postgres | 7 years (statutory) | β statutory |
| Backups | Encrypted backup store | Up to 30 days | β rolling rotation |
Key point: uploaded PDFs and their HTML/PDF outputs have no time-based auto-expiry. There is no R2 lifecycle rule aging them out β they stay until the user deletes the file or the account is deleted. (Values above mirror the published Data Retention Policy; the account-deletion mechanics are in
account-deletion.md.)
How data is removed
- Per-file delete β
DELETE /api/files/:fileIdremoves the original and outputs from R2 and the metadata + share tokens from KV. - Refund + delete β within the 7-day refund window
(
REFUND_WINDOW_DAYS), refunding a conversion also deletes its R2 objects. - Account deletion β
POST /api/account/deletesoft-deletes with a 30-day grace window (sign-in disabled, restore link emailed); a nightlypg_cronsweep then hard-deletes all the userβs R2 objects and anonymizes the account. Seeaccount-deletion.md. - DSAR / bulk deletion β broader requests (e.g. all documents in a date range) run through the Data Subject Rights Procedure.
- Backups β copies in the backup store roll off on their own bounded schedule (β€30 days) after the primary-store deletion.