@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 +15 -0
- package/README.md +13 -0
- package/express.js +126 -0
- package/index.js +4 -0
- package/notifier.js +92 -0
- package/package.json +55 -0
- package/socketIoCookieJwt.js +35 -0
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
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
|
+
};
|