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
compileTreeand 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.