Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ export async function createProtection(options = {}) {
// block mode (dry-run records via onDetect but returns 'pass').
const isTextCT = (ct) => {
ct = (ct || '').toLowerCase();
// Exclude live streams: a Server-Sent-Events / token stream must pass through unbuffered.
// Screening buffers the whole body, so it would withhold every chunk until the stream ends —
// breaking incremental LLM streaming, which AI-built apps lean on heavily.
if (ct.includes('event-stream')) return false;
return ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
};
const screenText = (text, meta, reqCtx) => {
Expand Down Expand Up @@ -585,6 +589,9 @@ function byPhase(rules, phase) {
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
if (!response || typeof response.clone !== 'function') return null;
const ct = (response.headers?.get?.('content-type') || '').toLowerCase();
// Live stream (SSE / token stream): never buffer it — reading to completion would withhold the
// response until the stream ends, breaking incremental streaming. Pass it through unscreened.
if (ct.includes('event-stream')) return null;
const isText = ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
if (!isText) return null;
const len = Number(response.headers?.get?.('content-length') || 0);
Expand Down
48 changes: 48 additions & 0 deletions tests/protect/response-streaming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { createProtection } from '../../src/protect/runtime.js';

// Response screening buffers the whole body, which would withhold a live stream (SSE / LLM tokens)
// until it ends — breaking incremental streaming. `text/event-stream` responses must pass through
// unbuffered.

describe('response phase — live streams pass through unbuffered', () => {
it('returns an SSE response immediately without consuming the (never-ending) stream', async () => {
const p: any = await createProtection({ mode: 'block' }); // default response rules active

// A stream that emits one event and never closes — modelling a long-lived token stream.
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: hello\n\n'));
// deliberately never call controller.close()
},
});
const sse = new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } });

// If screening tried to buffer this, it would await `done` forever and this would time out.
const out: any = await Promise.race([
p.screenResponse(sse),
new Promise((r) => setTimeout(() => r('TIMEOUT'), 500)),
]);

expect(out).not.toBe('TIMEOUT'); // returned promptly
expect(out).toBe(sse); // the ORIGINAL response, untouched (not rebuilt/screened)
});

it('still screens a normal (non-stream) text response', async () => {
const rule = {
phase: 'response',
category: 'x',
action: 'redact',
rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/topsecret/' } }],
};
const p: any = await createProtection({
rules: { firewall: [], whitelists: [], whitelist_keys: {} },
responseRules: [rule],
mode: 'block',
});
const out = await p.screenResponse(
new Response('a topsecret b', { status: 200, headers: { 'content-type': 'text/plain' } }),
);
expect(/topsecret/.test(await out.text())).toBe(false); // non-stream text is still screened
});
});
Loading