pg-sse-client 0.1.0 → 0.1.2

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 CHANGED
@@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.2] - 2026-10-12
9
+
10
+ ### Changed
11
+
12
+ - A rest chosen with `{ retryAfterMs }` from `onError` is never shorter than the server's `Retry-After` or last
13
+ `retry:`, as every other wait already was. With `wakeOnResume` the browser cannot end it before that.
14
+ - The package includes `llms.txt`, the rules for generating code with it.
15
+
16
+ ## [0.1.1] - 2026-10-12
17
+
18
+ ### Changed
19
+
20
+ - Published from CI with provenance. The library is the same as 0.1.0.
21
+
8
22
  ## [0.1.0] - 2026-10-12
9
23
 
10
24
  ### Added
@@ -18,5 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
18
32
  - `createSseParser` and `readSse`, also under `pg-sse-client/parser`: the text/event-stream parser, for a stream you
19
33
  open yourself.
20
34
 
21
- [Unreleased]: https://github.com/sansanth/pg-sse-client/compare/v0.1.0...HEAD
35
+ [Unreleased]: https://github.com/sansanth/pg-sse-client/compare/v0.1.2...HEAD
36
+ [0.1.2]: https://github.com/sansanth/pg-sse-client/compare/v0.1.1...v0.1.2
37
+ [0.1.1]: https://github.com/sansanth/pg-sse-client/compare/v0.1.0...v0.1.1
22
38
  [0.1.0]: https://github.com/sansanth/pg-sse-client/releases/tag/v0.1.0
