Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@bdky/ky-sse-hook

Package Overview
Dependencies
Maintainers
3
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@bdky/ky-sse-hook

ky afterResponse hook for processing SSE streams

latest
npmnpm
Version
1.0.2
Version published
Maintainers
3
Created
Source

@bdky/ky-sse-hook

npm version bundle size TypeScript License: MIT

English | 简体中文

A lightweight afterResponse hook for ky that handles Server-Sent Events (SSE) streaming. Built on top of eventsource-parser for spec-compliant SSE parsing.

Perfect for AI chat streaming, real-time data feeds, and any scenario where you need to consume SSE responses through ky.

Highlights

  • Seamless ky integration — Plugs directly into ky's hooks.afterResponse
  • Spec-compliant SSE parsing — Powered by eventsource-parser v3
  • Callback-driven APIonData / onCompleted / onAborted / onEvent / onMessage / onReconnectInterval
  • At-most-once completion guarantee — Internal guard ensures onCompleted fires at most once
  • Abort-aware — Detects AbortController signals and routes to onAborted
  • Full TypeScript support — Ships with TypeScript declarations out of the box

Install

# npm
npm install @bdky/ky-sse-hook

# yarn
yarn add @bdky/ky-sse-hook

# pnpm
pnpm add @bdky/ky-sse-hook

Peer dependency: ky >= 1.0.0 is required. Install it separately if you haven't already.

The package ships both ESM and CJS formats with TypeScript declarations.

Quick Start

import ky from 'ky';
import {createHook} from '@bdky/ky-sse-hook';

const hook = createHook({
    onData(message) {
        console.log('Received:', message);
    },
    onCompleted(error) {
        if (error) {
            console.error('Stream failed:', error);
            return;
        }
        console.log('Stream finished');
    }
});

await ky.post('https://api.example.com/chat/stream', {
    json: {prompt: 'Hello, world!'},
    hooks: {afterResponse: [hook]},
    timeout: false
});

Important: Set timeout: false when using SSE streams. Long-running streams will otherwise be interrupted by ky's default timeout.

API

createHook(options)

Creates a ky AfterResponseHook that consumes the response body as an SSE stream.

import type {AfterResponseHook} from 'ky';

const hook: AfterResponseHook = createHook(options);

Behavior:

  • If the response is not OK (response.ok === false) or has no body, the hook returns immediately without consuming the stream.
  • The response body is read via ReadableStream, decoded as UTF-8, and fed into the SSE parser.
  • The hook awaits full stream consumption before returning, preventing ky from attempting to read the already-consumed body.

CreateHookOptions

PropertyTypeRequiredDescription
onData(message: string) => voidYesCalled for each data line. When a single SSE message contains multiple data: fields, they are split by \n and onData is called once per line.
onCompleted(error?: Error) => voidNoCalled once when the stream ends. If the stream terminated due to an error (other than abort), the error argument is provided. Guaranteed to fire at most once.
onAborted() => voidNoCalled when the request is aborted via AbortController. When aborted, onCompleted is not called.
onEvent(event: EventSourceMessage) => voidNoCalled with the full EventSourceMessage for each SSE event that has a non-empty data field. Events with empty data are silently skipped. Fires before onData.
onMessage(event: EventSourceMessage) => voidNoAlias for onEvent. Both callbacks fire for the same event if both are provided. Events with empty data are also skipped.
onReconnectInterval(value: number) => voidNoCalled when the SSE stream contains a retry: directive with the interval value in milliseconds.

onData vs onEvent / onMessage

  • onData receives the raw string content of each data: line. If a single SSE message has multiple data: fields, each line triggers a separate onData call.
  • onEvent / onMessage receive the full EventSourceMessage object containing data, event, id, and retry fields. They fire once per SSE message, before onData.

EventSourceMessage

Re-exported from eventsource-parser. The shape is:

interface EventSourceMessage {
    data: string;
    event?: string;
    id?: string;
}

Usage Examples

AI Chat Streaming

Parse JSON-encoded SSE data and accumulate the response:

import ky from 'ky';
import {createHook} from '@bdky/ky-sse-hook';

interface ChatChunk {
    answer: string;
    is_end: boolean;
}

let fullResponse = '';

const hook = createHook({
    onData(data) {
        try {
            const chunk: ChatChunk = JSON.parse(data);
            fullResponse += chunk.answer;
            console.log('Current response:', fullResponse);
        }
        catch {
            console.error('Failed to parse chunk:', data);
        }
    },
    onCompleted(error) {
        if (error) {
            console.error('Stream error:', error);
            return;
        }
        console.log('Final response:', fullResponse);
    }
});

await ky.post('https://api.example.com/chat/stream', {
    json: {
        messages: [
            {role: 'user', content: 'Explain quantum computing'}
        ]
    },
    hooks: {afterResponse: [hook]},
    timeout: false
});

Abort Request

Cancel a streaming request using AbortController:

import ky from 'ky';
import {createHook} from '@bdky/ky-sse-hook';

