@dokebi/sse 0.1.0 → 0.4.1

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/AI.md CHANGED
@@ -1,69 +1,74 @@
1
- # AI 작업 안내 — @dokebi/sse 0.1.0
1
+ # AI guide — @dokebi/sse 0.4.0
2
2
 
3
- @dokebi/web 앱의 서버 전송 이벤트(SSE)다. 서버는 인증한 스트림에 이벤트를 쓰고, `bus`를 주면 @dokebi/event-bus로 다른 워커·서버의 스트림에도 보낸다. 브라우저 클라이언트는 같은 패키지의 `./client`(+ `./client/vue`, `./client/react`, `./client/bundle`). 많아야 한 번(ACK·재전송·보관 없음). Node >=24.0.0, core ^0.2.0, @dokebi/web(필수), @dokebi/event-bus(`bus`일 때만), @dokebi/health(있으면 check `sse`). 근거는 [D-0060](../../docs/decisions/D-0060-sse.md), 사람용 설명은 [README](README.md).
3
+ Server-sent events (SSE) for @dokebi/web apps. The server writes events to authenticated streams and, with `eventBus`, sends them through @dokebi/event-bus to the streams of other workers and servers too. The browser client is `./client` of the same package (plus `./client/vue`, `./client/react`, `./client/bundle`). At most once (no ACK, no resending, no retention). Node >=24.0.0, core ^0.4.0, @dokebi/web (required), @dokebi/event-bus (only with `eventBus`), @dokebi/health (check `sse` when present). The decision is [D-0060](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0060-sse.md); the human-oriented description is the [README](README.md).
4
4
 
5
- ## 1. 설치
5
+ ## 1. Install
6
6
 
7
7
  ```sh
8
8
  npx dokebi add @dokebi/sse
9
9
  ```
10
10
 
11
- - `bus`를 쓰면 CLI가 dokebi-event-bus를 요구한다(설치 모듈 `requires`). 버스는 `dokebi-event-bus.buses.<이름>`에 정의한다. 여러 워커·서버는 `transport: nats`.
12
- - 같은 프로세스 하나만이면 `bus` 없이 쓴다(publish는 이 프로세스 스트림에만).
11
+ > `npx dokebi` is the @dokebi/cli installed in the project (`dokebi init` adds it to devDependencies). The npm package `dokebi` is not this CLI, so never use `npx -y dokebi`. Without an installed CLI use `npx -y @dokebi/cli …`.
13
12
 
