@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 +94 -78
- package/README.md +44 -40
- package/dist/errors.d.ts +6 -4
- package/dist/errors.js +24 -4
- package/dist/gateway.js +83 -47
- package/dist/install.d.ts +1 -1
- package/dist/install.js +7 -5
- package/dist/options.d.ts +18 -3
- package/dist/options.js +47 -59
- package/dist/plugin.js +40 -32
- package/dist/transport.d.ts +13 -0
- package/dist/transport.js +45 -0
- package/dist/types.d.ts +12 -0
- package/options.schema.json +16 -3
- package/package.json +39 -17
- package/dist/log.d.ts +0 -25
- package/dist/log.js +0 -102
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Logger } from '@dokebi/core';
|
|
2
|
+
import type { WebRealtimeTransport } from '@dokebi/web/points';
|
|
3
|
+
import type { SseApi, SseNamespaceHandle, SseRoute } from './types.js';
|
|
4
|
+
/** The `sse` transport contributed to `dokebi-web:realtime` (D-0063 §5). */
|
|
5
|
+
export type SseTransport = WebRealtimeTransport<SseRoute, SseNamespaceHandle> & {
|
|
6
|
+
readonly kind: 'sse';
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* What dokebi-app needs to mount SSE routes of apps and modules and to publish per namespace,
|
|
10
|
+
* built on the public API: `mount` adds the scope's path, constraints and tenant rule to a
|
|
11
|
+
* route; `handle` prefixes targets with `<namespace>:`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sseTransport(api: SseApi, logger: Logger): SseTransport;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SseError } from './errors.js';
|
|
2
|
+
const NAMESPACE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
3
|
+
/**
|
|
4
|
+
* What dokebi-app needs to mount SSE routes of apps and modules and to publish per namespace,
|
|
5
|
+
* built on the public API: `mount` adds the scope's path, constraints and tenant rule to a
|
|
6
|
+
* route; `handle` prefixes targets with `<namespace>:`.
|
|
7
|
+
*/
|
|
8
|
+
export function sseTransport(api, logger) {
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
kind: 'sse',
|
|
11
|
+
mount(route, scope) {
|
|
12
|
+
if (route === null || typeof route !== 'object' || typeof route.authenticate !== 'function')
|
|
13
|
+
throw new SseError('INVALID_ROUTE', 'Invalid SSE route.');
|
|
14
|
+
api.route({
|
|
15
|
+
...route,
|
|
16
|
+
path: scope.path,
|
|
17
|
+
constraints: { ...(route.constraints ?? {}), ...scope.constraints },
|
|
18
|
+
authenticate: async (request) => {
|
|
19
|
+
const identity = await route.authenticate(request);
|
|
20
|
+
return identity ? scope.identity(identity) : undefined;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (logger.isEnabled('debug'))
|
|
24
|
+
logger.log('debug', 'SSE route mounted by the realtime point', { path: scope.path });
|
|
25
|
+
},
|
|
26
|
+
handle(namespace) {
|
|
27
|
+
if (typeof namespace !== 'string' || !NAMESPACE.test(namespace))
|
|
28
|
+
throw new SseError('INVALID_TARGET', 'A realtime namespace is a name of 1 to 64 characters.');
|
|
29
|
+
const prefix = `${namespace}:`;
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
namespace,
|
|
32
|
+
publish: (target, event, data) => {
|
|
33
|
+
if (target === null || typeof target !== 'object')
|
|
34
|
+
return Promise.reject(new SseError('INVALID_TARGET', 'A namespace target is { tenant?, member?, room? }.'));
|
|
35
|
+
const { tenant, member, room } = target;
|
|
36
|
+
return api.publish({
|
|
37
|
+
...(tenant === undefined ? { tenantPrefix: prefix } : { tenant: prefix + tenant }),
|
|
38
|
+
...(member === undefined ? {} : { member }),
|
|
39
|
+
...(room === undefined ? {} : { room }),
|
|
40
|
+
}, event, data);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -126,6 +126,18 @@ export interface SseApi {
|
|
|
126
126
|
/** The bus name, when publications cross processes. */
|
|
127
127
|
readonly bus: string | undefined;
|
|
128
128
|
}
|
|
129
|
+
/** Whom a namespace publication is for (D-0061): a tenant of the namespace, or all of them. */
|
|
130
|
+
export interface SseNamespaceTarget {
|
|
131
|
+
readonly tenant?: string;
|
|
132
|
+
readonly member?: string;
|
|
133
|
+
readonly room?: string;
|
|
134
|
+
}
|
|
135
|
+
/** The SSE operations on one realtime namespace (the tenants `<namespace>:<tenant>`). */
|
|
136
|
+
export interface SseNamespaceHandle {
|
|
137
|
+
readonly namespace: string;
|
|
138
|
+
/** `publish` with the tenant (or every tenant) of this namespace. */
|
|
139
|
+
publish(target: SseNamespaceTarget, event: string, data?: unknown): Promise<SsePublishResult>;
|
|
140
|
+
}
|
|
129
141
|
/** The events the plugin itself sends; application event names may not start with `dokebi-`. */
|
|
130
142
|
export declare const RELOAD_EVENT = "dokebi-reload";
|
|
131
143
|
export declare const RESTART_EVENT = "dokebi-restart";
|
package/options.schema.json
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
5
|
"properties": {
|
|
6
|
-
"
|
|
6
|
+
"eventBus": {
|
|
7
7
|
"type": "string",
|
|
8
8
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
|
9
9
|
"description": "A dokebi-event-bus bus (buses.<name>) that carries publications to every worker and server. Use a nats bus for more than one process. Unset: publish reaches this process only."
|
|
10
10
|
},
|
|
11
|
+
"bus": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
|
14
|
+
"deprecated": true,
|
|
15
|
+
"description": "Deprecated name of eventBus; use that key."
|
|
16
|
+
},
|
|
11
17
|
"busEvent": {
|
|
12
18
|
"type": "string",
|
|
13
19
|
"maxLength": 128,
|
|
@@ -18,7 +24,7 @@
|
|
|
18
24
|
"allowInCluster": {
|
|
19
25
|
"type": "boolean",
|
|
20
26
|
"default": false,
|
|
21
|
-
"description": "Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with
|
|
27
|
+
"description": "Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with LOCAL_IN_CLUSTER. The bus driver is read from dokebi-event-bus.buses in the application config."
|
|
22
28
|
},
|
|
23
29
|
"retryMs": {
|
|
24
30
|
"type": "integer",
|
|
@@ -62,12 +68,19 @@
|
|
|
62
68
|
"default": 65536,
|
|
63
69
|
"description": "Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes."
|
|
64
70
|
},
|
|
65
|
-
"
|
|
71
|
+
"shutdownTimeoutMs": {
|
|
66
72
|
"type": "integer",
|
|
67
73
|
"minimum": 1,
|
|
68
74
|
"maximum": 600000,
|
|
69
75
|
"default": 2000,
|
|
70
76
|
"description": "How long drain waits for streams to end after dokebi-restart before cutting them."
|
|
77
|
+
},
|
|
78
|
+
"closeTimeoutMs": {
|
|
79
|
+
"type": "integer",
|
|
80
|
+
"minimum": 1,
|
|
81
|
+
"maximum": 600000,
|
|
82
|
+
"deprecated": true,
|
|
83
|
+
"description": "Deprecated name of shutdownTimeoutMs; use that key."
|
|
71
84
|
}
|
|
72
85
|
}
|
|
73
86
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/sse",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dokebi",
|
|
7
|
+
"dokebi-plugin",
|
|
8
|
+
"web",
|
|
9
|
+
"sse",
|
|
10
|
+
"realtime"
|
|
11
|
+
],
|
|
4
12
|
"private": false,
|
|
5
13
|
"type": "module",
|
|
6
|
-
"description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
|
|
7
14
|
"license": "MIT",
|
|
8
15
|
"main": "./dist/index.js",
|
|
9
16
|
"types": "./dist/index.d.ts",
|
|
@@ -37,14 +44,14 @@
|
|
|
37
44
|
"node": ">=24.0.0"
|
|
38
45
|
},
|
|
39
46
|
"scripts": {
|
|
40
|
-
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsc -p tsconfig.browser.json && vite build -c vite.client.config.ts --logLevel warn",
|
|
47
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsc -p tsconfig.browser.json && vite build -c vite.client.config.ts --logLevel warn && node -e \"import('./dist/options.js').then(async (m) => { const { renderOptionsSchema } = await import('@dokebi/core'); process.stdout.write(renderOptionsSchema(m.options)); })\" > options.schema.json",
|
|
41
48
|
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
42
|
-
"test": "tsc -p tsconfig.test.json && node --experimental-eventsource --test \".tmp/tests/tests/*.test.js\""
|
|
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\""
|
|
43
50
|
},
|
|
44
51
|
"peerDependencies": {
|
|
45
|
-
"@dokebi/core": "^0.
|
|
46
|
-
"@dokebi/web": "^0.
|
|
47
|
-
"@dokebi/event-bus": "^0.
|
|
52
|
+
"@dokebi/core": "^0.4.0",
|
|
53
|
+
"@dokebi/web": "^0.4.0",
|
|
54
|
+
"@dokebi/event-bus": "^0.4.0",
|
|
48
55
|
"fastify": "^5.12.7",
|
|
49
56
|
"react": ">=18",
|
|
50
57
|
"vue": "^3.3.0"
|
|
@@ -61,12 +68,12 @@
|
|
|
61
68
|
}
|
|
62
69
|
},
|
|
63
70
|
"devDependencies": {
|
|
64
|
-
"@dokebi/cluster": "0.
|
|
65
|
-
"@dokebi/config": "0.
|
|
66
|
-
"@dokebi/event-bus": "0.1
|
|
67
|
-
"@dokebi/health": "0.
|
|
68
|
-
"@dokebi/nats": "0.
|
|
69
|
-
"@dokebi/web": "0.2
|
|
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",
|
|
70
77
|
"react": "19.3.0",
|
|
71
78
|
"react-test-renderer": "19.3.0",
|
|
72
79
|
"vite": "8.3.4",
|
|
@@ -75,27 +82,42 @@
|
|
|
75
82
|
"dokebi": {
|
|
76
83
|
"schemaVersion": 2,
|
|
77
84
|
"id": "dokebi-sse",
|
|
78
|
-
"core": "^0.
|
|
85
|
+
"core": "^0.4.0",
|
|
79
86
|
"dependencies": [
|
|
80
87
|
{
|
|
81
88
|
"name": "dokebi-web",
|
|
82
|
-
"version": "^0.
|
|
89
|
+
"version": "^0.4.0"
|
|
83
90
|
},
|
|
84
91
|
{
|
|
85
92
|
"name": "dokebi-event-bus",
|
|
86
|
-
"version": "^0.
|
|
93
|
+
"version": "^0.4.0",
|
|
87
94
|
"optional": true
|
|
88
95
|
},
|
|
89
96
|
{
|
|
90
97
|
"name": "dokebi-health",
|
|
91
|
-
"version": "^0.
|
|
98
|
+
"version": "^0.4.0",
|
|
92
99
|
"optional": true
|
|
93
100
|
}
|
|
94
101
|
],
|
|
102
|
+
"contributes": [
|
|
103
|
+
"dokebi-web:realtime"
|
|
104
|
+
],
|
|
95
105
|
"installModule": "dist/install.js",
|
|
96
106
|
"optionsSchema": "options.schema.json",
|
|
97
107
|
"documentation": "AI.md"
|
|
98
108
|
},
|
|
109
|
+
"dokebiCatalog": {
|
|
110
|
+
"kind": "plugin",
|
|
111
|
+
"summary": "Server-sent events (SSE) streams and rooms, delivery across workers, and a browser client",
|
|
112
|
+
"maturity": "preview",
|
|
113
|
+
"provides": [
|
|
114
|
+
"realtime.sse"
|
|
115
|
+
],
|
|
116
|
+
"doesNot": [
|
|
117
|
+
"No ACK, redelivery or retention (at most once)"
|
|
118
|
+
],
|
|
119
|
+
"recipes": []
|
|
120
|
+
},
|
|
99
121
|
"publishConfig": {
|
|
100
122
|
"access": "public",
|
|
101
123
|
"registry": "https://registry.npmjs.org/"
|
package/dist/log.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { LogFields, Logger, LogLevel } from '@dokebi/core';
|
|
2
|
-
/**
|
|
3
|
-
* The full text of a caught value for the `error` log field (D-0047): name, whole message and
|
|
4
|
-
* stack, then each `cause` the same way. Only credentials in URLs (`//user:password@`) are
|
|
5
|
-
* removed; a text over 16 KB is cut and marked.
|
|
6
|
-
*/
|
|
7
|
-
export declare function errorText(error: unknown): string;
|
|
8
|
-
/** A stable code of an error (hub, ws or errno code), or `fallback`. */
|
|
9
|
-
export declare function errorCode(error: unknown, fallback: string): string;
|
|
10
|
-
/**
|
|
11
|
-
* Logs a repeating event once per interval (D-0047): the first occurrence at once, the following
|
|
12
|
-
* ones of the same message and code (and `group`, when given) are counted and reported together
|
|
13
|
-
* (`repeated`) when the interval ends. `group` must come from a bounded set (e.g. registered
|
|
14
|
-
* handler types), never from client input, since each value keeps an entry for an interval. Hot paths (per message, per frame) can report every failure without a burst
|
|
15
|
-
* flooding the log. Field and error text are built only for lines that are written.
|
|
16
|
-
*/
|
|
17
|
-
export declare class LogThrottle {
|
|
18
|
-
#private;
|
|
19
|
-
readonly logger: Logger;
|
|
20
|
-
readonly intervalMs: number;
|
|
21
|
-
constructor(logger: Logger, intervalMs?: number);
|
|
22
|
-
report(level: LogLevel, message: string, code: string, error?: unknown, fields?: LogFields, group?: string): void;
|
|
23
|
-
/** Writes the counts still pending (on close), so a burst at shutdown is not lost. */
|
|
24
|
-
flush(): void;
|
|
25
|
-
}
|
package/dist/log.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
2
|
-
/**
|
|
3
|
-
* The full text of a caught value for the `error` log field (D-0047): name, whole message and
|
|
4
|
-
* stack, then each `cause` the same way. Only credentials in URLs (`//user:password@`) are
|
|
5
|
-
* removed; a text over 16 KB is cut and marked.
|
|
6
|
-
*/
|
|
7
|
-
export function errorText(error) {
|
|
8
|
-
const parts = [];
|
|
9
|
-
let current = error;
|
|
10
|
-
for (let depth = 0; current !== undefined && depth < 5; depth++) {
|
|
11
|
-
let text;
|
|
12
|
-
if (current instanceof Error)
|
|
13
|
-
text = current.stack ?? `${current.name}: ${current.message}`;
|
|
14
|
-
else if (typeof current === 'string')
|
|
15
|
-
text = current;
|
|
16
|
-
else {
|
|
17
|
-
try {
|
|
18
|
-
text = `NonError: ${JSON.stringify(current)}`;
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
text = `NonError: ${String(current)}`; // not serialisable (cycle, BigInt): its string form
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
parts.push(depth ? `Caused by: ${text}` : text);
|
|
25
|
-
current = current instanceof Error ? current.cause : undefined;
|
|
26
|
-
}
|
|
27
|
-
const text = parts.join('\n').replace(/\/\/[^\s/@]*@/g, '//***@');
|
|
28
|
-
return text.length > 16_384
|
|
29
|
-
? `${text.slice(0, 16_384)}… [truncated ${text.length - 16_384} characters]`
|
|
30
|
-
: text;
|
|
31
|
-
}
|
|
32
|
-
/** A stable code of an error (hub, ws or errno code), or `fallback`. */
|
|
33
|
-
export function errorCode(error, fallback) {
|
|
34
|
-
const code = error?.code;
|
|
35
|
-
return typeof code === 'string' && CODE.test(code) ? code : fallback;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Logs a repeating event once per interval (D-0047): the first occurrence at once, the following
|
|
39
|
-
* ones of the same message and code (and `group`, when given) are counted and reported together
|
|
40
|
-
* (`repeated`) when the interval ends. `group` must come from a bounded set (e.g. registered
|
|
41
|
-
* handler types), never from client input, since each value keeps an entry for an interval. Hot paths (per message, per frame) can report every failure without a burst
|
|
42
|
-
* flooding the log. Field and error text are built only for lines that are written.
|
|
43
|
-
*/
|
|
44
|
-
export class LogThrottle {
|
|
45
|
-
logger;
|
|
46
|
-
intervalMs;
|
|
47
|
-
#entries = new Map();
|
|
48
|
-
constructor(logger, intervalMs = 10_000) {
|
|
49
|
-
this.logger = logger;
|
|
50
|
-
this.intervalMs = intervalMs;
|
|
51
|
-
}
|
|
52
|
-
report(level, message, code, error, fields, group) {
|
|
53
|
-
if (!this.logger.isEnabled(level))
|
|
54
|
-
return;
|
|
55
|
-
const key = `${level}\u0000${code}\u0000${message}${group === undefined ? '' : `\u0000${group}`}`;
|
|
56
|
-
const entry = this.#entries.get(key);
|
|
57
|
-
if (entry) {
|
|
58
|
-
entry.count++;
|
|
59
|
-
entry.error = error;
|
|
60
|
-
entry.fields = fields;
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
this.#write(level, message, code, error, fields, 0);
|
|
64
|
-
const value = {
|
|
65
|
-
count: 0,
|
|
66
|
-
error: undefined,
|
|
67
|
-
fields: undefined,
|
|
68
|
-
timer: setTimeout(() => this.#elapsed(key, value, level, message, code), this.intervalMs),
|
|
69
|
-
};
|
|
70
|
-
value.timer.unref();
|
|
71
|
-
this.#entries.set(key, value);
|
|
72
|
-
}
|
|
73
|
-
#elapsed(key, entry, level, message, code) {
|
|
74
|
-
if (!entry.count) {
|
|
75
|
-
this.#entries.delete(key);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
this.#write(level, message, code, entry.error, entry.fields, entry.count);
|
|
79
|
-
entry.count = 0;
|
|
80
|
-
entry.timer = setTimeout(() => this.#elapsed(key, entry, level, message, code), this.intervalMs);
|
|
81
|
-
entry.timer.unref();
|
|
82
|
-
}
|
|
83
|
-
#write(level, message, code, error, fields, repeated) {
|
|
84
|
-
this.logger.log(level, message, {
|
|
85
|
-
...fields,
|
|
86
|
-
code,
|
|
87
|
-
...(error === undefined ? {} : { error: errorText(error) }),
|
|
88
|
-
...(repeated ? { repeated, intervalMs: this.intervalMs } : {}),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
/** Writes the counts still pending (on close), so a burst at shutdown is not lost. */
|
|
92
|
-
flush() {
|
|
93
|
-
for (const [key, entry] of this.#entries) {
|
|
94
|
-
clearTimeout(entry.timer);
|
|
95
|
-
if (entry.count) {
|
|
96
|
-
const [level, code, message] = key.split('\u0000');
|
|
97
|
-
this.#write(level, message, code, entry.error, entry.fields, entry.count);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
this.#entries.clear();
|
|
101
|
-
}
|
|
102
|
-
}
|