Skip to content

ADR 0001 — Shared Protocol Layer

Accepted · 2026-05-08

Restura ships as both a Cloudflare Pages SPA (which proxies network calls through a Hono Worker) and an Electron desktop app (which uses native Node IPC handlers). Before this refactor, each protocol — HTTP, gRPC, MCP — was implemented twice with subtly different SSRF guards, header denylists, body-builders, and error-mapping.

Concretely, two isPrivateAddress helpers existed (worker/shared/url-validation.ts and an inline copy in electron/main/http-handler.ts), which had already drifted: the Electron version covered carrier-grade NAT (100.64.0.0/10) while the Worker version did not. Each new protocol added meant two implementations to keep in sync.

flowchart TB
  subgraph Before["Before — duplicated implementations"]
    direction LR
    R1[Renderer]
    R1 --> W1[Worker handler<br/>http-proxy.ts]
    R1 --> E1[Electron handler<br/>http-handler.ts]
    W1 -.->|drift| E1
  end
  classDef drift stroke-dasharray: 4 4,stroke:#f43;
  class W1,E1 drift;

Promote protocol logic to shared/protocol/. Each protocol is implemented once as executeXxxProxy(spec, fetcher, options) returning a discriminated ExecuteResult union. The Fetcher interface ((req: FetcherRequest) => Promise<FetcherResponse>) lets each backend supply its own transport while sharing validation, sanitisation, body construction, response shaping, and timeout handling.

flowchart TB
  R[Renderer]
  R -->|RequestSpec| SP[shared/protocol/<br/>executeHttpProxy]
  SP -->|Fetcher| W[Worker adapter<br/>globalThis.fetch]
  SP -->|Fetcher| E[Electron adapter<br/>undici / Node net]
  SP -->|Fetcher| C[CLI adapter<br/>undici]
  W --> U[(upstream)]
  E --> U
  C --> U

  classDef core fill:#7b6ef6,stroke:#4f46e5,color:#fff;
  classDef adapter fill:#6366f122,stroke:#7b6ef6,color:#7b6ef6;
  class SP core;
  class W,E,C adapter;

Positive

  • New protocols slot in by adding one shared module and two ~30-line adapters.
  • SSRF rule changes happen in one place. The unified isPrivateAddress is now a strict superset of both prior implementations.
  • Worker handlers shrank significantly — proxy.ts: 232 → 115 lines; grpc.ts: 227 → 59; mcp.ts: 224 → 162.
  • Test coverage on the core (>80 tests across the shared modules) is reused by both backends.

Negative

  • Adds a @shared/* path alias which increases tsconfig surface area (renderer, worker, and Electron each declare their own paths block — TypeScript’s extends does not merge paths).
  • Electron-specific features (PAC, SOCKS, mTLS, interceptors) require thoughtful placement — they live inside the fetcher closure, not in shared, but the boundary takes care to maintain.
  • Streaming responses end-to-end. Today the shared core buffers the response body. The Fetcher interface is designed so a streaming variant can be added without breaking the existing buffered path. Tracked in ADR 0003.
  • HTTP/2 negotiation. Tracked in ADR 0003.
  • Web interceptor parity. Tracked in a later plan.
  • Real keychain encryption. Tracked in ADR 0004 and ADR 0007.
  • Multi-tab request store. Tracked in ADR 0002.
  • No-op (status quo): Two independent code paths. Rejected — review identified active drift between the two isPrivateAddress implementations.
  • Single backend (Electron only or Worker only): Either drops the web or desktop deployment. Rejected — both are strategic.
  • Plugin/extension model first: Skip the refactor, build a plugin layer over the duplicated handlers. Rejected — plugins built atop diverged handlers inherit the divergence.
  • Centralise paths in tsconfig.base.json: Initially attempted but reverted. TypeScript’s extends doesn’t merge paths, so any child tsconfig declaring its own (worker, electron, renderer all need to) overrides the base entirely. Decentralised paths in each child tsconfig is the actually-working model.