Pagedeck

9. One package per edge adapter, on a base that names no host

Date: 2026-10-07

Status

Accepted. Ruled by the maintainer on 2026-10-07, while #9 was being worked, and shipped on #19, before the new hosts of #8, #9 and #10 land, so each is born as a package. The names and the shape below are the ruling's.

Context

Until #19, @pagedeck/edge held all four targets, cloudfront-function, netlify, nginx and cloudflare-worker, behind one compileRouting(routing, { target }), with EDGE_TARGETS as the list and a table from each name to its compiler. Three new hosts were queued. Each would have widened that table, the per-role limits every caller passed and the one package every site installed, whichever host it served from.

#20 then asks pagedeck build to run the host's compiler from a config field. @pagedeck/edge depends on @pagedeck/core, so core cannot import it: whatever core calls has to be a shape core can declare on its own.

Decision

The base. @pagedeck/edge keeps what every host shares and names no host: the adapter contract, the routing normalisation (compiledTree), the JavaScript string encoder (jsLiteral), the faults and the one refusal they are reported in (throwIfAny), the host-free artifact type and the reserved key (UNSERVED_KEY). Nothing in it imports an adapter, and packages/edge/src/packages.test.ts refuses a host's name anywhere in its shipped source. A host-only concept lives in that host's adapter: CloudFront's event slot and function runtime are fields of CloudFrontArtifact, and the fix naming the one adapter that compiles a split is passed by each adapter that refuses one, as unsupportedFix.

The contract. An adapter is a value:

interface EdgeAdapter {
  readonly name: string;
  compile(routing: RoutingManifest): EdgeOutput;
}

compile throws one ConfigError naming every fault the document holds for that host; it never throws the first fault alone. name is what a refusal quotes, Edge target "<name>", and what EdgeOutput.target carries. Core declares that shape for #20 without importing this package, because both halves are core's own types: RoutingManifest and ConfigError.

defineAdapter({ name, limits, compileTree, describe?, unsupportedFix? }) builds one. compileTree is handed one tree at a time, already normalised, and a faults array it pushes to. defineAdapter checks the routing version and every header name and value first, measures each artifact against the limit for its role, and reports every fault. So no adapter re-implements the checks every host needs, and no two adapters can disagree about them.

The adapters. @pagedeck/adapter-cloudfront, @pagedeck/adapter-netlify, @pagedeck/adapter-nginx and @pagedeck/adapter-cloudflare-worker, each depending on @pagedeck/edge and on no other adapter, each exporting a factory (cloudfront(), netlify(), nginx(), cloudflareWorker()) that returns an EdgeAdapter. A per-role limit is an option of the adapter that has the role: cloudfront({ limits: { function, dataset } }) and cloudflareWorker({ limits: { "edge-module": … } }). Each adapter keeps its old target string as its name, so its output and every refusal it writes are byte-identical to what compileRouting wrote for that target.

compileRouting and EDGE_TARGETS are removed, not kept as aliases: the public set is 0.x. The one caller that took a target as a string, the site port's deploy.bin.js --edge <target>, now keeps its own list of adapters and refuses an unknown name with the message compileRouting used to write.

The tests. A case every adapter must pass is written once, in packages/edge/src/conformance.test-support.ts, and each adapter's conformance.test.ts registers it with its own interpreter. A case about one host is in that host's package. packages/edge/src/packages.test.ts refuses an adapter in the base and an adapter in another adapter, by dependency, by import and by relative path.

Consequences

  • A site installs the adapter for its host and no compiler for any other.
  • A new host is a new package with a factory, a compileTree and an interpreter for the conformance suite. Nothing in the base or in another adapter changes.
  • The public set grows from ten packages to fourteen. Each new package needs a first publish before npm trusted publishing can be configured for it (#16).
  • The emitted banner still reads Generated by @pagedeck/edge, because the output is byte-identical to the output before the split.
  • A program that picked a host at run time from a string now keeps its own table from name to factory, as the site port's deploy does.

Alternatives considered

One package with a target string, the old shape. One install and one version for every host. Rejected: every site pays for every host's compiler, the per-role limits of every host are one options bag, and each new host edits the shared table. A string also gives #20's config field nothing to type-check: a misspelled host is found when the build runs, not when the config is written.

Adapters in core. pagedeck build could then call them directly. Rejected: core would ship every host's compiler to every site, and a host's release cadence would become core's. #20 needs only the contract's shape in core, and core can declare that without the compilers.