@lazyapps/change-notifier-socket-io 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, Oliver Sturm
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @lazyapps/change-notifier-socket-io
2
+
3
+ Socket.io-based change notification broadcaster. Pushes real-time read model change notifications to connected clients via WebSockets.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @lazyapps/change-notifier-socket-io
9
+ ```
10
+
11
+ ## Part of LazyApps
12
+
13
+ This package is part of the [LazyApps](https://github.com/oliversturm/lazyapps-libs) event-sourcing and CQRS framework.
package/express.js ADDED
@@ -0,0 +1,126 @@
1
+ import express from 'express';
2
+ import bodyParser from 'body-parser';
3
+ import morgan from 'morgan';
4
+ import cors from 'cors';
5
+ import cookieParser from 'cookie-parser';
6
+ import { expressjwt } from 'express-jwt';
7
+ import { socketIoCookieJwt } from './socketIoCookieJwt.js';
8
+ import { Server as SocketIoServer } from 'socket.io';
9
+ import http from 'http';
10
+ import { nanoid } from 'nanoid';
11
+
12
+ import { getLogger, getStream } from '@lazyapps/logger';
13
+ import { initSockets, createNotifier } from './notifier.js';
14
+
15
+ const log = getLogger('Changes/HTTP', 'INIT');
16
+
17
+ const correlationId = (correlationConfig) => (req, res, next) => {
18
+ // check where a correlation Id might already exist
19
+ const existingId = req.body.correlationId || req.headers['x-correlation-id'];
20
+
21
+ // since we want to use it in code, make sure the body
22
+ // now has an id in any case
23
+ req.body.correlationId =
24
+ existingId || `${correlationConfig?.serviceId || 'UNK'}-${nanoid()}`;
25
+
26
+ // also in the result, not needed right now but can't hurt
27
+ // for debugging
28
+ res.setHeader('X-Correlation-ID', req.body.correlationId);
29
+ next();
30
+ };
31
+
32
+ morgan.token('correlation-id', function (req) {
33
+ return req.body.correlationId;
34
+ });
35
+
36
+ const runExpress = (
37
+ correlationConfig,
38
+ {
39
+ port = 3008,
40
+ host = '0.0.0.0',
41
+ jwtSecret,
42
+ authCookieName,
43
+ credentialsRequired,
44
+ ioAuthHandler,
45
+ changeInfoAuthHandler,
46
+ },
47
+ ) => {
48
+ return new Promise((resolve, reject) => {
49
+ const app = express();
50
+ app.use(cors());
51
+ app.use(bodyParser.json());
52
+ app.use(correlationId(correlationConfig));
53
+ app.use(
54
+ morgan(
55
+ '[:correlation-id] :method :url :status :response-time ms - :res[content-length]',
56
+ { stream: getStream(log.debugBare) },
57
+ ),
58
+ );
59
+ app.use(cookieParser());
60
+
61
+ // Similar code as in express/runExpress.js -- refactor?
62
+ if (jwtSecret) {
63
+ app.use(
64
+ expressjwt({
65
+ secret: jwtSecret,
66
+ algorithms: ['HS256'],
67
+ credentialsRequired: credentialsRequired || false,
68
+ getToken: (req) => {
69
+ // check Authorization header first
70
+ if (
71
+ req.headers.authorization &&
72
+ req.headers.authorization.split(' ')[0] === 'Bearer'
73
+ ) {
74
+ return req.headers.authorization.split(' ')[1];
75
+ }
76
+ // consider cookie if a name has been given
77
+ if (authCookieName) {
78
+ const token = req.cookies[authCookieName || 'access_token'];
79
+ if (token) {
80
+ return token;
81
+ }
82
+ }
83
+ return null;
84
+ },
85
+ }),
86
+ );
87
+ }
88
+
89
+ const server = http.createServer(app);
90
+ const io = new SocketIoServer(server, {
91
+ cors: { origin: true },
92
+ });
93
+ io.use(socketIoCookieJwt({ jwtSecret, cookieName: authCookieName }));
94
+ initSockets(
95
+ correlationConfig,
96
+ io,
97
+ jwtSecret && ioAuthHandler ? ioAuthHandler : () => true,
98
+ );
99
+ const notifier = createNotifier(
100
+ io,
101
+ jwtSecret && changeInfoAuthHandler ? changeInfoAuthHandler : () => true,
102
+ );
103
+ app.post('/change', notifier);
104
+
105
+ server.listen(port, host);
106
+ server.on('listening', () => {
107
+ resolve(server);
108
+ });
109
+ server.on('error', reject);
110
+ })
111
+ .catch((err) => {
112
+ log.error(`Can't run HTTP server: ${err}`);
113
+ })
114
+ .then((server) => {
115
+ log.info(
116
+ `HTTP API listening on port ${port}, ${
117
+ jwtSecret && credentialsRequired ? 'requiring ' : 'checking for '
118
+ } a JWT Bearer token${
119
+ authCookieName ? ` or a cookie named ${authCookieName}` : ''
120
+ }`,
121
+ );
122
+ return server;
123
+ });
124
+ };
125
+
126
+ export { runExpress };
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { runExpress } from './express.js';
2
+
3
+ export const express = (config) => (correlationConfig) =>
4
+ runExpress(correlationConfig, config);
package/notifier.js ADDED
@@ -0,0 +1,92 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+ import { nanoid } from 'nanoid';
3
+
4
+ const getRoomName = (endpointName, readModelName, resolverName) =>
5
+ `${endpointName}/${readModelName}/${resolverName}`;
6
+
7
+ const ioInitLog = getLogger('Changes/IO', 'INIT');
8
+
9
+ export const initSockets = (correlationConfig, io, ioAuthHandler) => {
10
+ ioInitLog.debug('Initializing sockets');
11
+ io.on('connect', (socket) => {
12
+ const existingId = socket.handshake.query?.correlationId;
13
+ socket.correlationId =
14
+ existingId || `${correlationConfig?.serviceId || 'UNK'}-${nanoid()}`;
15
+
16
+ const ioLog = getLogger('Changes/IO', socket.correlationId);
17
+ ioLog.debug(
18
+ `Connection: ${socket.id} (handshake: ${JSON.stringify(
19
+ socket.handshake,
20
+ )})${
21
+ socket.decoded_token
22
+ ? ` (JWT: ${JSON.stringify(socket.decoded_token)})`
23
+ : ' (no JWT)'
24
+ }`,
25
+ );
26
+
27
+ socket.on('disconnect', (reason) => {
28
+ ioLog.debug(`Disconnected ${socket.id}, reason: ${reason}`);
29
+ });
30
+
31
+ socket.on('error', (error) => {
32
+ ioLog.debug(`Communication error with ${socket.id}: ${error}`);
33
+ });
34
+
35
+ socket.on('register', (resolvers) => {
36
+ try {
37
+ if (!ioAuthHandler(socket.decoded_token, resolvers)) {
38
+ ioLog.error(
39
+ `Unauthorized register ${JSON.stringify(resolvers)} (claims ${
40
+ socket.decoded_token
41
+ })`,
42
+ );
43
+ socket.disconnect();
44
+ return;
45
+ }
46
+ socket.join(
47
+ resolvers.map(({ endpointName, readModelName, resolverName }) =>
48
+ getRoomName(endpointName, readModelName, resolverName),
49
+ ),
50
+ );
51
+ ioLog.debug(`Registered ${socket.id} for ${JSON.stringify(resolvers)}`);
52
+ } catch (err) {
53
+ ioLog.error(
54
+ `Can't register ${socket.id} for ${JSON.stringify(
55
+ resolvers,
56
+ )}: ${err}`,
57
+ );
58
+ }
59
+ });
60
+ });
61
+ };
62
+
63
+ export const createNotifier = (io, changeInfoAuthHandler) => {
64
+ const handler = (req, res) => {
65
+ const auth = req.auth;
66
+ const rmLog = getLogger('Changes/RM', req.body.correlationId);
67
+
68
+ if (!changeInfoAuthHandler(auth)) {
69
+ rmLog.error(
70
+ `Unauthorized changeInfo ${JSON.stringify(req.body)} (claims ${auth})`,
71
+ );
72
+ res.sendStatus(403);
73
+ return;
74
+ }
75
+
76
+ const changeInfo = req.body;
77
+ try {
78
+ const { endpointName, readModelName, resolverName } = changeInfo;
79
+ const roomName = getRoomName(endpointName, readModelName, resolverName);
80
+ io.to(roomName).emit('change', changeInfo);
81
+ rmLog.debug(`Forwarded changeInfo ${JSON.stringify(changeInfo)}`);
82
+ res.sendStatus(200);
83
+ } catch (err) {
84
+ rmLog.error(
85
+ `Can't forward changeInfo ${JSON.stringify(changeInfo)}: ${err}`,
86
+ );
87
+ res.sendStatus(500);
88
+ }
89
+ };
90
+
91
+ return handler;
92
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@lazyapps/change-notifier-socket-io",
3
+ "version": "0.1.0",
4
+ "description": "Socket.io-based change notification broadcaster for LazyApps",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "files": [
10
+ "*.js"
11
+ ],
12
+ "keywords": [
13
+ "event-sourcing",
14
+ "cqrs",
15
+ "lazyapps",
16
+ "change-notification",
17
+ "socket.io",
18
+ "websocket"
19
+ ],
20
+ "author": "Oliver Sturm",
21
+ "license": "ISC",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/oliversturm/lazyapps-libs.git",
25
+ "directory": "packages/change-notifier-socket-io"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/oliversturm/lazyapps-libs/issues"
29
+ },
30
+ "homepage": "https://github.com/oliversturm/lazyapps-libs/tree/main/packages/change-notifier-socket-io#readme",
31
+ "engines": {
32
+ "node": ">=18.20.3 || >=20.18.0"
33
+ },
34
+ "devDependencies": {
35
+ "eslint": "^8.46.0",
36
+ "vitest": "^4.0.18"
37
+ },
38
+ "type": "module",
39
+ "dependencies": {
40
+ "body-parser": "^1.20.2",
41
+ "cookie": "^0.5.0",
42
+ "cookie-parser": "^1.4.6",
43
+ "cors": "^2.8.5",
44
+ "express": "^4.18.2",
45
+ "jsonwebtoken": "^9.0.2",
46
+ "morgan": "^1.10.0",
47
+ "nanoid": "^5.0.7",
48
+ "socket.io": "^4.7.2",
49
+ "socketio-jwt": "^4.6.2",
50
+ "@lazyapps/logger": "^0.1.0"
51
+ },
52
+ "scripts": {
53
+ "test": "vitest"
54
+ }
55
+ }
@@ -0,0 +1,35 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import cookie from 'cookie';
3
+
4
+ const decodeToken = (jwtSecret, token) => {
5
+ try {
6
+ return jwt.verify(token, jwtSecret);
7
+ } catch (err) {
8
+ return null;
9
+ }
10
+ };
11
+
12
+ // Note that this middleware is written to extract the token
13
+ // if it can be found, but to simply proceed if it can't.
14
+
15
+ // Also note: the headers are always the ones from the first
16
+ // connection (https://github.com/socketio/socket.io/issues/2860#issuecomment-781411803) --
17
+ // so if I needed to refresh the token on the client, it would
18
+ // be necessary to reconnect to the server.
19
+ export const socketIoCookieJwt =
20
+ ({ cookieName = 'access_token', jwtSecret }) =>
21
+ (socket, next) => {
22
+ const token = socket.handshake.auth?.token;
23
+ if (token) {
24
+ socket.decoded_token = decodeToken(jwtSecret, token);
25
+ } else {
26
+ const cookieHeader = socket.handshake.headers.cookie;
27
+ if (cookieHeader) {
28
+ const cookies = cookie.parse(cookieHeader);
29
+ if (cookies[cookieName]) {
30
+ socket.decoded_token = decodeToken(jwtSecret, cookies[cookieName]);
31
+ }
32
+ }
33
+ }
34
+ next();
35
+ };