Skip to content

Commit 433e16c

Browse files
feat: add iframe support for Apollo Client detection (#1797)
### Summary This PR enables `apollo-client-devtools` to detect and interact with Apollo Clients running inside **same-origin iframes**, in addition to the main window. ### Problem Previously we only detected clients in the top-level window. If a page had iframes with their own Apollo Client instances, those clients were invisible to the extension. ### Solution 1. **Updated extension manifests**: Added `all_frames: true` to content scripts so `tab.js` and `hook.js` run in every frame (main window + iframes) 2. **Added multi-port architecture with frameId tracking**: Changed `background.ts` from tracking a single `tab` port per tab to a `Map<tabPorts, frameId>` per tab, enabling multiple frames to connect while tracking which frame each port belongs to 3. **Introduced client --> frame mapping**: Added a `clientFrames: Map<clientId, frameId>` to track which Apollo Client belongs to which frame, enabling targeted message routing 4. **Implemented frameId-based message routing**: RPC requests for a specific client are now routed only to the frame that owns that client, eliminating broadcast overhead 5. **Added SKIP_RESPONSE pattern for discovery**: The `getClients` handler in `hook.ts` uses `SKIP_RESPONSE` so frames without Apollo Clients don't pollute discovery responses ## Files Changed | File | Change | |------|--------| | [src/extension/chrome/manifest.json](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/src/extension/chrome/manifest.json:0:0-0:0) | Add `all_frames: true` | | [src/extension/firefox/manifest.json](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/src/extension/firefox/manifest.json:0:0-0:0) | Add `all_frames: true` | | [src/extension/rpc.ts](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/src/extension/rpc.ts:0:0-0:0) | Export `SKIP_RESPONSE` symbol + handler logic to skip sending responses | | [src/extension/background/background.ts](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/src/extension/background/background.ts:0:0-0:0) | `Set<tabPorts>` --> `Map<Port, frameId>`, add `clientFrames` mapping, implement targeted routing | | [src/extension/tab/hook.ts](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/src/extension/tab/hook.ts:0:0-0:0) | Add `SKIP_RESPONSE` to `getClients` handler for frames without clients | | [src/extension/tab/handleExplorerRequests.ts](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/src/extension/tab/handleExplorerRequests.ts:0:0-0:0) | Replace throw with silent return | | [development/client/public/iframe.html](cci:7://file:///Users/camillelawrence/Desktop/repos/apollo-client-devtools/development/client/public/iframe.html:0:0-0:0) | Test page for iframe scenarios | ## Automated Tests Added Two new unit tests in `src/extension/__tests__/rpc.test.ts`: | Test | Description | |------|-------------| | `does not send response when handler returns SKIP_RESPONSE` | Verifies that when a handler returns `SKIP_RESPONSE`, no RPC response message is posted | | `SKIP_RESPONSE allows handler to be re-registered after unsubscribe` | Verifies that handlers using `SKIP_RESPONSE` can be properly unsubscribed and re-registered | ## Manual Verification Steps 1. **Single frame (regression test)** - Load a page with a single Apollo Client (no iframes) - Open DevTools --> Apollo tab - Verify the client is detected and all features (Queries, Mutations, Cache) work normally 2. **Multi-frame detection** - Start dev server: `npm run start:dev` - Open Chrome with the extension loaded: `npm run chrome` - Navigate to `http://localhost:3000` - Add a client in the main window and a client inside the iframe - Verify **both clients** appear in the DevTools client dropdown 3. **Targeted routing verification** - With both clients (main window + iframe) registered from step 2 - Select the iframe's client in DevTools - Run queries/mutations and inspect the cache - In Chrome DevTools console for the **background script**, confirm RPC requests are routed only to the correct frame (look for `frameId` in console logs in dev mode) ## Limitations - **Same-origin only**: Per browser security, `all_frames: true` only works for same-origin iframes. Cross-origin iframes remain invisible. ## References - Closes #380 - Related: PR #828, PR #997 --------- Co-authored-by: Jerel Miller <jerelmiller@gmail.com>
1 parent 35f6cd5 commit 433e16c

9 files changed

Lines changed: 271 additions & 45 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Color App in an IFrame</title>
7+
<style>
8+
body {
9+
margin: 0;
10+
padding: 20px;
11+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
12+
sans-serif;
13+
background: #1a1a2e;
14+
color: #eee;
15+
}
16+
h1 {
17+
margin-top: 0;
18+
color: #9d4edd;
19+
}
20+
p {
21+
color: #aaa;
22+
margin-bottom: 20px;
23+
}
24+
.iframe-container {
25+
border: 2px solid #9d4edd;
26+
border-radius: 8px;
27+
overflow: hidden;
28+
background: #fff;
29+
}
30+
iframe {
31+
width: 100%;
32+
height: 600px;
33+
border: none;
34+
display: block;
35+
}
36+
</style>
37+
</head>
38+
39+
<body>
40+
<h1>🔍 Iframe Apollo Client Test Page</h1>
41+
<p>
42+
This page embeds the Color App in an iframe. With
43+
<code>all_frames: true</code> in the manifest, Apollo Client DevTools
44+
should detect the Apollo Client instance running inside this iframe.
45+
</p>
46+
<div class="iframe-container">
47+
<iframe src="/" title="Color App in IFrame"></iframe>
48+
</div>
49+
</body>
50+
</html>

development/client/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ function App() {
5656
<Link to="/favorites">Favorites</Link>
5757
<Link to="/lookup">Lookup</Link>
5858
<Link to="/playground">Playground</Link>
59+
<a href="/iframe.html" style={{ marginLeft: "1rem" }}>
60+
Iframe Test
61+
</a>
5962
</nav>
6063
<div style={{ display: "flex", gap: "1rem" }}>
6164
<select

src/extension/__tests__/rpc.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
createRpcClient,
1616
createRpcHandler,
1717
createRpcStreamHandler,
18+
SKIP_RESPONSE,
1819
} from "../rpc";
1920

2021
type RPCMessage = RPCRequestMessage | RPCResponseMessage;
@@ -923,3 +924,44 @@ test("runs cleanup and closes stream when calling close", async () => {
923924
done: true,
924925
});
925926
});
927+
928+
test("does not send response when handler returns SKIP_RESPONSE", async () => {
929+
const handlerAdapter = createTestAdapter();
930+
931+
const handle = createRpcHandler(handlerAdapter);
932+
handle("getClient", () => SKIP_RESPONSE as any);
933+
934+
// Simulate an RPC request
935+
handlerAdapter.simulateRPCMessage({
936+
id: "abc",
937+
type: MessageType.RPCRequest,
938+
name: "getClient",
939+
params: ["1"],
940+
});
941+
942+
// Give time for any response to be sent
943+
await wait(10);
944+
945+
// No response should have been posted
946+
expect(handlerAdapter.postMessage).not.toHaveBeenCalled();
947+
});
948+
949+
test("SKIP_RESPONSE allows handler to be re-registered after unsubscribe", async () => {
950+
const handlerAdapter = createTestAdapter();
951+
const clientAdapter = createTestAdapter();
952+
createBridge(clientAdapter, handlerAdapter);
953+
954+
const client = createRpcClient(clientAdapter);
955+
const handle = createRpcHandler(handlerAdapter);
956+
957+
// First handler skips
958+
const unsubscribe = handle("getClient", () => SKIP_RESPONSE as any);
959+
960+
unsubscribe();
961+
962+
// Re-register with a real handler
963+
handle("getClient", defaultGetClient);
964+
965+
const result = await client.request("getClient", "1");
966+
expect(result).toEqual(defaultGetClient("1"));
967+
});

