mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-18 05:05:28 +08:00
fix(stream): handle SSE line endings and disable proxy buffering (#636)
This commit is contained in:
parent
5ab20aa503
commit
024743c014
@ -45,6 +45,10 @@ public class Utf8SseEmitter extends SseEmitter {
|
|||||||
protected void extendResponse(ServerHttpResponse response) {
|
protected void extendResponse(ServerHttpResponse response) {
|
||||||
super.extendResponse(response);
|
super.extendResponse(response);
|
||||||
HttpHeaders headers = response.getHeaders();
|
HttpHeaders headers = response.getHeaders();
|
||||||
|
// Streaming frames must reach the client as they are emitted. Nginx
|
||||||
|
// honors this response header unless explicitly configured to ignore it.
|
||||||
|
headers.set("X-Accel-Buffering", "no");
|
||||||
|
headers.setCacheControl("no-store, no-transform");
|
||||||
// Spring's default sets Content-Type=text/event-stream without charset.
|
// Spring's default sets Content-Type=text/event-stream without charset.
|
||||||
// Only override when no charset is already specified, so callers that
|
// Only override when no charset is already specified, so callers that
|
||||||
// want to roll their own (rare) keep working.
|
// want to roll their own (rare) keep working.
|
||||||
|
|||||||
@ -22,6 +22,16 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
*/
|
*/
|
||||||
class Utf8SseEmitterTest {
|
class Utf8SseEmitterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disablesProxyBufferingAndCachingWhileKeepingUtf8() {
|
||||||
|
var response = new ServletServerHttpResponse(new MockHttpServletResponse());
|
||||||
|
new Utf8SseEmitter().extendResponse(response);
|
||||||
|
|
||||||
|
assertEquals("no", response.getHeaders().getFirst("X-Accel-Buffering"));
|
||||||
|
assertEquals("no-store, no-transform", response.getHeaders().getCacheControl());
|
||||||
|
assertEquals(StandardCharsets.UTF_8, response.getHeaders().getContentType().getCharset());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("extendResponse stamps charset=UTF-8 when Content-Type is unset")
|
@DisplayName("extendResponse stamps charset=UTF-8 when Content-Type is unset")
|
||||||
void stampsUtf8WhenContentTypeUnset() throws Exception {
|
void stampsUtf8WhenContentTypeUnset() throws Exception {
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useStream } from '../useStream'
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('incremental SSE framing', () => {
|
||||||
|
it.each(['\n', '\r\n', '\r'])('dispatches %j frames before the connection closes', async (newline) => {
|
||||||
|
let controller!: ReadableStreamDefaultController<Uint8Array>
|
||||||
|
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c } })
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body)))
|
||||||
|
const stream = useStream({ url: '/test' })
|
||||||
|
const received: string[] = []
|
||||||
|
stream.on('content_delta', data => received.push(data.delta))
|
||||||
|
const connected = stream.connect()
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
try {
|
||||||
|
for (const delta of ['first', 'second', 'third']) {
|
||||||
|
const frame = `event: content_delta${newline}data: ${JSON.stringify({ delta })}${newline}${newline}`
|
||||||
|
// Every possible CRLF pair is split across network reads.
|
||||||
|
for (const char of frame) controller.enqueue(encoder.encode(char))
|
||||||
|
await vi.waitFor(() => expect(received.at(-1)).toBe(delta), { timeout: 300 })
|
||||||
|
}
|
||||||
|
expect(received).toEqual(['first', 'second', 'third'])
|
||||||
|
} finally {
|
||||||
|
controller.close()
|
||||||
|
await connected
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins multiple data lines and ignores comment-only frames', async () => {
|
||||||
|
const payload = ': heartbeat\r\n\r\nevent: content_delta\r\ndata: {"delta":\r\ndata: "你好"}\r\n\r\n'
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(payload)))
|
||||||
|
const stream = useStream({ url: '/test' })
|
||||||
|
const received: unknown[] = []
|
||||||
|
stream.onEvent(event => received.push(event.data))
|
||||||
|
await stream.connect()
|
||||||
|
expect(received).toEqual([{ delta: '你好' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps streaming when line endings change after the first event', async () => {
|
||||||
|
let controller!: ReadableStreamDefaultController<Uint8Array>
|
||||||
|
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c } })
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body)))
|
||||||
|
const stream = useStream({ url: '/test' })
|
||||||
|
const received: string[] = []
|
||||||
|
stream.on('content_delta', data => received.push(data.delta))
|
||||||
|
const connected = stream.connect()
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode('data: {"delta":"first"}\n\n'))
|
||||||
|
await vi.waitFor(() => expect(received).toEqual(['first']))
|
||||||
|
controller.enqueue(encoder.encode('data: {"delta":"second"}\r\n\r\n'))
|
||||||
|
await vi.waitFor(() => expect(received).toEqual(['first', 'second']))
|
||||||
|
} finally {
|
||||||
|
controller.close()
|
||||||
|
await connected
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -147,9 +147,16 @@ export interface UseStreamReturn {
|
|||||||
class SSEParser {
|
class SSEParser {
|
||||||
private buffer = ''
|
private buffer = ''
|
||||||
private readonly separator = '\n\n'
|
private readonly separator = '\n\n'
|
||||||
|
private trailingCR = false
|
||||||
|
|
||||||
parse(chunk: string): SSEEvent[] {
|
parse(chunk: string): SSEEvent[] {
|
||||||
this.buffer += chunk
|
// SSE permits LF, CRLF and CR. Normalize before framing, including a
|
||||||
|
// CRLF pair split across network reads, without manufacturing blank lines.
|
||||||
|
if (chunk.length > 0) {
|
||||||
|
const skipLF = this.trailingCR && chunk.startsWith('\n')
|
||||||
|
this.trailingCR = chunk.endsWith('\r')
|
||||||
|
this.buffer += (skipLF ? chunk.slice(1) : chunk).replace(/\r\n?/g, '\n')
|
||||||
|
}
|
||||||
const events: SSEEvent[] = []
|
const events: SSEEvent[] = []
|
||||||
|
|
||||||
// 分割事件块
|
// 分割事件块
|
||||||
@ -179,8 +186,7 @@ class SSEParser {
|
|||||||
private parseEvent(part: string): SSEEvent | null {
|
private parseEvent(part: string): SSEEvent | null {
|
||||||
const lines = part.split('\n')
|
const lines = part.split('\n')
|
||||||
let eventType: SSEEventType = 'content_delta'
|
let eventType: SSEEventType = 'content_delta'
|
||||||
let data: any = {}
|
const dataLines: string[] = []
|
||||||
let hasData = false
|
|
||||||
let eventId: string | undefined
|
let eventId: string | undefined
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@ -189,24 +195,27 @@ class SSEParser {
|
|||||||
const colonIndex = line.indexOf(':')
|
const colonIndex = line.indexOf(':')
|
||||||
if (colonIndex === -1) continue
|
if (colonIndex === -1) continue
|
||||||
|
|
||||||
const key = line.slice(0, colonIndex).trim()
|
const key = line.slice(0, colonIndex)
|
||||||
const value = line.slice(colonIndex + 1).trim()
|
const rawValue = line.slice(colonIndex + 1)
|
||||||
|
const value = rawValue.startsWith(' ') ? rawValue.slice(1) : rawValue
|
||||||
|
|
||||||
if (key === 'event') {
|
if (key === 'event') {
|
||||||
eventType = value as SSEEventType
|
eventType = value as SSEEventType
|
||||||
} else if (key === 'id') {
|
} else if (key === 'id') {
|
||||||
eventId = value
|
eventId = value
|
||||||
} else if (key === 'data') {
|
} else if (key === 'data') {
|
||||||
hasData = true
|
dataLines.push(value)
|
||||||
try {
|
|
||||||
data = JSON.parse(value)
|
|
||||||
} catch {
|
|
||||||
data = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasData) return null
|
if (dataLines.length === 0) return null
|
||||||
|
const rawData = dataLines.join('\n')
|
||||||
|
let data: any
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData)
|
||||||
|
} catch {
|
||||||
|
data = rawData
|
||||||
|
}
|
||||||
return eventId !== undefined
|
return eventId !== undefined
|
||||||
? { type: eventType, data, id: eventId }
|
? { type: eventType, data, id: eventId }
|
||||||
: { type: eventType, data }
|
: { type: eventType, data }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user