Node.js · SSE

Stream Tikway responses in Node.js: fetch, SSE and UTF-8 chunk handling

Last updated:

Use Node.js built-in fetch to read Tikway’s OpenAI-compatible Chat Completions stream. This guide handles network chunks, split UTF-8 characters, multiline SSE data, error events and [DONE] for backend text streaming.

Why a network chunk is not a JSON event

Set stream: true to request streaming. A single read can contain part of an event or several events, and UTF-8 characters or CRLF line endings can split across reads. Decode bytes continuously, assemble SSE events at blank-line boundaries, then parse their data.

This example prints text from choices[].delta.content. Role, final and usage events are not text. Tool-call arguments and media results need separate assembly logic.

1. Prepare Node.js and your API key

Use Node.js 22 or newer. Built-in fetch, ReadableStream, TextDecoder and AbortSignal.timeout require no npm package. Confirm Chat Completions and streaming support in the catalog, then obtain your Tikway key. The example uses openai/gpt-5.4-mini; copy an exact catalog ID when switching models.

Windows PowerShell:

powershell
$env:TIKWAY_API_KEY = "YOUR_TIKWAY_API_KEY"

macOS / Linux:

bash
export TIKWAY_API_KEY='YOUR_TIKWAY_API_KEY'

Keep the key in backend environment variables. Browser code that includes it exposes it to visitors; send requests through your own backend.

2. Save the complete fetch and SSE example

Save as tikway_stream.mjs. The downloadable script uses the same parser and request logic, with Chinese prompts and messages. Running it makes a real request to https://api.tikway.ai/v1/chat/completions under the account’s billing rules.

javascript
import { pathToFileURL } from 'node:url'

const ENDPOINT = 'https://api.tikway.ai/v1/chat/completions'
const MAX_EVENT_CHARS = 1024 * 1024

export async function* readSSE(body) {
    const reader = body.getReader()
    const decoder = new TextDecoder('utf-8', { fatal: true })
    let pending = ''
    let dataLines = []
    let eventName = ''
    let eventChars = 0

    function acceptLine(line) {
        if (line === '') {
            const event = dataLines.length ? { event: eventName || 'message', data: dataLines.join('\n') } : null
            dataLines = []
            eventName = ''
            eventChars = 0
            return event
        }
        eventChars += line.length
        if (eventChars > MAX_EVENT_CHARS) throw new Error('SSE event is too large.')
        if (line.startsWith(':')) return null // Comment or heartbeat

        const colon = line.indexOf(':')
        const field = colon === -1 ? line : line.slice(0, colon)
        let value = colon === -1 ? '' : line.slice(colon + 1)
        if (value.startsWith(' ')) value = value.slice(1)
        if (field === 'data') dataLines.push(value)
        if (field === 'event') eventName = value
        return null // This example does not use id/retry fields
    }

    try {
        while (true) {
            const { value, done } = await reader.read()
            pending += done ? decoder.decode() : decoder.decode(value, { stream: true })

            while (true) {
                const match = /\r\n|\r|\n/.exec(pending)
                if (!match) break
                // A trailing CR may pair with the next chunk's leading LF.
                if (!done && match[0] === '\r' && match.index === pending.length - 1) break
                const event = acceptLine(pending.slice(0, match.index))
                pending = pending.slice(match.index + match[0].length)
                if (event) yield event
            }
            if (pending.length + eventChars > MAX_EVENT_CHARS) {
                throw new Error('SSE event is too large.')
            }
            if (done) return // Do not emit incomplete events without a terminating blank line
        }
    } finally {
        try {
            await reader.cancel()
        } catch {
            /* Clean up the connection */
        }
        reader.releaseLock()
    }
}

