@dokebi/sse 0.4.1 → 0.4.3

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/dist/retry.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which failures of the event bus may pass when the request is retried (D-0107 §6): the bus
3
+ * refused at its pending limit (`busy`), or the bus or its NATS connection is down, closed or
4
+ * draining (`unavailable`). Recognised by `plugin` and `code` (another copy of the package
5
+ * counts). Anything else (an invalid payload or event name) is a defect and answers 500.
6
+ */
7
+ const BUSY = new Set(['OVERLOADED']);
8
+ const UNAVAILABLE = new Set([
9
+ 'TRANSPORT_FAILED',
10
+ 'DEPENDENCY_UNAVAILABLE',
11
+ 'BUS_DRAINING',
12
+ 'CLOSED',
13
+ ]);
14
+ export function transientBusFailure(cause) {
15
+ if (cause === null || typeof cause !== 'object')
16
+ return undefined;
17
+ const { plugin, code } = cause;
18
+ if (plugin !== 'dokebi-event-bus' || typeof code !== 'string')
19
+ return undefined;
20
+ if (BUSY.has(code))
21
+ return 'busy';
22
+ if (UNAVAILABLE.has(code))
23
+ return 'unavailable';
24
+ return undefined;
25
+ }
package/dist/transport.js CHANGED
@@ -21,7 +21,10 @@ export function sseTransport(api, logger) {
21
21
  },
22
22
  });
23
23
  if (logger.isEnabled('debug'))
24
- logger.log('debug', 'SSE route mounted by the realtime point', { path: scope.path });
24
+ logger.log('debug', 'SSE route mounted by the realtime point', {
25
+ path: scope.path,
26
+ routeId: typeof route.config?.id === 'string' ? route.config.id : null,
27
+ });
25
28
  },
26
29
  handle(namespace) {
27
30
  if (typeof namespace !== 'string' || !NAMESPACE.test(namespace))
@@ -29,7 +32,7 @@ export function sseTransport(api, logger) {
29
32
  const prefix = `${namespace}:`;
30
33
  return Object.freeze({
31
34
  namespace,
32
- publish: (target, event, data) => {
35
+ publish: (target, event, data, options) => {
33
36
  if (target === null || typeof target !== 'object')
34
37
  return Promise.reject(new SseError('INVALID_TARGET', 'A namespace target is { tenant?, member?, room? }.'));
35
38
  const { tenant, member, room } = target;
@@ -37,7 +40,7 @@ export function sseTransport(api, logger) {
37
40
  ...(tenant === undefined ? { tenantPrefix: prefix } : { tenant: prefix + tenant }),
38
41
  ...(member === undefined ? {} : { member }),
39
42
  ...(room === undefined ? {} : { room }),
40
- }, event, data);
43
+ }, event, data, options);
41
44
  },
42
45
  });
43
46
  },
package/dist/types.d.ts CHANGED
@@ -2,6 +2,11 @@ import type { Awaitable } from '@dokebi/core';
2
2
  import type { FastifyRequest } from 'fastify';
3
3
  /** Who a stream belongs to, as `authenticate` decided. Extra fields are kept on `stream.identity`. */