src/extension/background/background.ts

Lines changed: 144 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,31 @@
33
// https://github.com/facebook/react/blob/18a9dd1c60fdb711982f32ce5d91acfe8f158fe1/LICENSE
44
import browser from "webextension-polyfill";
55
import "./errorcodes";
6-
import { createDevtoolsMessage, MessageType } from "../messages";
6+
import {
7+
createDevtoolsMessage,
8+
isDevtoolsMessage,
9+
MessageType,
10+
} from "../messages";
11+
12+
// Type guards for message routing
13+
function isActorMessage(message: unknown): message is {
14+
type: MessageType.Actor;
15+
message: { type: string; payload?: { id?: string }; clientId?: string };
16+
} {
17+
return isDevtoolsMessage(message) && message.type === MessageType.Actor;
18+
}
19+
20+
function isRPCRequestMessage(
21+
message: unknown
22+
): message is { type: MessageType.RPCRequest; params: unknown[] } {
23+
return isDevtoolsMessage(message) && message.type === MessageType.RPCRequest;
24+
}
725

826
const ports: Record<
927
number,
1028
{
11-
tab: browser.Runtime.Port | null;
29+
tabPorts: Map<browser.Runtime.Port, number>;
30+
clientFrames: Map<string, number>;
1231
extension: browser.Runtime.Port | null;
1332
disconnectPorts: (() => void) | null;
1433
}
@@ -17,19 +36,31 @@ const ports: Record<
1736
function registerTab(tabId: number) {
1837
if (!ports[tabId]) {
1938
ports[tabId] = {
20-
tab: null,
39+
tabPorts: new Map(),
40+
clientFrames: new Map(),
2141
extension: null,
2242
disconnectPorts: null,
2343
};
2444
}
2545
}
2646

