Files
FredAppsandCursor f1ae1ecacb Merge main into web-host
Keep both craft-web-api and craft-web-host in the workspace list, the
README and the lockfile. Point the host README at the command API that
landed in #413, and the command API README at these host messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 10:29:52 +01:00
..
2026-11-05 10:29:52 +01:00

craft-web-host

The host messages between a Craft app's web build and the page that hosts it, as one contract and one bridge for every app (storytold/craft-libs#358). A page frames the app with ?host=parent, or runs it directly with data-host="page" on its canvas; either way the same messages pass files in, saves out, and unsaved-changes state, so a page can host any of the apps the same way.

The crate has two parts:

  • The contract (all targets): the message kinds and their <app>:<kind> types, the contract version, how the hosting mode is read from the URL and the canvas, and the clean-up of file names a host passes in. Pure, tested natively.
  • The bridge (web, wasm32 only): Host::connect(app, canvas) finds the host, listen hears its messages, and post_ready, post_save, post_dirty, post_failed and post_handoff answer it. Only the same-origin parent window is heard and answered; with a parent on another origin the app runs as if unframed.

Messages

Kind Direction Fields Meaning
ready app → host version, appVersion?, file? the app can take a document; file: true means it reads a File/Blob in slices
host host → app version, handoff? what the host supports; handoff: true means it routes hand-offs
open host → app name, and bytes or file + size open a document as File › Open would
save app → host name, bytes, id?, saveAs? Save, Save As or Export: the host stores the file
saved host → app id the save with that id is stored; the app clears dirty
dirty app → host dirty unsaved changes appeared or went away
failed app → host error, id? a document or a save could not be taken
handoff app → host name, and bytes or file + size this file belongs to another app
handoff-done host → app name, app the host gave it to the app that edits it

With ?host=parent a message is an object with type (pdfcraft:open) and the fields, sent with same-origin postMessage; bytes go as a transferred ArrayBuffer. With data-host="page" it is a CustomEvent of that type on the canvas, with the fields in detail.

Compatibility with the bridges shipped before this crate:

  • Fields marked ? are optional. A host answers saved only for a save that carries an id; an app that sends none treats its save as done once it is posted.
  • A host that never sends host gets the behaviour of the earlier bridges.
  • size beside file is for hosts and older bridges; this crate takes a Blob's length from the Blob itself.
  • A page host may dispatch an event while the app's handler runs (a saved answering the save it just posted). The bridge holds it and calls the handler again once the running call returns, so messages arrive one at a time and in order.
  • version is "1". The earlier bridges put the app's own release in it ("0.14.0"), so a host treats a version it doesn't know as "1". The crate sends the release as appVersion.

Use in an app's web shell

#[cfg(target_arch = "wasm32")]
if let Some(host) = craft_web_host::web::Host::connect("pdfcraft", &canvas) {
    host.listen(move |message| match message {
        craft_web_host::web::Message::Open { name, data } => {
            // Check data.size() against the app's limit before copying it in.
        }
        craft_web_host::web::Message::Saved { id } => { /* clear dirty for that save */ }
        _ => {}
    });
    let _ = host.post_ready(env!("CARGO_PKG_VERSION"), false);
    // Saves: host.post_save(name, &bytes, None, save_as); unsaved state: host.post_dirty(dirty).
}

The page command API is craft-web-api (storytold/craft-libs#413): window.<app>.request and the command functions. It sits beside these messages. Opening, saving, the document name and the dirty flag stay here.