4
4
  export interface SseIdentity {
5
+ /** Local renewable authorization, e.g. request.auth.live(); never published on the bus. */
6
+ readonly authorization?: {
7
+ valid(): boolean;
8
+ subscribe(invalidated: () => void): () => void;
9
+ };
5
10
  readonly tenant: string;
6
11
  readonly member: string;
7
12
  /** Rooms the stream starts in (counted against maxRooms). */
@@ -47,18 +52,33 @@ export interface SseStream<TIdentity extends SseIdentity = SseIdentity> {
47
52
  readonly closed: boolean;
48
53
  /**
49
54
  * Sends one event to this client only. False when the stream is closed (or was just ended as a
50
- * slow consumer). Throws INVALID_EVENT, INVALID_DATA or EVENT_TOO_LARGE.
55
+ * slow consumer). Throws INVALID_EVENT, INVALID_DATA, EVENT_TOO_LARGE or INVALID_EVENT_ID.
51
56
  */
52
- send(event: string, data?: unknown): boolean;
57
+ send(event: string, data?: unknown, options?: SseSendOptions): boolean;
53
58
  /** Joins a room of this stream's tenant (this connection only). Throws ROOM_LIMIT, INVALID_ROOM. */
54
59
  join(room: string): void;
55
60
  leave(room: string): void;
56
61
  /** Ends the stream (`SERVER_CLOSED`). The browser's EventSource reconnects after `retry`. */
57
62
  close(): void;
58
63
  }
64
+ /**
65
+ * The route config of an SSE route (D-0140): only `id` for now. It becomes the Fastify route's
66
+ * `config`, so `dokebi describe` lists it as `routes[].id`, per-route hooks of dokebi-web
67
+ * (`web.routeHook`, audit hooks) see it, and a request reads it as `request.routeOptions.config.id`.
68
+ */
69
+ export interface SseRouteConfig {
70
+ /**
71
+ * The route id of dokebi-web (D-0077 A), for example `'live'` or `'API-12'`: 1 to 64 letters,
72
+ * digits, `.`, `_` or `-`, starting with a letter. Checked at start like a web route's
73
+ * (INVALID_ROUTE_CONFIG); the same id on two routes of one app fails it (DUPLICATE_ROUTE_ID).
74
+ */
75
+ readonly id?: string;
76
+ }
59
77
  export interface SseRoute<TIdentity extends SseIdentity = SseIdentity> {
60
78
  /** The GET path of the stream. */
61
79
  readonly path: string;
80
+ /** `{ id }` (D-0140); any other key is INVALID_ROUTE. */
81
+ readonly config?: SseRouteConfig;
62
82
  /** Fastify route constraints, for example `{ dokebiApp: 'shop' }` of @dokebi/app. */
63
83
  readonly constraints?: Readonly<Record<string, unknown>>;
64
84
  /**
@@ -98,6 +118,15 @@ export interface SsePrefixTarget {
98
118
  readonly member?: string;
99
119
  readonly room?: string;
100
120
  }
121
+ /**
122
+ * The event id the client sends back as `Last-Event-ID` when it reconnects. Left out, the plugin
123
+ * numbers events in this process (unique, but meaningless to another worker). Give an id of your
124
+ * own durable order (a message's sequence in its channel) so a client that reconnects to any
125
+ * worker can be sent what it missed from the database: 1 to 64 of A-Z a-z 0-9 and -.
126
+ */
127
+ export interface SseSendOptions {
128
+ readonly id?: string;
129
+ }
101
130
  export interface SsePublishResult {
102
131
  /** The event id every recipient sees as `lastEventId`. */
103
132
  readonly id: string;
@@ -120,7 +149,7 @@ export interface SseApi {
120
149
  * INVALID_TARGET, INVALID_EVENT, INVALID_DATA, EVENT_TOO_LARGE, BUS_UNAVAILABLE (nothing sent)
121
150
  * or BUS_PUBLISH_FAILED (sent here, other processes unknown; the error has `id` and `local`).
122
151
  */
123
- publish(target: SseTarget | SsePrefixTarget, event: string, data?: unknown): Promise<SsePublishResult>;
152
+ publish(target: SseTarget | SsePrefixTarget, event: string, data?: unknown, options?: SseSendOptions): Promise<SsePublishResult>;
124
153
  /** Open streams of this process (worker) that match. */
125
154
  connections(filter?: SseFilter): number;
126
155
  /** The bus name, when publications cross processes. */
@@ -136,7 +165,7 @@ export interface SseNamespaceTarget {
136
165
  export interface SseNamespaceHandle {
137
166
  readonly namespace: string;
138
167
  /** `publish` with the tenant (or every tenant) of this namespace. */
139
- publish(target: SseNamespaceTarget, event: string, data?: unknown): Promise<SsePublishResult>;
168
+ publish(target: SseNamespaceTarget, event: string, data?: unknown, options?: SseSendOptions): Promise<SsePublishResult>;
140
169
  }
141
170
  /** The events the plugin itself sends; application event names may not start with `dokebi-`. */
142
171
  export declare const RELOAD_EVENT = "dokebi-reload";
@@ -12,6 +12,7 @@
12
12
  "type": "string",
13
13
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
14
14
  "deprecated": true,
15
+ "x-dokebi-alias-of": "eventBus",
15
16
  "description": "Deprecated name of eventBus; use that key."
16
17
  },
17
18
  "busEvent": {
@@ -31,6 +32,7 @@
31
32
  "minimum": 100,
32
33
  "maximum": 3600000,
33
34
  "default": 3000,
35
+ "x-dokebi-reloadable": true,
34
36
  "description": "The retry: hint (browser reconnect delay), also the browser client's minimum backoff."
35
37
  },
36
38
  "heartbeatMs": {
@@ -38,6 +40,7 @@
38
40
  "minimum": 10,
39
41
  "maximum": 3600000,
40
42
  "default": 15000,
43
+ "x-dokebi-reloadable": true,
41
44
  "description": "How often a ': ping' comment is written to every stream. Proxy idle timeouts must be longer."
42
45
  },
43
46
  "maxConnections": {
@@ -45,6 +48,7 @@
45
48
  "minimum": 1,
46
49
  "maximum": 1000000,
47
50
  "default": 1024,
51
+ "x-dokebi-reloadable": true,
48
52
  "description": "Open streams per process (per cluster worker); more get 503 CAPACITY."
49
53
  },
50
54
  "maxBufferedBytes": {
@@ -52,6 +56,7 @@
52
56
  "minimum": 1,
53
57
  "maximum": 67108864,
54
58
  "default": 1048576,
59
+ "x-dokebi-reloadable": true,
55
60
  "description": "Bytes a stream may have waiting to be sent; past it the stream is ended (warn SLOW_CONSUMER)."
56
61
  },
57
62
  "maxRooms": {
@@ -66,6 +71,7 @@
66
71
  "minimum": 1,
67
72
  "maximum": 16777216,
68
73
  "default": 65536,
74
+ "x-dokebi-reloadable": true,
69
75
  "description": "Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes."
70
76
  },
71
77
  "shutdownTimeoutMs": {
@@ -80,7 +86,68 @@
80
86
  "minimum": 1,
81
87
  "maximum": 600000,
82
88
  "deprecated": true,
89
+ "x-dokebi-alias-of": "shutdownTimeoutMs",
83
90
  "description": "Deprecated name of shutdownTimeoutMs; use that key."
91
+ },
92
+ "endTimeoutMs": {
93
+ "type": "integer",
94
+ "minimum": 1,
95
+ "maximum": 600000,
96
+ "default": 5000,
97
+ "description": "How long a stream the server ended (stream.close(), revoked authorization, drain) may take to send its waiting bytes before it is destroyed (warn STREAM_END_TIMEOUT); frees the slot of a client that stopped reading."
98
+ },
99
+ "busRetryAfterMs": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "busy": {
104
+ "type": "integer",
105
+ "minimum": 1000,
106
+ "maximum": 3600000,
107
+ "default": 1000,
108
+ "description": "Retry-After (ms, sent in whole seconds) of the 503 SSE_BUS_BUSY: the bus refused a publication at its pending limit."
109
+ },
110
+ "unavailable": {
111
+ "type": "integer",
112
+ "minimum": 1000,
113
+ "maximum": 3600000,
114
+ "default": 5000,
115
+ "description": "Retry-After (ms, sent in whole seconds) of the 503 SSE_BUS_UNAVAILABLE: the bus or its NATS connection is down or draining."
116
+ }
117
+ },
118
+ "default": {
119
+ "busy": 1000,
120
+ "unavailable": 5000
121
+ },
122
+ "description": "Retry-After of the retryable 503 a route answers when a publish fails for a transient bus reason (D-0107); size it near how long the bus takes to recover."
123
+ },
124
+ "beforeStreamTimeoutMs": {
125
+ "type": "integer",
126
+ "minimum": 100,
127
+ "maximum": 600000,
128
+ "default": 5000,
129
+ "description": "The longest wait for the work that must finish before a stream starts (the session save and auth cookies, D-0089); slower refuses the stream with 500 COMMIT_FAILED (D-0114)."
130
+ },
131
+ "reloadRetryMs": {
132
+ "type": "integer",
133
+ "minimum": 100,
134
+ "maximum": 86400000,
135
+ "default": 60000,
136
+ "description": "The retry: sent with dokebi-reload (a route whose version changed): a client that ignores the reload reconnects this slowly (D-0114)."
137
+ },
138
+ "busWatchIntervalMs": {
139
+ "type": "integer",
140
+ "minimum": 100,
141
+ "maximum": 60000,
142
+ "default": 1000,
143
+ "description": "How often the event bus state is read for the health check and its warn/info log; a bus outage shows within this long (D-0114)."
144
+ },
145
+ "logRepeatWindowMs": {
146
+ "type": "integer",
147
+ "minimum": 1000,
148
+ "maximum": 3600000,
149
+ "default": 10000,
150
+ "description": "Window of the repeated stream failure logs (a failed before-stream step, a failed write) in ms: the first is logged in full, the rest of the window are counted (D-0114)."
84
151
  }
85
152
  }
86
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/sse",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
5
5
  "keywords": [
6
6
  "dokebi",
@@ -49,8 +49,8 @@
49
49
  "test": "node -e \"require('node:fs').rmSync('.tmp/tests',{recursive:true,force:true})\" && tsc -p tsconfig.test.json && node --experimental-eventsource --test \".tmp/tests/tests/*.test.js\""
50
50
  },
51
51
  "peerDependencies": {
52
- "@dokebi/core": "^0.4.0",
53
- "@dokebi/web": "^0.4.0",
52
+ "@dokebi/core": "^0.4.2",
53
+ "@dokebi/web": "^0.4.3",
54
54
  "@dokebi/event-bus": "^0.4.0",
55
55
  "fastify": "^5.12.7",
56
56
  "react": ">=18",
@@ -68,12 +68,12 @@
68
68
  }
69
69
  },
