Piercing the Shadow Root Using CDP
Part of my day job is orchestrating browsers using the Chrome DevTools Protocol, also known as CDP. CDP is a powerful protocol that exposes many ways to control and inspect a running Chrome instance. People usually interact with a wrapper around CDP like Puppeteer or Playwright but sometimes to get the full power of the protocol, you need to use it directly. Sadly it seems that the protocol was not properly designed and everything was bolted on as the protocol expanded to expose new functionality.
Since the protocol is a bit hard to understand and even remember, I usually document for myself how to do some non-obvious things in it. This post is hopefully one of many short snippets on how to wield the protocol in the best way possible. We will start with a post on how to pierce1 closed shadow roots.
The Best Way?
CDP is split into multiple domains like DOM, Network, Page etc. To interact with
the DOM we will first try to use the DOM domain of course. We will start by
sending the DOM.enable
method and look at what ways we can query the page. The
most straightforward way to query the page is with the DOM.querySelector
and
DOM.querySelectorAll
methods. Let’s try to query the following page:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Closed Shadow Root</title>
</head>
<body>
<p id="outside">OUTSIDE CLOSED SHADOW ROOT</p>
<template shadowrootmode="closed">
<p id="inside">INSIDE CLOSED SHADOW ROOT</p>
</template>
</body>
</html>
Both of the query selector methods already require us to have a node ID to query
on. We can get the root node ID by calling the method DOM.getDocument
. After
we retrieve the root node ID, we can try to query for the inside element. Using
the chrome-remote-interface
library, it would look like this:
import CDP from 'chrome-remote-interface';
const client = await CDP();
const getDocumentResponse = await client.DOM.getDocument();
const querySelectorResponse = await client.DOM.querySelector({ nodeId: getDocumentResponse.root.nodeId, selector: '#inside' });
console.log(querySelectorResponse.nodeId);
But if you will run it with the example page above, the node ID you will get
will be zero. That’s because the method actually failed to find the desired
element. We need to somehow pierce the closed shadow root. If we look at the
DOM.getDocument
method again, we will see that the method receives a pierce
argument that the DOM.querySelector
method does not. According to the
documentation, the argument will let us traverse iframes and shadow roots in the
tree2. Let’s use it alongside the depth
argument to get the whole DOM tree
together with the closed shadow root tree.
import CDP from 'chrome-remote-interface';
const client = await CDP();
const getDocumentResponse = await client.DOM.getDocument({
depth: -1,
pierce: true
});
console.log(JSON.stringify(getDocumentResponse, null, 2));
Notice that we’ve got the children
property for our regular elements and the
shadowRoots
property for our shadow root elements. Great, we’ve managed to
pierce the shadow root and get the desired element. But this approach is not
really scalable with a real web page, we will get too much data just for trying
to retrieve a single element. There is another method, similar to
DOM.getDocument
, that can give us the tree under a specific node using its ID.
In our example web page, the shadow root is just below the body
element so we
will query and describe it like so:
import CDP from 'chrome-remote-interface';
const client = await CDP();
const getDocumentResponse = await client.DOM.getDocument();
const querySelectorResponse = await client.DOM.querySelector({ nodeId: getDocumentResponse.root.nodeId, selector: 'body' });
const describeNodeResponse = await client.DOM.describeNode({ nodeId: querySelectorResponse.nodeId, depth: 1, pierce: true });
console.log(JSON.stringify(describeNodeResponse.node.shadowRoots, null, 2));
Now that we’ve got the top element of the shadow root, we can query it using
DOM.querySelector
and find our desired element. Our complete code would be
like so:
import CDP from 'chrome-remote-interface';
const client = await CDP();
const getDocumentResponse = await client.DOM.getDocument();
const querySelectorResponse = await client.DOM.querySelector({ nodeId: getDocumentResponse.root.nodeId, selector: 'body' });
const describeNodeResponse = await client.DOM.describeNode({ nodeId: querySelectorResponse.nodeId, depth: 0, pierce: true });
const shadowRoots = describeNodeResponse.node.shadowRoots ?? [];
const shadowRootQuerySelectorResponse = await client.DOM.querySelector({ nodeId: shadowRoots[0].nodeId, selector: '#inside' });
console.log(shadowRootQuerySelectorResponse); // Should log { nodeId: 13 }
If you executed the script against our example page, you should have gotten the desired node ID. You can now use the DOM domain to interact with the node using that ID.
Alternative paths
As I’ve said at the start, the protocol usually has more than one way of doing things. While looking at the best way of doing it, I’ve stumbled upon several other ways to pierce the shadow root and/or iframes.
Execution Contexts
Each iframe has its own execution context so we can use it to run code inside the iframe. This approach is kind of ugly for two reasons:
- The execution context of an iframe is obtained by listening to the
Runtime.executionContextCreated
and theRuntime.executionContextDestroyed
events. You can’t simply fetch the desired execution context for the desired frame3. - Usually you can convert objects from a script (that are accessed using object IDs in CDP) to a node ID if the object is pointing to a node. I haven’t found a way to do it in the case of an iframe but I might have missed it.
DOM.requestChildNodes
Another way of descending into a closed shadow root is by calling the method
DOM.requestChildNodes
. This method sounds exactly like what we need and it
seems that the inspector in Chrome is actually using this method. I’m not sure
why this method exists alongside the DOM.describeNode
method which almost does
the same thing but DOM.requestChildNodes
is a lot harder to use. Instead of
returning the children in the response of the method, you must also listen to a
separate event called DOM.setChildNodes
with the actual nodes. Not a big deal
but I’m not sure why they implemented it that way.
Exploring The Protocol
I’ve mentioned in the previous section that the inspector in Chrome is using the
DOM.requestChildNodes
method. There are two ways of learning about the
protocol usage in the devtools. One is to read the source code, which can take a
while to orientate yourself. The second option, which I highly recommend, is to
open the protocol monitor in the devtools and see for yourself how each
interaction in the devtools send and receive data through the protocol.