A single page that maps every layer of the cross-window connection bridge end-to-end. You shouldn't need this to build a plugin — the public APIs in javascript-reference.md are sufficient. Read this when you're debugging a stuck handshake, building unusual integrations (cross-origin frames, custom transports), or contributing to the shell itself.
PARENT SHELL IFRAME
(one per browser tab) (one per window)
──────────────── ────────────
Plugin code
│
│ wp.os.connect( id, opts )
▼
┌─────────────────────────────┐ ┌──────────────────────┐
│ src/connection/index.ts │ │ iframe-bridge.js │
│ ──────────────── │ ── handshake ──────────▶ │ (or inline bridge │
│ • createConnectionBridge │ ◀── handshake-ack ── │ from includes/ │
│ • _connections (Map) │ │ render/chromeless- │
│ • _connectionsByTarget │ ── publish ───────────▶ │ bridge.php) │
│ • _syntheticIframes │ ◀── publish ──────── │ • wp.os.iframe │
│ • routeIncomingFromIframe │ ── disconnect ────────▶ │ .publish │
│ • handleConnectionRequest │ ◀── disconnect ───── │ .subscribe │
└────────────┬────────────────┘ │ .onConnection │
│ │ .requestConnection│
│ window.__openStationConnectionBridge │ │
│ (side-channel install) │ │
▼ │ │
┌─────────────────────────────┐ │ │
│ src/window/iframe-bridge.ts │ │ │
│ handleWindowMessage │ ◀── postMessage events ── │ │
│ (per-Window listener) │ │ │
└────────────┬────────────────┘ │ │
│ │ │
│ — OR for native windows with `iframeContent`: │
│ │ │
┌────────────▼────────────────┐ │ │
│ src/native-windows.ts │ │ │
│ buildIframeContentRender │ ◀── postMessage events ── └──────────────────────┘
│ (synthesised iframe holder)│
│ • registerSyntheticIframe │
│ • forwards bridge-* msgs │
│ • shell-managed lifecycle │
└─────────────────────────────┘
Two protocol families flow over the same postMessage boundary:
- Window-self channel (
os-window-*) — the unifiedWindow.send/onAPI. The first thing most plugin code reaches for. Single channel, no handshake, scoped to one window's content. - Connection bridge (
os-bridge-*) — multi-connection peer-to-peer with handshakes, used bywp.os.connect()/wp.os.iframe.requestConnection().
Both sides validate event.origin against window.location.origin (or the iframe URL's resolved origin for iframeContent synthesised iframes); messages without a recognized prefix are dropped.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-window-send |
parent → iframe | { channel, payload } |
Posted by Window.send( channel, payload ). The iframe-side bridge fires every wp.os.on( channel, cb ) subscriber. |
os-window-publish |
iframe → parent | { channel, payload } |
Posted by wp.os.send( channel, payload ) inside the iframe. The parent forwards to every Window.on( channel, cb ) subscriber for this window. |
Native (non-iframe) windows skip postMessage entirely — Window.send and the render's windowApi.send reach the parent / native channel-bus registries directly. Plugin authors don't need to know the window's render strategy; the framework picks the right delivery path.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-content-identity |
iframe → parent | { identity: WindowContentRef | null } |
Which object this admin page shows — { type, id, label?, root?, links?, related? }, resolved server-side in real admin context (post/page/CPT editors are roots and carry their content's internal hyperlinks as links; comment-edit and attached-media screens arrive pre-rooted at their parent post; the openstation_window_content_identity PHP filter extends detection). related carries the ready-to-open navigation targets behind the title bar's "Related" button — { id, group, label, url, groupLabel?, icon?, count? } entries built for posts/pages and filterable via openstation_window_related_entities. Feeds wp.os.relations and the window-link visuals. |
Emitted on every chromeless page load, including identity: null — a full-page navigation away from an identified screen must clear the stale identity, and since every iframe navigation re-runs admin_footer, that same emission doubles as the re-announce-on-navigate path. It fires at the very TOP of the bridge script (right after the top-frame escape hatch, before any feature block) so a page-specific runtime failure elsewhere in the bridge can never cost the shell its window relations — unlike os-ready, which intentionally posts last.
Re-announced after block-editor saves: Gutenberg saves over REST without navigating, so the bridge also watches the core/editor save lifecycle and, after every real (non-autosave) save, refetches a server-recomputed identity from GET /desktop-mode/v1/content-identity?post={id} (capability-gated to edit_post; both identity filters run there with $screen = null) and posts this same message again. The parent engine diffs repeats, so identical re-announcements are free. See docs/examples/window-links.md.
Save broadcast: on the same save-success edge the watcher also posts an upstream { type: 'os-broadcast', topic: 'os.<postType>.changed', payload: { source: 'editor', action: 'created' | 'updated', ids: [ postId ] } } to the parent, which fans it out to every window — list windows showing that type refresh instantly. action is 'created' exactly when the post was still new on the tick the save started. This is the block editor's leg of the content-change realtime layer (includes/content-changes.php); form-POST → redirect flows are covered server-side by the chromeless-footer emitter instead. The iframe-side consumer is the soft-reload handler: edit.php / upload.php / edit-comments.php are matched generically by list type, and non-standard list screens (the HPOS wc-orders list) are declared via the PHP-printed /*__OPENSTATION_SOFT_RELOAD_EXTRAS__*/ placeholder, filterable server-side through openstation_soft_reload_rules.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-bridge-handshake |
parent → iframe | { connectionId, targetWindowId, topics } |
Open a new connection. Iframe must ack before parent flushes its message queue. The targetWindowId is the host window's id — the iframe stores it for wp.os.iframe.windowId / whenWindowId(). |
os-bridge-handshake-ack |
iframe → parent | { connectionId } |
Iframe acknowledges. Parent fires HOOKS.CONNECTION_OPENED + flushes. |
os-bridge-publish |
both ways | { connectionId, topic, payload } |
Pub/sub message. Wildcard subscribers ('*') see every topic. |
os-bridge-disconnect |
both ways | { connectionId } |
Tear the connection down. Idempotent. |
os-bridge-connection-request |
iframe → parent | { requestId, topics } |
wp.os.iframe.requestConnection(). Parent fires HOOKS.IFRAME_CONNECTION_REQUEST filter; default accept. |
os-bridge-connection-ack |
parent → iframe | { requestId, accepted, connectionId? | reason? } |
Reply to a request — accepts hand back the new connection id, rejects supply a reason. |
When the connection bridge targets a native window, no postMessages are exchanged — connect() opens synchronously and conn.send/subscribe route through the same in-process channel bus that powers Window.send/on. Same onOpen / isOpen / disconnect semantics, no observable difference to the caller.
When the user drags a file from the host operating system onto a chromeless admin iframe, the chromeless bridge (and the standalone iframe bridge) intercepts the drop event before the browser's default handler navigates the iframe, and forwards the raw File[] to the parent shell.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-file-drop |
iframe → parent | { files: File[], x: number, y: number } |
Native-OS file drop captured inside the iframe. Same-origin only — postMessage preserves File identity. The parent's OsFileDropManager resolves the source iframe's data-window-id via MessageEvent.source and routes the files through the drop pipeline. |
The forwarder listens in bubble phase at the iframe's document, so any in-page drop receiver runs first and gets the chance to claim the drop. Two bail conditions, in order:
- Curated allowlist —
.components-drop-zone,[data-drop-zone],.uploader-window,.media-frame-contentalways yield, so Gutenberg's media uploader and the legacy media library keep working as before even on edge cases that skip the spec dance. event.defaultPrevented === true— any inner handler that calledpreventDefault()ondragoverordropis signalling ownership per the HTML5 drag-and-drop contract. The forwarder yields. Third-party plugin drop zones (e.g. "Administrador de archivos WP") that already work in classic admin keep working untouched inside OpenStation iframes — no opt-in required.
Only drops where neither bail fires (the empty page background, or an inner handler that never called preventDefault()) escalate to the shell.
Native drag events don't cross iframe boundaries, so when the user holds any drag (an OS file, an image lifted off another admin page, a text selection) over an iframe window, the parent shell can't see the hover. Both bridges (inline chromeless + standalone) forward a throttled heartbeat while dragover fires inside the iframe, so the shell's focus-on-drag-hover module can raise the hovered window after its ~250 ms dwell (see the os.window.focus-on-drag-hover filter in javascript-reference.md).
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-drag-hover |
iframe → parent | { payloadType: 'os-file' | 'external' } |
"A drag is currently hovering me." Throttled to one message per 150 ms. Purely observational — the forwarder never calls preventDefault() and carries no coordinates or payload data; the parent resolves the hovered window from MessageEvent.source (the sender iframe is the hovered window). The parent resets its hover state when heartbeats stop (~1 s watchdog), so no end message exists or is needed. |
Pointer events don't cross iframe boundaries either, so the shell goes blind to the cursor the moment it enters a window. Anything shell-side that needs the real cursor position while it's over window content — today, Mio's gaze (mio.md) — arms the iframe and rebases what comes back through the iframe element's bounding rect.
Unlike the drag-hover heartbeat, this one is opt-in. It runs on every mouse move, so a shell with no consumer must not pay for it: the forwarder installs a no-op listener that returns immediately until the parent enables it.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-pointer-track |
parent → iframe | { enabled: boolean } |
Arm / disarm. Broadcast to every live iframe when the first consumer starts, re-sent to any frame that announces os-bridge-ready (so a frame re-arms after every navigation), and broadcast with enabled: false when the last consumer tears down. |
os-pointer-move |
iframe → parent | { x: number, y: number } |
The cursor in the iframe's own client coordinates. Throttled to one message per 40 ms (~25 Hz); the consumer interpolates. Coordinates only — no target element, no event object, nothing about the page content. Passive capture-phase listener; never calls preventDefault(). |
Both bridges install the forwarder behind the shared __openStationPointerForwarderInstalled sentinel, so a page carrying the inline chromeless bridge and the standalone bundle only forwards once.
Parent side: the consumer resolves the sending frame by matching MessageEvent.source against each <iframe>'s contentWindow (cached in a WeakMap), then adds that element's left / top. A message from a frame it can't resolve is dropped rather than guessed at.
Before tearing down an iframe-backed (non-native) window, Window.close() gives the page inside a chance to veto — the same protection a real browser tab close gets from the page's beforeunload handler, which a same-origin admin iframe never triggers on its own (there's no real navigation happening).
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-bridge-beforeunload-query |
parent → iframe | (none) | Sent once close() is called on a window whose bridge has announced readiness (os-ready already fired). |
os-bridge-beforeunload-response |
iframe → parent | { prevent: boolean, message?: string } |
Reply. prevent: true means the iframe's own beforeunload handling (window.onbeforeunload or an addEventListener('beforeunload', …) listener) set a message or called preventDefault(). |
Flow:
close()checkswin._iframeBridgeReady— a window whose iframe never announced readiness (still loading, or a non-openstation page) skips the query entirely and destroys immediately, same as before this feature existed.- Otherwise it posts the query, sets
win._closePending = true, and returns without destroying — a 500ms safety timer (win._iframeCloseTimeout) forces the close through if no response arrives (a hung or unresponsive iframe can't block closing forever). - Both bridge implementations (the inline PHP script in
includes/render/chromeless-bridge.phpand the standalonesrc/iframe-bridge-standalone.ts) answer the query the same way: synthesize abeforeunloadEvent, invokewindow.onbeforeunloadwith it if set, then (if not already prevented) dispatch a realbeforeunloadevent soaddEventListener('beforeunload', …)listeners run too. Whichever mechanism setsevent.returnValueor callspreventDefault()flipsprevent: true, carrying the handler's message string through if one was set. - On the parent side,
prevent: falsedestroys the window immediately.prevent: trueshows a<os-confirm-dialog>(title = the iframe's message, or a generic fallback) — the window is only destroyed if the user confirms.
Native windows are untouched — they still use the synchronous os.native-window.before-close filter (see javascript-reference.md), not this postMessage round-trip.
When the user clicks an editor window's Preview (eye) title-bar button, the shell asks the editor page to autosave first, so the front-end preview about to open reflects on-screen content — the same thing Gutenberg's own Preview button does. Deliberately not named os-bridge-*: that prefix is routed into the connection-bridge registry; this is a standalone request/response pair.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-editor-autosave-request |
parent → iframe | { requestId: string } |
"Autosave whatever you're editing, then answer." Sent by src/editor-preview/autosave.ts with a 10 s parent-side timeout. |
os-editor-autosave-response |
iframe → parent | { requestId, status: 'saved' | 'no-editor' | 'not-dirty' | 'error', previewUrl?: string } |
Reply, correlated by requestId. previewUrl is only present on the Gutenberg save-for-preview path, and only when same-origin. |
The answerer lives in the standalone bridge only (installEditorAutosaveHandler() in src/iframe-bridge-standalone.ts — installed on every admin page for OpenStation users, chromeless included, outside the bundle's double-install guard so it runs even where the inline chromeless bridge owns wp.os.iframe). Editor detection, in order:
- Gutenberg (
wp.data.select( 'core/editor' )resolves) — prefersdispatch( 'core/editor' ).__unstableSaveForPreview(), exactly what core's Preview button calls: it autosaves when needed and resolves to the freshest preview link, returned aspreviewUrl. Fallback when that action is absent:isEditedPostAutosaveable()false →not-dirtyimmediately; otherwiseautosave()watched to completion viawp.data.subscribe(8 s best-effort backstop answerssavedanyway). - Classic editor (
wp.autosave.server) —triggerSave()+ jQuery'safter-autosaveevent, with a 5 s best-effort backstop. - Neither —
no-editor, immediately, so the parent never waits on a page with nothing to save.
On the parent side every non-saved outcome degrades gracefully: the preview opens at the identity's server-computed previewUrl (the last saved/autosaved revision), with a warning toast only on error.
Live-preview watch — while the preview companion is open, the shell also asks the editor page to watch its own content, because typing detection can only live iframe-side (keystrokes never cross the frame boundary):
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-editor-live-watch |
parent → iframe | { watchId: string, debounceMs: number } |
Start watching. debounceMs (clamped 500–30000) is the settle window after the last edit. A re-watch with the same watchId replaces the previous watch. |
os-editor-live-unwatch |
parent → iframe | { watchId: string } |
Stop watching (sent on pairing teardown; best-effort — the watch dies with the page anyway). |
os-editor-live-saved |
iframe → parent | { watchId: string, previewUrl?: string } |
"The editor settled and autosaved — refresh the preview." previewUrl as in the autosave response. |
Gutenberg watch mechanics: wp.data.subscribe + reference comparison of core/block-editor's block list and the edited title (every real edit replaces those references). A completing save ALSO churns those references (the save response normalizes the entity and resyncs the block list), and drafts autosave in place — Gutenberg considers them forever autosaveable — so without guards the watcher's own save reads as a fresh edit and loops. Three guards break the feedback: (1) churn arriving while isSavingPost()/isAutosavingPost() is true — and on the settle tick right after — is absorbed into the baseline without scheduling; (2) a reference change only schedules while isEditedPostDirty() (user edits set dirty synchronously; a draft's completed in-place autosave clears it); (3) the settle itself bails when isEditedPostAutosaveable() is false (published posts stay dirty relative to published content after an autosave revision — nothing new to save, nothing to refresh). On settle it also defers while a save is in flight (1 s retry), then autosaves via __unstableSaveForPreview(). Classic editor: no reactive store — the watcher just announces after each of core's own after-autosave events.
Sidebar Window is a real shell-managed sibling attached to the source editor's inline end. The source keeps its original iframe, editable post canvas, and floating state. A transient iframe-backed companion named ${sourceWindowId}--sidebar-window opens at the source Gutenberg sidebar's measured width (320px fallback), aligned to the source's top edge and matching its height. The shell shifts or minimally narrows the source only when necessary to keep the whole connected pair on the desktop. It loads the source's current post URL with openstation_sidebar_window=1. After that companion announces os-ready, the shell selects the same complementary-area id and applies detached presentation CSS that hides the companion's duplicate header, canvas, and footer. The remaining Gutenberg sidebar fills the companion beneath narrow connected OpenStation window chrome. Neither window is expanded into a full-screen split.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-editor-sidecar-source |
parent → source iframe | { parked: boolean } |
Park or unpark the source's complementary area. Parking remembers the exact active area before closing it; unparking restores that area. Duplicate park requests are idempotent and do not overwrite the remembered id with null. |
os-editor-sidecar-source-area |
source iframe → parent | { area: string } |
While parked, report a complementary area opened through Gutenberg's own Settings or plugin-sidebar controls. The source immediately closes its local copy again; after validating the exact source iframe and area id, the parent switches the existing companion. |
os-editor-sidecar-set |
parent → companion iframe | { active: boolean, detached?: boolean, area?: string | null } |
Activate the requested area. The managed companion receives { active: true, detached: true, area } only after its iframe is ready; detached gates sidebar-only CSS. active: false remains the iframe handler's explicit cleanup path. |
os-editor-sidecar-state |
companion iframe → parent | { active: boolean, available: boolean, width: number, area: string | null } |
Report whether the companion exposed a compatible complementary area. While active, area keeps the companion's visible selector synchronized with Gutenberg's current panel. active: false or available: false makes the shell close the sibling, unpark the source, clear persistence, and repaint the source title-bar button. width is the bridge's legacy inner-sidebar width diagnostic, not the outer OpenStation window width. |
Lifecycle:
- After Gutenberg boots, the shell's capability probe finds
core/editorpluscore/interfaceorcore/edit-post, then paints the Sidebar Window button in the outer OpenStation title bar. Classic Editor and non-editor pages do not match. - Clicking it opens a shell
os-context-menucontaining Post settings, Block settings, the active area, and plugin areas discovered from Gutenberg's pinned/menu togglearia-controlsidentifiers. Choosing an area captures that identifier, measures the source sidebar width, persists the source window id, and sendsos-editor-sidecar-source { parked: true }. The source stays in normal floating state. The shell opens${sourceWindowId}--sidebar-windowon the same virtual desktop at that measured width (320px fallback), with its top and height matched to the source and its edge placed directly against the source's inline end. If the pair would cross a desktop edge, the source is shifted or minimally narrowed to fit. The source iframe is not replaced, reparented, maximized, or snapped into a half-screen tile. - The companion loads the same post URL plus
openstation_sidebar_window=1. The query marker identifies it as a companion and prevents the Sidebar Window button from recursively appearing there. - The shell waits for the companion's
IFRAME_READY/os-readysignal, then sendsos-editor-sidecar-set { active: true, detached: true, area }. The iframe usescore/interface(or legacycore/edit-post) to open that area.htmlandbodygainos-editor-sidecar-detached; CSS hides the duplicate editor canvas and lets.interface-interface-skeleton__sidebaroccupy the whole iframe. OpenStation's outer window supplies the visible title bar, close button, border, and connected drag surface; its resize/maximize/fullscreen controls are suppressed so the pair keeps the source sidebar geometry. The shell also mounts an always-visible Sidebar panel selector in the companion's after-title-bar slot. The old injected faux title bar is gone. - Choosing Post, Block, or a discovered plugin area from the companion's selector sends a fresh active
os-editor-sidecar-setto the existing companion; the iframe switches through the same complementary-area store without opening another window. The source title-bar chooser remains available as the launcher and can also switch the active companion. While connected, clicking Gutenberg's own Settings, Jetpack, or other plugin-sidebar buttons in the source top bar routes their complementary-area id to that same companion instead of reopening the parked source sidebar. All three paths reuseos-editor-sidecar-setand keep the selector's current value synchronized. Choosing Close Sidebar Window from the source title-bar chooser, closing the sibling, or closing Gutenberg's sidebar inside the sibling clears the active id, sendsos-editor-sidecar-source { parked: false }, restores the exact source area, and repaints the source button. The source window needs no size/state restoration because opening the attached sibling did not retile it. Closing the source destroys its companion immediately. - The companion has
ephemeral: true, so it is excluded from session snapshots and never restored independently. The parent stores at most 64 active source ids inopenstation.editorSidecar.activeWindows; when a source editor itself is session-restored, the shell waits for that source iframe to become Gutenberg-ready and creates a fresh ephemeral companion beside the source's restored floating geometry.
The protocol intentionally transports only area identity and lifecycle state—never block content, editor state, or rendered sidebar markup. A real sibling cannot reuse the source iframe's React DOM: an iframe cannot paint outside its rectangle, and adopting arbitrary PluginSidebar nodes into another document would break their React root, delegated events, contexts, portals, styles, owner-document queries, and focus behavior. The implemented compromise is therefore a second Gutenberg document at the same post URL. It gives the sidebar its normal plugin runtime in a genuine shell window, but it also creates a separate core/editor / block-editor registry, undo history, dirty flag, autosave timer, post-lock view, and save lifecycle. OpenStation does not synchronize those stores, so integrations must be tested for concurrent-save and stale-state behavior; plugin panels that persist through independent REST endpoints are generally less coupled than panels that only mutate the companion editor store.
Internal DOM sniff points while Sidebar Window is active:
html.os-editor-sidecar-activeandbody.os-editor-sidecar-activemark the active companion document.html.os-editor-sidecar-detachedandbody.os-editor-sidecar-detachedgate the companion-only layout that removes its duplicate header, content canvas, and footer..interface-interface-skeleton__sidebaris stretched to the companion iframe's full body. There is no.os-editor-sidecar-window-chrome; visible chrome belongs to the outer OpenStation window..os-window__slot--after-titlebarhosts the companion's always-visible<os-select class="os-editor-sidecar-panel-select" label="Sidebar panel">. Its current value tracks the selected complementary-area id.[aria-controls]values matching Gutenberg's complementary-area identifier shape expose choices for the source launch chooser and companion selector. While connected, an iframe-localwp.data.subscribeobserves the source's active complementary area so Gutenberg's native Settings and plugin-sidebar controls can switch the companion without relying on their DOM shape. Thecore/interfacestore supplies the active id but has no public registered-area enumeration selector.--os-editor-sidecar-widthand.os-editor-sidecar-resizerremain part of the bridge's legacy non-detached width contract. The inner resizer and outer resize handles are hidden in a connected detached companion because its width follows the source sidebar measurement.openstation_sidebar_window=1marks the companion URL; the deterministic window id is${sourceWindowId}--sidebar-window.openstation.editorSidecar.activeWindowsin the parent shell's local storage carries at most 64 ids. It is preference state, not a second session snapshot;IFRAME_READYis what reconnects it to restored windows.
Every chromeless iframe runs its own Heartbeat, and each heartbeat response carries core's wp-auth-check boolean (attached server-side, independent of whether the modal JS is loaded — chromeless iframes have the modal suppressed so the parent shell owns the single login prompt). When an iframe's heartbeat sees the flag flip false → true — the user re-authenticated somewhere — the bridge nudges the parent before reloading itself, so the shell's recovery (src/auth-recovery/index.ts) starts immediately instead of waiting out the parent's own heartbeat schedule.
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-reauth-detected |
iframe → parent | (none) | "My heartbeat just saw the session come back." The parent forces a tick of its own (fresh nonces ride it), sweeps a reload over the other iframes, and fires os-auth-restored (see javascript-reference.md). Recovery is cooldown-gated, so the one-message-per-open-window fan-in collapses into a single run. |
- Plugin calls
wp.os.connect( 'edit-post', { topics: [ 'gutenberg:content' ] } ). - Connection bridge mints a
connectionId(os-conn-N), stores the connection in_connections, indexes it by target window in_connectionsByTarget. - Bridge looks up the iframe via
_syntheticIframes.get( id ) ?? manager.getById( id )?.iframe. - Bridge
postMessagesos-bridge-handshaketo the iframe'scontentWindowwithtargetOrigin = INITIAL_ORIGIN. - Plugin code calls
conn.send( 'foo', payload )before the ack arrives — message goes into the connection'squeue, nopostMessageyet. - Iframe's bridge handler receives the handshake, stores the connection in its own
connectionsmap, postsos-bridge-handshake-ackback. - Parent's
routeIncomingFromIframereceives the ack, dispatches to the connection's_handleIframeMessage, which:- Sets
isOpen = true. - Fires
HOOKS.CONNECTION_OPENEDwith{ connectionId, targetWindowId, topics, connection }. Theconnectionfield is the liveWindowConnection— plug in.subscribe()directly from the hook handler without an extrawp.os.getConnection(id)round-trip. - Calls
opts.onOpen?.(). - Drains the queue with
flushQueue()— every queued message becomes a realpostMessage.
- Sets
- Iframe receives the publishes, looks up subscribers in
subs, calls each in turn.
- Iframe-side calls
wp.os.iframe.requestConnection({ topics: [ 'wpglp:content' ] }). - Iframe bridge mints a
requestId, registers a one-shot ack listener with a 5-second timeout, postsos-bridge-connection-requestto the parent. - Parent's
handleWindowMessage(or theiframeContentsynthesised render's listener) sees the bridge-prefixed message, callsrouteIncomingFromIframe( data, win.id ). routeIncomingFromIframerecognisesconnection-requestand callshandleConnectionRequest( windowId, requestId, topics ).- The shell runs
applyFilters( HOOKS.IFRAME_CONNECTION_REQUEST, true, { windowId, requestId, topics } ). Default value istrue(accept). Plugin code can returnfalseto reject, or{ topics: [ ... ] }to accept while narrowing. - On accept,
connect( windowId, { topics: finalTopics } )opens a parent-side connection. The parent then postsos-bridge-connection-ack { requestId, accepted: true, connectionId }back. - Iframe's ack listener resolves the original
requestConnection()promise with{ id, topics }and callsopts.onOpen?.(). - The handshake completes normally between this new connection and the iframe (the iframe's existing
os-bridge-handshakelistener picks it up and acks).
A native window registered via wp.os.registerWindow({ iframeContent }) is special: Window.iframe is null (only chromeless wp-admin pages set that), but the body contains a real <iframe> the shell created.
buildIframeContentRender:
- Creates the
<iframe>. - Calls
registerSyntheticIframe( windowId, iframe )— adds an entry to_syntheticIframesso the connection bridge's iframe lookup finds it. - Installs a
messagelistener that:- Validates
event.source === iframe.contentWindowandevent.originmatches the iframe URL's origin. - Forwards bridge-prefixed messages (
os-bridge-*) torouteIncomingFromIframe( data, windowId )so the iframe can participate inconnect()traffic. - Forwards every message (bridge or not) to
cfg.onMessage?.()so plugins that want raw access still get it.
- Validates
- On window close, the cleanup chain (passed through
onClose) callsunregisterSynth()and removes themessagelistener, so closed windows don't leak.
windowId throughout is the live instance id, resolved from the window root (id="wp-window-<windowId>") the render callback mounts into — not the id passed to registerWindow(). The two differ whenever manager.open() allocates a suffixed instance (chat → chat-2, e.g. opening the same registered window on a second virtual desktop). Anything keying off the registered id would attach the second instance's iframe, readiness signal, and channel dispatch to the first instance.
The chromeless bridge intercepts every same-origin <a href="/wp-admin/…"> click inside an iframe and lets the parent shell decide where the navigation should actually land. The decision lives in the parent because the iframe doesn't know the shell's window slug rules (which query params are identity-bearing, which URLs are remapped to a native window, which already-open window owns the destination, and so on).
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-iframe-admin-link |
iframe → parent | { url, label } |
Posted from the chromeless bridge's link interceptor for every admin-internal click that survived the modifier-key / target / download filters. label is the clicked link's visible text (falling back to its title / aria-label, truncated to 80 chars). The bridge preventDefaults the click first; the parent owns the navigation. |
Parent dispatch (in src/window/iframe-bridge.ts, wired by bindAdminLinkDispatch in desktop.ts):
-
Native-window remap — the URL goes through
tryNativeUrlRemap. On a hit the parent opens the native window and closes the source iframe so the brief in-flight nav never paints. -
Same-slug click —
deriveWindowId(url, adminUrl)matches the source window'sbaseIdor the slug its iframe is currently showing (getCurrentUrl()). The parent callsiframe.contentWindow.location.assign(url), which navigates the iframe in place AND adds a session-history entry. Pagination, list filtering, and per-window tab strips ride this path.Both slugs count because they diverge as soon as the iframe navigates in place: clicking the Menus tab in the Appearance window points the iframe at
nav-menus.phpwhile the window keepsbaseId: themes-php. Matching onlybaseIdwould classify the Menus screen's own tab links as cross-page and spawn a fresh window per click. The live slug only ever widens the same-page set — it never turns an in-place navigation into a new window, so a window that has navigated away still treats a link back to its landing page as in-page. -
Cross-slug click — slug matches neither the source window's
baseIdnor its live URL. The parent callswindowManager.open({ id, baseId, url, title, icon })with title/icon copied from the matching dock entry. When no dock tile owns the destination, the title falls back to thelabelfrom the message (the clicked link's visible text), then to the derived slug as a last resort. The source iframe is left untouched, so the user keeps both contexts. When a window for the destination slug is already open,open()'s URL-aware reuse applies: if the clicked URL isn't what that window is showing (nor its home / dock landing URL), the existing window's iframe navigates to it in place — so an action URL like the post-installplugins.php?action=activate&plugin=…&_wpnonce=…link actually runs instead of being dropped by a bare focus.
Modifier-key clicks (cmd / ctrl / shift / alt, middle-click, target="_blank", target other than _self, download attribute) short-circuit the bridge's interceptor entirely — the browser's native open-in-new-tab path runs unchanged.
Anchors carrying core's aria-button-if-js class are left alone. That class is core's marker for "this anchor is really an in-page button, the href is only the no-JS fallback", and the script that owns the button (media-grid.js, wp-lists, tags.js, updates.js) preventDefaults it in bubble phase. Since the bridge's interceptor is capture-phase it would otherwise win the race and hand the user the fallback URL: on the Media Library grid, clicking Add Media File opened a window for media-new.php while media-grid.js expanded the inline uploader in the Media window behind it.
The class doesn't promise a handler, though. The Media list table stamps it on Trash / Restore / Delete Permanently (.submitdelete) and binds nothing, so the href really is the navigation. Those still get yielded, but the interceptor first rewrites the href to carry _wp_http_referer=<this page>, the iframe-side twin of the parent's stampSourceReferer(), which no longer sees these clicks. Without it, a Referrer-Policy of strict-origin or tighter downgrades the Referer to the bare origin, post.php matches it against neither post.php nor post-new.php, and the post-delete redirect lands on the site front page inside the window instead of back on the media list. The destination keeps rendering chromeless via the Sec-Fetch-Dest: iframe fallback in openstation_is_chromeless_request().
Links owned by core's wp-admin/js/updates.js are also left alone: the card-style install-now / update-link / update-now / delete-plugin / delete-theme / install-theme buttons, the plugins-list-table row Delete ([data-plugin] a.delete), and the network themes row Delete (.themes-php.network-admin a.delete). updates.js preventDefaults these itself and runs an in-place AJAX operation; if the bridge hijacked them, the parent-driven navigation would race the AJAX call (a wp.updates.beforeunload "Leave site?" prompt followed by the no-JS fallback screen for an already-deleted plugin).
Forms submit through a separate submit listener that only rewrites the action URL (to keep openstation_chromeless=1) and never preventDefaults. Same-origin form posts to a different page would currently navigate the iframe in place; if that becomes a UX problem it can join this protocol as a os-iframe-admin-form-submit message.
The classic Users list table (users.php, rendered as a chromeless iframe) grows a "View activity footprint" row action — added server-side by openstation_user_footprint_row_action (see hooks-reference.md). Clicking it opens the target user's GitHub-style activity footprint inside the pinned site folder native window, without closing the Users list.
This deliberately does NOT reuse the admin-link path above: that path closes the source iframe on a native-window remap hit (it models a navigation away). A row action is an auxiliary peek, so it gets its own message.
Carrier contract. The row-action link declares the target on the anchor itself:
| Attribute | Value |
|---|---|
data-os-footprint |
Target user id (positive integer). Required — its presence is what the bridge sniffs. |
data-os-footprint-name |
Display name, used to seed the footprint breadcrumb before the REST payload resolves. Optional. |
href |
A real user-edit.php?user_id=N / profile.php URL — the graceful fallback followed only when JS is off or on a modifier / middle click. |
| Type | Direction | Carries | Purpose |
|---|---|---|---|
os-open-user-footprint |
iframe → parent | { userId: number, userName: string } |
Posted from the chromeless bridge when a [data-os-footprint] link is clicked (checked before the admin-link classifier, so the fallback href is never followed inside the shell). The parent opens / focuses the site folder window on that user's footprint route and leaves the source window open. |
Parent dispatch (src/window/iframe-bridge.ts): calls openUserFootprintWindow( { userId, userName } ) (src/my-wordpress/footprint-target.ts), which stashes the target in the desktop-mode/my-wordpress/footprint-target shared store, then opens the window via wp.os.openWindow. Cold-start safe: the site-folder bundle reads the target on mount and subscribes for re-targets while it's already open. See javascript-reference.md for the public wp.os.myWordpress.openUserFootprint.
| Hook | Kind | Status | Payload |
|---|---|---|---|
os.connection.opened |
action | Experimental | { connectionId, targetWindowId, topics, connection? } — connection (the live WindowConnection) is present for iframe-target opens; native-target opens currently omit it |
os.connection.closed |
action | Experimental | { connectionId, reason: 'disconnect' | 'window-closed' | 'navigated' } — 'navigated' is reserved in the type union; no code path emits it yet, so today only the first two are observed |
os.connection.message |
action | Experimental | { connectionId, topic, direction: 'in' | 'out' } — high-volume, keep subscribers cheap |
os.iframe.connection-request |
filter | Experimental | boolean | { topics: string[] } ← (accept, ctx) — return false to reject, an object to accept-with-narrowing |
When something's not working:
window.__openStationConnectionBridge— installed bydesktop.tson init. If it'sundefinedin DevTools, the shell hasn't booted yet (or you're in a frame that's not the parent shell).window.wp.os.iframe— the iframe-side API. If it'sundefinedinside an iframe, the bridge script wasn't loaded — for chromeless wp-admin pages it's inline; foriframeContent: { bridge: true }it's auto-injected after load; for any other same-origin iframe enqueueos-iframe-bridge.window.location.origincheck — every postMessage in both directions filters on this. A common cause of "messages don't arrive" is a shell mounted onhttps://example.testand an iframe loaded fromhttp://example.test(different origin); same domain ≠ same origin.event.source === iframe.contentWindowcheck — even same-origin, a foreign caller postingos-bridge-*messages from somewhere ELSE in the parent will be silently dropped.
Every bridge listener in this repo validates event.origin, and three of the four are strictly same-origin:
src/iframe-bridge-standalone.ts—parentOrigin = window.location.origin.src/connection/index.ts—INITIAL_ORIGIN = window.location.origin.src/drag-bridge.ts—this._origin = window.location.origin.
The fourth listener — src/native-windows.ts's iframeContent message handler — validates against the iframe URL's resolved origin (falling back to the shell origin for relative / invalid URLs) and forwards os-bridge-* messages into the connection registry. A native window configured with a cross-origin iframeContent.url therefore grants that foreign origin bridge access for that window: only point iframeContent.url at origins you trust.
Each postMessage's targetOrigin is set to its own captured origin, and each 'message' listener rejects events whose e.origin doesn't match. Cross-origin parents silently drop every bridge message — no warn, no fallback. This is deliberate: the bridge payloads feed into drop handlers that insert HTML and into hook subscribers that may execute code, so widening the trust boundary would create a clear XSS surface.
Concretely, the bridge will not operate in these contexts:
- Cross-origin parent — OpenStation loaded in an
<iframe>whose parent is on a different origin (top-level admin opened outside the shell, or shell embedded in a foreign host). - Foreign-origin Gutenberg
srcdoccanvas — by default the editor-canvas iframe inherits the parent's origin (works fine), but some plugin / theme combos overridesrcto a foreign URL. - Sandboxed iframes (
<iframe sandbox>withoutallow-same-origin) — the iframe's origin is"null", which never matches. - PWA wrappers loading OpenStation in a foreign service-worker scope.
wp.os.iframe.isParentReachable() returns true when the parent is same-origin and addressable, false otherwise:
if ( ! wp.os.iframe.isParentReachable() ) {
// No bridge — fall back to in-iframe UI, skip the feature,
// or surface a "this view requires OpenStation" notice.
return;
}
// Bridge is live; publish away.
wp.os.iframe.publish( 'editor:content', html );The predicate accesses window.parent.location.origin inside a try/catch — cross-origin parents throw on the access. Cheap, no postMessage round-trip. Use it before wiring expensive subscriptions or showing UI that promises cross-window behavior.
A separate channel from the connection bridge. Where the connection bridge carries app-level pub/sub between a window and its iframe, the drag bridge carries an in-flight drag payload between the parent shell and ALL same-origin iframes — receivers don't need to be "connected" to receive it.
The drag bridge stores a single DragBridgePayload at any given time. Two ways the payload gets in:
- Shell-side drag source — a DragManager
'shortcut'or'desktop-file'session whose payload carriesdata.bridgePayloadstarts (a shell-rendered tile from site-folder media / post / user, or an existing wallpaper placement dragged off the desktop). The shell'sDRAG_EVENTS.STARTlistener (src/desktop.ts) readspayload.data.bridgePayloadand callsdragBridge.start(payload). Cleared onDRAG_EVENTS.END. - Iframe-side drag source — an iframe postMessages
{ type: 'os-drag-start', payload }to the parent. The bridge stores the payload and broadcastsDRAG_BRIDGE_EVENTS.STARTas aCustomEventondocumentso other shell modules can react.
Drop-receiver iframes have two ways to consume the payload:
-
Push —
src/drag/iframe-drop-targets.tssuppressespointer-eventson every iframe window for the drag's duration and registers each window body as a drop target. When the pointer is over an iframe window and the gesture is a'shortcut'or'desktop-file'drag carrying abridgePayload, the shell postMessages:Message Direction Payload os-drag-overparent → iframe { type, payload: DragBridgePayload }os-drag-leaveparent → iframe { type }os-dropparent → iframe { type, payload: DragBridgePayload, position: { x, y } }Receivers listen on
window.message, checkevent.origin === window.location.origin, and switch ondata.payload.kind. The built-in Gutenberg receiver (src/gutenberg-drop-receiver.ts) is the canonical example. -
Pull — any iframe can postMessage
{ type: 'os-drag-payload-request' }and the parent replies (directly toevent.source) with{ type: 'os-drag-payload', payload }. Useful for iframes that bind their own nativedrophandler and need the rich payload after the browser has stripped the custom MIME from DataTransfer.
type DragBridgePayload =
| { kind: 'attachment'; id: number; url: string; title: string;
alt: string; mime: string; thumbnailUrl?: string;
sizes?: Record<string, unknown> }
| { kind: 'post'; id: number; postType: string; url: string;
title: string }
| { kind: 'user'; id: number; url: string; title: string };document.body[data-os-dragging]— set by the DragManager while ANY drag is in flight. Pair with[data-os-drag-type="shortcut"]to gate drag-state CSS in the shell.window.wp.os.dragBridge.getPayload()— read the current cross-frame payload from anywhere in the parent shell.os-cross-frame-drag-start/-endCustomEvents — dispatched ondocumenteach time the bridge transitions. Plugins layer drop-zone highlights on these without polling.
If you find yourself writing window.parent.postMessage or hand-rolling a handshake, check first:
- For shell-registered iframe windows (chromeless wp-admin) → use
wp.os.connect()+wp.os.iframe.publish/subscribe. - For your own iframe pages → enqueue
os-iframe-bridgeOR setiframeContent: { bridge: true }on a native window. - For iframe-initiated requests →
wp.os.iframe.requestConnection(). - For source-validation + load-vs-listener-race →
wp.os.registerWindow({ iframeContent: { bridge: true, onMessage } })—onMessageis pre-validated against the iframe'scontentWindow, and readiness needs no callback:Window.sendpayloads queue and flush automatically once the iframe loads (HOOKS.IFRAME_READYfires for observers).
The whole "shell.js coordinator" pattern is gone if you reach for these. The plugin's parent-shell footprint goes from ~150 lines of postMessage plumbing to a ~5-line config object.