Host mode and the zenith-host/1 protocol¶
Host mode lets another web app open the Zenith web editor in a popup window with a robot project, let the person edit an auto there, and take the saved auto back. The first host is DSIM, the FTC driver-practice simulator, whose robot builder already knows the robot and whose simulator can run the auto. Nothing here is specific to DSIM: a host is a row in a table.
The code is apps/web/src/project/: hostOrigins.ts (the allowlist), hostProtocol.ts (the message
types and the validator), hostBackend.ts (the ProjectBackend), hostSession.ts (the browser side)
and hostPanel.tsx (the UI). The protocol types and the validator have no DOM and are unit-tested in
node; apps/web/e2e/host-mode.spec.ts drives the whole exchange from a fake host page.
1. Lifecycle¶
host Zenith popup
│ window.open("<zenith>/?host=dsim") ─────────▶ loads, reads ?host=dsim
│ posts ready to window.opener, target "*"
│ ◀───────────────────────────── {type:"ready", protocol:"zenith-host/1"}
│ {type:"open", protocol, project, …} ────────▶ checks source and origin, pins the origin,
│ builds the project, opens the auto
│ … the person edits …
│ ◀───────────────────────────── {type:"save", name, text} (Save, Ctrl S)
│ {type:"saved", name, ok, error?} ────────────▶ resolves the save, or fails it
│ ◀───────────────────────────── {type:"run", name, text} (Simulate in DSIM)
│ {type:"trace", auto, trace} ────────────▶ lays the recorded run over the plan
│ or {type:"runFailed", auto?, reason} ──────▶ shows the host's reason (optional)
│ {type:"close"} ────────────▶ "DSIM closed this session", Save off
│ ◀───────────────────────────── {type:"bye"} (window closing)
- The host opens
<zenith>/?host=<hostId>withwindow.open, withoutnoopener, because Zenith talks back throughwindow.opener.hostIdis a short name Zenith knows (section 3). - On load Zenith posts
readytowindow.openerwith target origin"*". It carries nothing, so it is safe to send before Zenith knows who the host is. It is posted once; the host should listen before it callswindow.open. - Zenith waits up to 10 s for an
open. The first validopenpins the sender's origin. - Editing proceeds. Save posts
saveand waits up to 5 s forsaved. "Simulate in" postsrunand waits up to 60 s for atrace, or for arunFailedthat says why the host could not run it. - The host ends the session with
close, or the person closes the window, which postsbye(best effort:pagehideis not guaranteed). If the host's own window closes first, Zenith notices within a second, and at once when its window gets the focus back: the bar reads "DSIM's window was closed" and Save is off, as after aclose.
If no open arrives within 10 s, Zenith shows a full page: "Zenith was opened for DSIM but DSIM did
not send a project. Open it again from DSIM.", with a button that continues to the usual start
screen (the same address without ?host=). The same page, each with its own sentence, is shown at
once when ?host= is present but there is no opener ("… not from a DSIM window, so no project can
arrive", which also says to allow pop-ups), when an open from an allowed origin names another
protocol such as zenith-host/2 ("… the two are out of step. Reload DSIM and open Zenith from it
again."), and when the hostId is unknown.
2. Messages¶
Every message is a plain object sent with postMessage (structured clone, not a JSON string).
Host to Zenith¶
type |
fields | meaning |
|---|---|---|
open |
protocol: "zenith-host/1", project, open?, readOnlyRobot?, capabilities?, newAuto?, newAutoStart?, hostBuild? |
Here is the project. Required first; a later open from the pinned origin replaces the project (asking first if the open auto has unsaved edits). |
trace |
auto: string, trace: object |
A recorded run of auto, in the trace format core.parseTrace reads (Simulation). Laid over the plan when auto is the open auto; otherwise Zenith says so and ignores it. A trace posted right behind an open waits for that open to finish, so a host may send a project and a recorded run of it together. |
runFailed |
auto?: string \| null, reason: string |
Optional. The host could not run the auto a run asked for. Zenith stops waiting for that run's trace and shows "DSIM could not simulate auto is the name from the run; without it, or with null, the answer is for the run Zenith is waiting on. With no run waiting (a trace already came, or the wait timed out), or for another auto, it is ignored. reason must be non-empty text; control characters become spaces and only the first 500 characters are shown. A host that never sends it gets the 60 s timeout instead. |
saved |
name: string, ok: boolean, error?: string |
The answer to a save of name. ok: false fails the save and shows error. An answer nobody is waiting for is still shown on the status line. |
close |
none | The host has ended the session. Zenith shows it, fails anything pending, and disables Save. |
project is:
| field | type | notes |
|---|---|---|
name |
string, non-empty | The project's name, used when there is no hostLabel. |
hostLabel |
string, optional | The first line of the host bar, such as the host's name for the robot build. Without it Zenith uses robot.name. |
robot |
object or JSON text | A robot.json (File format reference). Validated on open. |
field |
object or JSON text | A *.field.json. Validated on open. |
waypoints |
object or JSON text, optional | A waypoints.json. |
autos |
Record<autoName, string> |
Each value is the auto file's text (an object is accepted and serialised). May be empty. |
open names the auto to open first. readOnlyRobot: true shows the robot as the host's ("This robot
comes from your DSIM build. Change it in DSIM's robot builder."). capabilities.simulate: true means
the host answers run with trace; without it there is no "Simulate in" button.
newAuto: true starts a new auto as soon as the project is open, even though autos is not empty
(see below). newAutoStart is where a new auto starts (see below). hostBuild is the host's own
build number (see Host builds). All three are optional additions to zenith-host/1:
a host that leaves them out gets exactly the behaviour it had before they existed, and a Zenith from
before them ignores them. Like every field, each must have the right type when present (newAuto a
boolean, newAutoStart an object as below, hostBuild a whole number from 0 up), or the open is
dropped.
Auto names. An auto name may be first-auto or first-auto.auto.json; Zenith's file name is the
name with .auto.json added when it lacks it, and every message back uses the host's own name. A name
may not contain /, \, a control character, or be . or ... A new auto made in Zenith takes the
style the host used (suffixed if any of the host's names were, bare otherwise), so a host that sent
bare names gets new-auto back.
If autos is empty, or the open says newAuto: true, Zenith starts a new auto from core's
newAutoFromTemplate (the same template
zenith new and the MCP tool build): the field's canonical alliance, a start at the host's
newAutoStart when it sent one, else at the waypoint called start or the first whose name contains
"start", else a legal wall start Zenith finds (else a PLACEHOLDER pose at the field centre), the
field's startRules.holds as its preload, and one placeholder leg. It is unsaved until Save sends it.
Where a new auto starts: newAutoStart. { alliance: "RED" | "BLUE", xIn, yIn, headingRad },
the start the person picked in the host, as a pose on that alliance's side (file-format frame,
inches and radians; each coordinate within 1000 in). Every new auto made in that session, from
newAuto or from the auto picker's "New auto", starts there, mirrored onto the field's canonical
alliance by the field's own symmetry when the host's robot plays the other one, with the provenance
SET BY HAND: the start picked in DSIM when this auto was made. It wins over a start waypoint and
over the legal start Zenith would find. Zenith does not move it to make it legal: a start the season
rules refuse is reported like any other. The next open replaces it, and an open without it
returns to the rules above.
Starting a new auto with newAuto. A host that stores autos by name should send the autos it
already has along with newAuto: true, rather than an empty autos. Zenith names the new auto
new-auto, or new-auto-2, new-auto-3 and so on when a name the host sent is taken, so two new
autos started one after the other never come back under the same name. open is ignored while
newAuto is true. Without the flag an empty autos still starts a new auto, as it always has.
Host builds¶
A host page stays open for days, so the page someone uses may be older than the host's latest deploy,
and hand Zenith an old field or robot. An open may carry hostBuild, a whole number the host raises
whenever what it sends in open changes. Each host's row in hostOrigins.ts may set minBuild, the
lowest build this version of Zenith expects from it. When hostBuild is below minBuild, the project
still opens, but the host cannot take what this Zenith would send back, so "Save to DSIM" and
"Simulate in DSIM" are off and the bar shows "DSIM needs an update" with a "Save a copy" button. The
badge's tooltip, the status line and both buttons' tooltips say "DSIM needs an update before it can
read autos from this Zenith, so Save to DSIM and Simulate in DSIM are off. Save a copy keeps your work.
This DSIM page is build 1, and this Zenith needs build 2 or later; if DSIM has been updated, reload it
and open Zenith from it again." When the auto that opens was written in an older format, the status
line adds that it was written in that format and that saving writes format 4, which this DSIM cannot
open until it is updated; an older-format auto opened later in the session says the same in its own
notice. Below 1100 px wide the update badge and the disabled "Simulate in DSIM" are not drawn, to
leave room for "Save a copy" and "Save to DSIM"; the status line and Save to DSIM's tooltip still say
why. With no hostBuild, or no minBuild for the host, nothing is said.
DSIM's row sets minBuild 2. DSIM build 1 vendors Zenith 0.1.1, which reads auto format 3 and refuses
the format 4 files this Zenith writes, so its Save and Simulate would fail inside DSIM. The DSIM
release that vendors this Zenith must send hostBuild: 2 (DSIM's host build number, HOST_BUILD)
and ship together with it. A DSIM page from before builds were numbered sends none, so it is not
called out of date.
Zenith to host¶
type |
fields | when |
|---|---|---|
ready |
protocol: "zenith-host/1" |
On load, to target origin "*". |
save |
name: string, text: string |
Save or Ctrl S. text is the canonical auto JSON (core.canonicalize("auto", …)), ending in a newline. |
run |
name: string, text: string |
"Simulate in DSIM", only when the host offered capabilities.simulate. text is the canonical JSON of the auto as it is in the editor, saved or not. |
bye |
none | On pagehide, best effort. |
Every message after ready is posted to the pinned origin, never to "*".
Timeouts¶
| wait | limit | on timeout |
|---|---|---|
open after load |
10 s | the full-page message above |
saved after save |
5 s | the save fails: "DSIM did not answer within 5 s, so … may not be saved in DSIM." |
trace after run |
60 s | "DSIM did not send a trace back within 60 s. Check DSIM's window, then try again." A runFailed ends the wait at once with the host's reason. |
A second save of the same name before the first is answered replaces it; the first fails.
3. Origin rules¶
apps/web/src/project/hostOrigins.ts maps each hostId to a display name and an allowlist:
hostId |
name | allowed origins |
|---|---|---|
dsim |
DSIM | https://www.playdsim.com, https://playdsim.com, https://alpha.playdsim.com, https://beta.playdsim.com, and any http://localhost[:port] or http://127.0.0.1[:port] |
The build may add origins with VITE_ZENITH_HOST_ORIGINS (comma separated, for a host's preview
deployments); entries that are not origins are dropped. Origins compare exactly after lower-casing
and dropping a trailing slash: scheme, host and port, no path. https://localhost is not a dev origin
and http://playdsim.com is not https://playdsim.com.
A message is accepted only when all of these hold:
event.source === window.opener: the window that opened Zenith sent it, not another frame, tab or Zenith itself.- Before the pin:
event.originis on the host's allowlist, and the message is a validopen. Any other message before the firstopenis dropped. - After the pin:
event.originis exactly the pinned origin. parseHostMessage(event.data)returns a message: the exact shape of section 2, with only the named fields copied over. Anything else is dropped silently.
4. The editor in host mode¶
- The project is a
HostBackend(ProjectBackendkindhost, source{kind: "host", hostId}), with a synthesizedzenith.json:autosDir: "autos",robot: "autos/robot.json",field: "autos/field.json"andwaypoints: "autos/waypoints.json"when waypoints came. It names no sim command, deploy target or codegen directory. An auto's ownrobotorfieldpath that names arobot.jsonor a*.field.jsonresolves to the host's robot and field; the host sent one of each. - The toolbar's right-hand group shows two short lines, the label and which alliance to draw for
("Draw for RED · DSIM mirrors for BLUE"), edged in that alliance's colour, with "Editing for DSIM"
and the whole sentence in its tooltip. Then come "Simulate in DSIM" when offered, and "Save to
DSIM", the screen's primary button.
Ctrl Sruns the same action. Below 1400 px wide "Simulate in DSIM" and "Save a copy" are their icons alone, and below 1100 px the two lines are left out, so "Save to DSIM" keeps its words and stays in view down to 900 px; there its tooltip says the two lines. While asavewaits for itssaved, the button reads "Saving to DSIM…" and is off. The toolbar's own Save and Propose buttons are not shown. - The Simulate dialog (
F5) offers "Simulate in DSIM" as the high-fidelity option, with the trace report and "Load trace"; the Full sim playback level's tooltip says to simulate in DSIM. - Hidden: open a folder, the bundled example, GitHub sign in and out, Propose, and the desktop app's sim, deploy and git actions. The start screen and the first-run example never appear, the last folder is not reopened, and no review route is followed. The Propose stop leaves the full tour.
- The auto picker (the title bar's auto name,
Ctrl O) lists the host's autos and has "New auto". - Kept: the editor, findings, estimate, instant sim, the three playback levels, the palette, the tour,
and "Save a copy to downloads". Once the session is over, whether the host sent
closeor its window closed, or while the host needs an update (Host builds), the bar has a "Save a copy" button next to the disabled "Save to DSIM", and the title bar reads "was in DSIM". - Unsaved edits still ask before the window closes.
5. Security notes¶
- Why a popup and not an iframe. The hosted build sends
X-Frame-Options: DENYandframe-ancestors 'none'(vercel.json), so no other site can frame it, and the password gate's session cookie isSameSite=Strict(api/_lib/session.ts), so a cross-site iframe would never carry it. A popup is a top-level window of Zenith's own origin; once the person is signed in to the gate inside it, its navigations are same-site and the cookie applies. - The gate and the first load.
SameSite=Strictalso withholds the cookie from a top-level navigation that another site started, which is whatwindow.openfrom the host is. The gate handles that in two steps. A cross-site navigation that arrives without the cookie (told apart bySec-Fetch-Site: cross-siteandSec-Fetch-Mode: navigate) gets a script-free page whose<meta>refresh reloads the same address; that reload is Zenith's own navigation, so the cookie goes with it and a signed-in person lands in host mode. Anyone still without a cookie gets the login page, which carries the interrupted address (a same-origin relative path, validated byapi/_lib/returnTo.ts) and returns to it after the login,?host=included. When that address carries?host=for a host Zenith knows, the login page also says so: "DSIM opened Zenith; sign in to edit your auto." A?host=value Zenith does not know adds no line. Zenith then postsreadyagain, and the host answers withopenas it would for a first load. The public build has no gate and is not affected. - Never postMessage secrets. Nothing Zenith posts carries a token, a cookie or anything from
another project:
readyis empty, andsaveandruncarry the one auto's canonical text. Host mode never signs in to GitHub. A host must not put credentials inopeneither; Zenith would not use them. - Pinning.
readygoes to"*"because it is empty. Everything after it goes to the pinned origin only, so a navigation of the opener to another site cannot receive an auto. - The host validates what it saves.
save.textis canonical auto JSON as Zenith wrote it, but the host must still parse and validate it (for example with@horizon36596/zenith-schema'sparseAuto) before it stores or runs it, and answersavedwithok: falseand a reason when it refuses. Zenith treats every host message the same way: shape-checked byparseHostMessage, then the robot, field and autos parsed through the schema like any file from a folder.