<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Iulian Stoian</title>
    <description>The latest articles on DEV Community by Iulian Stoian (@iulian_stoian_4fdc49515f7).</description>
    <link>https://dev.to/iulian_stoian_4fdc49515f7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1487075%2Fc012bb24-3e7a-4117-9ec3-3c4008f7c4c2.jpeg</url>
      <title>DEV Community: Iulian Stoian</title>
      <link>https://dev.to/iulian_stoian_4fdc49515f7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iulian_stoian_4fdc49515f7"/>
    <language>en</language>
    <item>
      <title>Writing a decoder that gives up on purpose</title>
      <dc:creator>Iulian Stoian</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:48:04 +0000</pubDate>
      <link>https://dev.to/iulian_stoian_4fdc49515f7/five-production-websocket-streams-unwrapped-layer-by-layer-1pio</link>
      <guid>https://dev.to/iulian_stoian_4fdc49515f7/five-production-websocket-streams-unwrapped-layer-by-layer-1pio</guid>
      <description>&lt;p&gt;I write decoders for WebSocket payloads. Someone's app sends 214 bytes, I have no schema, and I have to guess what they are.&lt;/p&gt;

&lt;p&gt;The obvious way to build this is a chain of attempts: try JSON, try base64, try gzip, try MessagePack, return whatever sticks. That's what I built first. It was worse than useless, and fixing it changed how I think about this kind of code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong answers cost more than no answer
&lt;/h2&gt;

&lt;p&gt;MessagePack is where I learned this. It's self-describing — the first byte tells you the type — so a decoder can start reading almost any byte sequence and produce something. Feed it a chunk of an encrypted blob and it will hand you back a map with two keys and an array, confidently.&lt;/p&gt;

&lt;p&gt;You then spend twenty minutes trying to work out what that structure means in the app you're debugging. It means nothing. It's noise that happened to start with a valid type byte.&lt;/p&gt;

&lt;p&gt;The fix is one rule: &lt;strong&gt;a decoder either consumes the entire payload or it fails&lt;/strong&gt;. No leftover bytes, no partial match, no "close enough". If 200 of 214 bytes parse and 14 are left over, that's a rejection, not a result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;consumed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tryMessagePack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;consumed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;partial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring rule, big effect. False positives dropped to roughly zero, and I stopped debugging my own decoder's imagination.&lt;/p&gt;

&lt;p&gt;There's a second-order version of the same problem. A Thrift Compact payload without a schema decodes fine structurally — you get field 1, field 2, field 3 with their types. But the field &lt;em&gt;names&lt;/em&gt; live in the schema you don't have, so what you're looking at is &lt;code&gt;f1: 8172, f2: "…"&lt;/code&gt;. That's honest, and occasionally useful, and it's important the UI doesn't dress it up as more than it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some things can't be decoded, and that's not a bug
&lt;/h2&gt;

&lt;p&gt;Discord's gateway taught me this one.&lt;/p&gt;

&lt;p&gt;It can compress with &lt;code&gt;zlib-stream&lt;/code&gt;. Not "each message is zlib-compressed" — a single zlib stream, opened once when the connection opens, with every message appended to it. Each message ends with &lt;code&gt;00 00 FF FF&lt;/code&gt;, the Z_SYNC_FLUSH marker.&lt;/p&gt;

&lt;p&gt;The consequence took me a while to accept: you cannot decompress message 400 on its own. zlib builds a dictionary from everything that came before. Message 400 is compressed &lt;em&gt;against&lt;/em&gt; messages 1 through 399. Without them you don't have partial information, you have none.&lt;/p&gt;

&lt;p&gt;So the decoder needs a persistent decompression context per connection, fed every frame in order, from the start. Mine keeps one &lt;code&gt;DecompressionStream('deflate')&lt;/code&gt; alive per connection, with a queue so frames can't overtake each other, and correlates output back to input frames by the flush boundaries.&lt;/p&gt;

