@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 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
+ }
@@ -0,0 +1,4 @@
1
+ import type { Module } from "@tymber/core";
2
+ import { SSEService } from "./services/SSEService.js";
3
+ export declare const SSEModule: Module;
4
+ export { SSEService };
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-alpha.0",
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
+ }