14
- ## 2. 설정 `options.dokebi-sse`
13
+ - With `eventBus` the CLI requires dokebi-event-bus (install module `requires`). Define the bus in `dokebi-event-bus.buses.<name>`. For several workers or servers use `driver: nats`.
14
+ - For a single process, leave out `eventBus` (publish then reaches only this process's streams).
15
15
 
16
- | 키 | 기본 | 뜻 |
17
- |---|---|---|
18
- | `bus` | 없음 | event-bus 버스 이름. 다른 프로세스로 퍼뜨린다 |
19
- | `busEvent` | `dokebi.sse` | 버스 이벤트 이름 |
20
- | `allowInCluster` | `false` | cluster 워커에서 NATS 버스 없이 시작 허용(워커 하나일 때만) |
21
- | `retryMs` | 3000 | 처음 보내는 `retry:`(브라우저 재접속 간격). 클라이언트 백오프의 최소값 |
22
- | `heartbeatMs` | 15000 | `: ping` 주석 간격. 프락시 idle 제한보다 짧게 |
23
- | `maxConnections` | 1024 | 프로세스(워커)당 스트림. 넘으면 503 `CAPACITY` |
24
- | `maxBufferedBytes` | 1048576 | 스트림 하나에 쌓인 송신 바이트 상한. 넘으면 끊고 warn `SLOW_CONSUMER` |
25
- | `maxRooms` | 32 | 스트림 하나의 방 수 |
26
- | `maxEventBytes` | 65536 | 이벤트 data(JSON) 바이트 상한. 버스 `maxPayloadBytes`보다 작게 |
27
- | `closeTimeoutMs` | 2000 | drain이 `dokebi-restart` 뒤 스트림 종료를 기다리는 시간 |
16
+ ## 2. Options `options.dokebi-sse`
28
17
 
29
- **cluster 검사(`SSE_LOCAL_IN_CLUSTER`)**: node:cluster 워커에서 `bus`가 없거나 그 버스의 transport가 `nats`가 아니면 error 로그(`fix` 포함)와 함께 시작이 실패한다. event-bus API는 transport를 알려 주지 않으므로 **앱 설정 `dokebi-event-bus.buses.<bus>.transport`를 읽는다**. event-bus 옵션을 코드로만 넘겼으면 transport를 알 수 없어(`unknown`) 거절된다. 그때는 설정 파일로 옮기거나, 정말 워커 하나면 `allowInCluster: true`.
18
+ | Key | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `eventBus` | string | none | event-bus bus name that carries publications to other processes (named `bus` before 0.4.0, D-0071) |
21
+ | `bus` | string | — | deprecated name of `eventBus`: warns `OPTION_DEPRECATED`; setting both is `INVALID_OPTIONS` |
22
+ | `busEvent` | string | `dokebi.sse` | the bus event name |
23
+ | `allowInCluster` | boolean | `false` | allow starting in a cluster worker without a NATS bus (only for a single worker) |
24
+ | `retryMs` | integer | 3000 | the first `retry:` sent (browser reconnect delay); also the client backoff's minimum |
25
+ | `heartbeatMs` | integer | 15000 | interval of the `: ping` comment. Keep it shorter than the proxy idle limit |
26
+ | `maxConnections` | integer | 1024 | streams per process (worker). Beyond it 503 `CAPACITY` |
27
+ | `maxBufferedBytes` | integer | 1048576 | bytes waiting to be sent on one stream. Beyond it the stream is cut with warn `SLOW_CONSUMER` |
28
+ | `maxRooms` | integer | 32 | rooms per stream |
29
+ | `maxEventBytes` | integer | 65536 | bytes of an event's data (JSON). Keep it below the bus `maxPayloadBytes` |
30
+ | `shutdownTimeoutMs` | integer | 2000 | how long drain waits for streams to end after `dokebi-restart` (named `closeTimeoutMs` before 0.4.0) |
31
+ | `closeTimeoutMs` | integer | — | deprecated name of `shutdownTimeoutMs` (D-0071) |
30
32
 
31
- ## 3. 서버 API
33
+ **Cluster check (`LOCAL_IN_CLUSTER`)**: in a node:cluster worker, the start fails with an error log (with `fix`) when there is no `eventBus` or its driver is not `nats`. The event-bus API does not report the driver, so **the app setting `dokebi-event-bus.buses.<eventBus>.driver` is read (and the old name `transport`)**. When the event-bus options were passed only in code, the driver is unknown (`unknown`) and the start is refused. Move them to the configuration file, or, if there really is only one worker, set `allowInCluster: true`.
32
34
 
33
- ```ts
35
+ ## 3. Server API
36
+
37
+ ```ts snippet
34
38
  import { createPlugin, dependency, type SseApi } from '@dokebi/sse';
35
- const sse = context.getDependency(dependency); // 없으면 이 패키지로 만든다
39
+ const sse = context.getDependency(dependency); // created from this package when missing
36
40
  sse.route({
37
- path: '/events', // GET만(HEAD 없음)
38
- constraints: { dokebiApp: 'shop' }, // 선택: @dokebi/app 도메인 라우팅
39
- origins: ['https://admin.example.com'], // 선택: 다른 출처 허용(CORS+credentials). '*'는 credentials 없이
40
- version: APP_VERSION, // 선택: D-0033
41
+ path: '/events', // GET only (no HEAD)
42
+ constraints: { dokebiApp: 'shop' }, // optional: @dokebi/app domain routing
43
+ origins: ['https://admin.example.com'], // optional: allow other origins (CORS + credentials); '*' without credentials
44
+ version: APP_VERSION, // optional: D-0033
41
45
  authenticate: (request) => ({ tenant, member, rooms: ['r1'] }), // undefined → 401
42
46
  onOpen: (stream) => { stream.join(`user:${stream.member}`); stream.send('snapshot', data); },
43
- onClose: (stream, reason) => {}, // CLIENT_CLOSED·SERVER_CLOSED·SLOW_CONSUMER·DRAIN·ON_OPEN_FAILED·WRITE_FAILED
47
+ onClose: (stream, reason) => {}, // CLIENT_CLOSED, SERVER_CLOSED, SLOW_CONSUMER, DRAIN, ON_OPEN_FAILED, WRITE_FAILED
44
48
  });
45
- const { id, local } = await sse.publish({ tenant, member?, room? }, 'order.changed', { id: 42 });
46
- sse.connections({ tenant, room }); // 이 워커의 수
49
+ const { id, local } = await sse.publish({ tenant, member, room }, 'order.changed', { id: 42 }); // member and room are optional
50
+ sse.connections({ tenant, room }); // the count on this worker
47
51
  ```
48
52
 
49
- - `route`는 setup에서만(`FROZEN`). `authenticate`·`onOpen`은 다시 접속할 때마다 돈다.
50
- - 대상: `{tenant}` 전체, `{tenant, member}` 한 사람의 모든 탭, `{tenant, room}` 방, `{tenant, room, member}` 방 안의 그 사람. 문자열은 1~256자.
51
- - 접두 대상(D-0061): `tenant` 대신 `tenantPrefix`(둘 중 정확히 하나, 아니면 `INVALID_TARGET`)면 이름이 그 접두로 시작하는 모든 tenant(+ 같은 `member`·`room` 규칙). 버스로 가는 메시지도 같은 대상을 싣고, 프로세스마다 자기 tenant 색인을 한 번 훑는다. @dokebi/app의 네임스페이스 `<ns>:`가 쓴다.
52
- - 이벤트 이름: `^[A-Za-z][A-Za-z0-9._:-]{0,63}$`, `dokebi-`로 시작 금지(플러그인 예약). data는 JSON 한 줄(`undefined`는 `null`), 함수·BigInt·순환은 `INVALID_DATA`.
53
- - `publish` 순서: 대상·이름·data 검사 → 버스가 `ready`가 아니면 `BUS_UNAVAILABLE`(아무 데도 안 감, error 로그) → 이 프로세스 스트림에 쓰기 → 버스 발행. 버스 발행 실패는 `BUS_PUBLISH_FAILED`(`cause`·`id`·`local`; 이 프로세스는 이미 받음, 다른 곳은 모름; error 로그에 전체 오류). 다시 보낼지는 호출자가 정한다(중복 가능).
54
- - 보낸 프로세스는 자기 버스 메시지를 건너뛴다(`origin`): 로컬·NATS 어느 버스든 그 프로세스 스트림은 한 번만 받는다.
55
- - `stream.send`는 한 연결에만, 쓴 경우 true(닫혔거나 느려서 끊겼으면 false). 이 프로세스에서 순서대로 증가하는 `id`가 붙는다.
56
- - 방은 연결마다다. 다시 접속하면 비어 있으니 `authenticate`의 `rooms`나 `onOpen`에서 넣는다.
53
+ - `route` works only in setup (`FROZEN` afterwards). `authenticate` and `onOpen` run on every reconnect.
54
+ - Targets: `{tenant}` everyone, `{tenant, member}` all tabs of one person, `{tenant, room}` a room, `{tenant, room, member}` that person in the room. Strings are 1 to 256 characters (any characters). A target only ever reaches streams of its own tenant.
55
+ - Prefix targets (D-0061): with `tenantPrefix` instead of `tenant` (exactly one of them, otherwise `INVALID_TARGET`), every tenant whose name starts with that prefix (with the same `member` and `room` rules). Bus messages carry the same target, and each process scans its own tenant index once. @dokebi/app's namespaces `<ns>:` use it.
56
+ - Event names: `^[A-Za-z][A-Za-z0-9._:-]{0,63}$`, not starting with `dokebi-` (reserved for the plugin). data is one line of JSON (`undefined` becomes `null`); functions, BigInt and cycles are `INVALID_DATA`.
57
+ - `publish` order: check target, name and data → when the bus is not `ready`, `BUS_UNAVAILABLE` (nothing is sent anywhere, error log) → write to this process's streams → publish on the bus. A failed bus publish is `BUS_PUBLISH_FAILED` (`cause`, `id`, `local`; this process already received it, other places are unknown; the error log has the full error). Whether to send again is the caller's choice (duplicates possible).
58
+ - The sending process skips its own bus messages (`origin`): with a local or a NATS bus, that process's streams receive each event once.
59
+ - `stream.send` writes to one connection only and returns true when written (false when closed or cut for being slow). Events get an `id` that increases in order within this process.
60
+ - Rooms are per connection. After a reconnect they are empty, so join in `authenticate`'s `rooms` or in `onOpen`.
61
+ - `parseOptions` (the options parser) and the API types (`SseApi`, `SseRoute`, `SseStream`, …) are exported too.
57
62
 
58
- ## 3a. @dokebi/app에 붙이기 (D-0061)
63
+ ## 3a. Mounting on @dokebi/app (D-0061)
59
64
 
60
- ```ts
65
+ ```ts snippet
61
66
  export default defineApp({
62
67
  events: (app) => [{
63
68
  path: '/events',
64
69
  authenticate: async (request) => {
65
70
  const user = await userOf(request);
66
- return user && { tenant: user.shop, member: user.id }; // namespace?: 앱 목록 중 하나(기본 첫째)
71
+ return user && { tenant: user.shop, member: user.id }; // namespace?: one of the app's list (default the first)
67
72
  },
68
73
  onOpen: (stream) => stream.send('hello', { tenant: stream.tenant }), // 'front:shop-1'
69
74
  }],
@@ -73,60 +78,71 @@ export default defineApp({
73
78
  });
74
79
  ```
75
80
 
76
- - 앱 도메인에만 붙고(제약 `dokebiApp`), 모듈 접두사 규칙은 소켓과 같다(모듈의 `events` 훅, @dokebi/module 0.1.3). tenant는 `<네임스페이스>:<tenant>`(기본 네임스페이스는 앱 이름), 인증의 `namespace`가 앱 목록 밖이면 401과 warn `NAMESPACE_FORBIDDEN`. `namespace`는 앱이 지우므로 `stream.identity`에 없다.
77
- - 이 패키지가 없는데 앱에 `events`가 있으면 시작 실패(`has event streams; add @dokebi/sse`). `app.realtime().publish`는 `REALTIME_UNAVAILABLE`.
81
+ - Streams attach only to the app's domains (constraint `dokebiApp`), with the same module prefix rules as sockets (the module `events` hook, @dokebi/module 0.1.3). The tenant is `<namespace>:<tenant>` (the default namespace is the app name). An authenticated `namespace` outside the app's list gets 401 and warn `NAMESPACE_FORBIDDEN`. app removes `namespace`, so it is not in `stream.identity`.
82
+ - Realtime contribution point `dokebi-web:realtime` (D-0063 §5, `realtime` of `@dokebi/web/points`): in setup this plugin contributes `{ kind: 'sse', mount(route, scope), handle(namespace) }`. `mount` adds `scope.path` and `scope.constraints`, turns the `authenticate` result into `scope.identity` and registers the route; `handle(ns).publish({ tenant?, member?, room? }, event, data)` sends with the `<ns>:` prefix. Until @dokebi/app collects the point it is unused and nothing changes.
83
+ - The cluster worker check (`LOCAL_IN_CLUSTER`) uses core's `context.runtime.role === 'worker'` (it never reads `node:cluster` itself).
84
+ - When an app has `events` but this package is missing, the start fails (`has event streams; add @dokebi/sse`), and `app.realtime().publish` throws `REALTIME_UNAVAILABLE`.
78
85
 
79
- ## 4. 연결 수명
86
+ ## 4. Connection lifetime
80
87
 
81
- - 응답: 200 `text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, `Connection: keep-alive`. 첫 줄은 `retry: <retryMs>`와 `event: dokebi-open`(data `{"retryMs":…}`). Fastify 응답을 hijack해 직접 쓰므로 압축 훅이 붙지 않는다(dokebi-web은 압축하지 않는다).
82
- - 거절: 다른 출처(목록 밖) 403 `ORIGIN_FORBIDDEN`, 시작 전·정지 중 503(dokebi-web `STARTING`·`DRAINING`, 이 플러그인 `CLOSING`), 가득 참 503 `CAPACITY`(`Retry-After: 5`), 인증 없음 401. EventSource는 이 상태를 보지 못한다(아래 클라이언트가 탐침으로 구분).
83
- - `?dokebiProbe=1`: 출처·용량·인증만 검사하고 204로 답한다(스트림을 열지 않음). 브라우저 클라이언트가 실패 원인을 알아낼 때 쓴다.
84
- - 버전(D-0033): 라우트 `version`과 질의 `dokebiVersion`(또는 `x-dokebi-version` 헤더)이 다르면 `retry: 60000` + `event: dokebi-reload`(data `{"version":…}`)를 보내고 끝낸다.
85
- - `Last-Event-ID` 헤더(브라우저가 스스로 다시 접속할 때) 또는 질의 `lastEventId`(클라이언트가 직접 다시 열 때)를 `stream.lastEventId`로 준다. 서버는 이벤트를 보관하지 않는다.
86
- - heartbeat: `heartbeatMs`마다 `: ping`. 느린 수신자: 쌓인 바이트 + 새 이벤트가 `maxBufferedBytes`를 넘으면 그 스트림을 끊는다(`SLOW_CONSUMER`, 다른 스트림은 영향 없음).
87
- - drain(정지·롤링 재시작): 새 스트림 거절 → 버스 구독 해제 → 모든 스트림에 `event: dokebi-restart`(data `{"retryMs":…}`) → 종료 → `closeTimeoutMs`(또는 core drain 기한) 뒤 남은 연결 끊기 → onClose 대기. dokebi-web보다 먼저 drain된다.
88
- - health: dokebi-health가 있으면 필수 아닌 check `sse`를 등록한다. 버스가 `ready`가 아니면 `failed`(`BUS_UNAVAILABLE`, warn 로그), 돌아오면 `ready`(info 로그). readiness를 떨어뜨리지 않는다.
88
+ - Response: 200 `text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, `Connection: keep-alive`. The first lines are `retry: <retryMs>` and `event: dokebi-open` (data `{"retryMs":…}`). The Fastify response is hijacked and written directly, so no compression hook applies (dokebi-web does not compress).
89
+ - Refusals: another origin (not listed) 403 `ORIGIN_FORBIDDEN`; before start or while stopping 503 (dokebi-web `STARTING`, `DRAINING`; this plugin `CLOSING`); full 503 `CAPACITY` (`Retry-After: 5`); no authentication 401. EventSource cannot see these statuses (the client below tells them apart with a probe).
90
+ - `?dokebiProbe=1`: checks only origin, capacity and authentication and answers 204 (no stream is opened). The browser client uses it to find out why a connection failed.
91
+ - Version (D-0033): when the route's `version` differs from the query `dokebiVersion` (or the `x-dokebi-version` header), it sends `retry: 60000` + `event: dokebi-reload` (data `{"version":…}`) and ends.
92
+ - The `Last-Event-ID` header (when the browser reconnects by itself) or the query `lastEventId` (when the client reopens itself) becomes `stream.lastEventId`. The server keeps no events.
93
+ - Heartbeat: `: ping` every `heartbeatMs`. Slow receivers: when the waiting bytes plus a new event exceed `maxBufferedBytes`, that stream is cut (`SLOW_CONSUMER`; other streams are unaffected).
94
+ - Drain (stop, rolling restart): refuse new streams → unsubscribe from the bus → send `event: dokebi-restart` (data `{"retryMs":…}`) to every stream → end them → cut the remaining connections after `shutdownTimeoutMs` (or the core drain deadline) → wait for onClose. It drains before dokebi-web.
95
+ - Health: with dokebi-health it registers the non-required check `sse`. When the bus is not `ready` the check is `failed` (`BUS_UNAVAILABLE`, warn log), and `ready` again when it returns (info log). It never drops readiness.
89
96
 
90
- ## 5. 브라우저 클라이언트
97
+ ## 5. Browser client
91
98
 
92
- ```ts
99
+ ```ts snippet
93
100
  import { connectEvents } from '@dokebi/sse/client';
94
101
  const events = connectEvents({
95
- path: '/events', // 또는 url: 'https://…'
102
+ path: '/events', // or url: 'https://…'
96
103
  query: { tab: 'orders' },
97
- version: APP_VERSION, // dokebiVersion 질의
98
- withCredentials: false, // 다른 출처에 쿠키를 보낼 때(서버 origins에 그 출처)
99
- onReload: () => location.reload(), // 기본값
100
- reconnect: { minMs: 1000, maxMs: 30000 }, // false면 실패 시 닫힌 채로
104
+ version: APP_VERSION, // the dokebiVersion query
105
+ withCredentials: false, // send cookies to another origin (that origin must be in the server's origins)
106
+ onReload: () => location.reload(), // the default
107
+ reconnect: { minMs: 1000, maxMs: 30000 }, // false stays closed after a failure
101
108
  });
102
109
  const off = events.on<Order>('order.changed', (order, { id }) => {});
103
110
  events.onStatus((status) => {}); // connecting · open · reconnecting · paused · closed
104
- events.onError((error) => {}); // { code: UNAUTHORIZED·FORBIDDEN·UNAVAILABLE·CONNECTION_FAILED·BAD_DATA·HANDLER_FAILED·NO_EVENTSOURCE, status? }
111
+ events.onError((error) => {}); // { code: UNAUTHORIZED, FORBIDDEN, UNAVAILABLE, CONNECTION_FAILED, BAD_DATA, HANDLER_FAILED, NO_EVENTSOURCE, status? }
105
112
  events.close(); events.reconnect();
106
113
  ```
107
114
 
108
- - import만으로는 window·EventSource를 건드리지 않는다(SSR 안전). `connectEvents`를 부를 때 연다.
109
- - 이벤트 이름마다 처음 `on` 할 때만 EventSource에 리스너를 단다. data는 JSON으로 읽는다(실패는 `BAD_DATA`).
110
- - `dokebi-reload` → `onReload`(기본 `location.reload()`), 다시 연결하지 않는다.
111
- - `dokebi-restart` → 닫고 백오프+지터(`max(minMs, 서버 retry)`부터 2배씩, `maxMs`까지, 그 값의 50~100%) 뒤 다시 연다.
112
- - EventSource가 스스로 다시 접속하는 경우(스트림이 끝남·네트워크): 그대로 둔다(`reconnecting`, 브라우저가 `Last-Event-ID`를 보낸다).
113
- - EventSource가 포기한 경우(HTTP 오류·잘못된 형식, readyState CLOSED): 닫고 `?dokebiProbe=1`로 원인을 묻는다. 401·403이면 `closed`(영구), 그 밖은 백오프 뒤 다시 연다. 직접 다시 열 때는 마지막 id를 `lastEventId` 질의로 보낸다(듣고 있는 이벤트의 id만 안다).
114
- - bfcache: `pagehide`(persisted)에 닫고(`paused`) `pageshow`(persisted)에 다시 연다.
115
- - Vue `useEvents(options)` → `{ status, client, on, close, reconnect }`(마운트 때 연결, 스코프 끝에 닫음, 컴포넌트 밖이면 바로 연결), `useEvent(events, name, handler)`.
116
- - React `useEvents(options)` → `{ status, client }`(같은 url·path·query·version·withCredentials는 EventSource 하나를 참조 계수로 공유, 마지막 사용자가 사라진 다음 틱에 닫음; 콜백은 처음 연 쪽 설정), `useEvent(name, handler, events?)`(없으면 가까운 `<EventsProvider options>`).
117
- - 번들: `@dokebi/sse/client/bundle` = `dist/browser/sse-client.js`(의존 없는 ES 모듈). 정적 파일로 내보내 `<script type="module">`이나 import map에서 쓴다.
115
+ - Importing touches neither window nor EventSource (SSR safe). It opens when you call `connectEvents`.
116
+ - A listener is added to the EventSource only on the first `on` per event name. data is read as JSON (a failure is `BAD_DATA`).
117
+ - `dokebi-reload` → `onReload` (default `location.reload()`); it does not reconnect.
118
+ - `dokebi-restart` → close, then reopen after backoff with jitter (starting at `max(minMs, server retry)`, doubling up to `maxMs`, 50–100% of that value).
119
+ - When EventSource reconnects by itself (the stream ended, network): left alone (`reconnecting`; the browser sends `Last-Event-ID`).
120
+ - When EventSource gave up (HTTP error, wrong format, readyState CLOSED): close and ask why with `?dokebiProbe=1`. 401 or 403 → `closed` (permanent); anything else reopens after backoff. When reopening itself it sends the last id as the `lastEventId` query (it knows only the ids of events it listens to).
121
+ - bfcache: closes on `pagehide` (persisted) (`paused`) and reopens on `pageshow` (persisted).
122
+ - Vue `useEvents(options)` → `{ status, client, on, close, reconnect }` (connects on mount, closes at the end of the scope; outside a component it connects at once), `useEvent(events, name, handler)`.
123
+ - React `useEvents(options)` → `{ status, client }` (the same url, path, query, version and withCredentials share one EventSource with reference counting; it closes on the tick after the last user is gone; the callbacks are those of whoever opened it first), `useEvent(name, handler, events?)` (without `events`, the nearest `<EventsProvider options>`).
124
+ - Bundle: `@dokebi/sse/client/bundle` = `dist/browser/sse-client.js` (an ES module without dependencies). Serve it as a static file and use it from `<script type="module">` or an import map.
125
+
126
+ ## 5a. Seal hosts (D-0062)
127
+
128
+ On streams of a seal host (@dokebi/app `domains.<host>.seal: true`) or of a `seal: true` web route, **the data of every event** (including `dokebi-open`, `dokebi-reload` and `dokebi-restart`) is sealed with that stream's path (`E:<ts>:<b64>`, dokebi-web `sealOf`). Event names and ids stay plain. Plain streams share one buffer; only sealed streams seal per receiver. A failed seal ends that stream (`SEAL_ENCODE_FAILED`; never sent in plain text). Browser: `connectEvents({ ..., seal: currentSealBridge() })` (`@dokebi/seal/http`; undefined on plain pages, so the same code works). With a bridge, plain data is refused as `SEAL_FAILED`; without one, sealed data is refused as `SEAL_BRIDGE_MISSING` (neither reaches the handler).
129
+
130
+ ## 6. Several workers and servers
131
+
132
+ - No sticky sessions needed. A NATS bus is required. `maxConnections` is per worker.
133
+ - Order: per publisher order is kept; there is no global order. During a NATS outage publish fails with `BUS_UNAVAILABLE`/`BUS_PUBLISH_FAILED` and that event is lost.
134
+ - Rolling restart: streams of the stopping worker get `dokebi-restart`, and clients reconnect to other workers with jitter.
135
+ - HTTP/1.1 browsers allow 6 connections per origin (one stream per tab) → with many tabs use HTTP/2 (TLS) at the proxy. Proxy idle limit > `heartbeatMs`, and turn response buffering off.
118
136
 
119
- ## 5a. 봉인 호스트 (D-0062)
137
+ ## 7. Logs (D-0047)
120
138
 
121
- seal 호스트(@dokebi/app `domains.<host>.seal: true`)나 `seal: true` 웹 라우트의 스트림은 **모든 이벤트의 data**(`dokebi-open`·`dokebi-reload`·`dokebi-restart` 포함)가 그 스트림 경로로 봉인된다(`E:<ts>:<b64>`, dokebi-web `sealOf`). 이벤트 이름·id는 평문이다. 평문 스트림은 버퍼 하나를 나눠 쓰고 봉인 스트림만 받는 쪽마다 봉인한다. 봉인 실패는 그 스트림을 끝낸다(`SEAL_ENCODE_FAILED`, 평문으로 보내지 않음). 브라우저: `connectEvents({ ..., seal: currentSealBridge() })`(`@dokebi/seal/http`; 평문 페이지면 undefined라 같은 코드). 다리가 있으면 평문 data를 `SEAL_FAILED`로, 다리가 없으면 봉인 data를 `SEAL_BRIDGE_MISSING`으로 거절한다(처리기에 가지 않는다).
139
+ debug: route registration, stream open and close (reason, ms, open count), refusal reasons, version refusals, room join and leave, publish (id, local), bus deliveries received, drain begin and end. warn: `SLOW_CONSUMER`, `BUS_UNAVAILABLE` (state change). error (the full error; repeats once per 10 seconds with `repeated`): `BUS_PUBLISH_FAILED`, `BUS_UNAVAILABLE` (publish refused), `BUS_MESSAGE_INVALID`, `SSE_ON_OPEN_FAILED`, `SSE_ON_CLOSE_FAILED`, `STREAM_WRITE_FAILED`, `BUS_UNSUBSCRIBE_FAILED`, `SSE_LOCAL_IN_CLUSTER`. A bad `authenticate` result (`INVALID_IDENTITY`) is logged by dokebi-web as 500 with `WEB_REQUEST_FAILED`.
122
140
 
123
- ## 6. 여러 워커·서버
141
+ ## 8. Errors
124
142
 
125
- - sticky 세션 불필요. NATS 버스 필수. `maxConnections`는 워커마다.
126
- - 순서: 같은 발행자 순서는 지켜지고 전체 순서는 없다. NATS 장애 중 publish는 `BUS_UNAVAILABLE`/`BUS_PUBLISH_FAILED`, 그 이벤트는 잃는다.
127
- - 롤링 재시작: 내려가는 워커의 스트림은 `dokebi-restart`를 받고, 클라이언트는 지터를 두고 다른 워커로 다시 붙는다.
128
- - HTTP/1.1 브라우저는 출처당 연결 6개(탭마다 스트림 하나) → 탭이 많으면 프락시에 HTTP/2(TLS). 프락시 idle 제한 > `heartbeatMs`, 응답 버퍼링 끄기.
143
+ `SseError` (branch on `code`; never `instanceof`).
129
144
 
130
- ## 7. 로그(D-0047)
145
+ <!-- docs-check: complete-error-codes SseErrorCode -->
146
+ Configuration and registration: `INVALID_OPTIONS`, `INVALID_ROUTE` (`path`, `origins` and so on), `FROZEN` (`route` after setup), `LOCAL_IN_CLUSTER` (§2; `SSE_LOCAL_IN_CLUSTER` before 0.4.0, D-0071). Connections: `INVALID_IDENTITY` (a bad `authenticate` result, 500). Sending: `INVALID_TARGET`, `INVALID_EVENT` (event name), `INVALID_DATA`, `EVENT_TOO_LARGE` (`maxEventBytes`), `INVALID_ROOM`, `ROOM_LIMIT` (`maxRooms`), `BUS_UNAVAILABLE`, `BUS_PUBLISH_FAILED` (`cause`, `id`, `local`, §3), `CLOSED` (`publish` after the app stopped).
131
147
 
132
- debug: 라우트 등록, 스트림 열림/닫힘(이유·ms·열린 수), 거절 이유, 버전 거절, 방 가입/탈퇴, publish(id·local), 버스 수신 전달 수, drain 시작/끝. warn: `SLOW_CONSUMER`, `BUS_UNAVAILABLE`(상태 바뀜). error(전체 오류, 반복은 10초에 한 번 `repeated`): `BUS_PUBLISH_FAILED`, `BUS_UNAVAILABLE`(publish 거절), `BUS_MESSAGE_INVALID`, `SSE_ON_OPEN_FAILED`, `SSE_ON_CLOSE_FAILED`, `STREAM_WRITE_FAILED`, `BUS_UNSUBSCRIBE_FAILED`, `SSE_LOCAL_IN_CLUSTER`. 잘못된 `authenticate` 결과(`INVALID_IDENTITY`)는 dokebi-web이 500과 `WEB_REQUEST_FAILED`로 남긴다.
148
+ `SseError` is a `DokebiError` (D-0063): `plugin: 'dokebi-sse'` and a `fix` that says how to repair it. `INVALID_OPTIONS` carries per-path problems in `details.issues` (no values). The `defineOptions` declaration in `src/options.ts` is the parser, and the build writes `options.schema.json` from it (D-0063 §3; never edit it by hand).
package/README.md CHANGED
@@ -1,84 +1,88 @@
1
1
  # @dokebi/sse
2
2
 
3
- 도깨비 웹 앱(@dokebi/web)의 서버 전송 이벤트(SSE, `text/event-stream`). 인증한 사용자마다 스트림을 열고, tenant·member·room을 골라 이벤트를 보낸다. @dokebi/event-bus의 NATS 버스를 주면 다른 워커·서버의 스트림에도 간다. 브라우저 클라이언트(`@dokebi/sse/client`, Vue·React 훅, 빌드 없이 쓰는 번들)가 같은 패키지에 있다. Node >=24.0.0, @dokebi/core ^0.2.0. 결정 기록은 [D-0060](../../docs/decisions/D-0060-sse.md).
3
+ Server-sent events (SSE, `text/event-stream`) for Dokebi web apps (@dokebi/web). It opens a stream per authenticated user and sends events chosen by tenant, member and room. Given a NATS bus of @dokebi/event-bus, events also reach the streams of other workers and servers. The browser client (`@dokebi/sse/client`, Vue and React hooks, a bundle usable without a build) is in the same package. Node >=24.0.0, @dokebi/core ^0.4.0. The decision record is [D-0060](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0060-sse.md).
4
4
 
5
- 보장은 **많아야 한 번**이다(ACK·재전송·보관 없음, [D-0057](../../docs/decisions/D-0057-websocket-broadcast-without-ack.md)과 같다). 놓치면 안 되는 상태는 DB에 두고, 이벤트는 「바뀌었으니 다시 읽어라」 알림으로 쓴다.
5
+ The guarantee is **at most once** (no ACK, no resending, no retention; the same as [D-0057](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0057-websocket-broadcast-without-ack.md)). Keep state that must not be missed in a DB and use events as "it changed, read it again" notices.
6
6
 
7
- ## 설치
7
+ ## Install
8
8
 
9
9
  ```sh
10
10
  npx dokebi add @dokebi/sse
11
11
  ```
12
12
 
13
- 한 프로세스면 설정이 없어도 된다. 워커가 여럿이거나 서버가 여럿이면 NATS 버스가 필요하다:
13
+ > `npx dokebi` is the @dokebi/cli installed in the project (`dokebi init` adds it to devDependencies). The npm package `dokebi` is not this CLI, so never use `npx -y dokebi`. Without an installed CLI use `npx -y @dokebi/cli …`.
14
+
15
+ A single process needs no settings. With several workers or servers a NATS bus is required:
14
16
 
15
17
  ```yaml
16
18
  options:
17
19
  dokebi-nats:
18
20
  connections: { main: { servers: [nats://127.0.0.1:4222] } }
19
21
  dokebi-event-bus:
20
- buses: { sse: { transport: nats, connection: main } }
22
+ buses: { sse: { driver: nats, connection: main } }
21
23
  dokebi-sse:
22
- bus: sse
24
+ eventBus: sse
23
25
  ```
24
26
 
25
- ## 서버
27
+ Before 0.4.0 the key was `bus` (and `closeTimeoutMs` is now `shutdownTimeoutMs`); the old names still work with an `OPTION_DEPRECATED` warning ([D-0071](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0071-option-vocabulary.md)).
28
+
29
+ ## Server
26
30
 
27
- ```ts
28
- const events = context.get(sse); // 또는 context.getDependency(dependency)
31
+ ```ts snippet
32
+ const events = context.get(sse); // or context.getDependency(dependency)
29
33
  events.route({
30
34
  path: '/events',
31
- version: APP_VERSION, // 다르면 dokebi-reload (D-0033)
32
- authenticate: async (request) => { // undefined면 401
35
+ version: APP_VERSION, // a different one gets dokebi-reload (D-0033)
36
+ authenticate: async (request) => { // undefined → 401
33
37
  const user = await request.session.get('user');
34
38
  return user ? { tenant: user.shop, member: user.id, rooms: [`shop:${user.shop}`] } : undefined;
35
39
  },
36
40
  onOpen: (stream) => stream.send('snapshot', { unread: 3 }),
37
41
  });
38
42
 
39
- await events.publish({ tenant: 'shop-1' }, 'order.changed', { id: 42 }); // tenant 전체
40
- await events.publish({ tenant: 'shop-1', member: 'u7' }, 'notice', { text: '안녕' }); // 한 사람
41
- await events.publish({ tenant: 'shop-1', room: 'order:42' }, 'order.changed', { id: 42 }); // 방
42
- await events.publish({ tenantPrefix: 'front:' }, 'notice', { text: '점검' }); // 접두가 맞는 모든 tenant
43
+ await events.publish({ tenant: 'shop-1' }, 'order.changed', { id: 42 }); // the whole tenant
44
+ await events.publish({ tenant: 'shop-1', member: 'u7' }, 'notice', { text: 'Hello' }); // one person
45
+ await events.publish({ tenant: 'shop-1', room: 'order:42' }, 'order.changed', { id: 42 }); // a room
46
+ await events.publish({ tenantPrefix: 'front:' }, 'notice', { text: 'Maintenance' }); // every tenant with the prefix
43
47
  ```
44
48
 
45
- | API | 뜻 |
49
+ | API | Meaning |
46
50
  |---|---|
47
- | `route({ path, constraints?, origins?, version?, authenticate, onOpen?, onClose? })` | setup 때만. GET 스트림 라우트 |
48
- | `publish(target, event, data)` | `{ id, local }`. 모든 프로세스의 맞는 스트림에(보낸 프로세스는 한 번). 대상은 `{ tenant }` 또는 `{ tenantPrefix }`(정확히 하나) + `member?`·`room?` |
49
- | `connections(filter?)` | 이 프로세스(워커)의 열린 스트림 수. `{ tenant?, member?, room?, path? }` |
50
- | `stream.send(event, data)` · `join(room)` · `leave(room)` · `close()` | 한 연결만 |
51
+ | `route({ path, constraints?, origins?, version?, authenticate, onOpen?, onClose? })` | setup only. A GET stream route |
52
+ | `publish(target, event, data)` | `{ id, local }`. To matching streams of every process (the sending process once). The target is `{ tenant }` or `{ tenantPrefix }` (exactly one) plus `member?` and `room?` |
53
+ | `connections(filter?)` | the number of open streams of this process (worker). `{ tenant?, member?, room?, path? }` |
54
+ | `stream.send(event, data)` · `join(room)` · `leave(room)` · `close()` | one connection only |
51
55
 
52
- **@dokebi/app과 함께(D-0061):** 앱의 `events: (app) => [route, …]`로 앱 도메인에만 붙인다(라우트 제약 `dokebiApp`). 스트림 tenant는 `<네임스페이스>:<tenant>`이고, `authenticate`가 `namespace`를 돌려주면 그 앱의 네임스페이스 중에서 고른다(목록 밖은 401). 서버 코드는 `app.realtime(namespace).publish({ tenant?, member?, room? }, event, data)`로 그 공간에 발행한다(tenant를 빼면 `tenantPrefix: '<네임스페이스>:'`). 자세한 것은 @dokebi/app README의 「실시간 네임스페이스」.
56
+ **With @dokebi/app (D-0061):** an app's `events: (app) => [route, …]` attaches streams only to the app's domains (route constraint `dokebiApp`). The stream tenant is `<namespace>:<tenant>`; when `authenticate` returns `namespace`, it is chosen from the app's namespaces (outside the list: 401). Server code publishes to that space with `app.realtime(namespace).publish({ tenant?, member?, room? }, event, data)` (without a tenant it is `tenantPrefix: '<namespace>:'`). See "Realtime namespaces" in the @dokebi/app README.
53
57
 
54
- 오류는 `code`로 본다: `INVALID_EVENT`(이름 규칙·`dokebi-` 접두사), `INVALID_DATA`, `EVENT_TOO_LARGE`, `INVALID_TARGET`, `ROOM_LIMIT`, `BUS_UNAVAILABLE`(아무 데도 안 보냄), `BUS_PUBLISH_FAILED`(이 프로세스엔 보냄, 다른 곳은 모름; `id`·`local` 포함).
58
+ Branch on error `code`s: `INVALID_EVENT` (naming rule, `dokebi-` prefix), `INVALID_DATA`, `EVENT_TOO_LARGE`, `INVALID_TARGET`, `ROOM_LIMIT`, `BUS_UNAVAILABLE` (sent nowhere), `BUS_PUBLISH_FAILED` (sent to this process, other places unknown; with `id` and `local`).
55
59
 
56
- ## 브라우저
60
+ ## Browser
57
61
 
58
- ```ts
62
+ ```ts snippet
59
63
  import { connectEvents } from '@dokebi/sse/client';
60
64
  const events = connectEvents({ path: '/events', version: APP_VERSION });
61
- const off = events.on('order.changed', (order) => refresh(order.id)); // data는 JSON으로 읽은 값
65
+ const off = events.on('order.changed', (order) => refresh(order.id)); // data parsed as JSON
62
66
  events.onStatus((status) => badge(status)); // connecting · open · reconnecting · paused · closed
63
67
  ```
64
68
 
65
- - Vue: `import { useEvents, useEvent } from '@dokebi/sse/client/vue'` — 마운트 때 열고 스코프가 끝나면 닫는다(SSR에서는 열지 않는다).
66
- - React: `import { useEvents, useEvent, EventsProvider } from '@dokebi/sse/client/react'` — 같은 설정의 훅들은 EventSource 하나를 나눠 쓴다(StrictMode 안전).
67
- - 빌드 없는 페이지: `@dokebi/sse/client/bundle`(`dist/browser/sse-client.js`, 의존 없는 ES 모듈 하나)을 정적 파일로 내보내고 `<script type="module">import { connectEvents } from '/sse-client.js'</script>`.
68
- - 표준 EventSource만 쓸 때: `es.addEventListener('dokebi-reload', () => location.reload())`, `dokebi-restart`를 받으면 잠시 뒤 다시 연다.
69
+ - Vue: `import { useEvents, useEvent } from '@dokebi/sse/client/vue'`. Opens on mount and closes when the scope ends (never opens during SSR).
70
+ - React: `import { useEvents, useEvent, EventsProvider } from '@dokebi/sse/client/react'`. Hooks with the same settings share one EventSource (StrictMode safe).
71
+ - Pages without a build: serve `@dokebi/sse/client/bundle` (`dist/browser/sse-client.js`, a single ES module without dependencies) as a static file and use `<script type="module">import { connectEvents } from '/sse-client.js'</script>`.
72
+ - With the standard EventSource only: `es.addEventListener('dokebi-reload', () => location.reload())`, and reopen a little later after `dokebi-restart`.
69
73
 
70
- ## 여러 워커·서버
74
+ ## Several workers and servers
71
75
 
72
- - 끈적 세션(sticky)이 필요 없다. 아무 워커에 붙어도 NATS 버스로 같은 이벤트를 받는다.
73
- - 워커가 여럿이면 `bus`(transport `nats`)가 필수다. 없으면 cluster 워커는 `SSE_LOCAL_IN_CLUSTER`로 시작하지 않는다(일부러 워커 하나면 `allowInCluster: true`).
74
- - `maxConnections`는 워커마다다.
75
- - 방(room)은 그 연결의 것이다. 다시 접속하면 없다: `authenticate`의 `rooms`나 `onOpen`에서 다시 넣는다.
76
- - 순서: 한 발행자가 보낸 것은 순서대로, 여러 발행자 사이 전체 순서는 없다. NATS가 끊기면 `publish`가 코드 있는 오류로 실패하고 그 이벤트는 사라진다.
77
- - 브라우저는 HTTP/1.1에서 출처당 연결 6개다. 탭이 많으면 프락시에서 HTTP/2(TLS)를 쓴다.
78
- - 프락시 idle 제한 시간은 `heartbeatMs`(기본 15초)보다 길어야 한다. nginx는 `X-Accel-Buffering: no`를 따른다.
76
+ - No sticky sessions needed. Whatever worker a client connects to, it receives the same events through the NATS bus.
77
+ - With several workers, `eventBus` (driver `nats`) is required. Without it a cluster worker does not start (`LOCAL_IN_CLUSTER`; with a deliberate single worker set `allowInCluster: true`).
78
+ - `maxConnections` is per worker.
79
+ - Rooms belong to their connection and are gone after a reconnect: join again in `authenticate`'s `rooms` or in `onOpen`.
80
+ - Order: what one publisher sends arrives in order; there is no global order across publishers. When NATS is down, `publish` fails with a coded error and that event is lost.
81
+ - Browsers allow 6 connections per origin over HTTP/1.1. With many tabs use HTTP/2 (TLS) at the proxy.
82
+ - The proxy idle limit must be longer than `heartbeatMs` (default 15 seconds). nginx honours `X-Accel-Buffering: no`.
79
83
 
80
- 자세한 설정·동작은 [AI.md](AI.md)와 [개발 문서](../../docs/plugins/dokebi-sse/README.md), 실행 예는 [examples/27-sse](../../examples/27-sse/README.md).
84
+ Detailed settings and behaviour are in [AI.md](AI.md) and the [design notes](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/plugins/dokebi-sse/README.md); a runnable example is [examples/27-sse](https://git.nyx-zone.com/dokebi/framework/-/blob/main/examples/27-sse/README.md).
81
85
 
82
- ## 봉인 호스트 (D-0062)
86
+ ## Seal hosts (D-0062)
83
87
 
84
- seal 호스트의 스트림은 모든 이벤트의 data가 봉인된다. 브라우저는 `connectEvents({ path, seal: currentSealBridge() })`(`@dokebi/seal/http`)로 연다. 자세한 것은 [AI.md](AI.md) §5a와 [D-0062](../../docs/decisions/D-0062-http-seal.md).
88
+ On a seal host the data of every event is sealed. The browser opens it with `connectEvents({ path, seal: currentSealBridge() })` (`@dokebi/seal/http`). See [AI.md](AI.md) §5a and [D-0062](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0062-http-seal.md).
package/dist/errors.d.ts CHANGED
@@ -1,12 +1,14 @@
1
- export type SseErrorCode = 'INVALID_OPTIONS' | 'INVALID_ROUTE' | 'INVALID_IDENTITY' | 'INVALID_EVENT' | 'INVALID_DATA' | 'INVALID_TARGET' | 'INVALID_ROOM' | 'EVENT_TOO_LARGE' | 'ROOM_LIMIT' | 'FROZEN' | 'CLOSED' | 'BUS_UNAVAILABLE' | 'BUS_PUBLISH_FAILED' | 'SSE_LOCAL_IN_CLUSTER';
1
+ import { DokebiError } from '@dokebi/core';
2
+ export type SseErrorCode = 'INVALID_OPTIONS' | 'INVALID_ROUTE' | 'INVALID_IDENTITY' | 'INVALID_EVENT' | 'INVALID_DATA' | 'INVALID_TARGET' | 'INVALID_ROOM' | 'EVENT_TOO_LARGE' | 'ROOM_LIMIT' | 'FROZEN' | 'CLOSED' | 'BUS_UNAVAILABLE' | 'BUS_PUBLISH_FAILED' | 'LOCAL_IN_CLUSTER';
2
3
  /** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
3
- export declare class SseError extends Error {
4
- readonly code: SseErrorCode;
4
+ export declare class SseError extends DokebiError<SseErrorCode> {
5
5
  /** BUS_PUBLISH_FAILED: the event id and how many streams of this process already got it. */
6
6
  readonly id?: string;
7
7
  readonly local?: number;
8
- constructor(code: SseErrorCode, message: string, options?: ErrorOptions & {
8
+ constructor(code: SseErrorCode, message: string, options?: {
9
+ readonly cause?: unknown;
9
10
  readonly id?: string;
10
11
  readonly local?: number;
12
+ readonly details?: unknown;
11
13
  });
12
14
  }
package/dist/errors.js CHANGED
@@ -1,12 +1,32 @@
1
+ import { DokebiError } from '@dokebi/core';
2
+ const FIX = {
3
+ INVALID_OPTIONS: "Correct options.dokebi-sse in dokebi.yaml; details.issues names the paths and the plugin's options.schema.json lists what is allowed.",
4
+ INVALID_ROUTE: 'Give route a path starting with / (no query), an authenticate function, and optional onOpen/onClose functions, version text, constraints object and origins (* or exact https://host origins).',
5
+ INVALID_IDENTITY: 'Return { tenant, member, rooms? } from authenticate, with strings of 1 to 256 characters (or undefined to refuse with 401).',
6
+ INVALID_EVENT: 'Name events with a letter first and up to 64 of A-Z a-z 0-9 . _ : -, not starting with dokebi-.',
7
+ INVALID_DATA: 'Send data that JSON.stringify accepts (no BigInt, functions or cycles).',
8
+ INVALID_TARGET: 'Target { tenant, member?, room? } or { tenantPrefix, member?, room? } with strings of 1 to 256 characters.',
9
+ INVALID_ROOM: 'Rooms are strings of 1 to 256 characters.',
10
+ EVENT_TOO_LARGE: 'Send less data per event, or raise options.dokebi-sse.maxEventBytes (keep it below the bus maxPayloadBytes).',
11
+ ROOM_LIMIT: 'Join fewer rooms per stream, or raise options.dokebi-sse.maxRooms.',
12
+ FROZEN: 'Register SSE routes during setup (a plugin, module or app setup), not after start.',
13
+ CLOSED: 'Publish only while the application runs; this SSE plugin has been closed.',
14
+ BUS_UNAVAILABLE: 'The event bus is not connected; retry after it recovers (warn BUS_UNAVAILABLE and info "SSE bus available again" in the log).',
15
+ BUS_PUBLISH_FAILED: 'This process delivered the event (local), the bus did not take it: retry, or check the bus connection; the cause has its error.',
16
+ LOCAL_IN_CLUSTER: 'Set options.dokebi-sse.eventBus to a dokebi-event-bus bus with driver nats, or allowInCluster: true for a deliberate single-worker cluster.',
17
+ };
1
18
  /** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
2
- export class SseError extends Error {
3
- code;
19
+ export class SseError extends DokebiError {
4
20
  /** BUS_PUBLISH_FAILED: the event id and how many streams of this process already got it. */
5
21
  id;
6
22
  local;
7
23
  constructor(code, message, options) {
8
- super(message, options);
9
- this.code = code;
24
+ super(code, message, {
25
+ plugin: 'dokebi-sse',
26
+ fix: FIX[code],
27
+ ...(options?.cause === undefined ? {} : { cause: options.cause }),
28
+ ...(options?.details === undefined ? {} : { details: options.details }),
29
+ });
10
30
  this.name = 'SseError';
11
31
  if (options?.id !== undefined)
12
32
  this.id = options.id;