&lt;p&gt;And if you attach in the middle of a conversation, it returns nothing. Not corrupted output — nothing. I spent a while looking for the bug before accepting there isn't one. The bytes genuinely don't contain the message.&lt;/p&gt;

&lt;p&gt;That limitation is worth surfacing to the user rather than hiding, because it tells them something actionable: reload the page with the tool already running, and it works.&lt;/p&gt;

&lt;p&gt;It's also the strongest argument I know for capturing from page load instead of from the moment someone opens DevTools. Stream compression makes late attachment useless, and the handshake is where the interesting failures live anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting what you can't read
&lt;/h2&gt;

&lt;p&gt;If a decoder is going to give up honestly, it needs to know the difference between "I don't have a decoder for this" and "nobody has a decoder for this, it's encrypted".&lt;/p&gt;

&lt;p&gt;The obvious measurement is entropy. Encrypted data should look random, compressed and encoded data less so. I implemented it and it doesn't work.&lt;/p&gt;

&lt;p&gt;Measured on real payloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;encrypted blob: &lt;strong&gt;5.3 bits/char&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;base64-encoded JSON: &lt;strong&gt;5.1 bits/char&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those distributions overlap. Pick a threshold anywhere in that range and you'll mislabel readable data as encrypted, or the reverse, depending on which way you lean. Base64 is just dense enough to look like ciphertext by that measure.&lt;/p&gt;

&lt;p&gt;What works is dumber. Base64-decode the string, then ask what fraction of the resulting bytes are printable characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;looksOpaque&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;printable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;printable&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;printable&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under about 75% printable and it's encrypted or otherwise opaque. Decoded JSON, decoded text, decoded XML — all overwhelmingly printable. Ciphertext isn't. Two clean populations, no overlap in practice.&lt;/p&gt;

&lt;p&gt;A caveat: this runs &lt;em&gt;after&lt;/em&gt; the real decoders, not before. gzip and LZ4 payloads aren't printable either, and you don't want to flag as encrypted something you can actually decompress. Hex strings and hashes need excluding too — they're printable, but they decode to nothing meaningful.&lt;/p&gt;

&lt;p&gt;The shape that shows up most often in the encrypted bucket looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0x01][key id][ciphertext…]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A version byte, a key identifier, then bytes. Once you've seen it a few times you recognise it instantly, which is itself useful — it means stop, this is not a puzzle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving up is a feature
&lt;/h2&gt;

&lt;p&gt;The UI paints those fields red, with a tooltip that says "likely encrypted / opaque binary". It decodes nothing. It's one of the more useful things in the tool.&lt;/p&gt;

&lt;p&gt;Not because it tells you what the data is. Because it tells you to stop looking. Knowing a field is permanently unreadable turns a twenty-minute rabbit hole into a two-second decision to go check something else.&lt;/p&gt;

&lt;p&gt;Three rules, then, for this kind of code:&lt;/p&gt;

&lt;p&gt;Consume everything or fail. Don't hide a limitation that has a workaround the user could act on. And when you can't read something, say so, instead of producing a structure that looks like an answer.&lt;/p&gt;

&lt;p&gt;I build this into &lt;a href="https://chromewebstore.google.com/detail/wirepeek/ojoojkjcpibfddgcljlfbjobkcpcbejn" rel="noopener noreferrer"&gt;Wirepeek&lt;/a&gt;, a free DevTools panel for WebSocket traffic — disclosure, it's mine. But the rules are general, and I arrived at all three by getting them wrong first.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>websocket</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The WebSocket frames your DevTools won't decode (and how I caught them)</title>
      <dc:creator>Iulian Stoian</dc:creator>
      <pubDate>Mon, 06 Jul 2026 04:59:39 +0000</pubDate>
      <link>https://dev.to/iulian_stoian_4fdc49515f7/the-websocket-frames-your-devtools-wont-decode-and-how-i-caught-them-50i8</link>
      <guid>https://dev.to/iulian_stoian_4fdc49515f7/the-websocket-frames-your-devtools-wont-decode-and-how-i-caught-them-50i8</guid>
      <description>&lt;p&gt;If you've ever debugged a real-time app — chat, trading, live sports, a collaborative editor — you've opened Chrome DevTools → Network → clicked the WebSocket connection → and stared at this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;↑ 42["chat message","hello"]
