holger's package-handler plugin contract — the one wire format a handler
speaks whether it is linked into the server or loaded from a .wasm at
startup.
Rickard's ask was that every package handler should be writable as native or
WASM. Before this crate holger had no wasm path at all: the sixteen
repository backends are unconditional path dependencies of server/lib, so
adding a package handler meant editing server/lib/Cargo.toml and rebuilding
the server.
The two seams
A RepositoryBackendTrait is a Rust trait object and cannot cross a wasm
boundary, so this crate splits a handler in two along the line that actually
matters:
- [
PackageHandler] — the format logic: coordinate parsing, the HTTP path scheme, listing, content types. This is what varies per ecosystem and what a plugin author writes. It is pure and target-independent, so the one implementation compiles for the host and forwasm32-unknown-unknown. - [
BlobStore] — the bytes. A wasm module has no filesystem and no socket. The host owns storage and hands it in.
That split is what makes "the wasm crate is ABI glue only" true rather than
aspirational: the native backend implements BlobStore over the filesystem,
the wasm shim implements it over four host imports, and both call the same
PackageHandler. It is the same shape as znippy's maven pair, where
host-decompressors compiles the threaded ljar fan-out out of the wasm
build and leaves the single-threaded linflate both sides share — same
logic, different plumbing underneath.
Which verbs cross, and why
RepositoryBackendTrait (traits/src/lib.rs:404) has twelve methods. Six
cross:
| verb | why it is in |
|---|---|
plugin_manifest |
the module must name itself, or registration needs a config file and the whole point of scanning a directory is lost |
fetch |
the read. Non-defaulted on the trait. |
put |
the write. Non-defaulted on the trait. |
list |
enumeration; the UI and retention planner both read it |
coordinate_for_path |
the serve-time quarantine gate calls it before bytes go out, and it is pure path logic — the cheapest and most format-specific thing a handler owns |
handle_http2_request |
non-defaulted, and it is the actual door a package client knocks on. A handler that could not answer it would not be a package handler. |
Six do not, and the host supplies the trait's own default for each:
| verb | why it is out |
|---|---|
name / format / is_writable |
constants. Read once from the manifest at load, not per call. |
archive_files / archive_info / has_archive |
these describe a znippy archive handle. A wasm module has none, and the trait already defaults them to "no archive" — which is the truth here, not a stub. |
delete_artifact |
the trait default fails closed, and the contract requires the implementer to recompute the stored digest and refuse a mismatch. A sandboxed module cannot be trusted to have done that, and a wrong answer deletes bytes. Failing closed is the correct answer, not a missing feature. |
ABI shape
Every exported verb takes a (ptr, len) pair into linear memory and returns
a pointer; the byte length of that result is read back with result_len(),
exactly as znippy's loader does. Payloads are length-prefixed
([codec]) — never JSON, whose separators and missing null representation
are a corruption source rather than a parser bug.