diff --git a/.changeset/fix-content-type-essence.md b/.changeset/fix-content-type-essence.md new file mode 100644 index 0000000..63afd5f --- /dev/null +++ b/.changeset/fix-content-type-essence.md @@ -0,0 +1,5 @@ +--- +'eventsource': patch +--- + +Accept event stream MIME types regardless of case. Reject different subtypes and select the last valid type when Content-Type contains multiple values. diff --git a/src/EventSource.ts b/src/EventSource.ts index e4dd9c6..834fe0c 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -484,7 +484,7 @@ class EventSourceImpl extends EventTarget implements EventSource { // [spec] …or if res's `Content-Type` is not `text/event-stream`, then fail the connection. const contentType = headers.get('content-type') || '' - if (!contentType.startsWith('text/event-stream')) { + if (getMimeTypeEssence(contentType) !== 'text/event-stream') { this.#failConnection('Invalid content type, expected "text/event-stream"', status) return } @@ -810,3 +810,38 @@ function defineEventProperty( configurable: true, }) } + +// Fetch selects the last valid MIME type, ignoring invalid values and */*. +// Commas inside quoted parameters belong to the same value. +// https://fetch.spec.whatwg.org/#concept-header-extract-mime-type +function getMimeTypeEssence(contentType: string): string | undefined { + let essence: string | undefined + let start = 0 + let quoted = false + + for (let i = 0; i <= contentType.length; i++) { + const char = contentType[i] + if (i < contentType.length) { + if (quoted && char === '\\' && i + 1 < contentType.length) { + i++ + continue + } + if (char === '"') { + quoted = !quoted + continue + } + if (char !== ',' || quoted) continue + } + + const candidate = contentType + .slice(start, i) + .match( + /^[\t\n\r ]*([!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+)[\t\n\r ]*(?:;|$)/, + )?.[1] + ?.toLowerCase() + if (candidate && candidate !== '*/*') essence = candidate + start = i + 1 + } + + return essence +} diff --git a/test/client.test.ts b/test/client.test.ts index d807576..387363b 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -793,6 +793,89 @@ browserTest( }, ) +test.each([ + {contentType: 'text/event-stream', valid: true}, + {contentType: 'Text/Event-Stream', valid: true}, + {contentType: 'TEXT/EVENT-STREAM', valid: true}, + {contentType: 'text/event-stream;charset=utf-8', valid: true}, + {contentType: 'Text/Event-Stream; charset=UTF-8', valid: true}, + {contentType: 'text/event-stream ; charset=utf-8', valid: true}, + {contentType: ' \ttext/event-stream\t ; charset=utf-8', valid: true}, + {contentType: 'text/event-stream; boundary="one;two"', valid: true}, + {contentType: 'text/event-stream;', valid: true}, + {contentType: 'text/event-stream; charset', valid: true}, + {contentType: 'text/event-stream; charset=iso-8859-1', valid: true}, + {contentType: 'text/plain, Text/Event-Stream; charset=utf-8', valid: true}, + {contentType: 'text/event-stream, invalid', valid: true}, + {contentType: 'text/event-stream, */*', valid: true}, + {contentType: 'text/event-stream,', valid: true}, + {contentType: ', text/event-stream', valid: true}, + {contentType: 'text/event-stream; note="a,b"', valid: true}, + {contentType: 'text/event-stream; note="a\\\",b"', valid: true}, + {contentType: 'text/event-stream; note="unclosed, text/plain', valid: true}, + {contentType: 'text/plain; note="a,b", text/event-stream', valid: true}, + {contentType: ['text/plain', 'Text/Event-Stream'], valid: true}, + {contentType: ['text/event-stream', 'invalid'], valid: true}, + {contentType: ['text/event-stream', '*/*'], valid: true}, + {contentType: null, valid: false}, + {contentType: '', valid: false}, + {contentType: 'text/plain', valid: false}, + {contentType: 'text/event-streaming', valid: false}, + {contentType: 'text/event-stream+json', valid: false}, + {contentType: 'text/event-stream/extra', valid: false}, + {contentType: 'text/event-stream, text/plain', valid: false}, + {contentType: 'text/event-stream; charset=utf-8, text/plain', valid: false}, + {contentType: 'text/plain; note="x, text/event-stream"', valid: false}, + {contentType: 'text/event-stream; note="a\\\",b", text/plain', valid: false}, + {contentType: ['text/event-stream', 'text/plain'], valid: false}, + {contentType: 'text/event-stream garbage', valid: false}, + {contentType: 'text /event-stream', valid: false}, + {contentType: 'text/ event-stream', valid: false}, + {contentType: 'text/event-stream\u00a0; charset=utf-8', valid: false}, + {contentType: '\u00a0text/event-stream', valid: false}, +])('checks SSE MIME essence for $contentType', async ({contentType, valid}) => { + const params = new URLSearchParams() + if (contentType !== null) { + for (const value of Array.isArray(contentType) ? contentType : [contentType]) { + params.append('value', value) + } + } + + let signal: AbortSignal | undefined + const onMessage = getCallCounter({name: 'MIME message'}) + const onError = getCallCounter({name: 'MIME error'}) + const es = new OurEventSource(`${serverUrl}/content-type?${params}`, { + ...esInit, + fetch(url, init) { + signal = init.signal + return request(url, init) + }, + }) + es.addEventListener('message', onMessage.listener) + es.addEventListener('error', onError.listener) + + try { + const outcome = await Promise.race([ + onMessage.waitForCallCount(1).then(() => 'message'), + onError.waitForCallCount(1).then(() => 'error'), + ]) + expect(outcome).toBe(valid ? 'message' : 'error') + + if (valid) { + expect(es.readyState).toBe(OurEventSource.OPEN) + expect(onMessage.lastArg.data).toBe('Hello, world!') + expect(onError.callCount).toBe(0) + } else { + expect(es.readyState).toBe(OurEventSource.CLOSED) + expect(onError.lastArg.message).toBe('Invalid content type, expected "text/event-stream"') + expect(onMessage.callCount).toBe(0) + expect(signal?.aborted).toBe(true) + } + } finally { + es.close() + } +}) + test.each([200, 403])( 'aborts a rejected HTTP %i response before reporting failure', async (status) => { diff --git a/test/helpers/server.ts b/test/helpers/server.ts index ace2733..ee6788a 100644 --- a/test/helpers/server.ts +++ b/test/helpers/server.ts @@ -75,6 +75,8 @@ export function handleRequest( return writeDebug(req, res) case '/invalid-stream': return writeInvalidStream(req, res) + case '/content-type': + return writeContentType(req, res) case '/set-cookie': return writeCookies(req, res) case '/authed': @@ -581,6 +583,13 @@ function writeInvalidStream(req: IncomingMessage, res: ServerResponse) { res.write('This response is not a usable event stream.') } +function writeContentType(req: IncomingMessage, res: ServerResponse) { + const contentTypes = new URL(req.url || '/', 'http://localhost').searchParams.getAll('value') + res.writeHead(200, contentTypes.length === 0 ? {} : {'Content-Type': contentTypes}) + tryWrite(res, encode({data: 'Hello, world!'})) + tryWrite(res, ':\n') +} + function writeFallback(_req: IncomingMessage, res: ServerResponse) { res.writeHead(404, { 'Content-Type': 'text/plain',