70
70
  "devDependencies": {
71
- "@dokebi/cluster": "0.4.0",
72
- "@dokebi/config": "0.4.1",
73
- "@dokebi/event-bus": "0.4.1",
74
- "@dokebi/health": "0.4.0",
75
- "@dokebi/nats": "0.4.0",
76
- "@dokebi/web": "0.4.2",
71
+ "@dokebi/cluster": "0.4.1",
72
+ "@dokebi/config": "0.4.4",
73
+ "@dokebi/event-bus": "0.4.2",
74
+ "@dokebi/health": "0.4.1",
75
+ "@dokebi/nats": "0.4.1",
76
+ "@dokebi/web": "0.4.3",
77
77
  "react": "19.3.0",
78
78
  "react-test-renderer": "19.3.0",
79
79
  "vite": "8.3.4",
@@ -82,11 +82,11 @@
82
82
  "dokebi": {
83
83
  "schemaVersion": 2,
84
84
  "id": "dokebi-sse",
85
- "core": "^0.4.0",
85
+ "core": "^0.4.2",
86
86
  "dependencies": [
87
87
  {
88
88
  "name": "dokebi-web",
89
- "version": "^0.4.0"
89
+ "version": "^0.4.3"
90
90
  },
91
91
  {
92
92
  "name": "dokebi-event-bus",