↓ 42["chat message","hi there"]
↓ 2
↑ 40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DevTools shows you the frames. What it doesn't do is &lt;strong&gt;tell you what they mean&lt;/strong&gt;.&lt;br&gt;
That &lt;code&gt;2&lt;/code&gt;? It's an engine.io ping. That &lt;code&gt;40&lt;/code&gt;? socket.io CONNECT. The &lt;code&gt;42[...]&lt;/code&gt;?&lt;br&gt;
A socket.io EVENT whose name is the first array element. You learn to read it by eye — &lt;code&gt;4&lt;/code&gt; = engine.io message, &lt;code&gt;2&lt;/code&gt; = socket.io EVENT — which is a silly thing to have memorized in 2026.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Wirepeek&lt;/strong&gt;, a DevTools panel that decodes this stuff live. Here's the interesting part — the bit that turned out harder than I expected.&lt;/p&gt;
&lt;h2&gt;
  
  
  The naive interceptor is wrong
&lt;/h2&gt;

&lt;p&gt;The obvious way to capture WebSocket traffic from an extension is to replace the constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Native&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WebSocket&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WebSocket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Native&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;strong&gt;misses socket.io traffic&lt;/strong&gt;, and the reason is subtle. engine.io (socket.io's transport layer) grabs its own reference to the WebSocket implementation early, and depending on bundling and world isolation, your replaced &lt;code&gt;window.WebSocket&lt;/code&gt; isn't the one it uses. Replace the constructor and you'll capture &lt;em&gt;some&lt;/em&gt; sockets and silently miss others.&lt;/p&gt;

&lt;p&gt;The fix is to stop fighting over the constructor and patch the &lt;strong&gt;prototype&lt;/strong&gt; instead — the methods every socket shares, no matter who holds the reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;
&lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// observe, never mutate&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outgoing is easy. Incoming is the trap: a page can read messages via &lt;code&gt;addEventListener('message', …)&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; the &lt;code&gt;onmessage&lt;/code&gt; setter, and you have to cover both — while attaching exactly one sniffer per socket so you don't double-count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sniffed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WeakSet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureSniffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sniffed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="nx"&gt;sniffed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;ensureSniffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// + override the onmessage accessor on the prototype to call ensureSniffer too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing: this has to run in the page's &lt;strong&gt;MAIN world&lt;/strong&gt; at &lt;code&gt;document_start&lt;/code&gt;, before any page script constructs a socket. In an MV3 extension that's a content script with &lt;code&gt;"world": "MAIN"&lt;/code&gt;. Your &lt;code&gt;chrome.*&lt;/code&gt; calls live in a separate ISOLATED-world content script; the two talk over &lt;code&gt;window.postMessage&lt;/code&gt; with a namespaced envelope you validate on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What real sites actually send
&lt;/h2&gt;

&lt;p&gt;I tested on whatever real apps I could find, and the variety was the fun part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A socket.io chat&lt;/strong&gt; — clean &lt;code&gt;42["event",…]&lt;/code&gt; frames. Wirepeek shows &lt;code&gt;socket.io · event "chat message"&lt;/code&gt; + pretty-printed payload. The thing it was built for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sportsbook (SignalR)&lt;/strong&gt; — &lt;code&gt;{"type":1,"target":"NewLiveOverviewDiffs", "arguments":["…base64…"]}&lt;/code&gt;. That's a SignalR hub invocation, and the argument is a &lt;strong&gt;base64-encoded, gzip-compressed diff&lt;/strong&gt; of live odds (so they ship a few hundred bytes per tick instead of full JSON). base64 → gunzip → and out falls readable sportsbook JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facebook Messenger&lt;/strong&gt; — this one is a Russian doll: &lt;strong&gt;MQTT-over-WebSocket&lt;/strong&gt; (magic &lt;code&gt;MQIsdp&lt;/code&gt; CONNECT), a JSON payload embedded in a binary frame, a base64 field inside &lt;em&gt;that&lt;/em&gt;, and inside it a &lt;strong&gt;Thrift-Compact&lt;/strong&gt; sync cursor (&lt;code&gt;last_applied_cursor&lt;/code&gt;, the Iris/Delta sync marker). Four layers, no schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TradingView&lt;/strong&gt; — a custom &lt;code&gt;~m~&amp;lt;len&amp;gt;~m~&amp;lt;payload&amp;gt;&lt;/code&gt; framing, and individual frames over &lt;strong&gt;300 KB&lt;/strong&gt;. Great stress test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson: the interesting bytes are almost never sitting there as plain JSON — they're base64'd, gzip'd, MessagePack'd, or wrapped in some binary framing. So "pretty-print the JSON, fall back to raw" wasn't enough. It became a &lt;strong&gt;decode pipeline&lt;/strong&gt; you toggle with one switch: deep-nested JSON, JWT, base64, gzip/deflate/LZ4, MessagePack, CBOR, Thrift, Discord's shared zlib-stream — each a small, pure, tested decoder that only fires when it &lt;em&gt;fully&lt;/em&gt; consumes the bytes (so no false positives), still falling back to raw for the rest. And when a blob is genuinely encrypted (high non-printable ratio), Wirepeek flags it red instead of pretending — so you stop trying to decode the undecodable. Never throw, never hide a frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that taught me MV3
&lt;/h2&gt;

&lt;p&gt;Early on, server-initiated frames at page load (the engine.io open/connect burst) sometimes didn't show up. The capture was fine — the frames were lost &lt;em&gt;downstream&lt;/em&gt;. In MV3, the service worker routes frames to the DevTools panel, but if a frame arrives &lt;strong&gt;before&lt;/strong&gt; the panel's port has connected, there's nowhere to send it, and there's no replay. Outgoing frames "worked" only because I emit them synchronously on a user click, by which point the panel was connected.&lt;/p&gt;

&lt;p&gt;Fix: buffer per-tab in the service worker and flush the history when a panel connects — capped by both frame count &lt;em&gt;and&lt;/em&gt; total bytes, because of those 300 KB TradingView frames. Obvious in hindsight; invisible until you test on a site whose interesting traffic all happens in the first 500 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is (and isn't)
&lt;/h2&gt;

&lt;p&gt;Wirepeek is a free, MV3 Chrome extension. Capture is &lt;strong&gt;read-only&lt;/strong&gt; and 100% local — no account, no servers, no telemetry, nothing leaves your machine. It does: live capture (↑/↓, timestamp, size, surviving reloads, iframes too), the decode pipeline above, a collapsible/colored JSON tree with inline image preview, filter/search &lt;em&gt;inside decoded content&lt;/em&gt; (substring or regex), timeline markers for navigation and capture start/stop, and copy/export (whole session or a selected range). It speaks 7 languages.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; do replay or mocking yet — you inspect your own live traffic, you don't rewrite it. If people actually use this, that's where it goes next.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwpfqkx92ab5bmpmxdwbs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwpfqkx92ab5bmpmxdwbs.gif" alt="Wirepeek decoding socket.io frames live in the DevTools panel" width="200" height="109"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build real-time apps, I'd genuinely like to know which protocol &lt;em&gt;you&lt;/em&gt; stare at raw the most. That's the next decoder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wirepeek:&lt;/strong&gt; &lt;a href="https://chromewebstore.google.com/detail/wirepeek/ojoojkjcpibfddgcljlfbjobkcpcbejn" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt; · &lt;a href="https://wirepeek.com" rel="noopener noreferrer"&gt;wirepeek.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>devtools</category>
      <category>websockets</category>
    </item>
  </channel>
</rss>