export async function main() {
    const apiKey = process.env.TIKWAY_API_KEY?.trim()
    if (!apiKey) throw new Error('Set TIKWAY_API_KEY first.')

    const response = await fetch(ENDPOINT, {
        method: 'POST',
        headers: {
            Authorization: `Bearer ${apiKey}`,
            'Content-Type': 'application/json',
            Accept: 'text/event-stream'
        },
        body: JSON.stringify({
            model: process.env.TIKWAY_MODEL?.trim() || 'openai/gpt-5.4-mini',
            messages: [{ role: 'user', content: 'Explain the purpose of SSE in three sentences.' }],
            stream: true
        }),
        signal: AbortSignal.timeout(90_000)
    })

    const requestId = response.headers.get('x-request-id') || 'not provided'
    if (!response.ok) {
        let detail = 'Check the error code documentation.'
        try {
            const body = await response.json()
            const apiError = body?.error
            if (apiError && typeof apiError === 'object') {
                detail = `${apiError.code || apiError.type || 'unknown'}: ` + String(apiError.message || '').slice(0, 300)
            }
        } catch {
            /* Error responses may not be JSON */
        }
        throw new Error(`HTTP ${response.status}; request_id=${requestId}; ${detail}`)
    }

    const contentType = response.headers.get('content-type') || ''
    if (!contentType.toLowerCase().includes('text/event-stream') || !response.body) {
        if (response.body) await response.body.cancel()
        throw new Error(`No SSE response received; request_id=${requestId}`)
    }

    let completed = false
    for await (const event of readSSE(response.body)) {
        if (event.data.trim() === '[DONE]') {
            completed = true
            break
        }
        let payload
        try {
            payload = JSON.parse(event.data)
        } catch {
            throw new Error(`SSE data is not valid JSON; request_id=${requestId}`)
        }

        if (event.event === 'error' || payload?.type === 'error' || payload?.error) {
            const apiError = payload?.error
            const detail = apiError && typeof apiError === 'object' ? `${apiError.code || apiError.type || 'unknown'}: ` + String(apiError.message || 'stream interrupted').slice(0, 300) : 'stream interrupted'
            throw new Error(`Stream error; request_id=${requestId}; ${detail}`)
        }

        const choices = Array.isArray(payload?.choices) ? payload.choices : []
        const choice = choices.find((item) => item?.index === 0) || choices[0]
        const text = choice?.delta?.content
        if (typeof text === 'string') process.stdout.write(text)
        // finish_reason or usage may arrive first; keep reading until [DONE].
    }
    if (!completed) throw new Error(`Stream ended early without [DONE]; request_id=${requestId}`)
    process.stdout.write('\n')
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
    main().catch((err) => {
        console.error(`Request did not complete: ${err.message}`)
        process.exitCode = 1
    })
}

Run:

bash
node tikway_stream.mjs

Received text is written incrementally to the terminal. The 90-second total client timeout is an example setting you may adjust; it is not a response-time promise.

3. Understand the parser and completion checks

TextDecoder with stream: true retains incomplete UTF-8 bytes across reads. The parser handles LF, CRLF and CR, dispatches an event only after a blank line, and joins multiple data: lines with newlines.

This fabricated event sequence demonstrates the structure. The Chinese word is intentional to illustrate non-ASCII text:

text
data: {"choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Do not stop as soon as finish_reason appears: usage events may follow. This example requires [DONE] for normal completion. If the connection closes first, it reports an interruption while retaining the text already displayed.

HTTP success does not mean the entire stream succeeded. An error in an event’s data is handled after the connection starts, so both HTTP and event-level checks are needed. The local single-event size cap prevents unbounded buffering; it is not a published Tikway response-size limit.

4. Test the parser offline first

Put the offline demo beside tikway_stream.mjs, then run:

bash
node 02-sse-offline-demo.mjs

The demo constructs two text fragments and [DONE], splitting Chinese UTF-8 and CRLF into individual bytes. It reads no key and calls no API. Expected output comes entirely from local fixed data:

text
你好,SSE
Local SSE parsing completed (no API calls).

If the offline demo passes but a real request fails, check the endpoint, headers, status and model’s streaming support. If a real request succeeds but the web UI delays displaying text, inspect your forwarding, buffering and frontend reader.

Troubleshooting and web integration

Symptom What to check
fetch is not defined Use Node.js 22 or newer.
401 Key, Bearer header and current process environment.
400 / 422 Endpoint and parameter support for the selected model.
429 Balance or quota versus rate limits, using the actual error.
5xx / timeout Request ID and timestamp; timeout does not prove no server work occurred.
All text appears at once Reverse-proxy, backend and frontend response buffering.
Partial text followed by an error Mark the result incomplete and inspect errors or missing [DONE].
No delta.content Role, usage, final and tool events use other fields.

In a web service, forward incremental text and cancel upstream reading when the user cancels or disconnects. Browser EventSource generally cannot supply this POST request, JSON body and custom Bearer header directly; fetch supports the required request and byte stream.

Accumulate delta.tool_calls separately when adding tools. Responses and other protocols use different event formats; do not reuse the Chat Completions text-reading rules unchanged.

Further reading

Streaming troubleshooting · Token costs · Model catalog

Protocol references currently in Chinese: Quick start · OpenAI protocol · Authentication · Error codes.