Architecture
One Worker: its surfaces, every binding and why it exists, the request path, usage metering and the code's layout.
Parsew is a single Cloudflare Worker, written in Effect. The aim is that changing the API is a small, cheap edit: one Worker, one database, no queues, no Durable Objects, no second service.
Surfaces
One Worker serves every hostname and tells them apart by the configured URLs (src/worker/router.ts):
| URL | Surface |
|---|---|
IMAGES_URL (logo.parsew.com, or /i on the app host) |
The image API |
APP_URL (app.parsew.com) |
Dashboard, Better Auth (/api/auth/*), dashboard RPC (/rpc/*), and the docs when there is no landing page |
SITE_URL (parsew.com, optional) |
Landing page and the docs at /docs |
www. of the site (optional) |
301 to the site, path and query kept (a zone redirect rule) |
DOCS_HOST (docs.parsew.com, optional) |
301 to <site>/docs<path> |
Bindings
The complete list. Anything added needs a line here saying why.
| Binding | Resource | Why it exists |
|---|---|---|
DB |
D1 | Better Auth’s tables (users, sessions, passkeys, organizations, members), publishable keys, and a small per-organization billing cache. Keys are read on the hot path through a 60-second in-memory cache per isolate |
USAGE |
Analytics Engine dataset | One data point per image request, read back through the SQL API for the dashboard and for metering. There are no usage tables in D1: Analytics Engine takes unlimited writes without blocking the request |
IMAGES |
Images binding | Resizes and re-encodes source images to PNG, WebP or JPG |
SOURCES |
R2 bucket | Source images as the sites served them, one per domain (and theme for icons), so origin sites are fetched about once a week instead of on every cache miss. Transformed output is not stored: the Cache API holds it |
ASSETS |
Static assets | The built dashboard, landing page and docs |
cron 5 * * * * |
Cron trigger | Hourly metering: reports usage to Autumn and refreshes the billing cache (does nothing with billing off) |
EMAIL |
Email Sending (optional) | The once-a-month “you reached your limit” email and password resets. Only bound when PARSEW_EMAIL_FROM is set, because it needs a zone; hosted-only by default |
Two outside services, both optional: Autumn (billing, only with AUTUMN_SECRET_KEY) and the Analytics Engine SQL API (with the read token Alchemy mints).
An image request
src/server/images/image-api.ts, in order:
GETorHEADonly; parse/{domain}or/og/{domain}and the query.- Look the token up (
PublishableKeys.lookup: memory, then D1). Unknown →401 Invalid token. - Check the page’s
Origin/Refereragainst the key’s allowed origins →401 Origin not allowed. - Look for the rendition in the edge cache (
caches.default). The cache key holds every parameter that changes the bytes and never the token, so all keys share one rendering. A hit is served now (steps 2–3 already passed). - Otherwise find the source (
Sources): R2, or a remembered “nothing here” marker in the edge cache, or resolve it from the web (SourceResolver) and store it in R2. - Transform it through the Images binding (SVG icons are served as sanitized SVG; no source gives the monogram or a 404), and cache the result after the response is sent.
- Write one usage data point: key, organization, kind, format, cache hit or miss, status and domain.
The image path never calls Autumn and never waits on billing. Going over a plan’s allowance changes nothing here.
Differences from Parsew v1
The public contract (paths, parameters, formats, error bodies) is unchanged, except that keys are pk_ plus 12 characters and an over-limit key is no longer answered with 429. Behind it:
- Source resolution runs in the Worker (with
HTMLRewriterinstead of cheerio) rather than on a separate server. - ICO files: the largest PNG frame inside is used. ICOs made only of BMP frames fall through to the favicon services, which serve the same icon as PNG. Single-colour raster mask icons are skipped instead of recoloured; SVG masks are still tinted.
Usage and billing
Usage lives in Analytics Engine only: index1 is the organization; blobs are organization, key, kind, format, cache and domain; double1 is the status. Counts use SUM(_sample_interval).
With billing on, the hourly cron (Billing.meter):
- Sums each organization’s requests per hour for the last 23 complete hours.
- Reports every hour after the organization’s
reported_throughmark to Autumn (balances.track, overage allowed), with the idempotency keyparsew:usage:<organization>:<hour>. Autumn keeps keys for 24 hours, so a retried or overlapping run can’t count an hour twice; a409means it was already counted. - Moves
reported_throughforward after each hour succeeds, then re-reads the customer intoorganization_billing(plan, allowance, usage, period). - Emails the owner once per period when usage reaches the allowance.
A new organization becomes an Autumn customer as it’s created; Community is auto-enabled, so there is no paywall and no card. Upgrades go through Autumn’s checkout from the Plan page. With billing off, every organization is unlimited and the Plan page and banner don’t exist.
Code layout
| Path | What |
|---|---|
src/server |
All backend logic, as Effect services and Layers (app-layer.ts wires them). Never imports web/ or src/worker |
src/server/platform |
Adapters over Cloudflare bindings, fetch, HTMLRewriter, the Cache API, Better Auth and Autumn: the only code that touches globals |
src/server/images |
The image API: request parsing, origin policy, icon discovery and scoring, source storage |
src/worker |
The thin adapter: fetch and scheduled handlers, hostname routing, Response ↔ Effect, oRPC |
src/shared |
Browser-safe contracts: the RPC contract and the plans |
web/app |
Dashboard (React, TanStack Router, HeroUI, Better Auth UI) |
web/www |
Landing page, prerendered at build time |
docs/ |
These docs (Blume), bundled into the Worker’s assets |
Imports go through path aliases (@server/*, @shared/*, @worker/*, @app/*, @www/*, @ui/*); oxlint.config.ts enforces the boundaries.
Linting
Ultracite’s Oxlint presets plus oxlint-plugin-effect. Two rules are relaxed for backend code (noNullish, noTernary), and platform adapters may use globals, async functions and new Promise, since they are the boundary.