Skip to content

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.com and admin.gevell.com (same worker; redirects bare root to /admin).
  • The worker is a thin shell: serves static assets, exposes a /__version probe, does fingerprint logging, and proxies API calls to API_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.ts in workers/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) and KV_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-extract sidecar (separate Docker service; rebuilt with docker compose build flowchart-extract).
  • The ChunkScheduler (workers/api/src/scheduler/chunk-scheduler.ts) β€” drives the chunked async pipeline, orphan recovery, and the stuck-assembling sweep. The scheduler runs ONLY on the Node box.
  • The SQS consumer loop that pulls PipelineQueue and executes conversions.

Operational gotchas:

  • Node does not auto-deploy on push. Manual npm run rebuild on 10.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-drives pending/processing. In-process form_conversions have no orphan recovery at all and are stranded at refining forever. Always check for active jobs before rebuilding.
  • New @scope/shared workspace 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:

QueueVisibility timeoutRetentionDLQ after
PipelineQueue (conversions)900s (15 min)7 days3 receives β†’ PipelineDlq (14-day retention)
S3IngestQueue (S3/SharePoint ingestion)900s7 days3 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_at triggers + the assembly idempotency RPC (migrations 225/226/237), form_conversions.
  • Billing & entitlements (two systems that can drift): legacy credit_balances wallet and account_packages entitlements; plus cost_ledger for per-operation cost tracking (CHECK constraint on operation_type is 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_keys for 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

WorkloadCF WorkerLambdaNode 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:

  1. API Gateway’s 29-second synchronous ceiling β€” not the 300s Lambda timeout β€” is what forces async for any non-trivial conversion.
  2. Chromium + WeasyPrint + heavy pdfjs-dist only 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, across c6a.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 same ChunkScheduler + SQS consumer as the Node box.
  • Scaling signal is SQS ApproximateAgeOfOldestMessage (Maximum), not queue depth:
    • age < 60s β†’ scale to 0 (the -maxWorkerInstances step)
    • age β‰₯ 300s β†’ +1
    • age β‰₯ 900s β†’ +2
    • age β‰₯ 1800s β†’ jump to max
    • 300s cooldown between actions.

β€œEnabling” it means one of:

  1. 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.
  2. Force capacity manually β€” raise the ASG desired/min capacity (or maxWorkerInstances in env-config.ts and redeploy the CDK compute stack) to keep warm workers running instead of relying on Node.
  3. Permanent shift off the Node box β€” bump maxWorkerInstances and 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 until npm run rebuild on 10.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

DataLocationHow long it staysAuto-expiry?
Original PDFR2 uploads/{fileId}While associated with the account❌ none β€” persists until deleted
HTML outputR2 output/{fileId}/result.htmlWhile associated with the account❌ none
Tagged PDF outputR2 output/{fileId}/accessible.pdfWhile associated with the account❌ none
File metadataCloudflare KVLives with the fileDeleted on file/account delete
Share-link tokensCloudflare KVUntil the share link’s TTL expiresβœ… TTL
SessionsCloudflare KVUp to 30 days from last useβœ… expiry / sign-out
Conversion job metadataPostgres13 monthsβœ… rolling by age
Request / usage logsPostgres (app_logs)90 daysβœ… rolling by age
AI prompt/response logsPostgres30 daysβœ… rolling by age
Billing records / invoicesPostgres7 years (statutory)βœ… statutory
BackupsEncrypted backup storeUp 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

  1. Per-file delete β€” DELETE /api/files/:fileId removes the original and outputs from R2 and the metadata + share tokens from KV.
  2. Refund + delete β€” within the 7-day refund window (REFUND_WINDOW_DAYS), refunding a conversion also deletes its R2 objects.
  3. Account deletion β€” POST /api/account/delete soft-deletes with a 30-day grace window (sign-in disabled, restore link emailed); a nightly pg_cron sweep then hard-deletes all the user’s R2 objects and anonymizes the account. See account-deletion.md.
  4. DSAR / bulk deletion β€” broader requests (e.g. all documents in a date range) run through the Data Subject Rights Procedure.
  5. Backups β€” copies in the backup store roll off on their own bounded schedule (≀30 days) after the primary-store deletion.