@cplieger/sse

中文文档
2.1.0发布于 2026/11/11Apache-2.0ES Module零依赖构建来源已验证近 30 天下载 6150

Server-Sent Events hub and client that resume from an exact cursor and reconcile after sleep

原始使用文档
文档目录6 节
  1. Install
  2. Usage
  3. Browser floor
  4. Full documentation
  5. Disclaimer
  6. License

@cplieger/sse

A Server-Sent-Events client that resumes exactly where it stopped, notices a dead stream, and reconciles after a sleep.

The TypeScript half of github.com/cplieger/go/sse/v2, speaking the wire the Go sse package serves: every frame carries an epoch:offset cursor, every connection opens with a hello that says whether that cursor was honoured, and the client turns both into a projection an application can trust across a page hide, a phone lock or a network change. A native EventSource cannot set a request header, cannot tell a silent stream from an idle one, and reconnects on its own schedule with whatever Last-Event-ID it last saw; this client owns the connection over fetch and ReadableStream instead, presents the cursor it holds, measures liveness on bytes, closes a hidden tab's stream after a minute and reopens it on return, backs off with full jitter, and holds incoming frames while the application asks the server what changed. It has no runtime dependencies, and the DOM readers (document.visibilityState, navigator.onLine) are injectable, so the same runtime runs in Node against the Go test fixture.

Install

npx jsr add @cplieger/sse
# or
npm i @cplieger/sse

Usage

import { createStream, createVersionMap } from "@cplieger/sse";

const versions = createVersionMap();

const stream = createStream({
  url: "/api/events",
  headers: { "SSE-Client": profileTag },
  alive: { url: "/api/alive" },
  versions,
  onFrame: (frame) => {
    applyToStore(frame.type, frame.data);
  },
  revalidate: async (ctx) => {
    // Runs once per hello and once per wake, never concurrently. Every fetch in
    // here takes ctx.signal; ctx.full means the map was cleared, so skip the digest.
    await reconcile(versions.snapshot(), ctx);
  },
  onLifecycle: (ev) => {
    log.debug("sse", ev);
  },
});

stream.start();

The first frame of every connection is a hello: it names the server epoch, the ring's floor and head, the keepalive interval and event name, and whether the cursor the client presented was resumed; a hello whose wire is outside [minWire, maxWire] (both default to the package's WIRE) ends the connection with a wire_unsupported record and keeps the cursor.

The client keeps its cursor as epoch:offset, presents it as Last-Event-ID on every connect, and advances it only as frames are delivered to onFrame, so a reconnect after a gap either replays exactly the missed frames or reports through the hello that the gap could not be covered, and the application reconciles instead of trusting the replay.

stream.state() returns a StreamState: the connection kind (stopped, connecting, open, backoff, offline or hidden_closed), its generation and attempt, and the readings that kind acts on, such as visible, online, the hello's keepaliveMs and when a backoff ends. The package exports the factories, the wire helpers and the types they take and return; its parser, reducer and tab fold are internal.

While revalidate runs, incoming frames are held and delivered in order once it settles; runs are single-flight, a second cause queues at most one more run, and a held queue past 2000 frames or 64 MiB ends the connection so the next connect resumes from the cursor instead.

With alive set, every keepalive (or every everyBeats-th) is acknowledged with a POST to that URL carrying the stream's headers, so a server-side presence table keyed on SSE-Client sees which clients are still reading; a failed acknowledgement is reported as an alive_ack record.

A 401 ends the session's stream instead of retrying it: a 401 on the stream request, on an alive acknowledgement, or from a revalidate that rejects with { status: 401 } stops the stream and emits an auth_lost record whose route is stream, alive or digest. A tab's own stream and the worker host behave alike, and the host passes the record to every tab. Any other status backs off and reconnects.

createWorkerHost runs one stream per browser profile inside a SharedWorker, and attachToWorker connects each tab to it over a MessagePort, folding the tabs' visibility and network readings into that one connection; where SharedWorker is absent or the worker never answers, the tab falls back to its own stream and reports the switch. The fallback option returns a TabFallback: the createStream result together with the versions map and the headers record it was built on, which is what lets the TabAttachment work the same in both modes. observe(subject, version, epoch) reports a stamp the tab applied, into the host's version map or the fallback's; reconnect({ resetCursor }) reconnects the stream the tab is attached to; setTag(tag) presents a new SSE-Client value with one reconnect, written into the host's headers or the fallback's; and detach("logout") ends the profile's stream. The tab writes its tag into the fallback's headers before the first connect, so a consumer never fills SSE-Client itself.

The host presents the first non-empty tag a tab attaches with and replaces it, reconnecting once, when a later attach or setTag carries a different one; the empty tag is ignored.

The host's revalidate(ctx, tabs) body performs one digest for the profile and fans the verdict to every tab through tabs.run(ctx, { changed, removed }), so each tab's revalidate receives a TabRevalidateContext carrying changed and removed beside its own signal. A state message reaches a tab before the lifecycle record of the same transition, so attachment.state(), a StreamState, is current inside the handler, and a tab attaching to a live stream receives state and then a tab_attached record naming that state.

The package typechecks under lib: ["ESNext", "WebWorker"] as well as under the DOM lib (tsconfig.worker.json is the check), so a consumer's worker entry can import createWorkerHost from a worker-only program: the DOM visibility and online sources read document and window through globalThis and are inert where they are absent.

onLifecycle receives every state change and diagnostic as one LifecycleEvent record (state, hello, watchdog, reset, revalidate, hidden_closed, alive_ack, worker_dead and the rest of the union), which is the surface to log and to count.

Browser floor

Response.body as a readable stream (Firefox 65, Chrome 43, Safari 10.1), AbortSignal.reason (Firefox 97, Chrome 98, Safari 15.4), visibilitychange (Chrome 62, Firefox 56, Safari 14.1), pagehide and pageshow, and BigInt (Chrome 67, Firefox 68, Safari 14). AbortSignal.any is not used; two signals are composed by hand. SharedWorker is needed by the worker host only and is not part of the floor: where it is absent (Chrome for Android before 148, Samsung Internet) the tab runs its own stream through the fallback.

Full documentation

The Go server API is in the repository README. The hub options and the presence hook are in Running the hub, and the hello's verdicts and the digest are in The wire contract. The shared timing constants both halves pin are in timing.json.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

Apache-2.0. See LICENSE.

版本与发布记录

@cplieger/sse 共发布 18 个版本,首次发布于 2026/09/14;过去 12 个月发布了 18 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.1.0latest2026/11/11今天
2.1.0-dev.4dev2026/11/11今天
2.0.02026/11/092 天前
2.0.0-dev.12026/11/082 天前
1.5.0-dev.82026/11/073 天前
1.5.0-dev.42026/11/056 天前

关于 @cplieger/sse 的常见问题

@cplieger/sse 是什么?

Server-Sent Events hub and client that resume from an exact cursor and reconcile after sleep

如何安装 @cplieger/sse?

在项目目录运行 npm install @cplieger/sse,或使用 pnpm add / yarn add / bun add。

@cplieger/sse 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/cplieger__sse 是否存在。

@cplieger/sse 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cplieger/sse 可以商用吗?

@cplieger/sse 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@cplieger/sse 的下载量有多少?

根据 npm 官方下载统计,@cplieger/sse 在 2026/10/11 – 2026/11/09 这 30 天内被下载 6,150 次,日均约 205 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cplieger/sse 还在维护吗?

最新版本 2.1.0 发布于 2026/11/11(今天);至今共发布 18 个版本,其中过去 12 个月发布了 18 个;首次发布于 2026/09/14。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。