Last active
December 24, 2025 11:58
-
-
Save mattzcarey/4eedfe2b266d378474fa40e1f0d9a911 to your computer and use it in GitHub Desktop.
Test SSE endpoints for newline terminator stripping (WARP/proxy issues)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env npx tsx | |
| /** | |
| * Script to test if SSE responses have proper newline terminators. | |
| * | |
| * SSE (Server-Sent Events) requires messages to be terminated with \n\n (two newlines). | |
| * Some proxies (like Cloudflare WARP) may strip trailing newlines, causing EventSource | |
| * to hang waiting for message terminators that never arrive. | |
| * | |
| * Usage: | |
| * npx tsx test-sse-newlines.ts [url] | |
| * | |
| * Example: | |
| * npx tsx test-sse-newlines.ts https://example.com/sse | |
| */ | |
| const DEFAULT_URL = "https://docs.mcp.cloudflare.com/sse"; | |
| const url = process.argv[2] || DEFAULT_URL; | |
| async function testSSENewlines(url: string): Promise<void> { | |
| console.log(`Testing SSE endpoint: ${url}\n`); | |
| const controller = new AbortController(); | |
| const rawBytes: number[] = []; | |
| let buffer = ""; | |
| let messageCount = 0; | |
| let hasDoubleNewline = false; | |
| const timeout = setTimeout(() => { | |
| console.log("\n⏱️ Timeout reached (5s) - aborting request\n"); | |
| controller.abort(); | |
| }, 5000); | |
| try { | |
| const response = await fetch(url, { | |
| method: "GET", | |
| headers: { | |
| Accept: "text/event-stream", | |
| }, | |
| signal: controller.signal, | |
| }); | |
| if (!response.ok) { | |
| console.error(`❌ HTTP error: ${response.status} ${response.statusText}`); | |
| return; | |
| } | |
| const contentType = response.headers.get("content-type"); | |
| console.log(`Content-Type: ${contentType}`); | |
| if (!contentType?.includes("text/event-stream")) { | |
| console.warn("⚠️ Warning: Content-Type is not text/event-stream"); | |
| } | |
| const reader = response.body?.getReader(); | |
| if (!reader) { | |
| console.error("❌ No response body"); | |
| return; | |
| } | |
| const decoder = new TextDecoder(); | |
| console.log("\n--- Raw SSE Data ---\n"); | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) { | |
| console.log("\n--- End of stream ---\n"); | |
| break; | |
| } | |
| // Collect raw bytes for analysis | |
| for (const byte of value) { | |
| rawBytes.push(byte); | |
| } | |
| const chunk = decoder.decode(value, { stream: true }); | |
| buffer += chunk; | |
| // Print chunk with visible newlines | |
| const visibleChunk = chunk | |
| .replace(/\r\n/g, "\\r\\n⏎\n") | |
| .replace(/\n/g, "\\n⏎\n") | |
| .replace(/\r/g, "\\r"); | |
| process.stdout.write(visibleChunk); | |
| // Check for double newlines (SSE message terminators) | |
| if (buffer.includes("\n\n") || buffer.includes("\r\n\r\n")) { | |
| hasDoubleNewline = true; | |
| messageCount++; | |
| // Keep only the last incomplete message | |
| const lastDoubleNewline = Math.max( | |
| buffer.lastIndexOf("\n\n"), | |
| buffer.lastIndexOf("\r\n\r\n") | |
| ); | |
| if (lastDoubleNewline !== -1) { | |
| buffer = buffer.slice(lastDoubleNewline + 2); | |
| } | |
| } | |
| // Stop after collecting enough data | |
| if (rawBytes.length >= 2048 || messageCount >= 3) { | |
| console.log("\n--- Collected enough data ---\n"); | |
| reader.cancel(); | |
| break; | |
| } | |
| } | |
| clearTimeout(timeout); | |
| } catch (error) { | |
| clearTimeout(timeout); | |
| if (!(error instanceof Error && error.name === "AbortError")) { | |
| console.error("Error:", error); | |
| return; | |
| } | |
| // AbortError is expected on timeout - continue to analysis | |
| } | |
| // Always analyze what we received | |
| analyzeResults(rawBytes, messageCount, hasDoubleNewline); | |
| } | |
| function analyzeResults(rawBytes: number[], messageCount: number, hasDoubleNewline: boolean): void { | |
| console.log("--- Byte Analysis ---\n"); | |
| // Find newline patterns | |
| const newlinePatterns: { position: number; pattern: string }[] = []; | |
| for (let i = 0; i < rawBytes.length; i++) { | |
| if (rawBytes[i] === 0x0a) { | |
| // \n | |
| if (i + 1 < rawBytes.length && rawBytes[i + 1] === 0x0a) { | |
| newlinePatterns.push({ position: i, pattern: "\\n\\n" }); | |
| i++; // Skip next \n | |
| } else if ( | |
| i > 0 && | |
| rawBytes[i - 1] === 0x0d && | |
| i + 1 < rawBytes.length && | |
| rawBytes[i + 1] === 0x0d && | |
| i + 2 < rawBytes.length && | |
| rawBytes[i + 2] === 0x0a | |
| ) { | |
| newlinePatterns.push({ position: i - 1, pattern: "\\r\\n\\r\\n" }); | |
| i += 2; // Skip remaining bytes | |
| } | |
| } | |
| } | |
| console.log(`Total bytes received: ${rawBytes.length}`); | |
| console.log(`Complete SSE messages: ${messageCount}`); | |
| console.log(`Double-newline terminators found: ${newlinePatterns.length}`); | |
| if (newlinePatterns.length > 0) { | |
| console.log("\nTerminator positions:"); | |
| for (const { position, pattern } of newlinePatterns.slice(0, 10)) { | |
| console.log(` Byte ${position}: ${pattern}`); | |
| } | |
| } | |
| // Check the end of the buffer for proper termination | |
| if (rawBytes.length > 0) { | |
| console.log("\n--- Last 30 bytes (hex) ---\n"); | |
| const lastBytes = rawBytes.slice(-30); | |
| const hexDump = lastBytes | |
| .map((b) => b.toString(16).padStart(2, "0")) | |
| .join(" "); | |
| const charDump = lastBytes | |
| .map((b) => { | |
| if (b === 0x0a) return "⏎"; | |
| if (b === 0x0d) return "↵"; | |
| return b >= 32 && b < 127 ? String.fromCharCode(b) : "."; | |
| }) | |
| .join(""); | |
| console.log(`Hex: ${hexDump}`); | |
| console.log(`Char: ${charDump}`); | |
| // Check if last bytes end with \n\n | |
| const endsWithDoubleNewline = | |
| (rawBytes.length >= 2 && rawBytes[rawBytes.length - 1] === 0x0a && rawBytes[rawBytes.length - 2] === 0x0a) || | |
| (rawBytes.length >= 4 && rawBytes[rawBytes.length - 1] === 0x0a && rawBytes[rawBytes.length - 2] === 0x0d && | |
| rawBytes[rawBytes.length - 3] === 0x0a && rawBytes[rawBytes.length - 4] === 0x0d); | |
| console.log(`\nEnds with \\n\\n: ${endsWithDoubleNewline ? "YES" : "NO"}`); | |
| } | |
| // Final verdict | |
| console.log("\n--- Verdict ---\n"); | |
| if (rawBytes.length === 0) { | |
| console.log("⚠️ No data received - check if the endpoint is working"); | |
| } else if (!hasDoubleNewline && newlinePatterns.length === 0) { | |
| console.log("❌ PROBLEM DETECTED: No SSE message terminators (\\n\\n) found!"); | |
| console.log(" A proxy is likely stripping trailing newlines."); | |
| console.log(" EventSource clients will hang waiting for message boundaries.\n"); | |
| console.log("Possible causes:"); | |
| console.log(" - Cloudflare WARP proxy stripping newlines"); | |
| console.log(" - Corporate proxy modifying response"); | |
| console.log(" - VPN intercepting traffic"); | |
| } else if (messageCount > 0 || hasDoubleNewline) { | |
| console.log(`✅ SSE format looks correct`); | |
| console.log(" Double-newline terminators (\\n\\n) are present."); | |
| console.log(" EventSource should parse messages correctly."); | |
| } else { | |
| console.log("⚠️ Inconclusive - received data but no complete messages"); | |
| } | |
| } | |
| testSSENewlines(url); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment