Prepared publications
The prepared subpath connects a changing manifest route to immutable notebook exports. The application supplies a port that stages and commits each complete visible state.
import { jsonLoader } from "@marimo-team/marimo-export/loader/json";
import {
PreparedPublicationRefresh,
PreparedStateController,
type PreparedStatePort,
} from "@marimo-team/marimo-export/prepared";
const port: PreparedStatePort = {
async apply({ next }, signal) {
const title = await next.state.output("title").load(jsonLoader(), { signal });
signal.throwIfAborted();
document.querySelector("#title")!.textContent = String(title);
},
};
const controller = new PreparedStateController(port);
const manifestUrl = new URL("/runtime/prepared.json", location.href);
const refresh = new PreparedPublicationRefresh(manifestUrl, controller);
await refresh.start();
await controller.updateInputs({ interval: "1wk" });
await refresh.dispose();
await controller.dispose();For several outputs, apply() should load and mount replacements in connected staging hosts, check the signal, commit the complete replacement, then dispose the previous mount owner. Build a browser application describes that DOM pattern.
Publication objects
| Object | Contract |
|---|---|
| Prepared manifest | Snake-case JSON served by an application route |
PreparedExportManifest | Parsed immutable TypeScript value with camel-case properties |
NotebookExport | Immutable reader opened from the manifest export_url |
PreparedPublication | { manifest, notebookExport, state } after URL, input, and fingerprint checks |
PreparedStateController | Mutable input intent and serialized application transitions |
PreparedPublicationRefresh | Manifest fetching, immutable export reuse, publication replacement, and polling |
The TypeScript API names the parsed value PreparedExportManifest. In prose, prepared manifest means the protocol document. The browser follows a prepared publication because the route can select another notebook export or exported state over time.
Prepared manifest document
Serve this exact JSON shape:
{
"schema": "marimo-export.prepared.v1",
"instance": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"export_url": "./aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/",
"inputs": { "interval": "1d" },
"state_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"refresh_interval_ms": 1000
}| Wire field | Parsed property | Contract |
|---|---|---|
schema | schema | Exact value marimo-export.prepared.v1 |
instance | instance | Lowercase SHA-256 of the selected export's canonical index.json, compared by equality in the browser |
export_url | exportUrl | Nonempty URL reference of at most 8,192 UTF-8 bytes |
inputs | inputs | Complete portable JSON input vector |
state_fingerprint | stateFingerprint | Fingerprint of inputs in the selected export |
refresh_interval_ms | refreshIntervalMs | Optional polling interval. Use zero to disable polling or 250 through 60,000 milliseconds to enable it |
interface PreparedExportManifest {
readonly schema: "marimo-export.prepared.v1";
readonly instance: string;
readonly exportUrl: string;
readonly inputs: JsonObject;
readonly stateFingerprint: string;
readonly refreshIntervalMs?: number;
}
interface PreparedPublication {
readonly manifest: PreparedExportManifest;
readonly notebookExport: NotebookExport;
readonly state: ExportState;
}Give each instance its own export_url, such as ./<instance>/. PreparedPublicationRefresh keeps the opened export while instance and the resolved URL both stay equal, and opens the export again when either changes. The browser never compares index.json with instance, so a reused URL can keep serving a cached index under a new instance.
Unknown or missing fields fail validation. export_url resolves relative to the manifest URL, then must produce an HTTP or HTTPS export base without a fragment or URL credentials.
An absolute export_url may name another origin. Enforce the application's origin allowlist before serving or fetching a manifest. The target origin must permit browser access through Cross-Origin Resource Sharing (CORS).
Parse, fetch, and open a publication
parsePreparedExportManifest(input)
function parsePreparedExportManifest<Input>(input: Input): PreparedExportManifest;Converts the snake-case document to the frozen camel-case value. It validates portable JSON, the exact field set, digest spelling, nonempty input names, URL length, and polling range. The later publication resolver checks that the input object is complete for the opened export. Invalid input raises PreparedExportError with code manifest_invalid.
fetchPreparedManifestDocument(url, options?)
function fetchPreparedManifestDocument(
url: URL,
options?: PreparedManifestFetchOptions,
): Promise<JsonValue>;Fetches a prepared manifest or application envelope as portable JSON. Requests use cache: "no-store" and Accept: application/json. The reader rejects bodies larger than 256 KiB, including streamed responses, and cancels the body on abort. Supply fetch for authentication and request policy. The application validates its envelope schema and selected view after this operation.
Transport and size failures raise PreparedExportError with code manifest_read_failed. Invalid UTF-8 or portable JSON raises manifest_invalid. Cancellation raises an abort error.
fetchPreparedExportManifest(url, options?)
interface PreparedManifestFetchOptions {
readonly fetch?: typeof globalThis.fetch;
readonly signal?: AbortSignal;
}
function fetchPreparedExportManifest(
url: URL,
options?: PreparedManifestFetchOptions,
): Promise<PreparedExportManifest>;Fetches with cache: "no-store" and Accept: application/json. It reads at most 256 KiB, requires strict UTF-8 portable JSON, then calls the manifest parser. Network, non-success response, stream, and byte-limit failures use manifest_read_failed. An empty, malformed, or contract-invalid response body uses manifest_invalid.
resolvePreparedPublication(manifest, manifestUrl, notebookExport)
function resolvePreparedPublication(
manifest: PreparedExportManifest,
manifestUrl: URL,
notebookExport: NotebookExport,
): PreparedPublication;Checks that the resolved export URL equals notebookExport.base, that the complete input vector exists, and that its fingerprint matches. It reuses an already opened export and performs no network request.
openPreparedPublication(manifest, manifestUrl, options?)
interface OpenPreparedPublicationOptions extends OpenExportOptions {
readonly openExport?: (
base: string | URL,
options?: OpenExportOptions,
) => Promise<NotebookExport>;
}
function openPreparedPublication(
manifest: PreparedExportManifest,
manifestUrl: URL,
options?: OpenPreparedPublicationOptions,
): Promise<PreparedPublication>;Resolves the export base, opens its index, then applies the same publication checks. fetch and signal pass to openExport(). openExport can supply an application-specific compatible reader.
Opening a publication validates the index and selected state. Output assets remain lazy until the application loads or verifies them.
PreparedStatePort
type PreparedStateChangeReason = "start" | "state" | "publication";
interface PreparedStateChange {
readonly previous: PreparedPublication | undefined;
readonly next: PreparedPublication;
readonly reason: PreparedStateChangeReason;
}
interface PreparedStatePort {
apply(change: PreparedStateChange, signal: AbortSignal): Promise<void>;
restore?(publication: PreparedPublication): void | Promise<void>;
dispose?(): void | Promise<void>;
}apply() owns output loading, DOM staging, visible commit, and disposal of the previous visible application view. Resolve its promise after the complete next view has committed. A later request aborts signal and removes the transition's authority to commit.
An optional output can be unavailable in the next state. Check output.unavailable inside apply() and stage a placeholder for it, because a rejected apply() keeps the previous state visible. With view as the chart's staging host:
const chart = next.state.output("chart");
if (chart.unavailable === null) {
view.replaceChildren();
await (await chart.load(imageLoader(), { signal })).mount(view, { signal });
} else {
view.textContent = "No chart for this selection.";
}restore() synchronizes optimistic controls or the visible application view to the last committed publication when an input request requires no new commit or a non-cancellation transition fails. An abort-shaped port failure does not invoke restore(). If restoration also fails, the controller raises an AggregateError containing the transition and restoration failures.
dispose() releases application-owned resources after active transitions have settled during controller disposal.
PreparedStateController
class PreparedStateController {
constructor(port: PreparedStatePort, signal?: AbortSignal);
snapshot(): PreparedStateSnapshot;
start(publication: PreparedPublication, signal?: AbortSignal): Promise<void>;
updateInputs(patch: JsonObject, signal?: AbortSignal): Promise<void>;
updateControl(objectId: string, value: JsonValue, signal?: AbortSignal): Promise<boolean>;
updateQuery(query: string, signal?: AbortSignal): Promise<boolean>;
replacePublication(publication: PreparedPublication, signal?: AbortSignal): Promise<void>;
cancel<Reason>(reason?: Reason): void;
settle(): Promise<readonly PromiseSettledResult<void>[]>;
dispose(): Promise<void>;
}Start and inspect
start() applies one initial publication with reason start. After one start commits, the controller rejects another. A failed start can be retried while no publication is current or targeted. snapshot() returns a frozen controller snapshot without waiting:
interface PreparedStateSnapshot {
readonly current: PreparedPublication | undefined;
readonly pendingInputs: JsonObject | undefined;
readonly transition: {
readonly generation: number;
readonly target: PreparedPublication | undefined;
readonly active: boolean;
};
readonly disposed: boolean;
}Update inputs
updateInputs(patch) merges a sparse root-input patch over pending intent or the current transition target. It resolves the resulting complete vector against the current immutable export and applies it with reason state.
Rapid updates abort prior transition signals and execute port calls serially. Only the latest transition generation can become current. If an unavailable state or a missing asset response interrupts the request, the requested input vector stays pending so a later publication can satisfy it. A caller-aborted request or an incompatible replacement input contract clears that pending intent.
When the requested vector equals the committed state, the controller calls restore() and performs no apply(). When it equals an active target, the call waits for that transition.
Route a control
updateControl(objectId, value) finds objectId in notebookExport.controlBindings, converts the accepted frontend value to portable JSON, and builds a sparse root-input patch. It returns:
trueafter routing and applying a root, mapping-key, or sequence-index bindingfalsewhen the object ID has no bindingfalsewhen the binding contains anelementstep owned by the application
An invalid binding path raises PreparedExportError with code manifest_invalid.
preparedControlInputPatch(inputs, binding, value) exposes the immutable patch operation directly. It returns undefined for an element step. samePreparedInputs(left, right) compares portable values structurally and ignores object key order.
Route a query
updateQuery(query) inspects parameters whose names occur in inputNames. Unknown parameters are ignored. A recognized parameter must occur exactly once. Strings match their raw text. Other exported values match their JSON spelling. The supplied parameters form a sparse patch over the current exported state.
The method returns false when the query contains no recognized input. It returns true after applying a recognized selection. No match raises query_miss. Text that matches more than one typed exported value raises query_ambiguous.
Arrays and objects use their exact JSON.stringify() text in the exported domain. For example, ?symbols=%5B%22AAPL%22%2C%22MSFT%22%5D selects the array ["AAPL", "MSFT"] when that value appears in an exported state. A string whose text is identical to another typed value can make the query ambiguous.
resolvePreparedQuery(current, query)
function resolvePreparedQuery(current: ExportState, query: string): ExportState;Reads query with the updateQuery() rules and returns the exported state that the recognized parameters select from current.notebookExport. Inputs absent from the query keep their values from current. A query with no recognized input returns current. query accepts location.search with or without its leading ?.
A recognized vector absent from the export raises NotebookExportError with code state_unavailable.
formatPreparedQuery(state, query?)
function formatPreparedQuery(state: ExportState, query?: string): string;Returns a query in location.search form that selects state: an empty string or text that starts with ?. Each input whose exported values differ between states becomes one parameter in inputNames order. An input with one exported value carries no selection and stays out of the query.
Input names own their query keys, so input parameters in query are replaced. Other parameters keep their order and values. The result is serialized as application/x-www-form-urlencoded text, so ?export=./v2/ comes back as ?export=.%2Fv2%2F and a space becomes +. The result resolves back to the same state from every state of the export:
resolvePreparedQuery(current, formatPreparedQuery(state)) === state;A query names a state within one export. Inputs that the query leaves out keep their values from the current state, so a link written for one export can select another state after a republished export varies more inputs. Keep the export in an application parameter when links must outlive a publication.
A value whose text matches more than one exported value of its input raises PreparedExportError with code query_ambiguous. A large varying input, such as widget state, produces a long URL that some HTTP servers reject.
Keep the page URL in step with each committed state and preserve application-owned parameters such as tab:
import { jsonLoader } from "@marimo-team/marimo-export/loader/json";
import {
formatPreparedQuery,
PreparedPublicationRefresh,
PreparedStateController,
type PreparedStatePort,
} from "@marimo-team/marimo-export/prepared";
const port: PreparedStatePort = {
async apply({ next, reason }, signal) {
const title = await next.state.output("title").load(jsonLoader(), { signal });
const url = new URL(location.href);
url.search = formatPreparedQuery(next.state, url.search);
signal.throwIfAborted();
document.querySelector("#title")!.textContent = String(title);
if (reason !== "start") history.replaceState(history.state, "", url);
},
};
const controller = new PreparedStateController(port);
const manifestUrl = new URL("/runtime/prepared.json", location.href);
const refresh = new PreparedPublicationRefresh(manifestUrl, controller);
await refresh.start();
await controller.updateQuery(location.search).catch((error) => console.warn(error));apply() formats the query before it changes the page, so a formatting error rejects the transition with the previous view still visible. The start commit leaves the URL unchanged, and updateQuery() then applies the state the link names. A shared link therefore commits the manifest state first and the linked state second. A URL without input parameters makes updateQuery() return false. An input parameter whose value matches no exported value rejects updateQuery() with query_miss and leaves the manifest state committed.
When interval is the only input that varies, a page at ?tab=chart that commits { interval: "1wk" } shows ?tab=chart&interval=1wk. Reloading that URL selects the same state.
Replace a publication
replacePublication() reconciles pending intent with a new publication and applies the selected result with reason publication. Pending intent survives when both exports have the same input-name set. It is discarded when the input contract changes.
When the publication keeps the same NotebookExport and state fingerprint as the committed publication, replacement updates manifest metadata without calling apply().
Cancel, settle, and dispose
cancel(reason) aborts the active transition. Error-shaped reasons are preserved. Other reasons become a DOMException named AbortError.
settle() waits for tracked work and returns PromiseSettledResult records. It does not rethrow a tracked rejection.
dispose() is idempotent. It aborts the controller lifecycle, settles active work, clears the current publication, calls port.dispose(), then clears the pending input vector in a final cleanup step. An external signal passed to the constructor triggers the same disposal. Operations after disposal raise Error.
PreparedPublicationRefresh
interface PreparedPublicationRefreshOptions {
readonly dependencies?: Partial<PreparedPublicationRefreshDependencies>;
readonly fetch?: typeof globalThis.fetch;
readonly openExport?: OpenPreparedPublicationOptions["openExport"];
readonly signal?: AbortSignal;
readonly onError?: (cause: unknown) => void;
}
interface PreparedPublicationRefreshDependencies {
fetchManifest(url: URL, options?: PreparedManifestFetchOptions): Promise<PreparedExportManifest>;
openPublication(
manifest: PreparedExportManifest,
manifestUrl: URL,
options?: OpenPreparedPublicationOptions,
): Promise<PreparedPublication>;
}
class PreparedPublicationRefresh {
constructor(
manifestUrl: URL,
state: PreparedStateController,
options?: PreparedPublicationRefreshOptions,
);
start(signal?: AbortSignal): Promise<void>;
refresh(signal?: AbortSignal): Promise<void>;
settle(): Promise<readonly PromiseSettledResult<void>[]>;
syncPolling(): void;
dispose(): Promise<void>;
}start() fetches the first manifest and starts a controller that has no current or target publication. A failed start can be retried while the controller still has no current or target publication.
refresh() fetches the current manifest. Concurrent calls share the active refresh operation. The refresh object reuses the opened NotebookExport when instance and base URL match. A new instance is opened and validated before publication replacement.
A local state selection survives refresh when the input-name set matches and the next export contains that complete vector. The manifest selection wins when the vector is unavailable or the input contract changes. A failed refresh leaves the last committed publication current.
Successful startup and refresh call syncPolling(). The method clears the old timer and schedules the current manifest interval. Background polling failures call onError. An explicit refresh() returns its rejection to the caller.
settle() returns zero or one settled operation result. dispose() stops polling, aborts active refresh work, and settles it. A later refresh() resolves without work. Refresh disposal does not dispose the state controller, so the application should dispose both owners.
fetch is the normal integration point for authentication and request policy and is passed to both manifest and export requests. dependencies replaces the two complete fetch-and-open operations for a test harness or compatible host adapter.
Cancellation and trust
Prepared cancellation prevents a stale transition from committing. A stream, decoder, dynamic import, or renderer that already started may settle later. The application port must check its signal immediately before visible commit and dispose staged mounts on every failure path.
The manifest authenticates neither its server nor its export. Validate the server through the deployment's HTTPS, authentication, and origin policy. The producer computes instance from the selected export, and the browser trusts that value to name the export at export_url.
Errors and limits separates prepared errors, reader errors, application errors, and aborts.