@dokebi/sse 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/AI.md +132 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/browser/sse-client.js +201 -0
- package/dist/client/index.d.ts +90 -0
- package/dist/client/index.js +353 -0
- package/dist/client/react.d.ts +18 -0
- package/dist/client/react.js +96 -0
- package/dist/client/vue.d.ts +15 -0
- package/dist/client/vue.js +64 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +16 -0
- package/dist/gateway.d.ts +41 -0
- package/dist/gateway.js +733 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/install.d.ts +18 -0
- package/dist/install.js +24 -0
- package/dist/log.d.ts +25 -0
- package/dist/log.js +102 -0
- package/dist/options.d.ts +27 -0
- package/dist/options.js +63 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +90 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.js +4 -0
- package/dist/wire.d.ts +17 -0
- package/dist/wire.js +44 -0
- package/options.schema.json +73 -0
- package/package.json +101 -4
package/AI.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# AI 작업 안내 — @dokebi/sse 0.1.0
|
|
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).
|
|
4
|
+
|
|
5
|
+
## 1. 설치
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx dokebi add @dokebi/sse
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- `bus`를 쓰면 CLI가 dokebi-event-bus를 요구한다(설치 모듈 `requires`). 버스는 `dokebi-event-bus.buses.<이름>`에 정의한다. 여러 워커·서버는 `transport: nats`.
|
|
12
|
+
- 같은 프로세스 하나만이면 `bus` 없이 쓴다(publish는 이 프로세스 스트림에만).
|
|
13
|
+
|
|
14
|
+
## 2. 설정 `options.dokebi-sse`
|
|
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` 뒤 스트림 종료를 기다리는 시간 |
|
|
28
|
+
|
|
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`.
|
|
30
|
+
|
|
31
|
+
## 3. 서버 API
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createPlugin, dependency, type SseApi } from '@dokebi/sse';
|
|
35
|
+
const sse = context.getDependency(dependency); // 없으면 이 패키지로 만든다
|
|
36
|
+
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
|
+
authenticate: (request) => ({ tenant, member, rooms: ['r1'] }), // undefined → 401
|
|
42
|
+
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
|
|
44
|
+
});
|
|
45
|
+
const { id, local } = await sse.publish({ tenant, member?, room? }, 'order.changed', { id: 42 });
|
|
46
|
+
sse.connections({ tenant, room }); // 이 워커의 수
|
|
47
|
+
```
|
|
48
|
+
|
|
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`에서 넣는다.
|
|
57
|
+
|
|
58
|
+
## 3a. @dokebi/app에 붙이기 (D-0061)
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
export default defineApp({
|
|
62
|
+
events: (app) => [{
|
|
63
|
+
path: '/events',
|
|
64
|
+
authenticate: async (request) => {
|
|
65
|
+
const user = await userOf(request);
|
|
66
|
+
return user && { tenant: user.shop, member: user.id }; // namespace?: 앱 목록 중 하나(기본 첫째)
|
|
67
|
+
},
|
|
68
|
+
onOpen: (stream) => stream.send('hello', { tenant: stream.tenant }), // 'front:shop-1'
|
|
69
|
+
}],
|
|
70
|
+
async start(app) {
|
|
71
|
+
await app.realtime().publish({ tenant: 'shop-1' }, 'order.changed', { id: 42 });
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
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`.
|
|
78
|
+
|
|
79
|
+
## 4. 연결 수명
|
|
80
|
+
|
|
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를 떨어뜨리지 않는다.
|
|
89
|
+
|
|
90
|
+
## 5. 브라우저 클라이언트
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { connectEvents } from '@dokebi/sse/client';
|
|
94
|
+
const events = connectEvents({
|
|
95
|
+
path: '/events', // 또는 url: 'https://…'
|
|
96
|
+
query: { tab: 'orders' },
|
|
97
|
+
version: APP_VERSION, // dokebiVersion 질의
|
|
98
|
+
withCredentials: false, // 다른 출처에 쿠키를 보낼 때(서버 origins에 그 출처)
|
|
99
|
+
onReload: () => location.reload(), // 기본값
|
|
100
|
+
reconnect: { minMs: 1000, maxMs: 30000 }, // false면 실패 시 닫힌 채로
|
|
101
|
+
});
|
|
102
|
+
const off = events.on<Order>('order.changed', (order, { id }) => {});
|
|
103
|
+
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? }
|
|
105
|
+
events.close(); events.reconnect();
|
|
106
|
+
```
|
|
107
|
+
|
|
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에서 쓴다.
|
|
118
|
+
|
|
119
|
+
## 5a. 봉인 호스트 (D-0062)
|
|
120
|
+
|
|
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`으로 거절한다(처리기에 가지 않는다).
|
|
122
|
+
|
|
123
|
+
## 6. 여러 워커·서버
|
|
124
|
+
|
|
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`, 응답 버퍼링 끄기.
|
|
129
|
+
|
|
130
|
+
## 7. 로그(D-0047)
|
|
131
|
+
|
|
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`로 남긴다.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dokebi contributors
|
|
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,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dokebi/sse
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
|
4
|
+
|
|
5
|
+
보장은 **많아야 한 번**이다(ACK·재전송·보관 없음, [D-0057](../../docs/decisions/D-0057-websocket-broadcast-without-ack.md)과 같다). 놓치면 안 되는 상태는 DB에 두고, 이벤트는 「바뀌었으니 다시 읽어라」 알림으로 쓴다.
|
|
6
|
+
|
|
7
|
+
## 설치
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx dokebi add @dokebi/sse
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
한 프로세스면 설정이 없어도 된다. 워커가 여럿이거나 서버가 여럿이면 NATS 버스가 필요하다:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
options:
|
|
17
|
+
dokebi-nats:
|
|
18
|
+
connections: { main: { servers: [nats://127.0.0.1:4222] } }
|
|
19
|
+
dokebi-event-bus:
|
|
20
|
+
buses: { sse: { transport: nats, connection: main } }
|
|
21
|
+
dokebi-sse:
|
|
22
|
+
bus: sse
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 서버
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const events = context.get(sse); // 또는 context.getDependency(dependency)
|
|
29
|
+
events.route({
|
|
30
|
+
path: '/events',
|
|
31
|
+
version: APP_VERSION, // 다르면 dokebi-reload (D-0033)
|
|
32
|
+
authenticate: async (request) => { // undefined면 401
|
|
33
|
+
const user = await request.session.get('user');
|
|
34
|
+
return user ? { tenant: user.shop, member: user.id, rooms: [`shop:${user.shop}`] } : undefined;
|
|
35
|
+
},
|
|
36
|
+
onOpen: (stream) => stream.send('snapshot', { unread: 3 }),
|
|
37
|
+
});
|
|
38
|
+
|
|
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
|
+
```
|
|
44
|
+
|
|
45
|
+
| API | 뜻 |
|
|
46
|
+
|---|---|
|
|
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
|
+
|
|
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의 「실시간 네임스페이스」.
|
|
53
|
+
|
|
54
|
+
오류는 `code`로 본다: `INVALID_EVENT`(이름 규칙·`dokebi-` 접두사), `INVALID_DATA`, `EVENT_TOO_LARGE`, `INVALID_TARGET`, `ROOM_LIMIT`, `BUS_UNAVAILABLE`(아무 데도 안 보냄), `BUS_PUBLISH_FAILED`(이 프로세스엔 보냄, 다른 곳은 모름; `id`·`local` 포함).
|
|
55
|
+
|
|
56
|
+
## 브라우저
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { connectEvents } from '@dokebi/sse/client';
|
|
60
|
+
const events = connectEvents({ path: '/events', version: APP_VERSION });
|
|
61
|
+
const off = events.on('order.changed', (order) => refresh(order.id)); // data는 JSON으로 읽은 값
|
|
62
|
+
events.onStatus((status) => badge(status)); // connecting · open · reconnecting · paused · closed
|
|
63
|
+
```
|
|
64
|
+
|
|
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
|
+
|
|
70
|
+
## 여러 워커·서버
|
|
71
|
+
|
|
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`를 따른다.
|
|
79
|
+
|
|
80
|
+
자세한 설정·동작은 [AI.md](AI.md)와 [개발 문서](../../docs/plugins/dokebi-sse/README.md), 실행 예는 [examples/27-sse](../../examples/27-sse/README.md).
|
|
81
|
+
|
|
82
|
+
## 봉인 호스트 (D-0062)
|
|
83
|
+
|
|
84
|
+
seal 호스트의 스트림은 모든 이벤트의 data가 봉인된다. 브라우저는 `connectEvents({ path, seal: currentSealBridge() })`(`@dokebi/seal/http`)로 연다. 자세한 것은 [AI.md](AI.md) §5a와 [D-0062](../../docs/decisions/D-0062-http-seal.md).
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
//#region client/index.ts
|
|
2
|
+
var e = "dokebi-reload", t = "dokebi-restart", n = "dokebi-open";
|
|
3
|
+
function r(e) {
|
|
4
|
+
let t = /^\{"retryMs":(\d{1,9})\}$/.exec(e);
|
|
5
|
+
return t ? Number(t[1]) : void 0;
|
|
6
|
+
}
|
|
7
|
+
function i(i) {
|
|
8
|
+
let a = i.EventSource ?? globalThis.EventSource, o = i.reconnect !== !1, s = i.reconnect === !1 ? 0 : Math.max(0, i.reconnect?.minMs ?? 1e3), c = i.reconnect === !1 ? 0 : Math.max(s, i.reconnect?.maxMs ?? 3e4), l = /* @__PURE__ */ new Map(), u = /* @__PURE__ */ new Map(), d = /* @__PURE__ */ new Set(), f = /* @__PURE__ */ new Set(), p, m = "connecting", h, g = 0, _ = 0, v, y = !1, b = !1, x = !1, S = i.url ?? i.path;
|
|
9
|
+
if (typeof S != "string" || !S) throw TypeError("connectEvents needs url or path");
|
|
10
|
+
let C = i.seal;
|
|
11
|
+
if (C !== void 0 && typeof C?.unsealFrame != "function") throw TypeError("connectEvents seal needs unsealFrame(frame, url)");
|
|
12
|
+
let w;
|
|
13
|
+
function T(e) {
|
|
14
|
+
if (m !== e) {
|
|
15
|
+
m = e, i.onStatus?.(e);
|
|
16
|
+
for (let t of [...d]) t(e);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function E(e) {
|
|
20
|
+
if (!i.onError && !f.size) console.warn(`[dokebi-sse] ${e.code}`, e.error ?? e.status ?? "");
|
|
21
|
+
else {
|
|
22
|
+
i.onError?.(e);
|
|
23
|
+
for (let t of [...f]) t(e);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function D(e) {
|
|
27
|
+
let t = globalThis.location?.href, n = new URL(S, t);
|
|
28
|
+
for (let [e, t] of Object.entries(i.query ?? {})) t != null && n.searchParams.set(e, String(t));
|
|
29
|
+
i.version !== void 0 && n.searchParams.set("dokebiVersion", i.version), x && h !== void 0 && n.searchParams.set("lastEventId", h);
|
|
30
|
+
for (let [t, r] of Object.entries(e ?? {})) n.searchParams.set(t, r);
|
|
31
|
+
return w = n.pathname, n.href;
|
|
32
|
+
}
|
|
33
|
+
function O(e, t) {
|
|
34
|
+
let n = t.startsWith("E:");
|
|
35
|
+
if (C) try {
|
|
36
|
+
if (!n) throw Error("plain data on a sealed stream");
|
|
37
|
+
return C.unsealFrame(t, w ?? "/");
|
|
38
|
+
} catch (t) {
|
|
39
|
+
E({
|
|
40
|
+
code: "SEAL_FAILED",
|
|
41
|
+
event: e,
|
|
42
|
+
error: t
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!n) return t;
|
|
47
|
+
E({
|
|
48
|
+
code: "SEAL_BRIDGE_MISSING",
|
|
49
|
+
event: e
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function k(e) {
|
|
53
|
+
return (t) => {
|
|
54
|
+
t.lastEventId && (h = t.lastEventId);
|
|
55
|
+
let n = O(e, t.data);
|
|
56
|
+
if (n === void 0) return;
|
|
57
|
+
let r;
|
|
58
|
+
try {
|
|
59
|
+
r = JSON.parse(n);
|
|
60
|
+
} catch (t) {
|
|
61
|
+
E({
|
|
62
|
+
code: "BAD_DATA",
|
|
63
|
+
event: e,
|
|
64
|
+
error: t
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let i = {
|
|
69
|
+
event: e,
|
|
70
|
+
id: t.lastEventId || void 0
|
|
71
|
+
};
|
|
72
|
+
for (let t of [...l.get(e) ?? []]) try {
|
|
73
|
+
t(r, i);
|
|
74
|
+
} catch (t) {
|
|
75
|
+
E({
|
|
76
|
+
code: "HANDLER_FAILED",
|
|
77
|
+
event: e,
|
|
78
|
+
error: t
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function A() {
|
|
84
|
+
v !== void 0 && clearTimeout(v), v = void 0;
|
|
85
|
+
}
|
|
86
|
+
function j() {
|
|
87
|
+
p &&= (p.onopen = null, p.onerror = null, p.close(), void 0);
|
|
88
|
+
}
|
|
89
|
+
function M() {
|
|
90
|
+
if (A(), y || b) return;
|
|
91
|
+
if (!a) {
|
|
92
|
+
y = !0, T("closed"), E({ code: "NO_EVENTSOURCE" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let o = new a(D(), i.withCredentials ? { withCredentials: !0 } : void 0);
|
|
96
|
+
p = o, o.onopen = () => {
|
|
97
|
+
p === o && (_ = 0, T("open"));
|
|
98
|
+
}, o.onerror = () => {
|
|
99
|
+
p === o && N(o);
|
|
100
|
+
}, o.addEventListener(n, (e) => {
|
|
101
|
+
g = r(O(n, e.data) ?? "") ?? g;
|
|
102
|
+
}), o.addEventListener(e, (t) => {
|
|
103
|
+
p === o && O(e, t.data) !== void 0 && I();
|
|
104
|
+
}), o.addEventListener(t, (e) => {
|
|
105
|
+
p === o && (g = r(O(t, e.data) ?? "") ?? g, j(), T("reconnecting"), F());
|
|
106
|
+
});
|
|
107
|
+
for (let [e, t] of u) o.addEventListener(e, t);
|
|
108
|
+
}
|
|
109
|
+
function N(e) {
|
|
110
|
+
if (e.readyState !== 2) {
|
|
111
|
+
T("reconnecting");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (j(), !o) {
|
|
115
|
+
y = !0, T("closed"), E({ code: "CONNECTION_FAILED" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
T("reconnecting");
|
|
119
|
+
let t = globalThis.fetch;
|
|
120
|
+
i.probe === !1 || !t ? (E({ code: "CONNECTION_FAILED" }), F()) : P(t);
|
|
121
|
+
}
|
|
122
|
+
async function P(e) {
|
|
123
|
+
let t;
|
|
124
|
+
try {
|
|
125
|
+
t = (await e(D({ dokebiProbe: "1" }), {
|
|
126
|
+
credentials: i.withCredentials ? "include" : "same-origin",
|
|
127
|
+
cache: "no-store"
|
|
128
|
+
})).status;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
E({
|
|
131
|
+
code: "CONNECTION_FAILED",
|
|
132
|
+
error: e
|
|
133
|
+
}), F();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
y || b || (t === 401 || t === 403 ? (y = !0, A(), T("closed"), E({
|
|
137
|
+
code: t === 401 ? "UNAUTHORIZED" : "FORBIDDEN",
|
|
138
|
+
status: t
|
|
139
|
+
})) : (E({
|
|
140
|
+
code: t === 503 ? "UNAVAILABLE" : "CONNECTION_FAILED",
|
|
141
|
+
status: t
|
|
142
|
+
}), F()));
|
|
143
|
+
}
|
|
144
|
+
function F() {
|
|
145
|
+
if (A(), y || b) return;
|
|
146
|
+
if (!o) {
|
|
147
|
+
y = !0, T("closed");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let e = Math.max(s, g), t = Math.min(Math.max(c, e), e * 2 ** _);
|
|
151
|
+
_ = Math.min(_ + 1, 20), x = !0, v = setTimeout(M, Math.round(t / 2 + Math.random() * t / 2));
|
|
152
|
+
}
|
|
153
|
+
function I() {
|
|
154
|
+
y = !0, j(), A(), T("closed"), i.onReload ? i.onReload() : globalThis.location?.reload();
|
|
155
|
+
}
|
|
156
|
+
let L = globalThis, R = (e) => {
|
|
157
|
+
e.persisted && !y && (b = !0, j(), A(), T("paused"));
|
|
158
|
+
}, z = (e) => {
|
|
159
|
+
e.persisted && !y && b && (b = !1, _ = 0, x = !0, T("connecting"), M());
|
|
160
|
+
};
|
|
161
|
+
return L.addEventListener?.("pagehide", R), L.addEventListener?.("pageshow", z), M(), {
|
|
162
|
+
get status() {
|
|
163
|
+
return m;
|
|
164
|
+
},
|
|
165
|
+
get lastEventId() {
|
|
166
|
+
return h;
|
|
167
|
+
},
|
|
168
|
+
on(e, t) {
|
|
169
|
+
if (typeof e != "string" || !e || typeof t != "function") throw TypeError("on(event, handler) needs an event name and a function");
|
|
170
|
+
let n = l.get(e);
|
|
171
|
+
if (!n) {
|
|
172
|
+
n = /* @__PURE__ */ new Set(), l.set(e, n);
|
|
173
|
+
let t = k(e);
|
|
174
|
+
u.set(e, t), p?.addEventListener(e, t);
|
|
175
|
+
}
|
|
176
|
+
let r = t;
|
|
177
|
+
return n.add(r), () => {
|
|
178
|
+
let t = l.get(e);
|
|
179
|
+
if (!t?.delete(r) || t.size) return;
|
|
180
|
+
l.delete(e);
|
|
181
|
+
let n = u.get(e);
|
|
182
|
+
u.delete(e), n && p?.removeEventListener(e, n);
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
onStatus(e) {
|
|
186
|
+
return d.add(e), () => d.delete(e);
|
|
187
|
+
},
|
|
188
|
+
onError(e) {
|
|
189
|
+
return f.add(e), () => f.delete(e);
|
|
190
|
+
},
|
|
191
|
+
close() {
|
|
192
|
+
y = !0, b = !1, j(), A(), L.removeEventListener?.("pagehide", R), L.removeEventListener?.("pageshow", z), T("closed");
|
|
193
|
+
},
|
|
194
|
+
reconnect() {
|
|
195
|
+
let e = y;
|
|
196
|
+
y = !1, b = !1, _ = 0, x = p !== void 0 || x, j(), e && (L.addEventListener?.("pagehide", R), L.addEventListener?.("pageshow", z)), T("connecting"), M();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
export { i as connectEvents };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type EventsStatus =
|
|
2
|
+
/** Opening the first time, or after reconnect(). */
|
|
3
|
+
'connecting' | 'open'
|
|
4
|
+
/** Lost; the browser or this client is opening it again. */
|
|
5
|
+
| 'reconnecting'
|
|
6
|
+
/** The page went into the back/forward cache; reopened when it is shown again. */
|
|
7
|
+
| 'paused'
|
|
8
|
+
/** close(), dokebi-reload, 401/403, or a failure with reconnect: false. */
|
|
9
|
+
| 'closed';
|
|
10
|
+
export type EventsErrorCode = 'NO_EVENTSOURCE' | 'CONNECTION_FAILED' | 'UNAVAILABLE' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'BAD_DATA' | 'HANDLER_FAILED'
|
|
11
|
+
/** Sealed data arrived but no `seal` bridge was given (D-0062). */
|
|
12
|
+
| 'SEAL_BRIDGE_MISSING'
|
|
13
|
+
/** With a `seal` bridge: data that is plain or does not open. Never handed on as plain. */
|
|
14
|
+
| 'SEAL_FAILED';
|
|
15
|
+
export interface EventsError {
|
|
16
|
+
readonly code: EventsErrorCode;
|
|
17
|
+
/** The HTTP status the probe saw, when it saw one. */
|
|
18
|
+
readonly status?: number;
|
|
19
|
+
/** The event name, for BAD_DATA, HANDLER_FAILED and the SEAL_ codes. */
|
|
20
|
+
readonly event?: string;
|
|
21
|
+
readonly error?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface EventMeta {
|
|
24
|
+
readonly event: string;
|
|
25
|
+
/** The event id (the server's `id:`), or undefined for events without one. */
|
|
26
|
+
readonly id: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
export type EventHandler<T = unknown> = (data: T, meta: EventMeta) => void;
|
|
29
|
+
/** The part of the standard EventSource this client uses (a test may pass its own). */
|
|
30
|
+
export interface EventSourceLike {
|
|
31
|
+
readonly readyState: number;
|
|
32
|
+
onopen: ((event: unknown) => unknown) | null;
|
|
33
|
+
onerror: ((event: unknown) => unknown) | null;
|
|
34
|
+
addEventListener(type: string, listener: (event: MessageEventLike) => void): void;
|
|
35
|
+
removeEventListener(type: string, listener: (event: MessageEventLike) => void): void;
|
|
36
|
+
close(): void;
|
|
37
|
+
}
|
|
38
|
+
export interface MessageEventLike {
|
|
39
|
+
readonly data: string;
|
|
40
|
+
readonly lastEventId: string;
|
|
41
|
+
}
|
|
42
|
+
export type EventSourceConstructor = new (url: string, init?: {
|
|
43
|
+
withCredentials?: boolean;
|
|
44
|
+
}) => EventSourceLike;
|
|
45
|
+
export interface EventsOptions {
|
|
46
|
+
/** An absolute or page-relative stream URL. Use this or `path`. */
|
|
47
|
+
readonly url?: string;
|
|
48
|
+
/** A path on the page's origin, for example `/events`. */
|
|
49
|
+
readonly path?: string;
|
|
50
|
+
readonly query?: Readonly<Record<string, string | number | boolean | null | undefined>>;
|
|
51
|
+
/** Sent as `dokebiVersion`; a server whose route has another version answers dokebi-reload. */
|
|
52
|
+
readonly version?: string;
|
|
53
|
+
/** Sends cookies on a cross-origin stream (the server must list the page's origin). */
|
|
54
|
+
readonly withCredentials?: boolean;
|
|
55
|
+
/** On `dokebi-reload` (the page's code is out of date). Default: `location.reload()`. */
|
|
56
|
+
readonly onReload?: () => void;
|
|
57
|
+
/** Backoff of this client's own reopens; false leaves a failed stream closed. */
|
|
58
|
+
readonly reconnect?: {
|
|
59
|
+
readonly minMs?: number;
|
|
60
|
+
readonly maxMs?: number;
|
|
61
|
+
} | false;
|
|
62
|
+
/** Ask the server why a stream was refused (default true where fetch exists). */
|
|
63
|
+
readonly probe?: boolean;
|
|
64
|
+
/** Another EventSource implementation (default: the browser's). */
|
|
65
|
+
readonly EventSource?: EventSourceConstructor;
|
|
66
|
+
readonly onStatus?: (status: EventsStatus) => void;
|
|
67
|
+
readonly onError?: (error: EventsError) => void;
|
|
68
|
+
/**
|
|
69
|
+
* The seal bridge of a seal host (D-0062): opens each event's sealed `data` with the stream
|
|
70
|
+
* path. `currentSealBridge()` of `@dokebi/seal/http` gives it on sealed pages only.
|
|
71
|
+
*/
|
|
72
|
+
readonly seal?: {
|
|
73
|
+
unsealFrame(frame: string, url: string): string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export interface EventsClient {
|
|
77
|
+
readonly status: EventsStatus;
|
|
78
|
+
/** The last event id seen on an event this client listens to. */
|
|
79
|
+
readonly lastEventId: string | undefined;
|
|
80
|
+
/** Listens to one event name; `data` is the parsed JSON. Returns a function that stops. */
|
|
81
|
+
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
|
82
|
+
onStatus(handler: (status: EventsStatus) => void): () => void;
|
|
83
|
+
onError(handler: (error: EventsError) => void): () => void;
|
|
84
|
+
/** Closes the stream; nothing reopens it except reconnect(). */
|
|
85
|
+
close(): void;
|
|
86
|
+
/** Closes and opens the stream now (also after close()). */
|
|
87
|
+
reconnect(): void;
|
|
88
|
+
}
|
|
89
|
+
/** Opens an event stream (browser only). */
|
|
90
|
+
export declare function connectEvents(options: EventsOptions): EventsClient;
|