Desktop and web parity contract
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:
- 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 bycargo test. scripts/test-parity.mjsis the acceptance gate for any engine change: it runs the same files through both hosts and diffs the PreviewResult JSON.- 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'swasmmay point into another id's directory (a shared binary); itsmanifest/viewer_manifestname the route's and viewer's ownplugin.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 primaryfile_extension/filenameoveraliases, then lowestid; each conflict is warned about.scripts/fixtures/routing-cases.jsonis run by bothcargo testandscripts/test-engine-routing.mjs. Content routing (#187) follows, on both hosts, from one table:web/src/content-signatures.json(compiled intocore/poka-core/src/content_sniff.rs, fetched byweb/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
vieweris inhex_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 whoseextensionslist names it apply..oggand.ogaare two because one Ogg container can hold Vorbis, Opus or FLAC: a stream whose first packet isOpusHeadgoes topoka-opus, and every other Ogg stays onpoka-symphonia..dbis one because SQLite, Berkeley DB, an OLE2 thumbnail cache and a Tekla store all use it: a SQLite header goes topoka-sqlite, which reads the tables, and every other.dbkeeps thepoka-dbcard that names its family (issue #511)..xmlis one so that an XML document is routed by its root element: a signature withxml_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.xmlstays onpoka-xml. Routed this way:<dsbXMLtopoka-dsbxml,<gbXMLtopoka-gbxml,<mvdXMLtopoka-mvdxml,<score-partwise>and<score-timewise>topoka-musicxml,<fcpxmltopoka-fcpxml,<THERM-XMLtopoka-thmx,<IfcProjecttopoka-ifcxmland<LandXMLtopoka-landxml.<definitions>is the root of BPMN, DMN and WSDL alike, so those signatures addxml_ns: the namespace bound to the root element itself (its ownxmlns:<prefix>, orxmlnsfor 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 onpoka-xml. The parsers keep their own extensions; one handler per extension. A root pastwindow_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 (COBieFacility,ParamSection,root) and the generic ones (project,Project) stay onpoka-xml; poka-parsers'scripts/xml-roots.pylists 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 whoseroute(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'scontent_casesare the shared test. Every result a route produced, success or failure, carries a top-levelhandler:{ "id": <parser id>, "viewer": <viewer id> },viewerleft out for a parser with no viewer (#456). Rust:Previewedincore/poka-core/src/preview_types.rs(desktoppreview_file, webrun_preview); JS:withHandlerinweb/src/routing.js(runPreview, the worker's and the timeout's failures). ANoExtensionanswer has none. The desktop's host-native results name the host ashost:<kind>(image,svg,pdf,markdown,text,ffmpeg;host_handlerincommands.rs).
- Known acceptable divergences (documented, do not "fix" silently):
- desktop
preview_filecomputessupportedfrom artifacts/registry for the not-installed path (#94-E, #132); the web engine has no install concept, so its catch-allNoExtensionissupported:falseon both. - a claimant with no servable wasm in
web/extensionsis not a web candidate, so where the rule's winner is unservable (.webptoday:poka-modern-images) the browser serves the next claimant while the desktop keeps the rule's winner.gen_formats_json.pywarns for each. - an ambiguous extension is never "natively handled" on the desktop, so a
.xml(inCODE_EXTS, plain text) is still sniffed and a dsbXML root previews with the dsbxml parser on both hosts (natively_handledincommands.rs, tested). With nopoka-xmlinstalled a.xmlis 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 reachespoka-dsbxmlthere too. Before that the table's ownxmlroute 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'swithout_casesrun 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
.binholding XML or PEM previews as plain text on the desktop (xml,pemare inCODE_EXTS) and as a tree on the web, just as a real.xmldoes. 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.mjsreads 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 ownmemory.growcannot be intercepted from JavaScript. A module that needs more than itsmemory_mbtherefore previews in the browser and fails on the desktop (poka-luks, poka-interfaces#406; poka-gdl, #407). The fix is in the parser or itsmemory_mb, never a host change, andscripts/test-parity.mjsreports it as a failure, not a bundle gap.
- desktop