Skip to content
Parsew Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

  1. GET or HEAD only; parse /{domain} or /og/{domain} and the query.
  2. Look the token up (PublishableKeys.lookup: memory, then D1). Unknown → 401 Invalid token.
  3. Check the page’s Origin/Referer against the key’s allowed origins → 401 Origin not allowed.
  4. 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).
  5. 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.
  6. 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.
  7. 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 HTMLRewriter instead 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):

  1. Sums each organization’s requests per hour for the last 23 complete hours.
  2. Reports every hour after the organization’s reported_through mark to Autumn (balances.track, overage allowed), with the idempotency key parsew:usage:<organization>:<hour>. Autumn keeps keys for 24 hours, so a retried or overlapping run can’t count an hour twice; a 409 means it was already counted.
  3. Moves reported_through forward after each hour succeeds, then re-reads the customer into organization_billing (plan, allowance, usage, period).
  4. 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.

Was this page helpful?