Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions development/client/public/iframe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Color App in an IFrame</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background: #1a1a2e;
color: #eee;
}
h1 {
margin-top: 0;
color: #9d4edd;
}
p {
color: #aaa;
margin-bottom: 20px;
}
.iframe-container {
border: 2px solid #9d4edd;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
iframe {
width: 100%;
height: 600px;
border: none;
display: block;
}
</style>
</head>

<body>
<h1>🔍 Iframe Apollo Client Test Page</h1>
<p>
This page embeds the Color App in an iframe. With
<code>all_frames: true</code> in the manifest, Apollo Client DevTools
should detect the Apollo Client instance running inside this iframe.
</p>
<div class="iframe-container">
<iframe src="/" title="Color App in IFrame"></iframe>
</div>
</body>
</html>
3 changes: 3 additions & 0 deletions development/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ function App() {
<Link to="/favorites">Favorites</Link>
<Link to="/lookup">Lookup</Link>
<Link to="/playground">Playground</Link>
<a href="/iframe.html" style={{ marginLeft: "1rem" }}>
Iframe Test
</a>
</nav>
<div style={{ display: "flex", gap: "1rem" }}>
<select
Expand Down
42 changes: 42 additions & 0 deletions src/extension/__tests__/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createRpcClient,
createRpcHandler,
createRpcStreamHandler,
SKIP_RESPONSE,
} from "../rpc";

type RPCMessage = RPCRequestMessage | RPCResponseMessage;
Expand Down Expand Up @@ -923,3 +924,44 @@ test("runs cleanup and closes stream when calling close", async () => {
done: true,
});
});

test("does not send response when handler returns SKIP_RESPONSE", async () => {
const handlerAdapter = createTestAdapter();

const handle = createRpcHandler(handlerAdapter);
handle("getClient", () => SKIP_RESPONSE as any);

// Simulate an RPC request
handlerAdapter.simulateRPCMessage({
id: "abc",
type: MessageType.RPCRequest,
name: "getClient",
params: ["1"],
});

// Give time for any response to be sent
await wait(10);

// No response should have been posted
expect(handlerAdapter.postMessage).not.toHaveBeenCalled();
});

test("SKIP_RESPONSE allows handler to be re-registered after unsubscribe", async () => {
const handlerAdapter = createTestAdapter();
const clientAdapter = createTestAdapter();
createBridge(clientAdapter, handlerAdapter);

const client = createRpcClient(clientAdapter);
const handle = createRpcHandler(handlerAdapter);

// First handler skips
const unsubscribe = handle("getClient", () => SKIP_RESPONSE as any);

unsubscribe();

// Re-register with a real handler
handle("getClient", defaultGetClient);

const result = await client.request("getClient", "1");
expect(result).toEqual(defaultGetClient("1"));
});
184 changes: 144 additions & 40 deletions src/extension/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,31 @@
// https://github.com/facebook/react/blob/18a9dd1c60fdb711982f32ce5d91acfe8f158fe1/LICENSE
import browser from "webextension-polyfill";
import "./errorcodes";
import { createDevtoolsMessage, MessageType } from "../messages";
import {
createDevtoolsMessage,
isDevtoolsMessage,
MessageType,
} from "../messages";

// Type guards for message routing
function isActorMessage(message: unknown): message is {
type: MessageType.Actor;
message: { type: string; payload?: { id?: string }; clientId?: string };
} {
return isDevtoolsMessage(message) && message.type === MessageType.Actor;
}

function isRPCRequestMessage(
message: unknown
): message is { type: MessageType.RPCRequest; params: unknown[] } {
return isDevtoolsMessage(message) && message.type === MessageType.RPCRequest;
}

const ports: Record<
number,
{
tab: browser.Runtime.Port | null;
tabPorts: Map<browser.Runtime.Port, number>;
clientFrames: Map<string, number>;
extension: browser.Runtime.Port | null;
disconnectPorts: (() => void) | null;
}
Expand All @@ -17,19 +36,31 @@ const ports: Record<
function registerTab(tabId: number) {
if (!ports[tabId]) {
ports[tabId] = {
tab: null,
tabPorts: new Map(),
clientFrames: new Map(),
extension: null,
disconnectPorts: null,
};
}
}