const controller = new AbortController();

const hook = createHook({
    onData(data) {
        console.log('Chunk:', data);

        // Abort after receiving the first chunk
        controller.abort();
    },
    onAborted() {
        console.log('Request was aborted by user');
    },
    onCompleted(error) {
        // Not called when aborted
        if (error) {
            console.error('Error:', error);
            return;
        }
        console.log('Done');
    }
});

await ky.post('https://api.example.com/chat/stream', {
    json: {prompt: 'Tell me a long story'},
    hooks: {afterResponse: [hook]},
    signal: controller.signal,
    timeout: false
});

Full Event Metadata

Access the complete EventSourceMessage for each SSE event:

import ky from 'ky';
import {createHook} from '@bdky/ky-sse-hook';

const hook = createHook({
    onData(data) {
        console.log('Data:', data);
    },
    onEvent(event) {
        console.log('Event type:', event.event);
        console.log('Event ID:', event.id);
        console.log('Event data:', event.data);
    },
    onReconnectInterval(interval) {
        console.log('Server suggests retry interval:', interval, 'ms');
    }
});

await ky.post('https://api.example.com/events', {
    hooks: {afterResponse: [hook]},
    timeout: false
});

Error Handling

Handle both stream-level and HTTP-level errors:

import ky, {HTTPError} from 'ky';
import {createHook} from '@bdky/ky-sse-hook';

const hook = createHook({
    onData(data) {
        console.log('Data:', data);
    },
    onCompleted(error) {
        if (error) {
            console.error('Stream reading failed:', error.message);
            return;
        }
        console.log('Stream completed successfully');
    }
});

try {
    await ky.post('https://api.example.com/stream', {
        json: {prompt: 'Hello'},
        hooks: {afterResponse: [hook]},
        timeout: false
    });
}
catch (error) {
    // ky throws HTTPError for non-2xx responses
    // The hook skips non-OK responses, so HTTP errors
    // are handled here, not in onCompleted
    if (error instanceof HTTPError) {
        console.error('HTTP error:', error.response.status);
    }
}

How It Works

ky.post(url, { hooks: { afterResponse: [hook] } })
        │
        ▼
  response.ok && response.body?
        │ no → return (skip)
        │ yes
        ▼
  response.body.getReader()
        │
        ▼
  TextDecoder (UTF-8)
        │
        ▼
  eventsource-parser
        │
        ├─ onEvent → options.onEvent()
        │            options.onMessage()
        │            data.split('\n') → options.onData() per line
        │
        └─ onRetry → options.onReconnectInterval()
        │
        ▼
  Stream done?
   ├─ yes        → onCompleted()
   ├─ aborted    → onAborted()
   └─ error      → onCompleted(error)
  • The hook receives the ky response and checks if it's successful with a readable body.
  • A ReadableStream reader consumes the body in chunks.
  • Each chunk is decoded from Uint8Array to string via TextDecoder.
  • The decoded text is fed to eventsource-parser, which emits structured SSE events.
  • When the stream ends naturally, onCompleted() is called with no arguments.
  • If the request is aborted via AbortController, onAborted() is called instead.
  • If an unexpected error occurs during reading, onCompleted(error) is called.

Browser Compatibility

BrowserMinimum Version
Chrome>= 74
Firefox>= 90
Safari>= 14.1
Edge>= 79
iOS Safari>= 14.1
Android Chrome>= 74

Requires ReadableStream, TextDecoder, and fetch API support.

FAQ

Why doesn't the hook throw errors?

By design, stream-reading errors are passed to onCompleted(error) instead of being thrown. This allows the caller to handle errors in a callback style that's consistent with the rest of the API, and avoids unhandled promise rejections in streaming scenarios.

Does it support GET requests?

Yes. The hook works with any HTTP method (GET, POST, PUT, etc.) as long as the response returns an SSE body. It plugs into ky's afterResponse hook, which fires regardless of the request method.

Why do both onEvent and onMessage exist?

They are convenience aliases. Both receive the same EventSourceMessage and both fire for every SSE event. This allows you to choose whichever name feels more natural in your codebase. If both are provided, both will be called.

What happens with non-2xx responses?

The hook checks response.ok and skips processing if the response is not successful. Non-2xx errors are handled by ky's built-in error handling (e.g., HTTPError), so you can catch them with a standard try/catch around the ky call.

Does it auto-reconnect?

No. This hook processes a single SSE response. If the server sends a retry: directive, the value is forwarded to onReconnectInterval, but reconnection logic is left to the consumer. For auto-reconnect behavior, implement retry logic at the application level.

Why should I set timeout: false?

SSE streams are long-running connections. ky's default timeout (10 seconds) will abort the request before the stream completes. Set timeout: false to disable the timeout for streaming requests:

await ky.post(url, {
    hooks: {afterResponse: [hook]},
    timeout: false
});

License

MIT

Keywords

sse

FAQs

Package last updated on 20 Mar 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts