devtools/docs/connection.md
This describes how the extension wires the Angular panel in Chrome DevTools to the inspected page, per browser tab, and how it routes messages between them. It covers the real browser extension ("chrome shell"). The dev shell is noted at the end.
| Script | Bundle | Where it runs | Job |
|---|---|---|---|
| Panel UI | index.html + main.ts | The "Angular" DevTools panel frame | The ng-devtools Angular app the user sees |
| Background | background_bundle.js (background.ts) | Extension service worker | Hosts the TabManager that routes per tab; toggles toolbar icon/popup |
ng-validate content script | ng_validate_bundle.js (ng-validate.ts) | Inspected page, isolated world, all frames | Injects detect_angular_bundle.js into the page's main world |
detect-angular | detect_angular_bundle.js (detect-angular.ts) | Inspected page, main world | Reports whether the page is a supported Angular app |
| Content script | content_script_bundle.js (content-script.ts) | Inspected page, isolated world, all frames | Opens the port to the background, injects the backend, relays messages |
| Backend | backend_bundle.js (backend.ts) | Inspected page, main world | Talks to Angular's debug APIs (ng-devtools-backend) |
Two manifest-registered content scripts run in every frame (all_frames: true):
ng_validate_bundle.js and content_script_bundle.js. The detect and backend bundles are
web_accessible_resources, injected into the page's main world by a <script> tag so they
can read the page's Angular globals.
The two in-page scripts run in different JavaScript environments, which is why the in-page relay exists.
content_script_bundle.js) runs in the content-script isolated world,
a separate JS context that shares the page's DOM but not its window, globals, or prototypes.
It can call extension chrome.* APIs (chrome.runtime.connect/sendMessage) to reach the
background, but can't read the page's ng debug globals. See
content-script.ts.backend_bundle.js) runs in the page's main world, the same context as the
app's own scripts, so it can reach Angular's debug APIs (ng-devtools-backend). The trade-off
is the reverse: no access to chrome.*, so it can't talk to the extension directly.
backend.ts uses only a SamePageMessageBus.The content script injects the backend and then bridges the two worlds. It injects by appending
a <script src="…backend_bundle.js"> element, which runs in the page's main world; the bundle
must be in web_accessible_resources for the page to load it, and the element is removed right
after appending because the script has already started. detect-angular reaches the page the
same way through ng-validate. For the bridge, the content script forwards every message between
its chrome.runtime.Port and a SamePageMessageBus, the only channels the two worlds have for
passing structured-cloneable data over window.postMessage. The full panel-to-backend chain is
in The double pipe and message relay below.
flowchart LR
subgraph panel["Chrome DevTools panel frame"]
app["ng-devtools app
PriorityAwareMessageBus
→ ChromeMessageBus"]
end
subgraph bg["Background service worker"]
tm["TabManager"]
dt["tabs[tabId].devtools"]
cs["tabs[tabId]
.contentScripts[frameId]"]
tm -. owns .- dt
tm -. owns .- cs
dt <==>|doublePipe| cs
end
subgraph page["Inspected page (per frame)"]
content["content_script
(isolated world)"]
backend["backend (main world)
Angular debug APIs"]
detect["detect-angular
(main world)"]
detect -->|detectAngular| content
content -->|backendInstalled| detect
content <==>|"window.postMessage
(SamePageMessageBus)"| backend
end
app <==>|"chrome.runtime port
name = tabId"| dt
content <==>|"chrome.runtime port
name = doc title / url"| cs
The background's TabManager (shell-browser/src/app/tab_manager.ts) keeps one entry per
tab:
tabs[tabId] = {
devtools: Port | null, // the panel's port
contentScripts: {[frameId]: {port, enabled, frameId, backendReady}},
};
Both sides reach the background through chrome.runtime.connect, and TabManager tells them
apart by the port name in its runtime.onConnect listener:
app.config.ts opens
chrome.runtime.connect({ name: '' + chrome.devtools.inspectedWindow.tabId }), so the name
is the tab id, and registerDevToolsForTab parses it and stores tabs[tabId].devtools.content-script.ts connects with
name: document.title || location.href. The port carries sender.tab.id and
sender.frameId, so registerContentScriptForTab files it under
tabs[tabId].contentScripts[frameId].sequenceDiagram
actor User
participant V as ng-validate
(content script)
participant D as detect-angular
(page main world)
participant C as content-script
(content script)
participant B as backend
(page main world)
participant BG as background
(TabManager)
participant P as Angular panel
Note over BG: already registered at browser start (manifest).
Event-driven — the connects and messages below
wake it if it was idle-terminated.
User->>V: open page
V->>D: inject detect_angular_bundle.js
loop every 1s until backend installed
D->>C: detectAngular (window.postMessage)
end
C->>BG: connect port (name = doc title / url)
C->>BG: sendMessage, set toolbar icon / popup
C->>B: inject backend_bundle.js
C->>D: backendInstalled (stops the 1s polling)
loop every 500ms until backendReady
C->>B: handshake
end
B->>C: backendReady
C->>BG: backendReady (relayed over port)
User->>P: open DevTools, click Angular panel
P->>BG: connect port (name = tabId)
BG->>BG: doublePipe(panel, content script)
BG->>P: contentScriptConnected (frameId, name, url)
Note over C,P: The panel and the content script can connect in either order.
The backendReady promise synchronizes the two arrival orders.
The page setup (everything up to backendReady) and the panel opening are order-independent.
If the panel connects first, registerDevToolsForTab waits on each content script's
backendReady promise before piping. If a content script connects first, its backendReady
handler sets up the pipe once the panel is present. The panel itself is registered by
devtools.ts through chrome.devtools.panels.create.
The sequence above does not start the background: Chrome registers the service worker before any page loads, and the connects and messages shown are the events that wake it when it has been idle-terminated. See Background service worker lifecycle.
doublePipe(devtoolsPort, contentScript) installs two listeners that forward messages between
the panel port and the content script port. Inside the page, the content script bridges that
port to the backend over a second SamePageMessageBus:
panel ⇄ [port] ⇄ background doublePipe ⇄ [port] ⇄ content script ⇄ [postMessage] ⇄ backend
ChromeMessageBus ChromeMessageBus / SamePageMessageBus
ChromeMessageBus (chrome-message-bus.ts) wraps a chrome.runtime.Port.
SamePageMessageBus (same-page-message-bus.ts) wraps window.postMessage and filters by
source and destination URIs built from the page URL in comm-utils.ts
(angular-devtools-content-script-<url>, angular-devtools-backend-<url>, and the detect
variant), which keeps frames and unrelated postMessage traffic from crossing wires. The
panel side wraps everything in a PriorityAwareMessageBus so a large, late response cannot
overwrite a newer one.
A tab can hold many frames, so contentScripts can hold many connections. Only one is
enabled at a time, and that is the frame the panel is talking to. The panel picks one by
sending enableFrameConnection [frameId, tabId]. In doublePipe, onDevToolsMessage matches
the frame id, disables every other frame on the tab, enables the chosen one, and replies
frameConnected. While a connection is disabled, the pipe drops messages in both directions.
The in-page SamePageMessageBus mirrors this with a BusStatus: it starts at init, moves
to waiting on backendReady (only enableFrameConnection passes), and reaches ready on
enableFrameConnection. This stops the bus from emitting before the panel has selected a
frame.
No code registers the background; Chrome registers app/background_bundle.js from the manifest's
background.service_worker key when the extension is installed or updated and on browser start. It
is then event-driven: terminated after ~30s idle and re-evaluated from scratch on each wake. Every
wake re-runs the top level of background.ts,
which sets the default icon, adds the runtime.onMessage listener, and adds the runtime.onConnect
listener via TabManager.initialize. It wakes when one of those events fires: a content
script connecting (every page load, since content-script.ts
connects unconditionally), the panel connecting, or a detect-angular result arriving over
runtime.sendMessage. Firefox instead uses an MV2 persistent background page (background.scripts),
which stays loaded for the extension's whole enabled lifetime. See Chrome's
service worker lifecycle
docs for the event details.
That ~30s idle termination would drop a live connection. Since Chrome 114, holding the port open
does not reset the idle timer, though messages over it still do. The content script posts
__NG_DEVTOOLS_BEAT every 20s to keep the worker alive
(content-script.ts).
tabs[tabId].devtools is set to null and every content script on the tab
is marked disabled.contentScriptDisconnected.pnpm devtools:devserver runs a development shell at http://localhost:4200 with no
extension and no chrome.runtime ports. It loads the user's app in an iframe and the panel
talks to it through plain window message passing. The protocol and message-bus interfaces
are the same, so the panel code does not change between the two shells.
Why is backendReady a Promise<void> and not a boolean?
The first version (#53934) stored it as a
boolean that the panel read once when it connected. If the panel connected before a frame's
backend finished initializing, the boolean was already false and nothing reconnected the
panel later. The panel got stuck showing "Angular application not detected" after a page reload
(issue #53953), and refreshing just re-ran
the same losing race. The re-land (#54805)
switched to a promise so the panel can attach a callback that fires whenever the content script
eventually emits backendReady, no matter which side connected first. That bug was bad enough
to force a full revert (#54629) before the fix
landed.
Why does registration handle the panel and content script connecting in either order?
A page can finish loading and run its content script before the user opens the Angular panel,
and an already-open panel can outlive a page reload. registerDevToolsForTab and
registerContentScriptForTab each set up the double pipe from their own side once
backendReady resolves, so the final wiring is identical regardless of arrival order. The
spec exercises every permutation of that ordering on purpose, to keep the property from
regressing. The intent is that TabManager's end state stays the same whatever order it
processes events in.
Why key connections by both tab and frame?
This whole change exists to support iframes (#53934). One panel can inspect an Angular app that lives
in nested frames, so the background keeps a contentScripts[frameId] entry per frame under
each tab and lets the user pick which frame to inspect. The single-enabled-frame gating in
doublePipe is how that selection works.
Why a static TabManager.initialize() instead of new TabManager()?
A bare constructor leaves the manager inert until someone wires up the onConnect listener,
which is easy to forget. The factory bundles construction and wiring together, so you cannot
hold a half-initialized manager.