function registerTabPort(tabId: number, port: browser.Runtime.Port) {
ports[tabId].tab = port;
function registerTabPort(
tabId: number,
port: browser.Runtime.Port,
frameId: number
) {
ports[tabId].tabPorts.set(port, frameId);

port.onDisconnect.addListener(() => {
ports[tabId].disconnectPorts?.();
ports[tabId].tab = null;
ports[tabId].tabPorts.delete(port);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self to dive in a bit deeper tomorrow:

I wonder if there is a chance to use the frameId as part of this identifier instead of using a Set. The idea is that if we can beef up the id a bit more, hopefully that would simplify some of the handling in other places where we don't have to try and ignore some messages due to it broadcasting to all frames on the same tab ID.

for (const [clientId, fId] of ports[tabId].clientFrames) {
if (fId === frameId) {
ports[tabId].clientFrames.delete(clientId);
}
}
if (ports[tabId].tabPorts.size === 0) {
ports[tabId].disconnectPorts?.();
}
});
}

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

const extensionPort = ports[tabId].extension;
const tabPort = ports[tabId].tab;
const tabPorts = ports[tabId].tabPorts;
const clientFrames = ports[tabId].clientFrames;

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

if (!tabPort) {
throw new Error("Attempted to connect tab port which does not exist");
if (tabPorts.size === 0) {
throw new Error("Attempted to connect with no tab ports");
}

if (ports[tabId].disconnectPorts) {
throw new Error(
`Attempted to connect already connected ports for tab ${tabId}`
);
// Already connected, but we may have new tab ports to add listeners to
// We need to add listeners for any new ports
return;
}

const tabPortListeners = new Map<
browser.Runtime.Port,
(message: unknown) => void
>();

function extensionPortListener(message: unknown) {
try {
tabPort!.postMessage(message);
} catch (e) {
if (process.env.NODE_ENV === "development") {
console.error(`Broken connection ${tabId}`);
let targetFrameId: number | undefined;
if (isRPCRequestMessage(message)) {
const clientId = message.params[0];
if (typeof clientId === "string") {
targetFrameId = clientFrames.get(clientId);
}
}

disconnectPorts();
for (const [tabPort, frameId] of tabPorts) {
if (targetFrameId !== undefined && frameId !== targetFrameId) {
continue;
}
try {
tabPort.postMessage(message);
} catch (e) {
if (process.env.NODE_ENV === "development") {
console.error(`Broken connection to frame in tab ${tabId}`);
}
tabPorts.delete(tabPort);
}
}
}

function tabPortListener(message: unknown) {
try {
extensionPort!.postMessage(message);
} catch (e) {
if (process.env.NODE_ENV === "development") {
console.error(`Broken connection ${tabId}`);
function createTabPortListener(frameId: number) {
return function tabPortListener(message: unknown) {
if (isActorMessage(message)) {
if (
message.message.type === "registerClient" &&
message.message.payload?.id
) {
clientFrames.set(message.message.payload.id, frameId);
} else if (
message.message.type === "clientTerminated" &&
message.message.clientId
) {
clientFrames.delete(message.message.clientId);
}
}

disconnectPorts();
}
try {
extensionPort!.postMessage(message);
} catch (e) {
if (process.env.NODE_ENV === "development") {
console.error(`Broken connection ${tabId}`);
}
disconnectPorts();
}
};
}

function disconnectPorts() {
extensionPort!.onMessage.removeListener(extensionPortListener);
tabPort!.onMessage.removeListener(tabPortListener);

for (const [tabPort, listener] of tabPortListeners) {
tabPort.onMessage.removeListener(listener);
}
tabPortListeners.clear();
ports[tabId].disconnectPorts = null;
}

Expand All @@ -102,7 +167,7 @@ function connectPorts(tabId: number) {
extensionPort.onMessage.addListener(extensionPortListener);
extensionPort.onDisconnect.addListener(disconnectPorts);
extensionPort.onDisconnect.addListener(() => {
if (tabPort) {
for (const [tabPort] of tabPorts) {
tabPort.postMessage(
createDevtoolsMessage({
type: MessageType.Actor,
Expand All @@ -112,8 +177,19 @@ function connectPorts(tabId: number) {
}
});

tabPort.onMessage.addListener(tabPortListener);
tabPort.onDisconnect.addListener(disconnectPorts);
// Add listeners for all current tab ports
for (const [tabPort, frameId] of tabPorts) {
const listener = createTabPortListener(frameId);
tabPortListeners.set(tabPort, listener);
tabPort.onMessage.addListener(listener);
tabPort.onDisconnect.addListener(() => {
const l = tabPortListeners.get(tabPort);
if (l) {
tabPort.onMessage.removeListener(l);
tabPortListeners.delete(tabPort);
}
});
}
}

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

const tabId = port.sender.tab.id;

if (ports[tabId]?.tab) {
ports[tabId].disconnectPorts?.();
ports[tabId].tab?.disconnect();
}
const frameId = port.sender.frameId ?? 0;

registerTab(tabId);
registerTabPort(tabId, port);
registerTabPort(tabId, port, frameId);

if (ports[tabId].extension) {
connectPorts(tabId);
// If already connected, add listener for this new port
if (ports[tabId].disconnectPorts) {
// Need to add message listener for this new tab port
const extensionPort = ports[tabId].extension!;
const clientFrames = ports[tabId].clientFrames;
const listener = (message: unknown) => {
if (isActorMessage(message)) {
if (
message.message.type === "registerClient" &&
message.message.payload?.id
) {
clientFrames.set(message.message.payload.id, frameId);
} else if (
message.message.type === "clientTerminated" &&
message.message.clientId
) {
clientFrames.delete(message.message.clientId);
}
}
try {
extensionPort.postMessage(message);
} catch (e) {
if (process.env.NODE_ENV === "development") {
console.error(`Broken connection ${tabId}`);
}
}
};
port.onMessage.addListener(listener);
port.onDisconnect.addListener(() => {
port.onMessage.removeListener(listener);
});
}
}
}

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

if (ports[tabId].tab) {
if (ports[tabId].tabPorts.size > 0) {
connectPorts(tabId);
}
}
Expand Down
Loading
Loading