Last reviewed: November 3, 2026 (Asia/Shanghai).
Direct answer
Test a CometAPI SSE reader by encoding one valid response fixture as bytes, splitting it at every possible byte position, and requiring the same accepted text and completion state for every split. Run those bytes through the actual streaming decoder, SSE parser, and chat-chunk checks. Add a one-byte-at-a-time case and negative fixtures that end before the completion marker.
The CometAPI chat reference
documents stream: true, chat.completion.chunk events, text in delta.content, and the final data: [DONE] marker. It also shows a usage event with an empty choices array. A reader that assumes every network read contains one complete JSON object, or every event contains choices[0], can fail on valid input.
The implementation below accepts one plain-text choice. It returns text only after both an acceptable stop finish and a complete [DONE] event. The offline fixtures establish local parser behavior; they do not establish model access or provider performance.
Who this is for
Use this tutorial when a server-side Node.js integration reads CometAPI response bodies directly and you need reproducible checks for intermittent streaming bugs. You need Node.js 22.12 or later and a small local project. The examples were exercised on Node.js 24.22.1 with eventsource-parser 4.1.3.
No credentials are needed for the fixture tests. A separate request example uses server-injected COMETAPI_KEY and a COMETAPI_MODEL value verified for your account and Chat Completions. No authenticated CometAPI request was performed while preparing this article.
Key takeaways
- Split encoded bytes, including positions inside multibyte characters and CRLF pairs.
- Keep one decoder and one SSE parser alive for each response.
- Parse JSON only after an SSE event is complete.
- Accept empty-choice usage events without reading
choices[0]. - Require a complete terminal marker; EOF by itself is insufficient.
- Test the same helper that the live request uses, with explicit local limits and sanitized outcomes.
Understand the three boundaries
The HTML SSE standard
specifies UTF-8, line endings of CRLF, LF, or CR, and event dispatch when a blank line arrives. Multiple data: fields form one event value with newline separators. Lines beginning with a colon are comments. An unfinished event at EOF is discarded.
Those rules create three stages: bytes become decoded text, text becomes complete SSE events, and event data becomes endpoint-specific chat chunks. A transport chunk may stop halfway through any stage. Keeping parser state across reads makes the result independent of where those reads happen.
The versioned eventsource-parser README
documents a stream adapter that receives decoded text and emits complete messages. Its maxBufferSize option limits retained partial-line and event-data buffers in characters. This is separate from an application’s accepted event size, accumulated output, and total request lifetime.
Build the reader used by the tests
Install the version exercised here and retain your lockfile:
npm install --save-exact [email protected]
Save the following as read-chat-stream.mjs. The limits are illustrative application settings: 65,536 characters for parser buffering and event data, 65,536 JavaScript string code units for accepted output, and 10,000 delivered data events. They are not CometAPI limits. The event counter includes [DONE] and excludes SSE comments.
import { EventSourceParserStream } from 'eventsource-parser/stream';
export class StreamFault extends Error {
constructor(code) {
super(code);
this.code = code;
}
}
export async function readTextChat(response, controller) {
let reader;
let text = '';
let events = 0;
let finishReason = null;
let usageSeen = false;
try {
if (!response.ok) throw new StreamFault('http_error');
const type = (response.headers.get('content-type') ?? '')
.split(';')[0].trim().toLowerCase();
if (type !== 'text/event-stream') {
throw new StreamFault('unexpected_content_type');
}
if (!response.body) throw new StreamFault('missing_body');
reader = response.body
.pipeThrough(new TextDecoderStream('utf-8', { fatal: true }))
.pipeThrough(new EventSourceParserStream({ maxBufferSize: 65536 }))
.getReader();
for (;;) {
controller.signal.throwIfAborted();
const { done, value: event } = await reader.read();
if (done) throw new StreamFault('missing_done');
if (++events > 10000) throw new StreamFault('event_limit');
if (event.event && event.event !== 'message') {
throw new StreamFault('unexpected_event_type');
}
if (event.data.length > 65536) throw new StreamFault('event_size_limit');
if (event.data === '[DONE]') {
controller.signal.throwIfAborted();
if (finishReason !== 'stop' || text.length === 0) {
throw new StreamFault('unaccepted_text_result');
}
return { text, events, usageSeen };
}
let chunk;
try { chunk = JSON.parse(event.data); }
catch { throw new StreamFault('invalid_event_json'); }
if (!chunk || chunk.object !== 'chat.completion.chunk' ||
!Array.isArray(chunk.choices) || chunk.error != null) {
throw new StreamFault('unexpected_chunk');
}
usageSeen ||= chunk.usage != null;
if (chunk.choices.length === 0) continue;
const choice = chunk.choices[0];
if (chunk.choices.length !== 1 || choice?.index !== 0) {
throw new StreamFault('unsupported_choices');
}
if (finishReason !== null) {
throw new StreamFault('choices_after_finish');
}
const delta = choice.delta;
if (!delta || typeof delta !== 'object' || Array.isArray(delta) ||
delta.tool_calls != null || delta.function_call != null ||
delta.refusal != null) {
throw new StreamFault('unsupported_delta');
}
const part = delta.content ?? '';
if (typeof part !== 'string') throw new StreamFault('unsupported_delta');
if (part.length > 65536 - text.length) {
throw new StreamFault('output_limit');
}
text += part;
if (choice.finish_reason != null) {
if (choice.finish_reason !== 'stop') {
throw new StreamFault('unaccepted_finish');
}
finishReason = 'stop';
}
}
} catch (error) {
const fault = error instanceof StreamFault ? error : new StreamFault(
error?.type === 'max-buffer-size-exceeded'
? 'sse_buffer_limit' : 'stream_read_failed'
);
controller.abort(fault);
throw fault;
} finally {
if (reader) {
await reader.cancel().catch(() => {});
reader.releaseLock();
} else {
await response.body?.cancel().catch(() => {});
}
}
}
The Node.js Web Streams reference
documents TextDecoderStream, fatal decoding, reader cancellation, and lock release. Fatal decoding is a deliberate application policy here: invalid UTF-8 fails the attempt instead of silently changing text. The reader cancels on acceptance as well as rejection so it can finish at the protocol marker without waiting for the server to close the connection.
This helper is intentionally specific. Tool-call deltas, refusals, multiple choices, and non-stop finishes reach rejection branches. usageSeen records only that a usage value appeared; it neither validates accounting fields nor requires them. The CometAPI reference says stream_options.include_usage: true requests a final usage chunk before [DONE]; the basic request below leaves that option unset.
Run every byte split through the reader
Save this as boundary-check.mjs. The fixture uses multilingual text, an emoji, CRLF delimiters, a comment, an empty-choice usage chunk, a finish chunk, and the terminal marker. Each loop iteration constructs a fresh response and parser. The two chunks concatenate to the same bytes in every iteration.
import assert from 'node:assert/strict';
import { readTextChat } from './read-chat-stream.mjs';
const encode = value => new TextEncoder().encode(value);
const frame = value => `data: ${JSON.stringify(value)}\r\n\r\n`;
const chunk = (delta, finish_reason = null) => ({
object: 'chat.completion.chunk',
choices: [{ index: 0, delta, finish_reason }]
});
const fixture = ': heartbeat\r\n\r\n' +
frame(chunk({ content: '你好 🌍' })) +
frame(chunk({}, 'stop')) +
frame({ object: 'chat.completion.chunk', choices: [], usage: { total_tokens: 9 } }) +
'data: [DONE]\r\n\r\n';
const bytes = encode(fixture);
function response(parts) {
return new Response(new ReadableStream({
start(c) {
for (const part of parts) c.enqueue(part);
c.close();
}
}), { headers: { 'content-type': 'text/event-stream' } });
}
for (let cut = 0; cut <= bytes.length; cut++) {
const result = await readTextChat(
response([bytes.slice(0, cut), bytes.slice(cut)]),
new AbortController()
);
assert.equal(result.text, '你好 🌍');
assert.equal(result.events, 4);
assert.equal(result.usageSeen, true);
}
const oneByteParts = Array.from(bytes, byte => Uint8Array.of(byte));
assert.equal((await readTextChat(
response(oneByteParts), new AbortController()
)).text, '你好 🌍');
for (const raw of [
fixture.replace('data: [DONE]\r\n\r\n', ''),
fixture.replace('data: [DONE]\r\n\r\n', 'data: [DONE]\r\n')
]) {
await assert.rejects(readTextChat(
response([encode(raw)]), new AbortController()
), { code: 'missing_done' });
}
await assert.rejects(readTextChat(
response([encode('data: {broken}\n\n')]), new AbortController()
), { code: 'invalid_event_json' });
console.info('Byte-split and incomplete-stream checks passed');
Run node boundary-check.mjs. The two incomplete fixtures test different failures: the marker is absent, or its line arrives without the blank line needed to dispatch the event. Neither should release the accumulated text as an accepted result. The malformed JSON fixture is already a complete SSE event, so it fails at the application-data stage.
During preparation, this published check passed. An expanded local fixture also passed all 480 possible two-part byte splits and one-byte delivery. Additional local checks covered repeated data: lines, LF and CR endings, a split byte-order mark, invalid UTF-8, malformed JSON, missing completion, refusals, tool calls, output and event-count limits, oversized event data, and cancellation of an open source. These were synthetic tests, with no paid API traffic.
Connect the same helper to Fetch
Save this as stream-chat.mjs. Supply the key through protected server configuration and select a current model supported by your account. The Node.js globals reference
documents Fetch and abort signals. The timer stays active while the body is read, including a stream that sends headers and then stalls.
import { readTextChat, StreamFault } from './read-chat-stream.mjs';
const key = process.env.COMETAPI_KEY;
const model = process.env.COMETAPI_MODEL;
if (!key || !model) throw new Error('Missing server configuration');
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new StreamFault('client_deadline'));
}, 30000);
let status = null;
try {
const response = await fetch('https://api.cometapi.com/v1/chat/completions', {
method: 'POST',
redirect: 'error',
signal: controller.signal,
headers: {
authorization: `Bearer ${key}`,
'content-type': 'application/json'
},
body: JSON.stringify({
model,
stream: true,
messages: [{ role: 'user', content: 'Return one short greeting.' }]
})
});
status = response.status;
const result = await readTextChat(response, controller);
// Pass result.text to an authorized application consumer here.
console.info(JSON.stringify({
event: 'chat_stream_accepted',
http_status: status,
events: result.events,
output_chars: result.text.length,
usage_seen: result.usageSeen
}));
} catch (error) {
const reason = controller.signal.reason;
const code = reason instanceof StreamFault ? reason.code :
error instanceof StreamFault ? error.code : 'transport_error';
console.error(JSON.stringify({ event: 'chat_stream_rejected', http_status: status, code }));
process.exitCode = 1;
} finally {
clearTimeout(timer);
}
Run the example with node stream-chat.mjs after injecting configuration. The 30-second deadline is a local choice. A separate loopback HTTP check during preparation sent SSE headers and a heartbeat, then stalled; aborting the Fetch signal rejected the read and the server observed connection closure. That check verifies the local cancellation path, without claiming an upstream generation or billing outcome.
Contract details to verify
| Boundary | Accepted behavior | Rejection to exercise |
|---|---|---|
| HTTP response | Successful status and text/event-stream | Non-success status or JSON/HTML media type |
| UTF-8 decoding | Characters survive arbitrary byte splits | Invalid bytes or an incomplete encoded character |
| SSE framing | Blank-line dispatch; comments ignored; repeated data fields joined | EOF before the terminal event is dispatched |
| Chat chunks | One choice at index zero, with optional empty-choice events | Unexpected envelope, extra choices, or unsupported delta |
| Completion | Nonempty text, stop, then [DONE] | Length-limited finish, missing stop, or missing marker |
| Resources | Configured event, output, and lifetime limits | Oversized pending data, oversized delivered event, or stalled body |
The two-part sweep covers every single split location in a fixture; it does not cover every possible partition, arbitrary payload, or timing pattern. Add randomized multipart delivery and delayed reads when testing your application’s transport. Keep representative fixtures small enough to run on every change, and add regression cases when an incident exposes a new shape.
Parser buffering and accepted-event checks also have practical limits. The complete event already exists when event.data.length is checked. Incoming chunks, transform queues, decoded strings, parsed objects, and concurrent requests consume additional memory. This tutorial therefore makes no process-memory guarantee. For complete JSON bodies, use the separate response-byte limit guide
.
Failure modes
The test stubs away the parser. Replacing readTextChat with a fake successful return cannot expose decoder or framing bugs. Supply synthetic bytes at the response-body boundary and keep the production helper in the test path.
A stream stops after plausible text. The helper withholds acceptance until both completion checks pass. Preserve that distinction if you later add provisional UI updates; displayed text should not imply a completed request.
A parser error leaks content. Library errors can carry line or field values. This helper maps unexpected read errors to fixed codes and logs neither raw error objects nor event data. Keep prompts, generated text, authorization headers, and upstream error bodies out of routine diagnostics.
A retry hides the original failure. The request example makes one attempt. A local parser failure does not establish that the upstream performed no work. Decide retry policy separately, after considering already displayed output and the remaining request budget.
A new route inherits old acceptance rules. Responses events, tool calls, or multiple-choice output need their own dispatch and completion logic. Update fixtures and acceptance rules together instead of relaxing checks until an example passes.
Sources checked
These primary technical references were fetched on November 2, 2026 UTC, which was November 3 in Asia/Shanghai. They support an evergreen implementation task. The parser reference is pinned to the exercised release; the standard and runtime pages were checked live rather than treated as product-launch news.
- CometAPI: Create a chat completion — endpoint, streaming chunks, text deltas, final marker, and empty-choice usage events.
- WHATWG: Server-sent events — UTF-8, line endings, field interpretation, event dispatch, and incomplete-event handling.
- eventsource-parser 4.1.3 README — installation, stream composition, comments, parse errors, and retained-buffer limits.
- Node.js: Web Streams API — decoding transforms, reader ownership, cancellation, and lock release.
- Node.js: Global objects — Fetch, AbortController, abort reasons, and abort-state checks.
FAQ
Does testing every byte split prove full SSE compliance?
No. It proves the selected fixture survives each two-part division and the additional partitions you test. Protocol compliance needs broader fixtures, and transport behavior needs controlled integration checks.
Why wait for [DONE] after finish_reason: stop?
The chat reference shows usage may arrive after the finish chunk and before [DONE]. Keeping the reader open until the terminal marker preserves that event and avoids accepting a stream that ends early.
Can I use the browser’s EventSource constructor for this request?
This example uses Fetch to send a POST body and bearer authorization. The standard EventSource constructor exposes a URL and a credentials option, without this request configuration. Use the Fetch stream path shown here for the server-side example.
Does cancellation stop upstream charges?
The tests establish local reader and connection behavior. They provide no upstream billing guarantee. Reconcile usage and retry decisions through the appropriate account and application evidence.
Reader next step
Copy the reader and fixture into one server-side integration, run the byte-split suite, then add your accepted response variants and rejection cases. Use the streaming transcript workflow to compare a sanitized real response with your fixtures. Finally, run one harmless authenticated request through the same helper and confirm that only an accepted terminal state reaches your application consumer.