Server-Sent Events (SSE) are everywhere in 2026. ChatGPT, Claude, Gemini, and dozens of AI platforms use SSE streaming to deliver real-time responses. If you're building a Chrome extension that needs to read or analyze these streams, the standard approach doesn't work out of the box.
Here's a complete guide to intercepting SSE in Chrome Manifest V3, with real code from building an extension that parses AI platform streams.
Why This Is Hard
In Manifest V2, you could use webRequest to intercept and read response bodies. MV3 removed that capability. The declarativeNetRequest API can block or redirect requests, but it can't read response content.
This means the only way to intercept SSE streams in MV3 is through MAIN world content script injection — overriding window.fetch or XMLHttpRequest before the page's JavaScript loads.
The Architecture
┌─────────────────────────────────────┐
│ MAIN World (page context) │
│ ┌─────────────────────────────┐ │
│ │ Override window.fetch │ │
│ │ Clone response streams │ │
│ │ Parse SSE data │ │
│ └──────────┬──────────────────┘ │
│ │ window.postMessage │
├─────────────┼───────────────────────┤
│ ISOLATED World (extension context) │
│ ┌──────────┴──────────────────┐ │
│ │ Content script listener │ │
│ │ Process & display data │ │
│ └──────────┬──────────────────┘ │
│ │ chrome.runtime │
├─────────────┼───────────────────────┤
│ Service Worker (background) │
│ ┌──────────┴──────────────────┐ │
│ │ State management │ │
│ │ Storage, badge updates │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
Three layers:
- MAIN world script — overrides fetch, reads the stream
- ISOLATED content script — receives data via postMessage, controls UI
- Service Worker — manages extension state
Step 1: Manifest Configuration
{
"manifest_version": 3,
"name": "SSE Interceptor",
"version": "1.0",
"permissions": ["activeTab", "storage"],
"content_scripts": [
{
"matches": ["https://chat.openai.com/*", "https://chatgpt.com/*"],
"js": ["content-isolated.js"],
"run_at": "document_start"
},
{
"matches": ["https://chat.openai.com/*", "https://chatgpt.com/*"],
"js": ["content-main.js"],
"run_at": "document_start",
"world": "MAIN"
}
],
"background": {
"service_worker": "background.js"
}
}
The critical part is "world": "MAIN". This injects your script into the page's JavaScript context, giving you access to the real window.fetch.
Important: run_at: "document_start" ensures your override loads before the page's own scripts. Without this, the page might call fetch() before your override is in place.
Step 2: Override window.fetch
// content-main.js — runs in MAIN world
(function() {
'use strict';
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const [resource, config] = args;
const url = typeof resource === 'string'
? resource
: resource?.url || '';
// Call original fetch
const response = await originalFetch.apply(this, args);
// Only intercept SSE endpoints
if (shouldIntercept(url)) {
// Clone before reading — the page still needs the original
const clonedResponse = response.clone();
// Parse the stream asynchronously (don't block the return)
parseSSEStream(clonedResponse.body, url).catch(err => {
console.warn('SSE parse error:', err.message);
});
}
return response;
};
function shouldIntercept(url) {
return url.includes('/backend-api/conversation') ||
url.includes('/api/chat') ||
url.includes('/v1/messages');
}
})();
Key details:
-
response.clone()— you must clone the response. Reading a stream consumes it. If you read the original, the page gets nothing. - Async parsing — don't
awaitthe parse. Return the response immediately so the page isn't blocked. - IIFE wrapper — prevents variable leaks into the page's global scope.
Step 3: Parse the SSE Stream
SSE streams follow a simple format: lines starting with data: contain the payload. Lines starting with event: contain the event type. Empty lines separate events.
async function parseSSEStream(readableStream, url) {
const reader = readableStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split on double newline (SSE event boundary)
const events = buffer.split('\n\n');
// Keep the last chunk — it might be incomplete
buffer = events.pop() || '';
for (const event of events) {
processSSEEvent(event, url);
}
}
// Process any remaining buffer
if (buffer.trim()) {
processSSEEvent(buffer, url);
}
} finally {
reader.releaseLock();
}
}
function processSSEEvent(eventText, url) {
const lines = eventText.split('\n');
let eventType = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
data += line.slice(6);
} else if (line.startsWith('data:')) {
// Some implementations omit the space
data += line.slice(5);
}
}
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
handleParsedData(parsed, eventType, url);
} catch {
// Not all data lines are JSON — some are plain text
if (data.trim()) {
handleRawData(data, eventType, url);
}
}
}
Common pitfalls:
- Incomplete chunks — SSE data arrives in arbitrary chunks that don't respect event boundaries. The buffer pattern above handles this.
- Missing space after
data:— the spec saysdata:(with space) but some implementations senddata:without space. -
[DONE]sentinel — most implementations senddata: [DONE]as the last event.
Step 4: Handle Platform-Specific Formats
Here's where it gets interesting. Each AI platform encodes data differently in their SSE streams.
ChatGPT: JSON Patch
ChatGPT uses RFC 6902 JSON Patch operations. Each event modifies a path in a running document:
function handleChatGPTData(parsed) {
// ChatGPT sends different message types
if (parsed.message?.content?.parts) {
// Text content
const text = parsed.message.content.parts.join('');
emit('text', text);
}
if (parsed.message?.metadata?.search_result_groups) {
// Search queries and results
for (const group of parsed.message.metadata.search_result_groups) {
for (const entry of group.entries || []) {
emit('source', {
url: entry.url,
title: entry.title,
snippet: entry.snippet
});
}
}
}
}
The tricky part: ChatGPT sends content character by character via patches. A search query like "best SEO tools" arrives as individual characters across multiple events. You need to accumulate them:
let accumulatedText = '';
function handleChatGPTPatch(patch) {
if (patch.op === 'add' && patch.path.includes('/parts/')) {
accumulatedText += patch.value || '';
} else if (patch.op === 'replace') {
// Full replacement — reset accumulator
accumulatedText = patch.value || '';
}
}
Claude: input_json_delta
Claude uses a cleaner format with typed content blocks:
function handleClaudeData(parsed) {
if (parsed.type === 'content_block_delta') {
if (parsed.delta?.type === 'text_delta') {
emit('text', parsed.delta.text);
}
if (parsed.delta?.type === 'input_json_delta') {
// Tool use (including search)
accumulateJSON(parsed.delta.partial_json);
}
}
if (parsed.type === 'content_block_stop') {
// Block complete — process accumulated JSON
const toolData = flushAccumulatedJSON();
if (toolData?.type === 'web_search') {
emit('search', toolData);
}
}
}
Claude's input_json_delta sends partial JSON strings. You need to accumulate them and parse only when content_block_stop fires.
Step 5: Communicate with Content Script
MAIN world and ISOLATED world can't directly share data. Use window.postMessage:
// In MAIN world script
function emit(type, data) {
window.postMessage({
source: 'sse-interceptor',
type: type,
data: data,
timestamp: Date.now()
}, '*');
}
// In ISOLATED content script (content-isolated.js)
window.addEventListener('message', (event) => {
if (event.data?.source !== 'sse-interceptor') return;
switch (event.data.type) {
case 'text':
updateTextDisplay(event.data.data);
break;
case 'source':
addSourceToList(event.data.data);
break;
case 'search':
displaySearchQuery(event.data.data);
break;
}
});
Security note: Always validate event.data.source to ignore messages from other scripts on the page.
Step 6: Deduplication
SSE streams often contain duplicate data. The same URL might appear when:
- The AI requests a search
- Search results are returned
- The AI decides to cite the source
Use fingerprint-based dedup:
const seen = new Map();
function dedup(type, data) {
const key = type + ':' + JSON.stringify(data);
const now = Date.now();
if (seen.has(key) && now - seen.get(key) < 5000) {
return true; // Duplicate within 5-second window
}
seen.set(key, now);
// Clean old entries periodically
if (seen.size > 1000) {
for (const [k, v] of seen) {
if (now - v > 30000) seen.delete(k);
}
}
return false;
}
Common Issues and Fixes
Issue 1: Override doesn't catch initial requests
If the page's JavaScript is cached or loaded via inline script, it might call fetch() before your content script runs.
Fix: Use "run_at": "document_start" and wrap your override in an immediately-invoked block with no async dependencies.
Issue 2: Some requests use XMLHttpRequest
Not all platforms use fetch(). Some use XHR for SSE. Override both:
const originalXHR = window.XMLHttpRequest;
// ... similar override pattern for XHR
Issue 3: Service Worker-based requests (Gemini)
Gemini routes some requests through Service Workers, which bypass window.fetch entirely. This is the hardest case — you may need to intercept at the Service Worker level or use chrome.debugger API (with appropriate permissions).
Full Working Example
I used these exact techniques to build AI Query Revealer, which intercepts hidden search queries from ChatGPT, Claude, and Gemini in real time. The core interception code handles all the platform-specific parsing described above.
The extension has been running in production for months, and the biggest maintenance challenge is keeping up with platform changes. ChatGPT and Claude occasionally change their endpoint URLs or stream formats, which requires updating the parsing logic.
If you're building something similar, I recommend adding health-check monitoring that detects when a platform changes its protocol — it saves hours of debugging when things suddenly break.
Building a Chrome extension that needs SSE interception? What platform are you targeting? Happy to dive deeper into any of these patterns in the comments.
Top comments (0)