27-
function registerTabPort(tabId: number, port: browser.Runtime.Port) {
28-
ports[tabId].tab = port;
47+
function registerTabPort(
48+
tabId: number,
49+
port: browser.Runtime.Port,
50+
frameId: number
51+
) {
52+
ports[tabId].tabPorts.set(port, frameId);
2953

3054
port.onDisconnect.addListener(() => {
31-
ports[tabId].disconnectPorts?.();
32-
ports[tabId].tab = null;
55+
ports[tabId].tabPorts.delete(port);
56+
for (const [clientId, fId] of ports[tabId].clientFrames) {
57+
if (fId === frameId) {
58+
ports[tabId].clientFrames.delete(clientId);
59+
}
60+
}
61+
if (ports[tabId].tabPorts.size === 0) {
62+
ports[tabId].disconnectPorts?.();
63+
}
3364
});
3465
}
3566

@@ -50,50 +81,84 @@ function connectPorts(tabId: number) {
5081
}
5182

5283
const extensionPort = ports[tabId].extension;
53-
const tabPort = ports[tabId].tab;
84+
const tabPorts = ports[tabId].tabPorts;
85+
const clientFrames = ports[tabId].clientFrames;
5486

5587
if (!extensionPort) {
5688
throw new Error("Attempted to connect extension port which does not exist");
5789
}
5890

59-
if (!tabPort) {
60-
throw new Error("Attempted to connect tab port which does not exist");
91+
if (tabPorts.size === 0) {
92+
throw new Error("Attempted to connect with no tab ports");
6193
}
6294

6395
if (ports[tabId].disconnectPorts) {
64-
throw new Error(
65-
`Attempted to connect already connected ports for tab ${tabId}`
66-
);
96+
// Already connected, but we may have new tab ports to add listeners to
97+
// We need to add listeners for any new ports
98+
return;
6799
}
68100

101+
const tabPortListeners = new Map<
102+
browser.Runtime.Port,
103+
(message: unknown) => void
104+
>();
105+
69106
function extensionPortListener(message: unknown) {
70-
try {
71-
tabPort!.postMessage(message);
72-
} catch (e) {
73-
if (process.env.NODE_ENV === "development") {
74-
console.error(`Broken connection ${tabId}`);
107+
let targetFrameId: number | undefined;
108+
if (isRPCRequestMessage(message)) {
109+
const clientId = message.params[0];
110+
if (typeof clientId === "string") {
111+
targetFrameId = clientFrames.get(clientId);
75112
}
113+
}
76114

77-
disconnectPorts();
115+
for (const [tabPort, frameId] of tabPorts) {
116+
if (targetFrameId !== undefined && frameId !== targetFrameId) {
117+
continue;
118+
}
119+
try {
120+
tabPort.postMessage(message);
121+
} catch (e) {
122+
if (process.env.NODE_ENV === "development") {
123+
console.error(`Broken connection to frame in tab ${tabId}`);
124+
}
125+
tabPorts.delete(tabPort);
126+
}
78127
}
79128
}
80129

81-
function tabPortListener(message: unknown) {
82-
try {
83-
extensionPort!.postMessage(message);
84-
} catch (e) {
85-
if (process.env.NODE_ENV === "development") {
86-
console.error(`Broken connection ${tabId}`);
130+
function createTabPortListener(frameId: number) {
131+
return function tabPortListener(message: unknown) {
132+
if (isActorMessage(message)) {
133+
if (
134+
message.message.type === "registerClient" &&
135+
message.message.payload?.id
136+
) {
137+
clientFrames.set(message.message.payload.id, frameId);
138+
} else if (
139+
message.message.type === "clientTerminated" &&
140+
message.message.clientId
141+
) {
142+
clientFrames.delete(message.message.clientId);
143+
}
87144
}
88-
89-
disconnectPorts();
90-
}
145+
try {
146+
extensionPort!.postMessage(message);
147+
} catch (e) {
148+
if (process.env.NODE_ENV === "development") {
149+
console.error(`Broken connection ${tabId}`);
150+
}
151+
disconnectPorts();
152+
}
153+
};
91154
}
92155

93156
function disconnectPorts() {
94157
extensionPort!.onMessage.removeListener(extensionPortListener);
95-
tabPort!.onMessage.removeListener(tabPortListener);
96-
158+
for (const [tabPort, listener] of tabPortListeners) {
159+
tabPort.onMessage.removeListener(listener);
160+
}
161+
tabPortListeners.clear();
97162
ports[tabId].disconnectPorts = null;
98163
}
99164

@@ -102,7 +167,7 @@ function connectPorts(tabId: number) {
102167
extensionPort.onMessage.addListener(extensionPortListener);
103168
extensionPort.onDisconnect.addListener(disconnectPorts);
104169
extensionPort.onDisconnect.addListener(() => {
105-
if (tabPort) {
170+
for (const [tabPort] of tabPorts) {
106171
tabPort.postMessage(
107172
createDevtoolsMessage({
108173
type: MessageType.Actor,
@@ -112,8 +177,19 @@ function connectPorts(tabId: number) {
112177
}
113178
});
114179

115-
tabPort.onMessage.addListener(tabPortListener);
116-
tabPort.onDisconnect.addListener(disconnectPorts);
180+
// Add listeners for all current tab ports
181+
for (const [tabPort, frameId] of tabPorts) {
182+
const listener = createTabPortListener(frameId);
183+
tabPortListeners.set(tabPort, listener);
184+
tabPort.onMessage.addListener(listener);
185+
tabPort.onDisconnect.addListener(() => {
186+
const l = tabPortListeners.get(tabPort);
187+
if (l) {
188+
tabPort.onMessage.removeListener(l);
189+
tabPortListeners.delete(tabPort);
190+
}
191+
});
192+
}
117193
}
118194

119195
function connectTabPort(port: browser.Runtime.Port) {
@@ -122,17 +198,45 @@ function connectTabPort(port: browser.Runtime.Port) {
122198
}
123199

124200
const tabId = port.sender.tab.id;
125-
126-
if (ports[tabId]?.tab) {
127-
ports[tabId].disconnectPorts?.();
128-
ports[tabId].tab?.disconnect();
129-
}
201+
const frameId = port.sender.frameId ?? 0;
130202

131203
registerTab(tabId);
132-
registerTabPort(tabId, port);
204+
registerTabPort(tabId, port, frameId);
133205

134206
if (ports[tabId].extension) {
135207
connectPorts(tabId);
208+
// If already connected, add listener for this new port
209+
if (ports[tabId].disconnectPorts) {
210+
// Need to add message listener for this new tab port
211+
const extensionPort = ports[tabId].extension!;
212+
const clientFrames = ports[tabId].clientFrames;
213+
const listener = (message: unknown) => {
214+
if (isActorMessage(message)) {
215+
if (
216+
message.message.type === "registerClient" &&
217+
message.message.payload?.id
218+
) {
219+
clientFrames.set(message.message.payload.id, frameId);
220+
} else if (
221+
message.message.type === "clientTerminated" &&
222+
message.message.clientId
223+
) {
224+
clientFrames.delete(message.message.clientId);
225+
}
226+
}
227+
try {
228+
extensionPort.postMessage(message);
229+
} catch (e) {
230+
if (process.env.NODE_ENV === "development") {
231+
console.error(`Broken connection ${tabId}`);
232+
}
233+
}
234+
};
235+
port.onMessage.addListener(listener);
236+
port.onDisconnect.addListener(() => {
237+
port.onMessage.removeListener(listener);
238+
});
239+
}
136240
}
137241
}
138242

@@ -142,7 +246,7 @@ function connectExtensionPort(port: browser.Runtime.Port) {
142246
registerTab(tabId);
143247
registerExtensionPort(tabId, port);
144248

145-
if (ports[tabId].tab) {
249+
if (ports[tabId].tabPorts.size > 0) {
146250
connectPorts(tabId);
147251
}
148252
}

0 commit comments

Comments
 (0)