package/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
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
4
  [![npm](https://img.shields.io/npm/v/pg-sse-client)](https://www.npmjs.com/package/pg-sse-client)
5
+ [![Node versions](https://img.shields.io/node/v/pg-sse-client)](https://www.npmjs.com/package/pg-sse-client)
6
+ [![Minified and gzipped](https://img.shields.io/bundlephobia/minzip/pg-sse-client)](https://bundlephobia.com/package/pg-sse-client)
7
+ [![License](https://img.shields.io/npm/l/pg-sse-client)](https://github.com/sansanth/pg-sse-client/blob/main/LICENSE)
5
8
 
6
9
  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
10
 
@@ -53,7 +56,7 @@ It starts at once, like `new EventSource(url)`. On the server, set `end_event="e
53
56
  | 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
57
  | 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
58
  | 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). |
59
+ | 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 }` (unless it asked for `wakeOnResume`), does not (`retryNow()` still ends it). |
57
60
 
58
61
  ### Options
59
62
 
@@ -64,7 +67,7 @@ It starts at once, like `new EventSource(url)`. On the server, set `end_event="e
64
67
  | `onOpen()` | | The server accepted the request. It has sent nothing yet. |
65
68
  | `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
69
  | `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`. |
70
+ | `onError(error, { failures })` | | Every failed attempt. Return `'retry'`, `'stop'` or `{ retryAfterMs }` (which also resets `failures`), or nothing for the default. `{ retryAfterMs }` is a rest that is never shorter than the server's `Retry-After` or last `retry:`; with `wakeOnResume: true` it also ends early when the browser comes back `online` or the tab becomes visible, after the first second and that floor. May be `async`. |
68
71
  | `onClose(error?)` | | The connection is over: `close()` was called (no argument), or an error stopped it. Once. |
69
72
  | `headers` | | `HeadersInit`, or a function returning one (or a promise of one), called before every attempt. |
70
73
  | `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. |
@@ -96,6 +99,35 @@ onError: (error, { failures }) => {
96
99
  onOpen: stopPolling,
97
100
  ```
98
101
 
102
+ ### Waiting for a new sign-in after a 401
103
+
104
+ A 401 stops the connection by default. To rest until the user signs in again instead, return a long `{ retryAfterMs }` and call `retryNow()` when the credential changes. The version lets `onError` tell whether the failed attempt carried the old credential: a sign-in can land while that attempt is still in flight, when there is no rest for `retryNow()` to end.
105
+
106
+ ```ts
107
+ import { connect, StreamRefused } from 'pg-sse-client';
108
+
109
+ let credentialVersion = 0; // add 1 on every sign-in
110
+ let sentVersion = 0;
111
+
112
+ const stream = connect(url, {
113
+ headers: () => {
114
+ sentVersion = credentialVersion;
115
+ return { Authorization: `Bearer ${currentToken()}` };
116
+ },
117
+ onError: (error) => {
118
+ if (!(error instanceof StreamRefused) || error.status !== 401) return;
119
+ if (sentVersion !== credentialVersion) return 'retry'; // signed in again while that attempt was out
120
+ return { retryAfterMs: 24 * 60 * 60_000 };
121
+ },
122
+ onEvent,
123
+ });
124
+
125
+ function onSignedIn() {
126
+ credentialVersion += 1;
127
+ stream.retryNow();
128
+ }
129
+ ```
130
+
99
131
  ### Cross-origin requests
100
132
 
101
133
  `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.
@@ -110,12 +142,36 @@ for await (const event of readSse(response.body!, onActivity)) { /* { event, dat
110
142
 
111
143
  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
144
 
145
+ ## Testing your code
146
+
147
+ Pass your own `fetch` and the stream is whatever you hand back, with no server:
148
+
149
+ ```ts
150
+ const body = new ReadableStream<Uint8Array>({
151
+ start(controller) {
152
+ controller.enqueue(new TextEncoder().encode('id: 1\nevent: change\ndata: {"n":1}\n\n'));
153
+ },
154
+ });
155
+ const stream = connect('https://api.example/stream', {
156
+ fetch: async () => new Response(body, { headers: { 'content-type': 'text/event-stream' } }),
157
+ onEvent: (event) => seen.push(JSON.parse(event.data)),
158
+ });
159
+ // ...assert on `seen`, then
160
+ stream.close();
161
+ ```
162
+
163
+ Fake timers work for the waits: a failed attempt, or a `retry:`, is a timer of the length the table above says, with the jitter from `Math.random()`. Stub that too if a test needs an exact wait.
164
+
113
165
  ## What is not here
114
166
 
115
167
  - **The meaning of your events.** The client does not know `change` from `ready`; that is your server's protocol.
116
168
  - **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
169
  - **React or any framework.** Wrap `connect` in your own hook; `close()` is the cleanup.
118
170
 
171
+ ## For AI coding agents
172
+
173
+ If you are generating code with this package, [llms.txt](https://github.com/sansanth/pg-sse-client/blob/main/llms.txt) carries the rules in a form meant to be fed to an agent. Runnable examples are in [examples/](examples/): a Node script, a page that carries the cursor in the URL and falls back to polling, and a React hook.
174
+
119
175
  ## Development
120
176
 
121
177
  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).
package/dist/connect.d.ts CHANGED
@@ -24,9 +24,10 @@ export declare class StreamEndedEarly extends Error {
24
24
  }
25
25
  /**
26
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.
27
+ * for good, and `{ retryAfterMs }` waits that long, for example to rest while the caller polls instead, but never less
28
+ * than the server's `Retry-After` or last `retry:`. With `wakeOnResume` the browser coming back online, or the tab
29
+ * becoming visible, ends that rest early, once it has run a second and that long. A `retryAfterMs` that is not a finite
30
+ * number of at least 0 is ignored, as if `onError` had returned nothing.
30
31
  */
31
32
  export type ErrorAction = 'retry' | 'stop' | {
32
33
  retryAfterMs: number;
package/dist/connect.js CHANGED
@@ -386,7 +386,7 @@ export function connect(url, options) {
386
386
  function delayAfter(failures, retryAfterMs, retry) {
387
387
  const hint = capped(retry);
388
388
  if (failures === 0 && hint !== null)
389
- return { ms: hint, holdMs: hint };
389
+ return { ms: hint, holdMs: hint, floor: hint };
390
390
  const floor = Math.max(capped(retryAfterMs) ?? 0, capped(serverRetry) ?? 0);
391
391
  const ms = failures === 0
392
392
  ? backoff.reconnectFloorMs + Math.random() * backoff.reconnectSpreadMs
@@ -394,7 +394,7 @@ export function connect(url, options) {
394
394
  // Spread, so the clients a full server refused together do not come back at the same moment.
395
395
  const refused = capped(retryAfterMs);
396
396
  const spread = refused === null ? 0 : refused + Math.random() * backoff.reconnectSpreadMs;
397
- return { ms: Math.max(floor, spread, ms), holdMs: Math.max(floor, spread) };
397
+ return { ms: Math.max(floor, spread, ms), holdMs: Math.max(floor, spread), floor };
398
398
  }
399
399
  async function run() {
400
400
  let failures = 0;
@@ -446,7 +446,9 @@ export function connect(url, options) {
446
446
  if (typeof action === 'object') {
447
447
  failures = 0;
448
448
  retryAsked = false;
449
- wait = { ms: action.retryAfterMs, holdMs: action.wakeOnResume ? 0 : action.retryAfterMs };
449
+ // The server's word is a floor under this wait too.
450
+ const ms = Math.max(action.retryAfterMs, wait.floor);
451
+ wait = { ms, holdMs: action.wakeOnResume ? wait.floor : ms, floor: wait.floor };
450
452
  }
451
453
  }
452
454
  if (retryAsked)
package/llms.txt ADDED
@@ -0,0 +1,26 @@
1
+ # pg-sse-client
2
+
3
+ > JavaScript/TypeScript client for pg-sse streams (the Python library that sends server-sent events from Postgres LISTEN/NOTIFY). `EventSource` on top of `fetch`: it can send headers, and it keeps what pg-sse's contract needs from `EventSource` (resume from the last id, the server's reconnect delay, the goodbye frame). Browser and Node 20+, ESM only, no dependencies.
4
+
5
+ Install: `npm install pg-sse-client`. Not the unrelated `pg-sse` package on npm.
6
+
7
+ Rules when generating code with pg-sse-client:
8
+
9
+ - Use plain `new EventSource(url)` when the stream authenticates with a same-origin cookie or `withCredentials`: it reconnects and sends `Last-Event-ID` by itself. Use this package to send a header (`Authorization`), to read a token that changes between attempts, to see why a stream ended or was refused, or to decide what to do after repeated failures.
10
+ - `const stream = connect(url, { onEvent, ...options })` starts at once, like `new EventSource`. `stream.close()` ends it for good and is the cleanup (return `() => stream.close()` from a React effect). `stream.retryNow()` cuts a wait short. `stream.lastEventId` is the cursor.
11
+ - `url` is a string, a `URL`, or a function `(lastEventId) => string | URL` called before every attempt. A string or `URL` that cannot be fetched, `headers` given as a value that cannot be sent, and any `cursorIn` but `'header'` or `'url'` throw `TypeError` from `connect`.
12
+ - Auth: `headers: async () => ({ Authorization: `Bearer ${await getToken()}` })`, called before every attempt. `Accept` and `Last-Event-ID` are the client's; put no `Last-Event-ID` in `headers`.
13
+ - Cross-origin: `Last-Event-ID` needs `Access-Control-Allow-Headers: Last-Event-ID` on the server (and `Authorization` if sent), or the first reconnect fails. Without that, `cursorIn: 'url'` with a URL function carries the cursor in the query and sends no header: `connect((id) => `${base}?cursor=${encodeURIComponent(id)}`, { cursorIn: 'url', onEvent })`. `cursorIn: 'url'` needs a URL function. The function gets the cursor (`''` at first) whatever `cursorIn` is.
14
+ - `onEvent(event)` gets `{ event, data, id }` for every event but the goodbye; `data` is a string, so `JSON.parse(event.data)`. A callback that throws or rejects does not stop the stream: the error goes to `onCallbackError(error)`, else a page or worker rethrows it to `window.onerror` and anything else logs it with `console.error`.
15
+ - The goodbye is the event named `endEvent` (default `end`, the hub's `end_event`): `onEnd(reason)` (`max_age`, `shutdown`, `behind`, `revoked`, `recheck`, or any other string; `unknown` when it has no reason), then it reconnects. It is not passed to `onEvent`; an app event of your own named `end` would be taken for it, so name yours otherwise or set `endEvent`.
16
+ - Callbacks: `onOpen()`, `onDisconnect()` (a stream that had opened has ended, before `onError`; not called by `close()`), `onEnd(reason)`, `onError(error, { failures })`, `onClose(error?)` (over for good: no argument after `close()`, the error otherwise; once).
17
+ - `onError` hears every failed attempt and returns `'retry'`, `'stop'`, `{ retryAfterMs, wakeOnResume? }` or nothing (the default), and may be `async`. Default: stop on a 4xx other than 408 and 429 (a 401 too: return `'retry'` for it when `headers` can get a fresh token), on a 3xx that was not followed, on a 2xx that is not `text/event-stream`, and on `SseEventTooLarge`; retry everything else. `{ retryAfterMs }` waits that long, never less than the server's `Retry-After` or last `retry:`, and resets `failures`; `wakeOnResume: true` also ends the rest when the browser comes back `online` or the tab becomes visible, after the first second and that floor.
18
+ - Polling fallback: `onError: (error, { failures }) => { if (failures >= 3) { startPolling(); return { retryAfterMs: 180_000, wakeOnResume: true }; } }` and `onOpen: stopPolling`.
19
+ - Errors are classes: `StreamRefused` (`status`, `retryAfterMs`), `StreamTimeout` (`phase`: `'connect'` or `'idle'`), `StreamEndedEarly` (`lastedMs`, `reason`: a stream closed within `backoff.shortMs`, 5 s, counts as a failure), `SseEventTooLarge` (`limit`, `events`). Anything else is what `fetch` threw.
20
+ - Reconnecting: after a stream open 5 s or more the wait is the stream's `retry:`, else 0.5 to 2 s at random. After a failure it doubles from 1 s to 30 s with jitter, never under the last `retry:` or a `Retry-After` (both capped at `backoff.serverMaxMs`, 1 hour). `backoff` takes `baseMs`, `maxMs`, `shortMs`, `reconnectFloorMs`, `reconnectSpreadMs`, `serverMaxMs`; an entry that is not a finite number of at least 0 keeps its default.
21
+ - Timeouts: `connectTimeoutMs` (15000, the `headers` function included) and `idleTimeoutMs` (45000, pings count); 0 or a value too long for a timer turns one off.
22
+ - `lastEventId` resumes from there on the first attempt. `init` is more `fetch` options (`credentials`, `mode`); `method`, `headers`, `body` and `signal` are the client's. `fetch` replaces the global one and is called with no receiver. `maxEventChars` (no limit by default, or when 0) caps how much of one event is held.
23
+ - Only the parser, for a stream you open yourself (a `POST` that answers with events): `import { createSseParser, readSse } from 'pg-sse-client/parser'`; `for await (const event of readSse(response.body, onActivity))`. To resume use the `id` of the last event you handled, not `parser.lastEventId`.
24
+ - Not here: the meaning of your events (that is your server's protocol), a message queue (the server re-reads from the cursor it was sent), and any framework binding.
25
+
26
+ Full reference: https://github.com/sansanth/pg-sse-client#readme
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pg-sse-client",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
5
  "keywords": [
6
6
  "sse",
@@ -16,6 +16,7 @@
16
16
  "type": "git",
17
17
  "url": "git+https://github.com/sansanth/pg-sse-client.git"
18
18
  },
19
+ "homepage": "https://github.com/sansanth/pg-sse-client#readme",
19
20
  "bugs": "https://github.com/sansanth/pg-sse-client/issues",
20
21
  "type": "module",
21
22
  "sideEffects": false,
@@ -45,7 +46,8 @@
45
46
  "dist",
46
47
  "README.md",
47
48
  "CHANGELOG.md",
48
- "LICENSE"
49
+ "LICENSE",
50
+ "llms.txt"
49
51
  ],
50
52
  "scripts": {
51
53
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
@@ -64,7 +66,7 @@
64
66
  "eslint": "^10.12.0",
65
67
  "globals": "^17.13.0",
66
68
  "jsdom": "^30.1.2",
67
- "typescript": "^5.9.3",
69
+ "typescript": "^6.0.3",
68
70
  "typescript-eslint": "^8.71.1",
69
71
  "vitest": "^5.0.3"
70
72
  }