pg-sse-client 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-10-12
9
+
10
+ ### Added
11
+
12
+ - `connect(url, options)`: a client for pg-sse streams on `fetch`. It sends your headers, resumes from the last `id:`
13
+ with `Last-Event-ID`, waits the `retry:` and `Retry-After` the server sends, reports the goodbye reason, and backs
14
+ off with jitter. `onError` decides whether to retry, stop or rest (`wakeOnResume` ends a rest when the browser is
15
+ back), and `retryNow()` ends a wait. `cursorIn: 'url'` carries the cursor in the URL, which needs no CORS
16
+ permission for a header.
17
+ - `maxEventChars`: an opt-in limit on how much of one event is held. More fails with `SseEventTooLarge`.
18
+ - `createSseParser` and `readSse`, also under `pg-sse-client/parser`: the text/event-stream parser, for a stream you
19
+ open yourself.
20
+
21
+ [Unreleased]: https://github.com/sansanth/pg-sse-client/compare/v0.1.0...HEAD
22
+ [0.1.0]: https://github.com/sansanth/pg-sse-client/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandhu Santhakumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # pg-sse-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/sansanth/pg-sse-client/actions/workflows/ci.yml/badge.svg)](https://github.com/sansanth/pg-sse-client/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pg-sse-client)](https://www.npmjs.com/package/pg-sse-client)
5
+
6
+ The browser and Node client for [pg-sse](https://github.com/sansanth/pg-sse) streams, for when `EventSource` is not enough. pg-sse is a Python library (`pip install pg-sse`) that streams Postgres changes from a FastAPI or other Python server; this package is its JavaScript side, not the unrelated `pg-sse` package on npm.
7
+
8
+ It is `EventSource` on top of `fetch`: it can send an `Authorization` header, and it keeps the parts of the pg-sse contract that `EventSource` handles for you (resume from the last id, the server's reconnect delay, the goodbye frame).
9
+
10
+ No dependencies, ESM only, about 3.7 kB minified and gzipped (1.2 kB for `pg-sse-client/parser`).
11
+
12
+ ## Do you need it?
13
+
14
+ If your stream authenticates with a cookie sent to the same origin, or with `withCredentials`, use `EventSource`. It reconnects by itself and sends `Last-Event-ID`, and pg-sse is written for it. Use this package when you need what `EventSource` cannot do:
15
+
16
+ - send a header (`Authorization: Bearer ...`), or read a token that changes between attempts
17
+ - see why a stream ended and why a request was refused (a 401, a 503 with `Retry-After`)
18
+ - stop retrying on an error that repeating will not fix
19
+ - decide what to do after repeated failures, such as falling back to polling
20
+
21
+ ```sh
22
+ npm install pg-sse-client
23
+ ```
24
+
25
+ ## Use it
26
+
27
+ ```ts
28
+ import { connect } from 'pg-sse-client';
29
+
30
+ const stream = connect('https://api.example/rooms/lobby/stream', {
31
+ headers: async () => ({ Authorization: `Bearer ${await getToken()}` }), // read before every attempt
32
+ onEvent: (event) => render(JSON.parse(event.data)), // { event, data, id }
33
+ onEnd: (reason) => console.debug('the server said goodbye:', reason),
34
+ onClose: (error) => error && showSignIn(), // over for good
35
+ });
36
+
37
+ stream.close();
38
+ ```
39
+
40
+ It starts at once, like `new EventSource(url)`. On the server, set `end_event="end"` on the hub if you want `onEnd` to hear why a stream ended; without it the client still reconnects correctly, it just does not know the reason.
41
+
42
+ ## What it does
43
+
44
+ | Situation | Behaviour |
45
+ |---|---|
46
+ | Request | `GET` with `Accept: text/event-stream`, your headers, and `Last-Event-ID` once it has a cursor, sent as UTF-8 bytes as `EventSource` does. |
47
+ | `id:` | Moves the cursor when the frame ends. A frame with only an `id:` moves it and delivers nothing (pg-sse sends one first when you pass a cursor). |
48
+ | `retry:` | After a stream that stayed open 5 s or more, the next reconnect waits exactly that long. After a shorter one it is the backoff below, never less than that. No later wait is shorter than the last `retry:` the server sent. |
49
+ | `end` event (`max_age`, `shutdown`, `behind`, `revoked`, `recheck`) | `onEnd(reason)`, then reconnect after the `retry:` that came with it (after a stream open 5 s or more; a shorter one is backed off from, below). The event is not passed to `onEvent`. A reason the client does not know is passed through unchanged. |
50
+ | Stream closes with no goodbye | Reconnect after 0.5 to 2 s, random (never less than the last `retry:`). |
51
+ | Failure (network error, timeout, non-2xx, not an event stream) | `onError`, then wait. The wait doubles from 1 s to 30 s with jitter, never less than a `Retry-After` (plus 0 to 1.5 s of spread) or the last `retry:` the server sent. Those two are capped at `backoff.serverMaxMs`, an hour. |
52
+ | A stream that closed within 5 s of opening | Counts as a failure even if it closed cleanly, so a server that keeps dropping clients is backed off from. `onError` hears a `StreamEndedEarly`. |
53
+ | A stream that stayed open 5 s or more | Healthy, however it ended: the failure count starts again. A clean end reconnects as above; an error is failure 1, backed off from as such. A stream that fails sooner adds to the count. |
54
+ | 4xx other than 408 and 429, a 3xx you chose not to follow, a 2xx that is not an event stream (a 204, say), or an event past `maxEventChars` | Stops, and `onClose(error)` says why. A repeat would only get the same answer, and `EventSource` stops too. A 401 is one of these: when your `headers` function can get a fresh token, return `'retry'` from `onError` for it. |
55
+ | Silence | No bytes (pings count) for 45 s aborts and retries. No response headers in 15 s does too, and a `headers` function that takes that long counts. |
56
+ | Browser | A wait of the client's own choosing ends early when the browser comes back `online` or the tab becomes visible, once it has run a second and as long as the last `retry:`. A wait the server asked for, or `onError` chose with `{ retryAfterMs }`, does not (`retryNow()` still ends it). |
57
+
58
+ ### Options
59
+
60
+ | Option | Default | |
61
+ |---|---|---|
62
+ | `onEvent(event)` | required | Every event but the goodbye. A throw does not stop the stream: it goes to `onCallbackError`. |
63
+ | `onCallbackError(error)` | | Gets what any callback threw, or the rejection of one that is `async`. Without it a page or a worker has the error rethrown on its own, to `window.onerror`, and anything else logs it with `console.error`. If it throws, that goes the default way. |
64
+ | `onOpen()` | | The server accepted the request. It has sent nothing yet. |
65
+ | `onDisconnect()` | | A stream that had opened has ended, for any reason, before `onError` and the wait. Where a "live" flag goes false. Not called by `close()`. |
66
+ | `onEnd(reason)` | | The goodbye event's `reason`, or `unknown`. Data that is not JSON is the reason as it came. |
67
+ | `onError(error, { failures })` | | Every failed attempt. Return `'retry'`, `'stop'` or `{ retryAfterMs }` (which also resets `failures`), or nothing for the default. `{ retryAfterMs, wakeOnResume: true }` also ends the rest early when the browser comes back `online` or the tab becomes visible, after the first second. May be `async`. |
68
+ | `onClose(error?)` | | The connection is over: `close()` was called (no argument), or an error stopped it. Once. |
69
+ | `headers` | | `HeadersInit`, or a function returning one (or a promise of one), called before every attempt. |
70
+ | `init` | | More `fetch` options, such as `credentials: 'include'`. `method`, `headers`, `body` and `signal` are the client's. `EventSource` sends `cache: 'no-store'`; pg-sse's `Cache-Control: no-cache` does the same job. |
71
+ | `fetch` | global `fetch` | For tests, or a wrapped fetch. Looked up on every attempt, and called with no receiver, as `fetch` must be: bind a method. |
72
+ | `cursorIn` | `'header'` | `'url'` sends the cursor to the URL function and no `Last-Event-ID` header. |
73
+ | `lastEventId` | | Resume from here on the first attempt. |
74
+ | `endEvent` | `end` | The hub's `end_event`. An event of your own with this name is taken for the goodbye, so name yours otherwise or set this. |
75
+ | `maxEventChars` | no limit | The most characters of one event to hold: its lines, field names included, and the line still arriving. More fails the attempt with `SseEventTooLarge`. `EventSource` has no limit and neither does this by default; set one when you do not trust the server to stay small. |
76
+ | `connectTimeoutMs`, `idleTimeoutMs` | 15000, 45000 | `0` turns a timeout off, and so does a value too long for a timer, such as `Infinity`. |
77
+ | `backoff` | see `Backoff` in `src/connect.ts` | `baseMs`, `maxMs`, `shortMs`, `reconnectFloorMs`, `reconnectSpreadMs`, `serverMaxMs`. An entry that is not a finite number of at least 0 keeps its default. |
78
+
79
+ The first argument is a URL string, a `URL`, or a function returning one, called before every attempt. A function is given the cursor (`''` at first). With `cursorIn: 'url'` it is the only place the cursor goes, and no `Last-Event-ID` header is sent: `` url: (id) => `${base}?cursor=${encodeURIComponent(id)}` ``, encoded. `cursorIn: 'url'` without a URL function, or any `cursorIn` but `'header'` and `'url'`, throws a `TypeError` from `connect`. A string or `URL` that can never be fetched, and `headers` given as a value that cannot be sent, throw a `TypeError` from `connect`, as `new EventSource` throws.
80
+
81
+ `connect` returns `{ close(), retryNow(), lastEventId }`. `retryNow()` cuts a wait short, for example when your own app learns the network is back.
82
+
83
+ Errors are `SseEventTooLarge` (`limit`) for an event past `maxEventChars`, `StreamRefused` (`status`, `retryAfterMs`) for a non-2xx answer or a body that is not an event stream, `StreamTimeout` (`phase`) for silence, and `StreamEndedEarly` (`lastedMs`, `reason`) for a stream that closed within `backoff.shortMs` of opening. Anything else is what `fetch` threw.
84
+
85
+ ### Falling back to polling
86
+
87
+ `failures` in `onError` is the number of attempts in a row that failed. Use it to hand over to polling and rest:
88
+
89
+ ```ts
90
+ onError: (error, { failures }) => {
91
+ if (failures >= 3) {
92
+ startPolling();
93
+ return { retryAfterMs: 3 * 60_000, wakeOnResume: true }; // try the stream again in three minutes, or when the tab is back
94
+ }
95
+ },
96
+ onOpen: stopPolling,
97
+ ```
98
+
99
+ ### Cross-origin requests
100
+
101
+ `Last-Event-ID` is not a header a cross-origin `fetch` may send without permission (`EventSource` is exempt). If you cannot change the server's CORS, carry the cursor in the URL with `cursorIn: 'url'` (above). Otherwise the server must answer the preflight with `Access-Control-Allow-Headers: Last-Event-ID` (plus `Authorization` if you send it), or the first reconnect fails and the stream never resumes. To read a `Retry-After` from a refusal, the response needs `Access-Control-Expose-Headers: Retry-After`; without it `retryAfterMs` is null.
102
+
103
+ ### Only the parser
104
+
105
+ ```ts
106
+ import { createSseParser, readSse } from 'pg-sse-client/parser';
107
+
108
+ for await (const event of readSse(response.body!, onActivity)) { /* { event, data, id } */ }
109
+ ```
110
+
111
+ For a stream you open yourself, such as a `POST` that answers with events. The parser follows the WHATWG rules; `parser.lastEventId` and `parser.retry` hold the connection-level state. The parser reads a whole chunk before the first of its events reaches you, so to resume use the `id` of the last event you handled, not `parser.lastEventId`.
112
+
113
+ ## What is not here
114
+
115
+ - **The meaning of your events.** The client does not know `change` from `ready`; that is your server's protocol.
116
+ - **A message queue.** It delivers what the stream sends; a gap is closed by the server re-reading from the cursor it was sent.
117
+ - **React or any framework.** Wrap `connect` in your own hook; `close()` is the cleanup.
118
+
119
+ ## Development
120
+
121
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The wire contract this client follows is in the [pg-sse README](https://github.com/sansanth/pg-sse#frames-you-will-see).
@@ -0,0 +1,126 @@
1
+ import { type SseEvent } from './event.js';
2
+ /** The server answered, but not with a stream: a non-2xx status, or a 2xx that is not `text/event-stream`. */
3
+ export declare class StreamRefused extends Error {
4
+ readonly status: number;
5
+ /** The server's `Retry-After`, in milliseconds, when it sent one it could be read. */
6
+ readonly retryAfterMs: number | null;
7
+ constructor(status: number,
8
+ /** The server's `Retry-After`, in milliseconds, when it sent one it could be read. */
9
+ retryAfterMs?: number | null, message?: string);
10
+ }
11
+ /** The server went quiet: no response headers within `connectTimeoutMs`, or no bytes at all within `idleTimeoutMs`. */
12
+ export declare class StreamTimeout extends Error {
13
+ readonly phase: 'connect' | 'idle';
14
+ constructor(phase: 'connect' | 'idle');
15
+ }
16
+ /** A stream opened and then closed within `backoff.shortMs`, with or without a goodbye: a server that keeps dropping clients. */
17
+ export declare class StreamEndedEarly extends Error {
18
+ readonly lastedMs: number;
19
+ /** The goodbye reason, or null when the server just closed. */
20
+ readonly reason: string | null;
21
+ constructor(lastedMs: number,
22
+ /** The goodbye reason, or null when the server just closed. */
23
+ reason: string | null);
24
+ }
25
+ /**
26
+ * What `onError` decides. `retry` waits as long as the backoff and the server's hints say, `stop` ends the connection
27
+ * for good, and `{ retryAfterMs }` waits exactly that long, for example to rest while the caller polls instead. With
28
+ * `wakeOnResume` the browser coming back online, or the tab becoming visible, ends that rest early, once it has run a second. A `retryAfterMs`
29
+ * that is not a finite number of at least 0 is ignored, as if `onError` had returned nothing.
30
+ */
31
+ export type ErrorAction = 'retry' | 'stop' | {
32
+ retryAfterMs: number;
33
+ wakeOnResume?: boolean;
34
+ };
35
+ export interface ErrorInfo {
36
+ /**
37
+ * Attempts in a row that failed, or that ended within `backoff.shortMs` of opening, this one included. A stream that was
38
+ * open for `backoff.shortMs` before it failed starts the count again, so it is 1.
39
+ */
40
+ failures: number;
41
+ }
42
+ export interface Backoff {
43
+ /** The first retry waits about this long, then doubles. */
44
+ baseMs: number;
45
+ maxMs: number;
46
+ /**
47
+ * A stream that closed within this long of opening counts as a failure, even if it closed cleanly: `onError` hears a
48
+ * `StreamEndedEarly`. One that lasted this long was healthy, however it ended: the failure count starts again, at 0 for a
49
+ * clean end and at 1 for an error.
50
+ */
51
+ shortMs: number;
52
+ /** After a stream that ended cleanly and long enough, and with no `retry:` from the server, wait this long plus up to `reconnectSpreadMs`. */
53
+ reconnectFloorMs: number;
54
+ reconnectSpreadMs: number;
55
+ /** The longest the client waits on the server's word, a `Retry-After` or a `retry:`. A mistake there must not stop a stream for weeks. */
56
+ serverMaxMs: number;
57
+ }
58
+ export interface ConnectOptions {
59
+ /**
60
+ * Called for every event except the `end` event. A callback that throws does not stop the stream: see `onCallbackError`
61
+ * for where the error goes.
62
+ */
63
+ onEvent(event: SseEvent): void;
64
+ /** The server accepted the request and a stream is open. It has sent nothing yet. */
65
+ onOpen?(): void;
66
+ /**
67
+ * A stream that had opened has ended, for any reason: a goodbye, the server closing, or an error. It is called before
68
+ * `onError` and before the wait, so it is the place to mark the stream no longer live. It follows `onEnd`. Not called by
69
+ * `close()`.
70
+ */
71
+ onDisconnect?(): void;
72
+ /** The server said goodbye (`max_age`, `shutdown`, `behind`, `revoked`, `recheck`) and the client is about to reconnect. */
73
+ onEnd?(reason: string): void;
74
+ /**
75
+ * Every failed attempt, whether or not it will be retried. Return what to do; return nothing for the default: stop
76
+ * on a 4xx other than 408 and 429, a 3xx that was not followed, a 2xx that is not an event stream, and an event past
77
+ * `maxEventChars` (answers that repeating will not change), retry anything else. Returning `{ retryAfterMs }` also resets `failures`, so the next attempts are counted afresh. It
78
+ * may be `async`: the client waits for the answer.
79
+ */
80
+ onError?(error: unknown, info: ErrorInfo): ErrorAction | void | Promise<ErrorAction | void>;
81
+ /** The connection is over for good: `close()` was called, or an error ended it (passed in). Called once. */
82
+ onClose?(error?: unknown): void;
83
+ /**
84
+ * Gets the error a callback threw, or the rejection of one that is `async` (this one's and every other's), instead of
85
+ * the default: in a page or a worker the error is rethrown on its own, so `window.onerror` sees it, and elsewhere it is
86
+ * logged with `console.error`. If this throws, that error goes the default way.
87
+ */
88
+ onCallbackError?(error: unknown): void;
89
+ /** Request headers. A function is called before every attempt, so a token can be refreshed. `Accept` and `Last-Event-ID` are the client's: the cursor goes in the header, or in the URL for a URL function that takes it. */
90
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
91
+ /** More `fetch` options, such as `credentials` or `mode`. `method`, `headers` and `signal` are the client's. */
92
+ init?: Omit<RequestInit, 'method' | 'headers' | 'signal' | 'body'>;
93
+ /** A `fetch` to use instead of the global one, looked up on every attempt. */
94
+ fetch?: typeof fetch;
95
+ /**
96
+ * Where the cursor goes: the `Last-Event-ID` header (default), or the URL. `'url'` needs a URL function, which is given
97
+ * the cursor (`''` at first) whatever this is, and sends no header: a cross-origin server then needs no CORS permission
98
+ * for one.
99
+ */
100
+ cursorIn?: 'header' | 'url';
101
+ /** Where to resume from on the first attempt, sent as `Last-Event-ID` or given to a URL function. An id with a line break or NUL cannot be one and is ignored. */
102
+ lastEventId?: string;
103
+ /** The name of the goodbye event, as set with `end_event` on the hub. Default `end`. */
104
+ endEvent?: string;
105
+ /** The most characters of one event to hold, a line still arriving included. More fails the attempt with `SseEventTooLarge`. No limit by default, or when 0. */
106
+ maxEventChars?: number;
107
+ /** How long to wait for the response headers, the `headers` function included. Default 15000. */
108
+ connectTimeoutMs?: number;
109
+ /** Default 45000, which is more than the 25 s between pg-sse's longest pings, with room for a slow network. 0, or a value too long for a timer, turns it off. */
110
+ idleTimeoutMs?: number;
111
+ backoff?: Partial<Backoff>;
112
+ }
113
+ export interface Connection {
114
+ /** Ends the connection for good. */
115
+ close(): void;
116
+ /** Cuts a wait before the next attempt short, or the wait that is about to begin. Does nothing while a stream is open. */
117
+ retryNow(): void;
118
+ /** The cursor the next attempt will send, as `Last-Event-ID` or to a URL function. */
119
+ readonly lastEventId: string;
120
+ }
121
+ /**
122
+ * Opens a stream the way `EventSource` would, with `fetch` underneath so it can send headers. It reconnects on its own,
123
+ * sends `Last-Event-ID` from the last `id:` it saw, waits the `retry:` the server asked for, and backs off with jitter
124
+ * when attempts fail. With `cursorIn: 'url'` the cursor goes to the URL function instead, and no header is sent. It starts at once; call `close()` to end it.
125
+ */
126
+ export declare function connect(url: string | URL | ((lastEventId: string) => string | URL), options: ConnectOptions): Connection;
@@ -0,0 +1,477 @@
1
+ import { SseEventTooLarge } from './event.js';
2
+ import { createSseParser } from './parser.js';
3
+ import { readChunks } from './read.js';
4
+ /** The server answered, but not with a stream: a non-2xx status, or a 2xx that is not `text/event-stream`. */
5
+ export class StreamRefused extends Error {
6
+ status;
7
+ retryAfterMs;
8
+ constructor(status,
9
+ /** The server's `Retry-After`, in milliseconds, when it sent one it could be read. */
10
+ retryAfterMs = null, message = `Stream refused: HTTP ${status}`) {
11
+ super(message);
12
+ this.status = status;
13
+ this.retryAfterMs = retryAfterMs;
14
+ this.name = 'StreamRefused';
15
+ }
16
+ }
17
+ /** The server went quiet: no response headers within `connectTimeoutMs`, or no bytes at all within `idleTimeoutMs`. */
18
+ export class StreamTimeout extends Error {
19
+ phase;
20
+ constructor(phase) {
21
+ super(`Stream timed out waiting for ${phase === 'connect' ? 'a response' : 'data'}`);
22
+ this.phase = phase;
23
+ this.name = 'StreamTimeout';
24
+ }
25
+ }
26
+ /** A stream opened and then closed within `backoff.shortMs`, with or without a goodbye: a server that keeps dropping clients. */
27
+ export class StreamEndedEarly extends Error {
28
+ lastedMs;
29
+ reason;
30
+ constructor(lastedMs,
31
+ /** The goodbye reason, or null when the server just closed. */
32
+ reason) {
33
+ super(`The stream closed ${Math.round(lastedMs)} ms after it opened`);
34
+ this.lastedMs = lastedMs;
35
+ this.reason = reason;
36
+ this.name = 'StreamEndedEarly';
37
+ }
38
+ }
39
+ // `setTimeout` runs a longer delay than this (24.8 days) at once.
40
+ const MAX_TIMER_MS = 2 ** 31 - 1;
41
+ // A timeout of any length, made of several when it is longer than a timer can be. Returns what cancels it.
42
+ function longTimeout(ms, run) {
43
+ let timer;
44
+ const sleep = (left) => {
45
+ timer = setTimeout(left > MAX_TIMER_MS ? () => sleep(left - MAX_TIMER_MS) : run, Math.min(left, MAX_TIMER_MS));
46
+ };
47
+ sleep(ms);
48
+ return () => clearTimeout(timer);
49
+ }
50
+ // Monotonic: a step of the wall clock must not read as a stream gone quiet or a wait that is over.
51
+ const now = () => performance.now();
52
+ // How long a wait must have run before `online` or `visibilitychange` may end it.
53
+ const EARLY_RETRY_MS = 1000;
54
+ const DEFAULT_BACKOFF = {
55
+ baseMs: 1000,
56
+ maxMs: 30_000,
57
+ shortMs: 5000,
58
+ reconnectFloorMs: 500,
59
+ reconnectSpreadMs: 1500,
60
+ serverMaxMs: 3_600_000,
61
+ };
62
+ function retryAfterOf(header) {
63
+ if (header === null)
64
+ return null;
65
+ // Clamped, as a very long string of digits is Infinity.
66
+ if (/^\d+$/.test(header.trim()))
67
+ return Math.min(Number(header) * 1000, Number.MAX_SAFE_INTEGER);
68
+ // An HTTP date starts with a day name and has a time in it. `Date.parse` alone also reads '1.5' or 'May 5' as a date.
69
+ if (!/^[a-z]{3,9}\b.*\d{2}:\d{2}:\d{2}/i.test(header.trim()))
70
+ return null;
71
+ // The asctime form has no zone, and `Date.parse` would read it as local time.
72
+ const date = Date.parse(/\b(GMT|UTC)\b|\s[+-]\d{4}\s*$/i.test(header) ? header : `${header} GMT`);
73
+ return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
74
+ }
75
+ // A finite number of at least 0. NaN, a negative and Infinity would each run a timer at once.
76
+ function isDelay(value) {
77
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
78
+ }
79
+ // What `onError` returned, if it is something to act on. Anything else leaves it to the default.
80
+ function actionOf(returned) {
81
+ if (returned === 'retry' || returned === 'stop')
82
+ return returned;
83
+ if (typeof returned !== 'object' || returned === null)
84
+ return null;
85
+ const ms = returned.retryAfterMs;
86
+ if (!isDelay(ms))
87
+ return null;
88
+ return { retryAfterMs: ms, wakeOnResume: returned.wakeOnResume === true };
89
+ }
90
+ // Not a usable number: the default. Too long for a timer, Infinity say: none, as 0.
91
+ function timeoutOf(value, fallback) {
92
+ if (value === Number.POSITIVE_INFINITY)
93
+ return 0;
94
+ if (!isDelay(value))
95
+ return fallback;
96
+ return value > MAX_TIMER_MS ? 0 : value;
97
+ }
98
+ // Stop on what repeating will not change: as `EventSource` does, a 4xx (a timeout and a rate limit aside) and a 2xx that is
99
+ // no stream, and an event that will be as large when sent again.
100
+ function defaultAction(error) {
101
+ if (error instanceof SseEventTooLarge)
102
+ return 'stop';
103
+ if (!(error instanceof StreamRefused))
104
+ return 'retry';
105
+ if (error.status < 400)
106
+ return 'stop';
107
+ return error.status < 500 && error.status !== 408 && error.status !== 429 ? 'stop' : 'retry';
108
+ }
109
+ // The id goes as its UTF-8 bytes, as `EventSource` sends it: `Headers` throws on a character outside Latin-1.
110
+ function headerValue(id) {
111
+ return Array.from(new TextEncoder().encode(id), (byte) => String.fromCharCode(byte)).join('');
112
+ }
113
+ // Settles with `work`, or rejects when the signal aborts, so work that never settles cannot hold an attempt. `late` gets
114
+ // what `work` settles with after that.
115
+ function untilAborted(signal, work, late) {
116
+ return new Promise((resolve, reject) => {
117
+ const abort = () => reject(signal.reason);
118
+ Promise.resolve(work)
119
+ .then((value) => {
120
+ if (signal.aborted)
121
+ late?.(value);
122
+ resolve(value);
123
+ }, reject)
124
+ .finally(() => signal.removeEventListener('abort', abort));
125
+ // `work` may have aborted the signal itself.
126
+ if (signal.aborted)
127
+ abort();
128
+ else
129
+ signal.addEventListener('abort', abort, { once: true });
130
+ });
131
+ }
132
+ // The media type is case-insensitive and may have parameters.
133
+ function isEventStream(response) {
134
+ return (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() === 'text/event-stream';
135
+ }
136
+ // Frees an answer the client will not read. Not awaited: a cancel that hangs must not hold up the refusal.
137
+ async function discard(response) {
138
+ try {
139
+ await response.body?.cancel();
140
+ }
141
+ catch {
142
+ /* nothing to free */
143
+ }
144
+ }
145
+ function endReason(data) {
146
+ try {
147
+ const parsed = JSON.parse(data);
148
+ const reason = typeof parsed === 'string' ? parsed : parsed?.reason;
149
+ return typeof reason === 'string' ? reason : 'unknown';
150
+ }
151
+ catch {
152
+ return data || 'unknown';
153
+ }
154
+ }
155
+ function rethrowLater(error) {
156
+ queueMicrotask(() => {
157
+ throw error;
158
+ });
159
+ }
160
+ // A page or a worker has a handler for an error nobody caught (`window.onerror`), which an error rethrown on its own
161
+ // reaches. Node, Deno, Bun and React Native have no `document`, and an uncaught error ends them: it is logged.
162
+ function reportByDefault(error) {
163
+ if (typeof document !== 'undefined' || 'WorkerGlobalScope' in globalThis)
164
+ rethrowLater(error);
165
+ else
166
+ console.error(error);
167
+ }
168
+ /**
169
+ * Opens a stream the way `EventSource` would, with `fetch` underneath so it can send headers. It reconnects on its own,
170
+ * sends `Last-Event-ID` from the last `id:` it saw, waits the `retry:` the server asked for, and backs off with jitter
171
+ * when attempts fail. With `cursorIn: 'url'` the cursor goes to the URL function instead, and no header is sent. It starts at once; call `close()` to end it.
172
+ */
173
+ export function connect(url, options) {
174
+ if (options.cursorIn !== undefined && options.cursorIn !== 'header' && options.cursorIn !== 'url') {
175
+ throw new TypeError(`cursorIn is 'header' or 'url', not ${JSON.stringify(options.cursorIn)}`);
176
+ }
177
+ if (options.cursorIn === 'url' && typeof url !== 'function') {
178
+ throw new TypeError("cursorIn: 'url' needs a URL function, which is given the cursor");
179
+ }
180
+ // A URL that can never be fetched fails here, as `new EventSource(url)` does, instead of being retried for ever. A `fetch`
181
+ // of the caller's may read URLs its own way.
182
+ if (typeof url !== 'function' && !options.fetch && !/^https?:$/.test(new URL(url, globalThis.location?.href).protocol)) {
183
+ throw new TypeError(`Not a URL fetch can read: ${String(url)}`);
184
+ }
185
+ // Headers given as a value, not a function, fail the same way every time: they fail here too.
186
+ if (typeof options.headers !== 'function')
187
+ new Headers(options.headers);
188
+ // An entry that is not a usable number keeps its default.
189
+ const entries = Object.entries(options.backoff ?? {}).filter(([, value]) => isDelay(value));
190
+ const backoff = { ...DEFAULT_BACKOFF, ...Object.fromEntries(entries) };
191
+ const endEvent = options.endEvent ?? 'end';
192
+ const connectTimeoutMs = timeoutOf(options.connectTimeoutMs, 15_000);
193
+ const idleTimeoutMs = timeoutOf(options.idleTimeoutMs, 45_000);
194
+ let closed = false;
195
+ let finished = false;
196
+ let controller = null;
197
+ let wake = null;
198
+ // From the end of a stream or a failed attempt until the wait begins, `retryNow` has no wait to cut short, and is held.
199
+ let between = false;
200
+ let retryAsked = false;
201
+ // Sent as `Last-Event-ID`: the id of the last event handed over, not the parser's, which can be ahead in the middle of a
202
+ // chunk. An id that cannot be a header value is no cursor.
203
+ let cursor = options.lastEventId && !/[\0\r\n]/.test(options.lastEventId) ? options.lastEventId : '';
204
+ // The last `retry:` any stream sent.
205
+ let serverRetry = null;
206
+ function report(error) {
207
+ try {
208
+ if (options.onCallbackError)
209
+ watch(options.onCallbackError(error), reportByDefault);
210
+ else
211
+ reportByDefault(error);
212
+ }
213
+ catch (inner) {
214
+ reportByDefault(inner);
215
+ }
216
+ }
217
+ // A rejection of an `async` callback is an error like a throw, not an unhandled rejection.
218
+ function watch(result, fail = report) {
219
+ if (typeof result?.then === 'function') {
220
+ result.then(undefined, fail);
221
+ }
222
+ }
223
+ // Calls a callback as a method of the options. What it throws, or rejects with, goes to `report`.
224
+ function notify(callback, ...args) {
225
+ try {
226
+ watch(callback?.call(options, ...args));
227
+ }
228
+ catch (error) {
229
+ report(error);
230
+ }
231
+ }
232
+ // With no argument for a close, with one, even `undefined`, for an error that ended the connection.
233
+ function finish(...error) {
234
+ if (finished)
235
+ return;
236
+ finished = true;
237
+ notify(options.onClose, ...error);
238
+ }
239
+ // `holdMs` is how long the wait must run before the browser may end it: all of it when the server or `onError` chose it.
240
+ function rest(ms, holdMs) {
241
+ // The browser coming back online, or the page becoming visible, is a reason to try now. `online` needs a global event
242
+ // target (a page, a worker), `visibilitychange` a `document`.
243
+ const hold = Math.max(EARLY_RETRY_MS, holdMs);
244
+ const listening = hold < ms && typeof globalThis.addEventListener === 'function';
245
+ const page = typeof document === 'undefined' ? null : document;
246
+ return new Promise((resolve) => {
247
+ const begun = now();
248
+ // An event before the hold is over sets the retry for its end, so switching tabs is not a request each time.
249
+ let early;
250
+ const onOnline = () => {
251
+ const left = hold - (now() - begun);
252
+ if (left <= 0)
253
+ done();
254
+ else
255
+ early ??= setTimeout(done, Math.min(left, MAX_TIMER_MS));
256
+ };
257
+ const onVisible = () => {
258
+ if (!page.hidden)
259
+ onOnline();
260
+ };
261
+ const done = () => {
262
+ cancel();
263
+ clearTimeout(early);
264
+ wake = null;
265
+ if (listening) {
266
+ globalThis.removeEventListener('online', onOnline);
267
+ page?.removeEventListener('visibilitychange', onVisible);
268
+ }
269
+ resolve();
270
+ };
271
+ const cancel = longTimeout(ms, done);
272
+ wake = done;
273
+ if (listening) {
274
+ globalThis.addEventListener('online', onOnline);
275
+ page?.addEventListener('visibilitychange', onVisible);
276
+ }
277
+ });
278
+ }
279
+ async function attempt(outcome) {
280
+ const current = new AbortController();
281
+ controller = current;
282
+ between = false;
283
+ retryAsked = false;
284
+ const parser = createSseParser({ lastEventId: cursor }, { maxEventChars: options.maxEventChars });
285
+ let timedOut = null;
286
+ let opened = false;
287
+ let timer;
288
+ let deadline = 0;
289
+ // A chunk moves the idle deadline without touching the timer, which sleeps on for what is left when it fires early.
290
+ const arm = (phase, ms) => {
291
+ clearTimeout(timer);
292
+ if (ms <= 0)
293
+ return;
294
+ deadline = now() + ms;
295
+ const check = () => {
296
+ const left = deadline - now();
297
+ if (left > 0) {
298
+ timer = setTimeout(check, left);
299
+ return;
300
+ }
301
+ timedOut = phase;
302
+ current.abort();
303
+ };
304
+ timer = setTimeout(check, ms);
305
+ };
306
+ const touch = () => {
307
+ deadline = now() + idleTimeoutMs;
308
+ };
309
+ try {
310
+ // The connect timer covers `headers()` too: a dead token endpoint is a silent server.
311
+ arm('connect', connectTimeoutMs);
312
+ const given = typeof options.headers === 'function' ? await untilAborted(current.signal, options.headers()) : options.headers;
313
+ const headers = new Headers(given);
314
+ headers.set('Accept', 'text/event-stream');
315
+ // The client's cursor, not one from `headers`.
316
+ if (cursor && options.cursorIn !== 'url')
317
+ headers.set('Last-Event-ID', headerValue(cursor));
318
+ else
319
+ headers.delete('Last-Event-ID');
320
+ const fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
321
+ // A `fetch` that ignores the signal is cut off, and an answer it gives later is freed.
322
+ const response = await untilAborted(current.signal, fetchFn(typeof url === 'function' ? url(cursor) : url, {
323
+ ...options.init,
324
+ body: undefined,
325
+ method: 'GET',
326
+ headers,
327
+ signal: current.signal,
328
+ }), discard);
329
+ if (!response.ok) {
330
+ void discard(response);
331
+ throw new StreamRefused(response.status, retryAfterOf(response.headers.get('retry-after')));
332
+ }
333
+ if (!isEventStream(response)) {
334
+ void discard(response);
335
+ throw new StreamRefused(response.status, null, 'The response is not an event stream');
336
+ }
337
+ if (!response.body) {
338
+ throw new StreamRefused(response.status, null, 'The response has no body to read: this runtime\'s fetch does not stream');
339
+ }
340
+ arm('idle', idleTimeoutMs);
341
+ opened = true;
342
+ outcome.openedAt = now();
343
+ notify(options.onOpen);
344
+ if (closed) {
345
+ void discard(response);
346
+ return null;
347
+ }
348
+ // The cursor is the id of the event being handled, and where the parser stands once a chunk's events are all handed
349
+ // over, which includes a frame of only an `id:` (pg-sse sends one first).
350
+ for await (const events of readChunks(response.body, touch, parser, current.signal)) {
351
+ for (const event of events) {
352
+ if (closed)
353
+ return null;
354
+ cursor = event.id;
355
+ if (event.event === endEvent) {
356
+ const reason = endReason(event.data);
357
+ between = true;
358
+ notify(options.onEnd, reason);
359
+ return reason;
360
+ }
361
+ notify(options.onEvent, event);
362
+ }
363
+ cursor = parser.lastEventId;
364
+ }
365
+ current.signal.throwIfAborted();
366
+ return null;
367
+ }
368
+ catch (error) {
369
+ if (timedOut)
370
+ throw new StreamTimeout(timedOut);
371
+ throw error;
372
+ }
373
+ finally {
374
+ clearTimeout(timer);
375
+ outcome.retry = parser.retry;
376
+ if (parser.retry !== null)
377
+ serverRetry = parser.retry;
378
+ between = true;
379
+ if (opened && !closed)
380
+ notify(options.onDisconnect);
381
+ }
382
+ }
383
+ const capped = (ms) => (ms === null ? null : Math.min(ms, backoff.serverMaxMs));
384
+ // The server's word (`retry:`, `Retry-After`) is a floor. After a clean end the `retry:` of the stream that ended is the
385
+ // whole wait, so the pause that follows a goodbye is the server's to set; without one the wait is spread at random.
386
+ function delayAfter(failures, retryAfterMs, retry) {
387
+ const hint = capped(retry);
388
+ if (failures === 0 && hint !== null)
389
+ return { ms: hint, holdMs: hint };
390
+ const floor = Math.max(capped(retryAfterMs) ?? 0, capped(serverRetry) ?? 0);
391
+ const ms = failures === 0
392
+ ? backoff.reconnectFloorMs + Math.random() * backoff.reconnectSpreadMs
393
+ : Math.min(backoff.maxMs, backoff.baseMs * 2 ** Math.min(failures - 1, 30)) * (0.5 + Math.random() / 2);
394
+ // Spread, so the clients a full server refused together do not come back at the same moment.
395
+ const refused = capped(retryAfterMs);
396
+ const spread = refused === null ? 0 : refused + Math.random() * backoff.reconnectSpreadMs;
397
+ return { ms: Math.max(floor, spread, ms), holdMs: Math.max(floor, spread) };
398
+ }
399
+ async function run() {
400
+ let failures = 0;
401
+ while (!closed) {
402
+ let wait;
403
+ let failure;
404
+ let failed = false;
405
+ const outcome = { openedAt: null, retry: null };
406
+ try {
407
+ const reason = await attempt(outcome);
408
+ if (closed)
409
+ return;
410
+ // From when the stream opened: the attempt began earlier, with a `headers()` and a connect.
411
+ const lasted = now() - outcome.openedAt;
412
+ if (lasted >= backoff.shortMs) {
413
+ failures = 0;
414
+ wait = delayAfter(0, null, outcome.retry);
415
+ }
416
+ else {
417
+ failures += 1;
418
+ failed = true;
419
+ failure = new StreamEndedEarly(lasted, reason);
420
+ wait = delayAfter(failures, null, outcome.retry);
421
+ }
422
+ }
423
+ catch (error) {
424
+ if (closed)
425
+ return;
426
+ failures = outcome.openedAt !== null && now() - outcome.openedAt >= backoff.shortMs ? 1 : failures + 1;
427
+ failed = true;
428
+ failure = error;
429
+ wait = delayAfter(failures, error instanceof StreamRefused ? error.retryAfterMs : null, outcome.retry);
430
+ }
431
+ if (failed) {
432
+ let asked;
433
+ try {
434
+ asked = await options.onError?.(failure, { failures });
435
+ }
436
+ catch (error) {
437
+ report(error);
438
+ }
439
+ const action = actionOf(asked) ?? defaultAction(failure);
440
+ if (closed)
441
+ return;
442
+ if (action === 'stop') {
443
+ finish(failure);
444
+ return;
445
+ }
446
+ if (typeof action === 'object') {
447
+ failures = 0;
448
+ retryAsked = false;
449
+ wait = { ms: action.retryAfterMs, holdMs: action.wakeOnResume ? 0 : action.retryAfterMs };
450
+ }
451
+ }
452
+ if (retryAsked)
453
+ continue;
454
+ between = false;
455
+ await rest(wait.ms, wait.holdMs);
456
+ }
457
+ }
458
+ // Nothing in `run` is meant to throw. If something does, the connection is over.
459
+ void run().catch((error) => finish(error));
460
+ return {
461
+ close() {
462
+ closed = true;
463
+ controller?.abort();
464
+ wake?.();
465
+ finish();
466
+ },
467
+ retryNow() {
468
+ if (wake)
469
+ wake();
470
+ else if (between)
471
+ retryAsked = true;
472
+ },
473
+ get lastEventId() {
474
+ return cursor;
475
+ },
476
+ };
477
+ }
@@ -0,0 +1,17 @@
1
+ export interface SseEvent {
2
+ /** The `event:` field, or `message` when the frame had none. */
3
+ event: string;
4
+ /** The `data:` lines joined with `\n`. */
5
+ data: string;
6
+ /** The stream's last event id once this frame was read: `''` when the server never sent one and the parser was not given one. */
7
+ id: string;
8
+ }
9
+ /** An event, or a line of one that has not ended yet, grew past `maxEventChars`. */
10
+ export declare class SseEventTooLarge extends Error {
11
+ readonly limit: number;
12
+ /** The events the same chunk completed before the one that is too large, which `push` could not return. */
13
+ readonly events: SseEvent[];
14
+ constructor(limit: number,
15
+ /** The events the same chunk completed before the one that is too large, which `push` could not return. */
16
+ events?: SseEvent[]);
17
+ }
package/dist/event.js ADDED
@@ -0,0 +1,13 @@
1
+ /** An event, or a line of one that has not ended yet, grew past `maxEventChars`. */
2
+ export class SseEventTooLarge extends Error {
3
+ limit;
4
+ events;
5
+ constructor(limit,
6
+ /** The events the same chunk completed before the one that is too large, which `push` could not return. */
7
+ events = []) {
8
+ super(`An event grew past ${limit} characters`);
9
+ this.limit = limit;
10
+ this.events = events;
11
+ this.name = 'SseEventTooLarge';
12
+ }
13
+ }
@@ -0,0 +1,4 @@
1
+ export { connect, StreamEndedEarly, StreamRefused, StreamTimeout } from './connect.js';
2
+ export type { Backoff, Connection, ConnectOptions, ErrorAction, ErrorInfo } from './connect.js';
3
+ export { createSseParser, readSse, SseEventTooLarge } from './standalone.js';
4
+ export type { SseEvent, SseParser, SseParserLimits, SseParserState } from './standalone.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { connect, StreamEndedEarly, StreamRefused, StreamTimeout } from './connect.js';
2
+ export { createSseParser, readSse, SseEventTooLarge } from './standalone.js';
@@ -0,0 +1,34 @@
1
+ import { SseEventTooLarge, type SseEvent } from './event.js';
2
+ export { SseEventTooLarge };
3
+ export type { SseEvent };
4
+ export interface SseParserState {
5
+ lastEventId?: string;
6
+ retry?: number | null;
7
+ }
8
+ export interface SseParserLimits {
9
+ /**
10
+ * The most characters the parser holds for one event: the lines of the frame it is reading, field names included, and
11
+ * the line still arriving. More throws `SseEventTooLarge`. No limit by default, as with `EventSource`, or when it is 0;
12
+ * a client that sends credentials to a server it only half trusts wants one.
13
+ */
14
+ maxEventChars?: number;
15
+ }
16
+ export interface SseParser {
17
+ /** Feeds a decoded chunk in and returns the events it completes. Throws `SseEventTooLarge` past a limit: use a new parser then. */
18
+ push(chunk: string): SseEvent[];
19
+ /**
20
+ * The cursor to send as `Last-Event-ID`. It moves at the end of a frame, including a frame that carries only an `id:`
21
+ * line, which dispatches nothing.
22
+ */
23
+ readonly lastEventId: string;
24
+ /** The reconnect delay in milliseconds the server last asked for with `retry:`, or null if it never did. */
25
+ readonly retry: number | null;
26
+ }
27
+ /**
28
+ * An incremental parser for the text/event-stream format, following the WHATWG rules `EventSource` uses: lines end in
29
+ * `\n`, `\r\n` or `\r` (a chunk may split `\r\n`), `:` lines are comments, consecutive `data:` lines join with `\n`, a
30
+ * frame with no `data:` line is dropped, and so is a frame still open when the stream ends.
31
+ *
32
+ * `state` carries what outlives a connection: the last event id and the retry delay. The partial frame does not.
33
+ */
34
+ export declare function createSseParser(state?: SseParserState, limits?: SseParserLimits): SseParser;
package/dist/parser.js ADDED
@@ -0,0 +1,143 @@
1
+ import { SseEventTooLarge } from './event.js';
2
+ export { SseEventTooLarge };
3
+ // Where a line ends. Searched with `lastIndex`, so a scan starts where it is told and allocates nothing.
4
+ const LINE_END = /[\r\n]/g;
5
+ /**
6
+ * An incremental parser for the text/event-stream format, following the WHATWG rules `EventSource` uses: lines end in
7
+ * `\n`, `\r\n` or `\r` (a chunk may split `\r\n`), `:` lines are comments, consecutive `data:` lines join with `\n`, a
8
+ * frame with no `data:` line is dropped, and so is a frame still open when the stream ends.
9
+ *
10
+ * `state` carries what outlives a connection: the last event id and the retry delay. The partial frame does not.
11
+ */
12
+ export function createSseParser(state = {}, limits = {}) {
13
+ // 0, as for the timeouts, is none; so is anything that is not a number above it.
14
+ const limit = limits.maxEventChars !== undefined && limits.maxEventChars > 0 ? limits.maxEventChars : undefined;
15
+ // The line still arriving, in the pieces it came in: joined once, when it ends, so a long one is not copied per chunk.
16
+ let partial = [];
17
+ let partialLength = 0;
18
+ let event = '';
19
+ let data = [];
20
+ // The characters of the open frame's lines so far, not counting the line still arriving.
21
+ let held = 0;
22
+ let pendingId = state.lastEventId ?? '';
23
+ let lastEventId = pendingId;
24
+ let retry = state.retry ?? null;
25
+ // A line that ended in \r at the end of a chunk: a \n that opens the next chunk is the other half of its \r\n.
26
+ let skipLf = false;
27
+ // The first chunk of a stream may open with a byte order mark, which is no part of the first line.
28
+ let fresh = true;
29
+ // The line still arriving is a comment: it is dropped as it comes, and counts for nothing.
30
+ let skipLine = false;
31
+ return {
32
+ get lastEventId() {
33
+ return lastEventId;
34
+ },
35
+ get retry() {
36
+ return retry;
37
+ },
38
+ push,
39
+ };
40
+ function push(chunk) {
41
+ if (chunk === '')
42
+ return [];
43
+ if (fresh) {
44
+ fresh = false;
45
+ if (chunk.startsWith('\uFEFF'))
46
+ chunk = chunk.slice(1);
47
+ if (chunk === '')
48
+ return [];
49
+ }
50
+ if (skipLf) {
51
+ skipLf = false;
52
+ if (chunk.startsWith('\n'))
53
+ chunk = chunk.slice(1);
54
+ if (chunk === '')
55
+ return [];
56
+ }
57
+ LINE_END.lastIndex = 0;
58
+ const ends = LINE_END.test(chunk);
59
+ if (skipLine) {
60
+ if (!ends)
61
+ return [];
62
+ let cut = LINE_END.lastIndex;
63
+ if (chunk[cut - 1] === '\r') {
64
+ if (cut === chunk.length)
65
+ skipLf = true;
66
+ else if (chunk[cut] === '\n')
67
+ cut += 1;
68
+ }
69
+ skipLine = false;
70
+ return push(chunk.slice(cut));
71
+ }
72
+ if (!ends) {
73
+ if (partial.length === 0 && chunk.startsWith(':')) {
74
+ skipLine = true;
75
+ return [];
76
+ }
77
+ partial.push(chunk);
78
+ partialLength += chunk.length;
79
+ if (limit !== undefined && held + partialLength > limit)
80
+ throw new SseEventTooLarge(limit);
81
+ return [];
82
+ }
83
+ const text = partial.length > 0 ? partial.join('') + chunk : chunk;
84
+ partial = [];
85
+ partialLength = 0;
86
+ const events = [];
87
+ let start = 0;
88
+ let tooLarge = false;
89
+ for (;;) {
90
+ LINE_END.lastIndex = start;
91
+ if (!LINE_END.test(text))
92
+ break;
93
+ const end = LINE_END.lastIndex - 1;
94
+ const line = text.slice(start, end);
95
+ // A \r ends the line at once; whether a \n follows it is for the next chunk to say.
96
+ if (text[end] === '\r' && end === text.length - 1)
97
+ skipLf = true;
98
+ start = text[end] === '\r' && text[end + 1] === '\n' ? end + 2 : end + 1;
99
+ if (line === '') {
100
+ lastEventId = pendingId;
101
+ if (data.length > 0)
102
+ events.push({ event: event || 'message', data: data.join('\n'), id: lastEventId });
103
+ event = '';
104
+ data = [];
105
+ held = 0;
106
+ continue;
107
+ }
108
+ if (line.startsWith(':'))
109
+ continue;
110
+ // The event this line belongs to is too large already, however it ends: it is not read on, so the limit does not
111
+ // depend on where a chunk happened to end.
112
+ if (limit !== undefined && held + line.length > limit) {
113
+ tooLarge = true;
114
+ break;
115
+ }
116
+ held += line.length;
117
+ const colon = line.indexOf(':');
118
+ const field = colon === -1 ? line : line.slice(0, colon);
119
+ let value = colon === -1 ? '' : line.slice(colon + 1);
120
+ if (value.startsWith(' '))
121
+ value = value.slice(1);
122
+ if (field === 'event')
123
+ event = value;
124
+ else if (field === 'data')
125
+ data.push(value);
126
+ // An id with a NUL is ignored, so a hostile server cannot plant one the next request would send back.
127
+ else if (field === 'id' && !value.includes('\0'))
128
+ pendingId = value;
129
+ else if (field === 'retry' && /^\d+$/.test(value))
130
+ retry = Math.min(Number(value), Number.MAX_SAFE_INTEGER);
131
+ }
132
+ const rest = text.slice(start);
133
+ if (rest.startsWith(':'))
134
+ skipLine = true;
135
+ else if (rest !== '') {
136
+ partial.push(rest);
137
+ partialLength = rest.length;
138
+ }
139
+ if (tooLarge || (limit !== undefined && held + partialLength > limit))
140
+ throw new SseEventTooLarge(limit, events);
141
+ return events;
142
+ }
143
+ }
package/dist/read.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type SseEvent } from './event.js';
2
+ import { type SseParser } from './parser.js';
3
+ /**
4
+ * Reads a response body a chunk at a time and yields what each chunk completed, an empty array included: a chunk that
5
+ * completes no event may still have moved the parser's cursor, and the consumer decides when to look at it. Aborting
6
+ * `signal` cancels the reader, which ends a read still pending even when the body itself ignores the signal. Not exported
7
+ * from the package: `readSse` is the public way in.
8
+ */
9
+ export declare function readChunks(body: ReadableStream<Uint8Array>, onActivity: (() => void) | undefined, parser: SseParser, signal?: AbortSignal): AsyncGenerator<SseEvent[]>;
10
+ /**
11
+ * Yields the events of an SSE response body as they arrive. `onActivity` fires for every chunk of bytes read, comments
12
+ * and pings included, which is what an idle timeout wants to reset on. Pass a `parser` to read its `lastEventId` and
13
+ * `retry` while the stream is open or after it has ended. The parser reads a whole chunk before the first of its events
14
+ * is yielded, so its `lastEventId` can be ahead of the event you hold: to resume, use the `id` of the last event you handled.
15
+ * Give it a parser made for this body: one that has read before does not take a byte order mark off the start of the next.
16
+ */
17
+ export declare function readSse(body: ReadableStream<Uint8Array>, onActivity?: () => void, parser?: SseParser): AsyncGenerator<SseEvent>;
package/dist/read.js ADDED
@@ -0,0 +1,58 @@
1
+ import { SseEventTooLarge } from './event.js';
2
+ import { createSseParser } from './parser.js';
3
+ /**
4
+ * Reads a response body a chunk at a time and yields what each chunk completed, an empty array included: a chunk that
5
+ * completes no event may still have moved the parser's cursor, and the consumer decides when to look at it. Aborting
6
+ * `signal` cancels the reader, which ends a read still pending even when the body itself ignores the signal. Not exported
7
+ * from the package: `readSse` is the public way in.
8
+ */
9
+ export async function* readChunks(body, onActivity, parser, signal) {
10
+ const reader = body.getReader();
11
+ // The parser takes a leading byte order mark off, once, wherever its text comes from.
12
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
13
+ const cancel = () => void reader.cancel().catch(() => { });
14
+ // Aborted already: there is no event to wait for, and the read below must not either.
15
+ if (signal?.aborted)
16
+ cancel();
17
+ else
18
+ signal?.addEventListener('abort', cancel, { once: true });
19
+ try {
20
+ for (;;) {
21
+ const { done, value } = await reader.read();
22
+ if (done)
23
+ return;
24
+ onActivity?.();
25
+ let events;
26
+ const before = parser.lastEventId;
27
+ try {
28
+ events = parser.push(decoder.decode(value, { stream: true }));
29
+ }
30
+ catch (error) {
31
+ // The events before the one that is too large are still the consumer's.
32
+ if (error instanceof SseEventTooLarge && error.events.length > 0)
33
+ yield error.events;
34
+ throw error;
35
+ }
36
+ // A ping completes nothing and moves no cursor: nothing for the consumer to look at.
37
+ if (events.length > 0 || parser.lastEventId !== before)
38
+ yield events;
39
+ }
40
+ }
41
+ finally {
42
+ // Also runs when the consumer stops early or the read throws (an abort): free the connection.
43
+ signal?.removeEventListener('abort', cancel);
44
+ cancel();
45
+ }
46
+ }
47
+ /**
48
+ * Yields the events of an SSE response body as they arrive. `onActivity` fires for every chunk of bytes read, comments
49
+ * and pings included, which is what an idle timeout wants to reset on. Pass a `parser` to read its `lastEventId` and
50
+ * `retry` while the stream is open or after it has ended. The parser reads a whole chunk before the first of its events
51
+ * is yielded, so its `lastEventId` can be ahead of the event you hold: to resume, use the `id` of the last event you handled.
52
+ * Give it a parser made for this body: one that has read before does not take a byte order mark off the start of the next.
53
+ */
54
+ export async function* readSse(body, onActivity, parser = createSseParser()) {
55
+ // The reader is freed when the consumer stops early or the read throws, because the generator below is closed too.
56
+ for await (const events of readChunks(body, onActivity, parser))
57
+ yield* events;
58
+ }
@@ -0,0 +1,3 @@
1
+ export { createSseParser, SseEventTooLarge } from './parser.js';
2
+ export type { SseEvent, SseParser, SseParserLimits, SseParserState } from './parser.js';
3
+ export { readSse } from './read.js';
@@ -0,0 +1,2 @@
1
+ export { createSseParser, SseEventTooLarge } from './parser.js';
2
+ export { readSse } from './read.js';
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "pg-sse-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A fetch-based server-sent events client for pg-sse streams: custom headers, Last-Event-ID resume, server retry hints and backoff.",
5
+ "keywords": [
6
+ "sse",
7
+ "server-sent-events",
8
+ "eventsource",
9
+ "fetch",
10
+ "pg-sse",
11
+ "postgres"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Sandhu Santhakumar <code@sandhu.sh>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sansanth/pg-sse-client.git"
18
+ },
19
+ "bugs": "https://github.com/sansanth/pg-sse-client/issues",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./parser": {
33
+ "types": "./dist/standalone.d.ts",
34
+ "default": "./dist/standalone.js"
35
+ }
36
+ },
37
+ "typesVersions": {
38
+ "*": {
39
+ "parser": [
40
+ "./dist/standalone.d.ts"
41
+ ]
42
+ }
43
+ },
44
+ "files": [
45
+ "dist",
46
+ "README.md",
47
+ "CHANGELOG.md",
48
+ "LICENSE"
49
+ ],
50
+ "scripts": {
51
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
52
+ "typecheck": "tsc --noEmit",
53
+ "lint": "eslint --max-warnings 0 .",
54
+ "test": "vitest run --exclude \"test/integration/**\"",
55
+ "test:coverage": "vitest run --coverage --exclude \"test/integration/**\"",
56
+ "check": "npm run typecheck && npm run lint && npm run test:coverage && npm run build && npm run smoke",
57
+ "prepublishOnly": "npm run check",
58
+ "smoke": "node scripts/smoke.mjs"
59
+ },
60
+ "devDependencies": {
61
+ "@eslint/js": "^10.0.1",
62
+ "@types/node": "^24.19.1",
63
+ "@vitest/coverage-v8": "^5.0.3",
64
+ "eslint": "^10.12.0",
65
+ "globals": "^17.13.0",
66
+ "jsdom": "^30.1.2",
67
+ "typescript": "^5.9.3",
68
+ "typescript-eslint": "^8.71.1",
69
+ "vitest": "^5.0.3"
70
+ }
71
+ }