poka.sh

Docs

Desktop and web parity contract

explanation · reviewed 2026-10-07

Poka runs the same extension pipeline on a desktop host (Rust, Wasmtime) and a web host (JavaScript, in a Web Worker). This page explains the contract that keeps them one product, and lists the divergences that are known and deliberate. It moved here from the architecture page, which links back to it; the text is otherwise unchanged and was last reviewed on 2026-10-07.

Poka runs the same extension pipeline on two hosts:

Concern Desktop Web
Host runtime core/poka-core/src/extension_host.rs (wasmtime) web/src/engine-core.js + web/src/wasi.js (JS WASI shim in a Web Worker)
Protobuf codec prost (generated from core/proto/preview.proto) web/src/proto.js (hand-rolled proto3, same schema)
Request encoding PreviewRequest via prost encodePreviewRequest in proto.js
Result mapping to_preview_result responseToResult in engine-core.js
Module cache in-memory + wasmtime disk cache per-Worker moduleCache
Frontend Tauri (invoke) blob-URL Worker + ?server=1 fallback

Rules that keep them one product:

  1. Every host behaviour change lands in both implementations — pipeline order, response arms, timeout/limits semantics, error mapping. The scripts/test-client-engine.mjs --strict (run by pre-push) runs the web engine over the full poka-samples corpus so drift fails the build; the desktop side is covered by cargo test.
  2. scripts/test-parity.mjs is the acceptance gate for any engine change: it runs the same files through both hosts and diffs the PreviewResult JSON.
  3. The catalog (web/src/api/formats.json) is the single routing source for both; the desktop additionally uses it as the install registry (#132). A row's wasm may point into another id's directory (a shared binary); its manifest / viewer_manifest name the route's and viewer's own plugin.json, which is what the desktop installs (#153, desktop/src-tauri/src/registry_install.rs: verify manifest ids and hashes against the row, stage, publish viewer then parser, roll back on any failure). Routing is one rule on both hosts (extension_registry.rs, scripts/gen_formats_json.py, web/src/routing.js): the lowercased basename is tried as an exact filename, then as a whole extension, then its last _ segment against filename routes, then its longest dotted suffix. Two manifests claiming one key resolve as primary file_extension/filename over aliases, then lowest id; each conflict is warned about. scripts/fixtures/routing-cases.json is run by both cargo test and scripts/test-engine-routing.mjs. Content routing (#187) follows, on both hosts, from one table: web/src/content-signatures.json (compiled into core/poka-core/src/content_sniff.rs, fetched by web/src/content-sniff.js). The first bytes (window_bytes, 8 KiB) are sniffed only when the name gives
    • no route,
    • a hex stub: a parser whose manifest viewer is in hex_stub_viewers (poka-hex-viewer), the viewer every generated byte-dump shell declares, or
    • a route matched by one of generic_extensions (.bin, .dat, .tmp, .out, .bak), which no parser can own, or
    • a route matched by one of ambiguous_extensions (.bdf, .db, .imd, .oga, .ogg, .pmd, .pot, .ts, .xml): a real parser owns the name but the extension is shared by unrelated formats, so only signatures whose extensions list names it apply. .ogg and .oga are two because one Ogg container can hold Vorbis, Opus or FLAC: a stream whose first packet is OpusHead goes to poka-opus, and every other Ogg stays on poka-symphonia. .db is one because SQLite, Berkeley DB, an OLE2 thumbnail cache and a Tekla store all use it: a SQLite header goes to poka-sqlite, which reads the tables, and every other .db keeps the poka-db card that names its family (issue #511). .xml is one so that an XML document is routed by its root element: a signature with xml_root (the first start tag, after the declaration, comments and DOCTYPE; not a search of the text) sends the document to the parser that owns its vocabulary, and any other .xml stays on poka-xml. Routed this way: <dsbXML to poka-dsbxml, <gbXML to poka-gbxml, <mvdXML to poka-mvdxml, <score-partwise> and <score-timewise> to poka-musicxml, <fcpxml to poka-fcpxml, <THERM-XML to poka-thmx, <IfcProject to poka-ifcxml and <LandXML to poka-landxml. <definitions> is the root of BPMN, DMN and WSDL alike, so those signatures add xml_ns: the namespace bound to the root element itself (its own xmlns:<prefix>, or xmlns for an unprefixed root) must be the BPMN 2.0 model namespace (poka-bpmn) or one of the DMN 1.1, 1.2 and 1.3 model namespaces (poka-dmn); a later DMN version, a namespace declared on a child or past the window, and WSDL stay on poka-xml. The parsers keep their own extensions; one handler per extension. A root past window_bytes, a UTF-16 file, or a .bin/no-name file holding one of these is not routed to it (the generic XML row applies). Roots with no parser for them (COBie Facility, ParamSection, root) and the generic ones (project, Project) stay on poka-xml; poka-parsers' scripts/xml-roots.py lists every root in the corpus. An exact-filename route and any other extension routed to a real parser are never sniffed. Signatures are tried in order; the first whose route (an extension key) exists and is not itself a hex stub wins, and the file is then handled as a file with that extension. Both hosts log the signature and the reason. The fixture's content_cases are the shared test. Every result a route produced, success or failure, carries a top-level handler: { "id": <parser id>, "viewer": <viewer id> }, viewer left out for a parser with no viewer (#456). Rust: Previewed in core/poka-core/src/preview_types.rs (desktop preview_file, web run_preview); JS: withHandler in web/src/routing.js (runPreview, the worker's and the timeout's failures). A NoExtension answer has none. The desktop's host-native results name the host as host:<kind> (image, svg, pdf, markdown, text, ffmpeg; host_handler in commands.rs).
  4. Known acceptable divergences (documented, do not "fix" silently):
    • desktop preview_file computes supported from artifacts/registry for the not-installed path (#94-E, #132); the web engine has no install concept, so its catch-all NoExtension is supported:false on both.
    • a claimant with no servable wasm in web/extensions is not a web candidate, so where the rule's winner is unservable (.webp today: poka-modern-images) the browser serves the next claimant while the desktop keeps the rule's winner. gen_formats_json.py warns for each.
    • an ambiguous extension is never "natively handled" on the desktop, so a .xml (in CODE_EXTS, plain text) is still sniffed and a dsbXML root previews with the dsbxml parser on both hosts (natively_handled in commands.rs, tested). With no poka-xml installed a .xml is sniffed all the same, on both hosts and only against the signatures that name .xml (SniffTable::reason, sniffReason: an ambiguous name the table routes itself is never treated as a format that must keep its name), so a renamed DesignBuilder file reaches poka-dsbxml there too. Before that the table's own xml route made the name look "known" and nothing was sniffed, in the browser as well as on the desktop. The other ambiguous names (.ts, .bdf, ...) have no route of their own in the table and were already sniffed with no parser (reason "no route for the name"). The shared fixture's without_cases run this on both engines.
    • content routing hands the desktop an extension key, so a sniffed file gets whatever the desktop does for that extension, native arms included: a .bin holding XML or PEM previews as plain text on the desktop (xml, pem are in CODE_EXTS) and as a tree on the web, just as a real .xml does. For the same reason the desktop also accepts a signature whose format it previews natively (PNG, PDF) when no wasm claims it, and never sniffs an extension it handles natively.
    • the desktop runs extensions under full WASI with epoch interruption; the web shim implements the subset extensions actually use (verified by the sweep).
    • a generated preview (a STEP file tessellated to STL) reaches the desktop as an on-disk path under generated/ and the browser as inline bytes. scripts/test-parity.mjs reads the server's file and compares the mesh bytes, so the path string is not part of the parity check (#405).
    • the desktop caps a module's linear memory at its manifest's memory_mb (StoreLimits); the web shim sets no cap, because a module's own memory.grow cannot be intercepted from JavaScript. A module that needs more than its memory_mb therefore previews in the browser and fails on the desktop (poka-luks, poka-interfaces#406; poka-gdl, #407). The fix is in the parser or its memory_mb, never a host change, and scripts/test-parity.mjs reports it as a failure, not a bundle gap.