diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index 645cf817..ae660140 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -5490,8 +5490,8 @@ Lifecycle + instrumentation for the chromeless iframe inside each window. Re-dis | Hook | Kind | Status | Payload | |---|---|---|---| | `os.iframe.ready` | action | Stable | `{ windowId }` — fires once per iframe when the chromeless bridge script has attached its listeners. Use this instead of the iframe's `load` event when timing matters (the native `load` fires before our bridge attaches, so messages sent on `load` can miss the listener). | -| `os.iframe.error` | action | Stable | `{ windowId, kind: 'error' \| 'unhandledrejection', message, filename, lineno, colno, stack }` — bridged from the iframe's `error` / `unhandledrejection` handlers. Cross-origin iframe errors are origin-filtered at the bridge and never reach this hook. | -| `os.iframe.network-completed` | action | Stable | `{ windowId, method, url, status, duration, failed }` — every `fetch` + `XMLHttpRequest` call inside the iframe. `status === 0` indicates a network-level failure with no response received. | +| `os.iframe.error` | action | Stable | `{ windowId, kind: 'error' \| 'unhandledrejection', message, filename, lineno, colno, stack }` — bridged from the iframe's `error` / `unhandledrejection` handlers, for an iframe window's page and for an admin page embedded in a native window (`iframeContent`, `embedAdminPage()`); `windowId` is the owning window. Cross-origin iframe errors are origin-filtered at the bridge and never reach this hook. | +| `os.iframe.network-completed` | action | Stable | `{ windowId, method, url, status, duration, failed }` — every `fetch` + `XMLHttpRequest` call inside the iframe: an iframe window's page, or an admin page embedded in a native window (`iframeContent`, `embedAdminPage()` — the Posts app's block editor included); `windowId` is the owning window. `status === 0` indicates a network-level failure with no response received. | Use `IFRAME_READY` when you need to send a `os-focus` (or any parent→iframe message) as early as possible without racing the bridge setup. Use `IFRAME_ERROR` / `IFRAME_NETWORK_COMPLETED` to build a monitor widget that surfaces per-window reliability data. diff --git a/src/hooks.ts b/src/hooks.ts index d5d5ecc1..13269c5c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -236,8 +236,12 @@ export const HOOKS = { * `unhandledrejection` handler catches an exception. Payload: `{ * windowId: string, kind: 'error' | 'unhandledrejection', message: * string, filename: string | null, lineno: number | null, colno: - * number | null, stack: string | null }`. Origin-filtered at the - * parent shell; cross-origin iframe errors never reach here. + * number | null, stack: string | null }`. Covers an iframe window's + * page and an admin page embedded in a native window (an + * `iframeContent` window, `embedAdminPage()` — the Posts app's + * editor); `windowId` is the owning window either way. Origin- + * filtered at the parent shell; cross-origin iframe errors never + * reach here. */ IFRAME_ERROR: 'os.iframe.error', /** @@ -247,7 +251,9 @@ export const HOOKS = { * duration: number, failed: boolean }`. Subscribers get a faithful * view of admin-ajax + REST calls that previously never left the * iframe boundary. `status === 0` indicates a network failure with - * no response received. + * no response received. Covers an iframe window's page and an + * admin page embedded in a native window (the Posts app's block + * editor included); `windowId` is the owning window either way. */ IFRAME_NETWORK_COMPLETED: 'os.iframe.network-completed', /** diff --git a/src/native-windows.ts b/src/native-windows.ts index 42770423..f444cccd 100644 --- a/src/native-windows.ts +++ b/src/native-windows.ts @@ -30,6 +30,7 @@ import { injectInlineScript, loadVendorScript } from './wallpapers/vendor-loader import { registerSyntheticIframe } from './connection'; import { isShellDocumentUrl } from './shell-url'; import { recordFrameLocation, setWindowMenuPages } from './window/share-link'; +import { relayIframeReport } from './window/iframe-reports'; import { registerNativeUrlRemap, unregisterNativeUrlRemap, @@ -587,6 +588,15 @@ function buildIframeContentRender( ( data as { payload?: unknown } ).payload, ); } + // The chromeless bridge's error and network reports, relayed + // exactly as `src/window/iframe-bridge.ts` relays them for an + // iframe window. Without this an embedded page — the Posts + // app's block editor — never reached `os.iframe.error` or + // `os.iframe.network-completed`. Same-origin only, matching + // the bridge's own trust boundary. + if ( e.origin === window.location.origin ) { + relayIframeReport( windowId, data ); + } // Content identity of the page in this iframe — the same // announcement `src/window/iframe-bridge.ts` forwards for an // iframe window. Same-origin only: the identity carries diff --git a/src/window/iframe-bridge.ts b/src/window/iframe-bridge.ts index 04c137fe..1c1db9c1 100644 --- a/src/window/iframe-bridge.ts +++ b/src/window/iframe-bridge.ts @@ -18,6 +18,7 @@ import { tryNativeUrlRemap } from '../native-url-remap'; import { createSharedStore } from '../shared-store'; import { matchDestructiveAdminAction } from '../destructive-admin-actions'; import { setWindowContent } from '../window-links/engine'; +import { relayIframeReport } from './iframe-reports'; import { openUserFootprintWindow } from '../open-targets/footprint-target'; /** @@ -493,24 +494,9 @@ export function handleWindowMessage( win: Window, event: MessageEvent ): void { addExternalTab( win, data.url, label ); } - // Iframe error relay — the chromeless bridge posts - // `os-iframe-error` from inside the iframe's error + - // unhandledrejection handlers. We annotate with the owning - // windowId and dispatch the hook, where monitor widgets pick it - // up. Shape matches `HOOKS.IFRAME_ERROR`. - if ( data.type === 'os-iframe-error' ) { - doAction( HOOKS.IFRAME_ERROR, { - windowId: win.id, - kind: data.kind === 'unhandledrejection' - ? 'unhandledrejection' - : 'error', - message: typeof data.message === 'string' ? data.message : '', - filename: typeof data.filename === 'string' ? data.filename : null, - lineno: typeof data.lineno === 'number' ? data.lineno : null, - colno: typeof data.colno === 'number' ? data.colno : null, - stack: typeof data.stack === 'string' ? data.stack : null, - } ); - } + // The chromeless bridge's error and network reports — shared with + // admin pages embedded in native windows (`src/native-windows.ts`). + relayIframeReport( win.id, data ); // Layer-1 theme — iframe content can re-theme its own window // via the bridge. `tokens` is a CSS-variable map; `setAppearanceTheme` @@ -609,35 +595,6 @@ export function handleWindowMessage( win: Window, event: MessageEvent ): void { ); } } - - // Iframe network completion — bridged from the fetch + XHR - // wrappers inside the chromeless iframe. Every completed call - // (success or failure) fires here. `status === 0` indicates a - // network-level failure before a response arrived; `failed` is - // pre-computed inside the iframe (by the chromeless bridge's - // fetch/XHR wrappers) so subscribers don't have to re-derive - // the success / 4xx / 5xx / network boundary. - if ( data.type === 'os-iframe-network' ) { - const networkPayload: Record< string, unknown > = { - windowId: win.id, - method: typeof data.method === 'string' ? data.method : 'GET', - url: typeof data.url === 'string' ? data.url : '', - status: typeof data.status === 'number' ? data.status : 0, - duration: typeof data.duration === 'number' ? data.duration : 0, - failed: !! data.failed, - }; - // `requestHeaders` / `responseHeaders` only ride along when - // devtools have asked the iframe to observe (via - // `wp.os.devtools.onRequest( id, cb, { observe: true } )`). - // Default deliveries stay summary-only for privacy. - if ( data.requestHeaders && typeof data.requestHeaders === 'object' ) { - networkPayload.requestHeaders = data.requestHeaders; - } - if ( data.responseHeaders && typeof data.responseHeaders === 'object' ) { - networkPayload.responseHeaders = data.responseHeaders; - } - doAction( HOOKS.IFRAME_NETWORK_COMPLETED, networkPayload ); - } } /** diff --git a/src/window/iframe-reports.test.ts b/src/window/iframe-reports.test.ts new file mode 100644 index 00000000..414949dc --- /dev/null +++ b/src/window/iframe-reports.test.ts @@ -0,0 +1,44 @@ +/** + * `relayIframeReport()` — the one relay both iframe windows and + * admin pages embedded in native windows use for the chromeless + * bridge's `os-iframe-error` / `os-iframe-network` reports. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { addAction, HOOKS } from '../hooks'; +import { clearHooksStub, installHooksStub } from '../../tests/vitest/helpers/hooks-stub'; +import { relayIframeReport } from './iframe-reports'; + +beforeEach( () => installHooksStub() ); +afterEach( () => clearHooksStub() ); + +describe( 'relayIframeReport', () => { + test( 'normalises a network report and keeps headers only when present', () => { + const seen: unknown[] = []; + addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( p: unknown ) => seen.push( p ) ); + + expect( relayIframeReport( 'w1', { type: 'os-iframe-network', status: 'nope', failed: 1 } ) ).toBe( true ); + expect( relayIframeReport( 'w1', { type: 'os-iframe-network', url: '/a', status: 503, duration: 12, requestHeaders: { a: '1' } } ) ).toBe( true ); + + expect( seen ).toEqual( [ + { windowId: 'w1', method: 'GET', url: '', status: 0, duration: 0, failed: true }, + { windowId: 'w1', method: 'GET', url: '/a', status: 503, duration: 12, failed: false, requestHeaders: { a: '1' } }, + ] ); + } ); + + test( 'normalises an error report', () => { + const seen: unknown[] = []; + addAction( HOOKS.IFRAME_ERROR, 'test', ( p: unknown ) => seen.push( p ) ); + + relayIframeReport( 'w2', { type: 'os-iframe-error', kind: 'unhandledrejection', message: 'boom', lineno: 'x' } ); + + expect( seen ).toEqual( [ + { windowId: 'w2', kind: 'unhandledrejection', message: 'boom', filename: null, lineno: null, colno: null, stack: null }, + ] ); + } ); + + test( 'anything else is not a report', () => { + expect( relayIframeReport( 'w3', { type: 'os-content-identity' } ) ).toBe( false ); + expect( relayIframeReport( 'w3', null ) ).toBe( false ); + expect( relayIframeReport( 'w3', 'os-iframe-network' ) ).toBe( false ); + } ); +} ); diff --git a/src/window/iframe-reports.ts b/src/window/iframe-reports.ts new file mode 100644 index 00000000..f5541695 --- /dev/null +++ b/src/window/iframe-reports.ts @@ -0,0 +1,84 @@ +/** + * Relay of the chromeless bridge's error and network reports. + * + * The chromeless bridge wraps `fetch` / `XMLHttpRequest` and listens + * for `error` / `unhandledrejection` inside every admin page the shell + * frames, posting `os-iframe-network` and `os-iframe-error` to the + * parent. Two kinds of frame carry such a page: + * + * - an iframe window's own `Window.iframe` (`src/window/iframe-bridge.ts`); + * - an admin page embedded in a NATIVE window's body — an + * `iframeContent` window, or `embedAdminPage()` (the Posts app's + * editor tab) — whose `Window.iframe` is null + * (`src/native-windows.ts`). + * + * Both hand verified messages (same origin, from their own frame) to + * {@link relayIframeReport}, so the two paths dispatch the same hooks + * with the same payloads. Before the native path called it, an + * embedded page's reports were dropped: the block editor's REST, + * autosave and Heartbeat traffic and its JavaScript errors never + * reached `os.iframe.network-completed` / `os.iframe.error`. + */ + +import { doAction, HOOKS } from '../hooks'; + +/** + * Dispatch the hook for a bridge report, annotated with the owning + * window. The caller has already checked the message's origin and + * source. + * + * @param windowId Window the reporting frame belongs to. + * @param data The message's `data`. + * @return Whether `data` was a report (and a hook fired). + */ +export function relayIframeReport( windowId: string, data: unknown ): boolean { + if ( ! data || typeof data !== 'object' ) { + return false; + } + const d = data as Record< string, unknown >; + + // `os-iframe-error` from inside the page's error + + // unhandledrejection handlers. Shape matches `HOOKS.IFRAME_ERROR`. + if ( d.type === 'os-iframe-error' ) { + doAction( HOOKS.IFRAME_ERROR, { + windowId, + kind: d.kind === 'unhandledrejection' ? 'unhandledrejection' : 'error', + message: typeof d.message === 'string' ? d.message : '', + filename: typeof d.filename === 'string' ? d.filename : null, + lineno: typeof d.lineno === 'number' ? d.lineno : null, + colno: typeof d.colno === 'number' ? d.colno : null, + stack: typeof d.stack === 'string' ? d.stack : null, + } ); + return true; + } + + // Network completion from the fetch + XHR wrappers. Every completed + // call (success or failure) fires here. `status === 0` is a + // network-level failure before a response arrived; `failed` is + // pre-computed inside the page so subscribers don't re-derive the + // success / 4xx / 5xx / network boundary. + if ( d.type === 'os-iframe-network' ) { + const payload: Record< string, unknown > = { + windowId, + method: typeof d.method === 'string' ? d.method : 'GET', + url: typeof d.url === 'string' ? d.url : '', + status: typeof d.status === 'number' ? d.status : 0, + duration: typeof d.duration === 'number' ? d.duration : 0, + failed: !! d.failed, + }; + // `requestHeaders` / `responseHeaders` only ride along when + // devtools have asked the page to observe (via + // `wp.os.devtools.onRequest( id, cb, { observe: true } )`). + // Default deliveries stay summary-only for privacy. + if ( d.requestHeaders && typeof d.requestHeaders === 'object' ) { + payload.requestHeaders = d.requestHeaders; + } + if ( d.responseHeaders && typeof d.responseHeaders === 'object' ) { + payload.responseHeaders = d.responseHeaders; + } + doAction( HOOKS.IFRAME_NETWORK_COMPLETED, payload ); + return true; + } + + return false; +} diff --git a/tests/vitest/embed-admin-page.test.ts b/tests/vitest/embed-admin-page.test.ts index aeab24dc..8f91e9c5 100644 --- a/tests/vitest/embed-admin-page.test.ts +++ b/tests/vitest/embed-admin-page.test.ts @@ -22,6 +22,7 @@ import { LOADING_SPINNER_SIZE, } from '../../src/window/constants'; import { clearHooksStub, installHooksStub } from './helpers/hooks-stub'; +import { addAction, HOOKS } from '../../src/hooks'; const WINDOW_ID = 'desktop-mode-posts'; @@ -162,3 +163,54 @@ describe( 'embedAdminPage', () => { teardown(); } ); } ); + +describe( 'the embedded page’s error and network reports', () => { + /** A message from inside the embedded page, as the chromeless bridge posts it. */ + function post( iframe: HTMLIFrameElement | Window | null, data: unknown, origin = window.location.origin ): void { + window.dispatchEvent( + new MessageEvent( 'message', { + data, + origin, + source: iframe instanceof HTMLIFrameElement ? iframe.contentWindow : iframe, + } ), + ); + } + + test( 'fetch / XHR completions reach os.iframe.network-completed, tagged with the window', () => { + const seen: unknown[] = []; + addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( payload: unknown ) => seen.push( payload ) ); + const { iframe, teardown } = mount(); + + post( iframe, { type: 'os-iframe-network', method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false } ); + + expect( seen ).toEqual( [ + { windowId: WINDOW_ID, method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false }, + ] ); + teardown(); + } ); + + test( 'errors reach os.iframe.error, tagged with the window', () => { + const seen: Array< { windowId: string; message: string } > = []; + addAction( HOOKS.IFRAME_ERROR, 'test', ( payload: { windowId: string; message: string } ) => seen.push( payload ) ); + const { iframe, teardown } = mount(); + + post( iframe, { type: 'os-iframe-error', kind: 'error', message: 'TypeError: x is undefined' } ); + + expect( seen ).toEqual( [ expect.objectContaining( { windowId: WINDOW_ID, message: 'TypeError: x is undefined' } ) ] ); + teardown(); + } ); + + test( 'reports from another frame, another origin, or after teardown are ignored', () => { + const seen: unknown[] = []; + addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( payload: unknown ) => seen.push( payload ) ); + const { iframe, teardown } = mount(); + const report = { type: 'os-iframe-network', url: '/x', status: 200 }; + + post( window, report ); + post( iframe, report, 'https://evil.example' ); + teardown(); + post( iframe, report ); + + expect( seen ).toEqual( [] ); + } ); +} );