@tymber/sse 0.0.1-alpha.0 → 0.0.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/LICENSE +22 -0
- package/dist/endpoints/SSEEndpoint.d.ts +17 -0
- package/dist/endpoints/SSEEndpoint.js +112 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/services/SSEService.d.ts +52 -0
- package/dist/services/SSEService.js +69 -0
- package/package.json +33 -2
- package/src/endpoints/SSEEndpoint.ts +151 -0
- package/src/index.ts +16 -0
- package/src/services/SSEService.ts +126 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
(The MIT License)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Damien ARRACHEQUESNE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
'Software'), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
20
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
21
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
22
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ConfigService, type HttpContext, INJECT, UserEndpoint } from "@tymber/core";
|
|
2
|
+
import { SSEService } from "../services/SSEService.js";
|
|
3
|
+
/**
|
|
4
|
+
* Endpoint responsible for managing Server-Sent Events (SSE) connections from authenticated users.
|
|
5
|
+
*
|
|
6
|
+
* Reference: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
7
|
+
*/
|
|
8
|
+
export declare class SSEEndpoint extends UserEndpoint {
|
|
9
|
+
static [INJECT]: (typeof SSEService | typeof ConfigService)[];
|
|
10
|
+
private timerId?;
|
|
11
|
+
private clients;
|
|
12
|
+
constructor(sse: SSEService, configService: ConfigService);
|
|
13
|
+
private schedulePing;
|
|
14
|
+
private send;
|
|
15
|
+
close(): void | Promise<void>;
|
|
16
|
+
protected handle(ctx: HttpContext): import("undici-types").Response;
|
|
17
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ConfigService, INJECT, randomUUID, UserEndpoint, } from "@tymber/core";
|
|
2
|
+
import { SSEService } from "../services/SSEService.js";
|
|
3
|
+
import {} from "node:stream/web";
|
|
4
|
+
function isIncluded(filter, user) {
|
|
5
|
+
switch (filter.type) {
|
|
6
|
+
case "all":
|
|
7
|
+
return true;
|
|
8
|
+
case "user":
|
|
9
|
+
return user.id === filter.data.userId;
|
|
10
|
+
case "role":
|
|
11
|
+
return user.roles.includes(filter.data.role);
|
|
12
|
+
case "group":
|
|
13
|
+
return user.groups.some((group) => group.id === filter.data.groupId);
|
|
14
|
+
case "group-role":
|
|
15
|
+
return user.groups.some((group) => group.id === filter.data.groupId && group.role === filter.data.role);
|
|
16
|
+
default:
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Endpoint responsible for managing Server-Sent Events (SSE) connections from authenticated users.
|
|
22
|
+
*
|
|
23
|
+
* Reference: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
24
|
+
*/
|
|
25
|
+
export class SSEEndpoint extends UserEndpoint {
|
|
26
|
+
static [INJECT] = [SSEService, ConfigService];
|
|
27
|
+
timerId;
|
|
28
|
+
clients = new Map();
|
|
29
|
+
constructor(sse, configService) {
|
|
30
|
+
super();
|
|
31
|
+
configService.subscribe({
|
|
32
|
+
SSE_PING_INTERVAL_IN_SECONDS: {
|
|
33
|
+
type: "number",
|
|
34
|
+
minimum: 1,
|
|
35
|
+
default: 30,
|
|
36
|
+
},
|
|
37
|
+
}, (config) => {
|
|
38
|
+
this.schedulePing(config.SSE_PING_INTERVAL_IN_SECONDS);
|
|
39
|
+
});
|
|
40
|
+
sse.onEvent((event) => {
|
|
41
|
+
// note: no Last-Event-ID support
|
|
42
|
+
const payload = `event: ${event.type}\ndata: ${event.data !== undefined ? JSON.stringify(event.data) : ""}\n\n`;
|
|
43
|
+
for (const [id, client] of this.clients.entries()) {
|
|
44
|
+
if (isIncluded(event.filter, client.user)) {
|
|
45
|
+
this.send(id, client, payload);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
schedulePing(intervalInSeconds) {
|
|
51
|
+
if (this.timerId) {
|
|
52
|
+
clearInterval(this.timerId);
|
|
53
|
+
}
|
|
54
|
+
this.timerId = setInterval(() => {
|
|
55
|
+
for (const [id, client] of this.clients.entries()) {
|
|
56
|
+
if (client.lastMessage + intervalInSeconds * 1000 < Date.now()) {
|
|
57
|
+
this.send(id, client, ": ping\n\n");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, intervalInSeconds * 1000);
|
|
61
|
+
}
|
|
62
|
+
send(id, client, payload) {
|
|
63
|
+
try {
|
|
64
|
+
client.controller.enqueue(payload);
|
|
65
|
+
client.lastMessage = Date.now();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
this.clients.delete(id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
close() {
|
|
72
|
+
if (this.timerId) {
|
|
73
|
+
clearInterval(this.timerId);
|
|
74
|
+
}
|
|
75
|
+
for (const [_, client] of this.clients.entries()) {
|
|
76
|
+
try {
|
|
77
|
+
client.controller.close();
|
|
78
|
+
}
|
|
79
|
+
catch (e) { }
|
|
80
|
+
}
|
|
81
|
+
this.clients.clear();
|
|
82
|
+
}
|
|
83
|
+
handle(ctx) {
|
|
84
|
+
const id = randomUUID();
|
|
85
|
+
const stream = new ReadableStream({
|
|
86
|
+
start: (controller) => {
|
|
87
|
+
const client = {
|
|
88
|
+
controller,
|
|
89
|
+
user: ctx.user,
|
|
90
|
+
lastMessage: Date.now(),
|
|
91
|
+
};
|
|
92
|
+
this.clients.set(id, client);
|
|
93
|
+
ctx.signal.addEventListener("abort", () => {
|
|
94
|
+
try {
|
|
95
|
+
controller.close();
|
|
96
|
+
}
|
|
97
|
+
catch { }
|
|
98
|
+
this.clients.delete(id);
|
|
99
|
+
});
|
|
100
|
+
// comment line used to ensure the connection is functional (will not fire on the client)
|
|
101
|
+
this.send(id, client, ": connected\n\n");
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return new Response(stream, {
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "text/event-stream",
|
|
107
|
+
"cache-control": "no-cache",
|
|
108
|
+
connection: "keep-alive",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SSEService } from "./services/SSEService.js";
|
|
2
|
+
import { SSEEndpoint } from "./endpoints/SSEEndpoint.js";
|
|
3
|
+
export const SSEModule = {
|
|
4
|
+
name: "@tymber/sse",
|
|
5
|
+
version: "0.0.1",
|
|
6
|
+
init(app) {
|
|
7
|
+
app.component(SSEService);
|
|
8
|
+
app.userEndpoint("GET", "/api/events", SSEEndpoint);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
export { SSEService };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Component, type Context, EventEmitter, type GroupId, type GroupRole, INJECT, PubSubService, type UserId, type UserRole } from "@tymber/core";
|
|
2
|
+
export type Filter = {
|
|
3
|
+
type: "all";
|
|
4
|
+
} | {
|
|
5
|
+
type: "user";
|
|
6
|
+
data: {
|
|
7
|
+
userId: UserId;
|
|
8
|
+
};
|
|
9
|
+
} | {
|
|
10
|
+
type: "role";
|
|
11
|
+
data: {
|
|
12
|
+
role: UserRole;
|
|
13
|
+
};
|
|
14
|
+
} | {
|
|
15
|
+
type: "group";
|
|
16
|
+
data: {
|
|
17
|
+
groupId: GroupId;
|
|
18
|
+
};
|
|
19
|
+
} | {
|
|
20
|
+
type: "group-role";
|
|
21
|
+
data: {
|
|
22
|
+
groupId: GroupId;
|
|
23
|
+
role: GroupRole;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export interface ServerSentEvent {
|
|
27
|
+
type: string;
|
|
28
|
+
data: unknown;
|
|
29
|
+
filter: Filter;
|
|
30
|
+
}
|
|
31
|
+
export declare class SSEService extends Component {
|
|
32
|
+
private readonly pubSubService;
|
|
33
|
+
static [INJECT]: (typeof PubSubService)[];
|
|
34
|
+
private emitter;
|
|
35
|
+
constructor(pubSubService: PubSubService);
|
|
36
|
+
onEvent(listener: (event: ServerSentEvent) => void): void;
|
|
37
|
+
toUser(userId: UserId): SSEPublisher;
|
|
38
|
+
toUsersWithRole(role: UserRole): SSEPublisher;
|
|
39
|
+
toGroup(groupId: GroupId): SSEPublisher;
|
|
40
|
+
toGroupWithRole(groupId: GroupId, role: GroupRole): SSEPublisher;
|
|
41
|
+
publish(ctx: Context, type: string, data?: unknown): void;
|
|
42
|
+
}
|
|
43
|
+
declare class SSEPublisher {
|
|
44
|
+
private readonly emitter;
|
|
45
|
+
private readonly pubSubService;
|
|
46
|
+
private readonly filter;
|
|
47
|
+
constructor(emitter: EventEmitter<{
|
|
48
|
+
sse: ServerSentEvent;
|
|
49
|
+
}>, pubSubService: PubSubService, filter: Filter);
|
|
50
|
+
publish(ctx: Context, type: string, data?: unknown): void;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Component, EventEmitter, INJECT, PubSubService, } from "@tymber/core";
|
|
2
|
+
export class SSEService extends Component {
|
|
3
|
+
pubSubService;
|
|
4
|
+
static [INJECT] = [PubSubService];
|
|
5
|
+
emitter = new EventEmitter();
|
|
6
|
+
constructor(pubSubService) {
|
|
7
|
+
super();
|
|
8
|
+
this.pubSubService = pubSubService;
|
|
9
|
+
pubSubService.subscribe("sse", (event) => {
|
|
10
|
+
this.emitter.emit("sse", event);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
onEvent(listener) {
|
|
14
|
+
this.emitter.on("sse", listener);
|
|
15
|
+
}
|
|
16
|
+
toUser(userId) {
|
|
17
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
18
|
+
type: "user",
|
|
19
|
+
data: { userId },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
toUsersWithRole(role) {
|
|
23
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
24
|
+
type: "role",
|
|
25
|
+
data: { role },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
toGroup(groupId) {
|
|
29
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
30
|
+
type: "group",
|
|
31
|
+
data: { groupId },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
toGroupWithRole(groupId, role) {
|
|
35
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
36
|
+
type: "group-role",
|
|
37
|
+
data: { groupId, role },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
publish(ctx, type, data) {
|
|
41
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
42
|
+
type: "all",
|
|
43
|
+
}).publish(ctx, type, data);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
class SSEPublisher {
|
|
47
|
+
emitter;
|
|
48
|
+
pubSubService;
|
|
49
|
+
filter;
|
|
50
|
+
constructor(emitter, pubSubService, filter) {
|
|
51
|
+
this.emitter = emitter;
|
|
52
|
+
this.pubSubService = pubSubService;
|
|
53
|
+
this.filter = filter;
|
|
54
|
+
}
|
|
55
|
+
publish(ctx, type, data) {
|
|
56
|
+
// notify connected clients on this instance
|
|
57
|
+
this.emitter.emit("sse", {
|
|
58
|
+
type,
|
|
59
|
+
data,
|
|
60
|
+
filter: this.filter,
|
|
61
|
+
});
|
|
62
|
+
// notify other instances
|
|
63
|
+
this.pubSubService.publish(ctx, "sse", {
|
|
64
|
+
type,
|
|
65
|
+
data,
|
|
66
|
+
filter: this.filter,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tymber/sse",
|
|
3
|
-
"version": "0.0.1
|
|
3
|
+
"version": "0.0.1",
|
|
4
4
|
"description": "Server-sent events module for the Tymber framework",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"author": "Damien ARRACHEQUESNE"
|
|
6
|
+
"author": "Damien ARRACHEQUESNE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tymber-framework/tymber.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"compile": "rm -rf dist/ && tsc",
|
|
16
|
+
"format:check": "prettier -c src/ test/",
|
|
17
|
+
"format:fix": "prettier -w src/ test/",
|
|
18
|
+
"test": "tsx --test"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@tymber/core": "*"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src/",
|
|
25
|
+
"dist/"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
"node": "./dist/index.js",
|
|
29
|
+
"bun": "./src/index.ts",
|
|
30
|
+
"deno": "./src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"tymber",
|
|
34
|
+
"typescript",
|
|
35
|
+
"framework",
|
|
36
|
+
"sse"
|
|
37
|
+
]
|
|
7
38
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigService,
|
|
3
|
+
type ConnectedUser,
|
|
4
|
+
type HttpContext,
|
|
5
|
+
INJECT,
|
|
6
|
+
randomUUID,
|
|
7
|
+
UserEndpoint,
|
|
8
|
+
} from "@tymber/core";
|
|
9
|
+
import { type Filter, SSEService } from "../services/SSEService.js";
|
|
10
|
+
import { type ReadableStreamController } from "node:stream/web";
|
|
11
|
+
|
|
12
|
+
interface Config {
|
|
13
|
+
SSE_PING_INTERVAL_IN_SECONDS: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SSEClient {
|
|
17
|
+
controller: ReadableStreamController<string>;
|
|
18
|
+
lastMessage: number;
|
|
19
|
+
user: ConnectedUser;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isIncluded(filter: Filter, user: ConnectedUser) {
|
|
23
|
+
switch (filter.type) {
|
|
24
|
+
case "all":
|
|
25
|
+
return true;
|
|
26
|
+
case "user":
|
|
27
|
+
return user.id === filter.data.userId;
|
|
28
|
+
case "role":
|
|
29
|
+
return user.roles.includes(filter.data.role);
|
|
30
|
+
case "group":
|
|
31
|
+
return user.groups.some((group) => group.id === filter.data.groupId);
|
|
32
|
+
case "group-role":
|
|
33
|
+
return user.groups.some(
|
|
34
|
+
(group) =>
|
|
35
|
+
group.id === filter.data.groupId && group.role === filter.data.role,
|
|
36
|
+
);
|
|
37
|
+
default:
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Endpoint responsible for managing Server-Sent Events (SSE) connections from authenticated users.
|
|
44
|
+
*
|
|
45
|
+
* Reference: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
46
|
+
*/
|
|
47
|
+
export class SSEEndpoint extends UserEndpoint {
|
|
48
|
+
static [INJECT] = [SSEService, ConfigService];
|
|
49
|
+
|
|
50
|
+
private timerId?: ReturnType<typeof setInterval>;
|
|
51
|
+
private clients = new Map<string, SSEClient>();
|
|
52
|
+
|
|
53
|
+
constructor(sse: SSEService, configService: ConfigService) {
|
|
54
|
+
super();
|
|
55
|
+
|
|
56
|
+
configService.subscribe<Config>(
|
|
57
|
+
{
|
|
58
|
+
SSE_PING_INTERVAL_IN_SECONDS: {
|
|
59
|
+
type: "number",
|
|
60
|
+
minimum: 1,
|
|
61
|
+
default: 30,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
(config) => {
|
|
65
|
+
this.schedulePing(config.SSE_PING_INTERVAL_IN_SECONDS);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
sse.onEvent((event) => {
|
|
70
|
+
// note: no Last-Event-ID support
|
|
71
|
+
const payload = `event: ${event.type}\ndata: ${event.data !== undefined ? JSON.stringify(event.data) : ""}\n\n`;
|
|
72
|
+
|
|
73
|
+
for (const [id, client] of this.clients.entries()) {
|
|
74
|
+
if (isIncluded(event.filter, client.user)) {
|
|
75
|
+
this.send(id, client, payload);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private schedulePing(intervalInSeconds: number) {
|
|
82
|
+
if (this.timerId) {
|
|
83
|
+
clearInterval(this.timerId);
|
|
84
|
+
}
|
|
85
|
+
this.timerId = setInterval(() => {
|
|
86
|
+
for (const [id, client] of this.clients.entries()) {
|
|
87
|
+
if (client.lastMessage + intervalInSeconds * 1000 < Date.now()) {
|
|
88
|
+
this.send(id, client, ": ping\n\n");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}, intervalInSeconds * 1000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private send(id: string, client: SSEClient, payload: string) {
|
|
95
|
+
try {
|
|
96
|
+
client.controller.enqueue(payload);
|
|
97
|
+
client.lastMessage = Date.now();
|
|
98
|
+
} catch {
|
|
99
|
+
this.clients.delete(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override close(): void | Promise<void> {
|
|
104
|
+
if (this.timerId) {
|
|
105
|
+
clearInterval(this.timerId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const [_, client] of this.clients.entries()) {
|
|
109
|
+
try {
|
|
110
|
+
client.controller.close();
|
|
111
|
+
} catch (e) {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.clients.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
protected handle(ctx: HttpContext) {
|
|
118
|
+
const id = randomUUID();
|
|
119
|
+
|
|
120
|
+
const stream = new ReadableStream({
|
|
121
|
+
start: (controller) => {
|
|
122
|
+
const client: SSEClient = {
|
|
123
|
+
controller,
|
|
124
|
+
user: ctx.user as ConnectedUser,
|
|
125
|
+
lastMessage: Date.now(),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
this.clients.set(id, client);
|
|
129
|
+
|
|
130
|
+
ctx.signal.addEventListener("abort", () => {
|
|
131
|
+
try {
|
|
132
|
+
controller.close();
|
|
133
|
+
} catch {}
|
|
134
|
+
|
|
135
|
+
this.clients.delete(id);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// comment line used to ensure the connection is functional (will not fire on the client)
|
|
139
|
+
this.send(id, client, ": connected\n\n");
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return new Response(stream, {
|
|
144
|
+
headers: {
|
|
145
|
+
"content-type": "text/event-stream",
|
|
146
|
+
"cache-control": "no-cache",
|
|
147
|
+
connection: "keep-alive",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Module } from "@tymber/core";
|
|
2
|
+
import { SSEService } from "./services/SSEService.js";
|
|
3
|
+
import { SSEEndpoint } from "./endpoints/SSEEndpoint.js";
|
|
4
|
+
|
|
5
|
+
export const SSEModule: Module = {
|
|
6
|
+
name: "@tymber/sse",
|
|
7
|
+
version: "0.0.1",
|
|
8
|
+
|
|
9
|
+
init(app) {
|
|
10
|
+
app.component(SSEService);
|
|
11
|
+
|
|
12
|
+
app.userEndpoint("GET", "/api/events", SSEEndpoint);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { SSEService };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
type Context,
|
|
4
|
+
EventEmitter,
|
|
5
|
+
type GroupId,
|
|
6
|
+
type GroupRole,
|
|
7
|
+
INJECT,
|
|
8
|
+
PubSubService,
|
|
9
|
+
type UserId,
|
|
10
|
+
type UserRole,
|
|
11
|
+
} from "@tymber/core";
|
|
12
|
+
|
|
13
|
+
export type Filter =
|
|
14
|
+
| {
|
|
15
|
+
type: "all";
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: "user";
|
|
19
|
+
data: {
|
|
20
|
+
userId: UserId;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
type: "role";
|
|
25
|
+
data: {
|
|
26
|
+
role: UserRole;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: "group";
|
|
31
|
+
data: {
|
|
32
|
+
groupId: GroupId;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
type: "group-role";
|
|
37
|
+
data: {
|
|
38
|
+
groupId: GroupId;
|
|
39
|
+
role: GroupRole;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ServerSentEvent {
|
|
44
|
+
type: string;
|
|
45
|
+
data: unknown;
|
|
46
|
+
filter: Filter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SSEService extends Component {
|
|
50
|
+
static override [INJECT] = [PubSubService];
|
|
51
|
+
|
|
52
|
+
private emitter = new EventEmitter<{
|
|
53
|
+
sse: ServerSentEvent;
|
|
54
|
+
}>();
|
|
55
|
+
|
|
56
|
+
constructor(private readonly pubSubService: PubSubService) {
|
|
57
|
+
super();
|
|
58
|
+
pubSubService.subscribe("sse", (event: ServerSentEvent) => {
|
|
59
|
+
this.emitter.emit("sse", event);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onEvent(listener: (event: ServerSentEvent) => void) {
|
|
64
|
+
this.emitter.on("sse", listener);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toUser(userId: UserId) {
|
|
68
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
69
|
+
type: "user",
|
|
70
|
+
data: { userId },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toUsersWithRole(role: UserRole) {
|
|
75
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
76
|
+
type: "role",
|
|
77
|
+
data: { role },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
toGroup(groupId: GroupId) {
|
|
82
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
83
|
+
type: "group",
|
|
84
|
+
data: { groupId },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
toGroupWithRole(groupId: GroupId, role: GroupRole) {
|
|
89
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
90
|
+
type: "group-role",
|
|
91
|
+
data: { groupId, role },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public publish(ctx: Context, type: string, data?: unknown) {
|
|
96
|
+
return new SSEPublisher(this.emitter, this.pubSubService, {
|
|
97
|
+
type: "all",
|
|
98
|
+
}).publish(ctx, type, data);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class SSEPublisher {
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly emitter: EventEmitter<{
|
|
105
|
+
sse: ServerSentEvent;
|
|
106
|
+
}>,
|
|
107
|
+
private readonly pubSubService: PubSubService,
|
|
108
|
+
private readonly filter: Filter,
|
|
109
|
+
) {}
|
|
110
|
+
|
|
111
|
+
public publish(ctx: Context, type: string, data?: unknown) {
|
|
112
|
+
// notify connected clients on this instance
|
|
113
|
+
this.emitter.emit("sse", {
|
|
114
|
+
type,
|
|
115
|
+
data,
|
|
116
|
+
filter: this.filter,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// notify other instances
|
|
120
|
+
this.pubSubService.publish(ctx, "sse", {
|
|
121
|
+
type,
|
|
122
|
+
data,
|
|
123
|
+
filter: this.filter,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|