@rvoh/psychic-websockets 0.2.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 RVO Health
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ > ATTENTION: we are currently in the process of releasing this code to the world, as of the afternoon of March 10th, 2025. This notice will be removed, and the version of this repo will be bumped to 1.0.0, once all of the repos have been migrated to the new spaces and we can verify that it is all working. This is anticipated to take 1 day.
2
+
3
+ # Psychic websockets
4
+
5
+ Documentation for this repo can be found at [https://psychic-docs.netlify.app/docs/plugins/websockets/overview](https://psychic-docs.netlify.app/docs/plugins/websockets/overview)
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const psychic_1 = require("@rvoh/psychic");
4
+ const redis_adapter_1 = require("@socket.io/redis-adapter");
5
+ const colors = require("colorette");
6
+ const socketio = require("socket.io");
7
+ const MissingWsRedisConnection_js_1 = require("../error/ws/MissingWsRedisConnection.js");
8
+ const EnvInternal_js_1 = require("../helpers/EnvInternal.js");
9
+ const index_js_1 = require("../psychic-application-websockets/index.js");
10
+ class Cable {
11
+ app;
12
+ io;
13
+ httpServer;
14
+ config;
15
+ redisConnections = [];
16
+ constructor(app, config) {
17
+ this.app = app;
18
+ this.config = config;
19
+ }
20
+ connect() {
21
+ if (this.io)
22
+ return;
23
+ // for socket.io, we have to circumvent the normal process for starting a
24
+ // psychic server so that we can bind socket.io to the http instance.
25
+ this.httpServer = psychic_1.PsychicServer.createPsychicHttpInstance(this.app, this.config.psychicApp.sslCredentials);
26
+ this.io = new socketio.Server(this.httpServer, { cors: this.config.psychicApp.corsOptions });
27
+ }
28
+ async start(port, { withFrontEndClient = false, frontEndPort = 3000, } = {}) {
29
+ this.connect();
30
+ for (const hook of this.config.hooks.wsStart) {
31
+ await hook(this.io);
32
+ }
33
+ this.io.on('connect', async (socket) => {
34
+ try {
35
+ for (const hook of this.config.hooks.wsConnect) {
36
+ await hook(socket);
37
+ }
38
+ }
39
+ catch (error) {
40
+ if (EnvInternal_js_1.default.boolean('PSYCHIC_DANGEROUSLY_PERMIT_WS_EXCEPTIONS'))
41
+ throw error;
42
+ else {
43
+ ;
44
+ this.config.psychicApp.constructor.logWithLevel('error', `
45
+ An exception was caught in your websocket thread.
46
+ To prevent your server from crashing, we are rescuing this error here for you.
47
+ If you would like us to raise this exception, make sure to set
48
+
49
+ PSYCHIC_DANGEROUSLY_PERMIT_WS_EXCEPTIONS=1
50
+
51
+ the error received is:
52
+
53
+ ${error.message}
54
+ `);
55
+ console.trace();
56
+ }
57
+ }
58
+ });
59
+ this.bindToRedis();
60
+ const psychicAppWebsockets = index_js_1.default.getOrFail();
61
+ await this.listen({
62
+ port: parseInt((port || psychicAppWebsockets.psychicApp.port).toString()),
63
+ withFrontEndClient,
64
+ frontEndPort,
65
+ });
66
+ }
67
+ async stop() {
68
+ try {
69
+ await this.io?.close();
70
+ }
71
+ catch {
72
+ // noop
73
+ }
74
+ for (const connection of this.redisConnections) {
75
+ try {
76
+ connection.disconnect();
77
+ }
78
+ catch {
79
+ // noop
80
+ }
81
+ }
82
+ }
83
+ async listen({ port, withFrontEndClient, frontEndPort, }) {
84
+ return new Promise(accept => {
85
+ this.httpServer.listen(port, () => {
86
+ if (!EnvInternal_js_1.default.isTest) {
87
+ const app = index_js_1.default.getOrFail().psychicApp;
88
+ app.logger.info(psychic_1.PsychicServer.asciiLogo());
89
+ app.logger.info('\n');
90
+ app.logger.info(colors.cyan('socket server started '));
91
+ app.logger.info(colors.cyan(`psychic dev server started at port ${colors.bgBlueBright(colors.green(port))}`));
92
+ if (withFrontEndClient)
93
+ app.logger.info(`client server started at port ${colors.cyan(frontEndPort)}`);
94
+ app.logger.info('\n');
95
+ }
96
+ accept(true);
97
+ });
98
+ });
99
+ }
100
+ bindToRedis() {
101
+ const pubClient = this.config.websocketOptions.connection;
102
+ const subClient = this.config.websocketOptions.subConnection;
103
+ if (!pubClient || !subClient)
104
+ throw new MissingWsRedisConnection_js_1.default();
105
+ this.redisConnections.push(pubClient);
106
+ this.redisConnections.push(subClient);
107
+ pubClient.on('error', error => {
108
+ index_js_1.default.log('PUB CLIENT ERROR', error);
109
+ });
110
+ subClient.on('error', error => {
111
+ index_js_1.default.log('sub CLIENT ERROR', error);
112
+ });
113
+ try {
114
+ this.io.adapter((0, redis_adapter_1.createAdapter)(pubClient, subClient));
115
+ }
116
+ catch (error) {
117
+ index_js_1.default.log('FAILED TO ADAPT', error);
118
+ }
119
+ }
120
+ }
121
+ exports.default = Cable;
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = redisWsKey;
4
+ function redisWsKey(userId, redisKeyPrefix) {
5
+ return `${redisKeyPrefix}:${userId}:socket_ids`;
6
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InvalidWsPathError = void 0;
4
+ const dream_1 = require("@rvoh/dream");
5
+ const redis_emitter_1 = require("@socket.io/redis-emitter");
6
+ const luxon_1 = require("luxon");
7
+ const EnvInternal_js_1 = require("../helpers/EnvInternal.js");
8
+ const redisWsKey_js_1 = require("./redisWsKey.js");
9
+ const index_js_1 = require("../psychic-application-websockets/index.js");
10
+ class Ws {
11
+ allowedPaths;
12
+ io;
13
+ redisClient;
14
+ booted = false;
15
+ namespace;
16
+ redisKeyPrefix;
17
+ static async register(socket, id, redisKeyPrefix = 'user') {
18
+ const psychicWebsocketsApp = index_js_1.default.getOrFail();
19
+ const redisClient = psychicWebsocketsApp.websocketOptions.connection;
20
+ const interpretedId = id?.isDreamInstance ? id.primaryKeyValue : id;
21
+ const key = (0, redisWsKey_js_1.default)(interpretedId, redisKeyPrefix);
22
+ const socketIdsToKeep = await redisClient.lrange((0, redisWsKey_js_1.default)(interpretedId, redisKeyPrefix), -2, -1);
23
+ await redisClient
24
+ .multi()
25
+ .del(key)
26
+ .rpush(key, ...socketIdsToKeep, socket.id)
27
+ .expireat(key,
28
+ // TODO: make this configurable in non-test environments
29
+ luxon_1.DateTime.now()
30
+ .plus(EnvInternal_js_1.default.isTest ? { seconds: 15 } : { day: 1 })
31
+ .toSeconds())
32
+ .exec();
33
+ socket.on('disconnect', async () => {
34
+ await redisClient.lrem(key, 1, socket.id);
35
+ });
36
+ const ws = new Ws(['/ops/connection-success']);
37
+ await ws.emit(interpretedId, '/ops/connection-success', {
38
+ message: 'Successfully connected to psychic websockets',
39
+ });
40
+ }
41
+ constructor(allowedPaths, { namespace = '/', redisKeyPrefix = 'user', } = {}) {
42
+ this.allowedPaths = allowedPaths;
43
+ this.namespace = namespace;
44
+ this.redisKeyPrefix = redisKeyPrefix;
45
+ }
46
+ boot() {
47
+ if (this.booted)
48
+ return;
49
+ const psychicWebsocketsApp = index_js_1.default.getOrFail();
50
+ this.redisClient = psychicWebsocketsApp.websocketOptions.connection;
51
+ this.io = new redis_emitter_1.Emitter(this.redisClient).of(this.namespace);
52
+ this.booted = true;
53
+ }
54
+ async emit(id, path,
55
+ // eslint-disable-next-line
56
+ data = {}) {
57
+ if (this.allowedPaths.length && !this.allowedPaths.includes(path))
58
+ throw new InvalidWsPathError(path);
59
+ this.boot();
60
+ const socketIds = await this.findSocketIds(id?.isDreamInstance ? id.primaryKeyValue : id);
61
+ for (const socketId of socketIds) {
62
+ this.io.to(socketId).emit(path, data);
63
+ }
64
+ }
65
+ async findSocketIds(id) {
66
+ this.boot();
67
+ return (0, dream_1.uniq)(await this.redisClient.lrange(this.redisKey(id), 0, -1));
68
+ }
69
+ redisKey(userId) {
70
+ return (0, redisWsKey_js_1.default)(userId, this.redisKeyPrefix);
71
+ }
72
+ }
73
+ exports.default = Ws;
74
+ class InvalidWsPathError extends Error {
75
+ invalidPath;
76
+ constructor(invalidPath) {
77
+ super();
78
+ this.invalidPath = invalidPath;
79
+ }
80
+ get message() {
81
+ return `
82
+ Invalid path passed to Ws: "${this.invalidPath}"
83
+ `;
84
+ }
85
+ }
86
+ exports.InvalidWsPathError = InvalidWsPathError;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ class MissingWsRedisConnection extends Error {
4
+ get message() {
5
+ return `
6
+ No websocket redis connection was found, even though
7
+ the application is configured to establish websockets.
8
+
9
+ In conf/app.ts, either:
10
+
11
+ 1.) disable websockets by omitting the call to psy.set('websockets', ...), OR
12
+ 2.) provide a redis connection for your websockets, as shown below:
13
+
14
+ export default async (psy: PsychicApplication) => {
15
+ ...
16
+
17
+ psy.set('websockets', {
18
+ connection: new Redis({
19
+ host: AppEnv.string('WS_REDIS_HOST'),
20
+ port: AppEnv.integer('WS_REDIS_PORT'),
21
+ username: AppEnv.string('WS_REDIS_USERNAME', { optional: true }),
22
+ password: AppEnv.string('WS_REDIS_PASSWORD', { optional: true }),
23
+ tls: AppEnv.isProduction ? {} : undefined,
24
+ maxRetriesPerRequest: null,
25
+ })
26
+ })
27
+ }
28
+
29
+ `;
30
+ }
31
+ }
32
+ exports.default = MissingWsRedisConnection;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const dream_1 = require("@rvoh/dream");
4
+ const EnvInternal = new dream_1.Env();
5
+ exports.default = EnvInternal;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Ws = exports.Cable = exports.PsychicApplicationWebsockets = void 0;
4
+ var index_js_1 = require("./psychic-application-websockets/index.js");
5
+ Object.defineProperty(exports, "PsychicApplicationWebsockets", { enumerable: true, get: function () { return index_js_1.default; } });
6
+ var index_js_2 = require("./cable/index.js");
7
+ Object.defineProperty(exports, "Cable", { enumerable: true, get: function () { return index_js_2.default; } });
8
+ var ws_js_1 = require("./cable/ws.js");
9
+ Object.defineProperty(exports, "Ws", { enumerable: true, get: function () { return ws_js_1.default; } });
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cachePsychicApplicationWebsockets = cachePsychicApplicationWebsockets;
4
+ exports.getCachedPsychicApplicationWebsockets = getCachedPsychicApplicationWebsockets;
5
+ exports.getCachedPsychicApplicationWebsocketsOrFail = getCachedPsychicApplicationWebsocketsOrFail;
6
+ let _psychicAppWebsockets = undefined;
7
+ function cachePsychicApplicationWebsockets(psychicAppWebsockets) {
8
+ _psychicAppWebsockets = psychicAppWebsockets;
9
+ }
10
+ function getCachedPsychicApplicationWebsockets() {
11
+ return _psychicAppWebsockets;
12
+ }
13
+ function getCachedPsychicApplicationWebsocketsOrFail() {
14
+ if (!_psychicAppWebsockets)
15
+ throw new Error('must call `cachePsychicApplicationWebsockets` before loading cached psychic application websockets');
16
+ return _psychicAppWebsockets;
17
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_js_1 = require("../cable/index.js");
4
+ const cache_js_1 = require("./cache.js");
5
+ class PsychicApplicationWebsockets {
6
+ static async init(psychicApp, cb) {
7
+ const psychicWsApp = new PsychicApplicationWebsockets(psychicApp);
8
+ await cb(psychicWsApp);
9
+ psychicApp.on('server:shutdown', async (psychicServer) => {
10
+ const cable = psychicServer.$attached.cable;
11
+ await cable?.stop();
12
+ });
13
+ psychicApp.override('server:start', async (psychicServer, { port, withFrontEndClient, frontEndPort }) => {
14
+ const cable = new index_js_1.default(psychicServer.expressApp, psychicWsApp);
15
+ await cable.start(port, {
16
+ withFrontEndClient,
17
+ frontEndPort,
18
+ });
19
+ psychicServer.attach('cable', cable);
20
+ return cable.httpServer;
21
+ });
22
+ (0, cache_js_1.cachePsychicApplicationWebsockets)(psychicWsApp);
23
+ return psychicWsApp;
24
+ }
25
+ /**
26
+ * Returns the cached psychic application if it has been set.
27
+ * If it has not been set, an exception is raised.
28
+ *
29
+ * The psychic application can be set by calling PsychicApplication#init
30
+ */
31
+ static getOrFail() {
32
+ return (0, cache_js_1.getCachedPsychicApplicationWebsocketsOrFail)();
33
+ }
34
+ psychicApp;
35
+ static log(...args) {
36
+ const psychicWebsocketsApp = this.getOrFail();
37
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
38
+ return psychicWebsocketsApp.psychicApp.constructor.log(...args);
39
+ }
40
+ constructor(psychicApp) {
41
+ this.psychicApp = psychicApp;
42
+ }
43
+ _websocketOptions;
44
+ get websocketOptions() {
45
+ return this._websocketOptions;
46
+ }
47
+ _hooks = {
48
+ wsStart: [],
49
+ wsConnect: [],
50
+ };
51
+ get hooks() {
52
+ return this._hooks;
53
+ }
54
+ on(hookEventType, cb) {
55
+ switch (hookEventType) {
56
+ case 'ws:start':
57
+ this._hooks.wsStart.push(cb);
58
+ break;
59
+ case 'ws:connect':
60
+ this._hooks.wsConnect.push(cb);
61
+ break;
62
+ default:
63
+ throw new Error(`unrecognized event provided to PsychicApplicationWebsockets#on: ${hookEventType}`);
64
+ }
65
+ }
66
+ set(option, value) {
67
+ switch (option) {
68
+ case 'websockets':
69
+ this._websocketOptions = {
70
+ ...value,
71
+ subConnection: value?.connection?.duplicate(),
72
+ };
73
+ break;
74
+ default:
75
+ throw new Error(`Unhandled option type passed to PsychicApplicationWebsockets#set: ${option}`);
76
+ }
77
+ }
78
+ }
79
+ exports.default = PsychicApplicationWebsockets;
@@ -0,0 +1,118 @@
1
+ import { PsychicServer } from '@rvoh/psychic';
2
+ import { createAdapter } from '@socket.io/redis-adapter';
3
+ import * as colors from 'colorette';
4
+ import * as socketio from 'socket.io';
5
+ import MissingWsRedisConnection from '../error/ws/MissingWsRedisConnection.js';
6
+ import EnvInternal from '../helpers/EnvInternal.js';
7
+ import PsychicApplicationWebsockets from '../psychic-application-websockets/index.js';
8
+ export default class Cable {
9
+ app;
10
+ io;
11
+ httpServer;
12
+ config;
13
+ redisConnections = [];
14
+ constructor(app, config) {
15
+ this.app = app;
16
+ this.config = config;
17
+ }
18
+ connect() {
19
+ if (this.io)
20
+ return;
21
+ // for socket.io, we have to circumvent the normal process for starting a
22
+ // psychic server so that we can bind socket.io to the http instance.
23
+ this.httpServer = PsychicServer.createPsychicHttpInstance(this.app, this.config.psychicApp.sslCredentials);
24
+ this.io = new socketio.Server(this.httpServer, { cors: this.config.psychicApp.corsOptions });
25
+ }
26
+ async start(port, { withFrontEndClient = false, frontEndPort = 3000, } = {}) {
27
+ this.connect();
28
+ for (const hook of this.config.hooks.wsStart) {
29
+ await hook(this.io);
30
+ }
31
+ this.io.on('connect', async (socket) => {
32
+ try {
33
+ for (const hook of this.config.hooks.wsConnect) {
34
+ await hook(socket);
35
+ }
36
+ }
37
+ catch (error) {
38
+ if (EnvInternal.boolean('PSYCHIC_DANGEROUSLY_PERMIT_WS_EXCEPTIONS'))
39
+ throw error;
40
+ else {
41
+ ;
42
+ this.config.psychicApp.constructor.logWithLevel('error', `
43
+ An exception was caught in your websocket thread.
44
+ To prevent your server from crashing, we are rescuing this error here for you.
45
+ If you would like us to raise this exception, make sure to set
46
+
47
+ PSYCHIC_DANGEROUSLY_PERMIT_WS_EXCEPTIONS=1
48
+
49
+ the error received is:
50
+
51
+ ${error.message}
52
+ `);
53
+ console.trace();
54
+ }
55
+ }
56
+ });
57
+ this.bindToRedis();
58
+ const psychicAppWebsockets = PsychicApplicationWebsockets.getOrFail();
59
+ await this.listen({
60
+ port: parseInt((port || psychicAppWebsockets.psychicApp.port).toString()),
61
+ withFrontEndClient,
62
+ frontEndPort,
63
+ });
64
+ }
65
+ async stop() {
66
+ try {
67
+ await this.io?.close();
68
+ }
69
+ catch {
70
+ // noop
71
+ }
72
+ for (const connection of this.redisConnections) {
73
+ try {
74
+ connection.disconnect();
75
+ }
76
+ catch {
77
+ // noop
78
+ }
79
+ }
80
+ }
81
+ async listen({ port, withFrontEndClient, frontEndPort, }) {
82
+ return new Promise(accept => {
83
+ this.httpServer.listen(port, () => {
84
+ if (!EnvInternal.isTest) {
85
+ const app = PsychicApplicationWebsockets.getOrFail().psychicApp;
86
+ app.logger.info(PsychicServer.asciiLogo());
87
+ app.logger.info('\n');
88
+ app.logger.info(colors.cyan('socket server started '));
89
+ app.logger.info(colors.cyan(`psychic dev server started at port ${colors.bgBlueBright(colors.green(port))}`));
90
+ if (withFrontEndClient)
91
+ app.logger.info(`client server started at port ${colors.cyan(frontEndPort)}`);
92
+ app.logger.info('\n');
93
+ }
94
+ accept(true);
95
+ });
96
+ });
97
+ }
98
+ bindToRedis() {
99
+ const pubClient = this.config.websocketOptions.connection;
100
+ const subClient = this.config.websocketOptions.subConnection;
101
+ if (!pubClient || !subClient)
102
+ throw new MissingWsRedisConnection();
103
+ this.redisConnections.push(pubClient);
104
+ this.redisConnections.push(subClient);
105
+ pubClient.on('error', error => {
106
+ PsychicApplicationWebsockets.log('PUB CLIENT ERROR', error);
107
+ });
108
+ subClient.on('error', error => {
109
+ PsychicApplicationWebsockets.log('sub CLIENT ERROR', error);
110
+ });
111
+ try {
112
+ this.io.adapter(createAdapter(pubClient, subClient));
113
+ }
114
+ catch (error) {
115
+ PsychicApplicationWebsockets.log('FAILED TO ADAPT', error);
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,3 @@
1
+ export default function redisWsKey(userId, redisKeyPrefix) {
2
+ return `${redisKeyPrefix}:${userId}:socket_ids`;
3
+ }
@@ -0,0 +1,81 @@
1
+ import { uniq } from '@rvoh/dream';
2
+ import { Emitter } from '@socket.io/redis-emitter';
3
+ import { DateTime } from 'luxon';
4
+ import EnvInternal from '../helpers/EnvInternal.js';
5
+ import redisWsKey from './redisWsKey.js';
6
+ import PsychicApplicationWebsockets from '../psychic-application-websockets/index.js';
7
+ export default class Ws {
8
+ allowedPaths;
9
+ io;
10
+ redisClient;
11
+ booted = false;
12
+ namespace;
13
+ redisKeyPrefix;
14
+ static async register(socket, id, redisKeyPrefix = 'user') {
15
+ const psychicWebsocketsApp = PsychicApplicationWebsockets.getOrFail();
16
+ const redisClient = psychicWebsocketsApp.websocketOptions.connection;
17
+ const interpretedId = id?.isDreamInstance ? id.primaryKeyValue : id;
18
+ const key = redisWsKey(interpretedId, redisKeyPrefix);
19
+ const socketIdsToKeep = await redisClient.lrange(redisWsKey(interpretedId, redisKeyPrefix), -2, -1);
20
+ await redisClient
21
+ .multi()
22
+ .del(key)
23
+ .rpush(key, ...socketIdsToKeep, socket.id)
24
+ .expireat(key,
25
+ // TODO: make this configurable in non-test environments
26
+ DateTime.now()
27
+ .plus(EnvInternal.isTest ? { seconds: 15 } : { day: 1 })
28
+ .toSeconds())
29
+ .exec();
30
+ socket.on('disconnect', async () => {
31
+ await redisClient.lrem(key, 1, socket.id);
32
+ });
33
+ const ws = new Ws(['/ops/connection-success']);
34
+ await ws.emit(interpretedId, '/ops/connection-success', {
35
+ message: 'Successfully connected to psychic websockets',
36
+ });
37
+ }
38
+ constructor(allowedPaths, { namespace = '/', redisKeyPrefix = 'user', } = {}) {
39
+ this.allowedPaths = allowedPaths;
40
+ this.namespace = namespace;
41
+ this.redisKeyPrefix = redisKeyPrefix;
42
+ }
43
+ boot() {
44
+ if (this.booted)
45
+ return;
46
+ const psychicWebsocketsApp = PsychicApplicationWebsockets.getOrFail();
47
+ this.redisClient = psychicWebsocketsApp.websocketOptions.connection;
48
+ this.io = new Emitter(this.redisClient).of(this.namespace);
49
+ this.booted = true;
50
+ }
51
+ async emit(id, path,
52
+ // eslint-disable-next-line
53
+ data = {}) {
54
+ if (this.allowedPaths.length && !this.allowedPaths.includes(path))
55
+ throw new InvalidWsPathError(path);
56
+ this.boot();
57
+ const socketIds = await this.findSocketIds(id?.isDreamInstance ? id.primaryKeyValue : id);
58
+ for (const socketId of socketIds) {
59
+ this.io.to(socketId).emit(path, data);
60
+ }
61
+ }
62
+ async findSocketIds(id) {
63
+ this.boot();
64
+ return uniq(await this.redisClient.lrange(this.redisKey(id), 0, -1));
65
+ }
66
+ redisKey(userId) {
67
+ return redisWsKey(userId, this.redisKeyPrefix);
68
+ }
69
+ }
70
+ export class InvalidWsPathError extends Error {
71
+ invalidPath;
72
+ constructor(invalidPath) {
73
+ super();
74
+ this.invalidPath = invalidPath;
75
+ }
76
+ get message() {
77
+ return `
78
+ Invalid path passed to Ws: "${this.invalidPath}"
79
+ `;
80
+ }
81
+ }
@@ -0,0 +1,29 @@
1
+ export default class MissingWsRedisConnection extends Error {
2
+ get message() {
3
+ return `
4
+ No websocket redis connection was found, even though
5
+ the application is configured to establish websockets.
6
+
7
+ In conf/app.ts, either:
8
+
9
+ 1.) disable websockets by omitting the call to psy.set('websockets', ...), OR
10
+ 2.) provide a redis connection for your websockets, as shown below:
11
+
12
+ export default async (psy: PsychicApplication) => {
13
+ ...
14
+
15
+ psy.set('websockets', {
16
+ connection: new Redis({
17
+ host: AppEnv.string('WS_REDIS_HOST'),
18
+ port: AppEnv.integer('WS_REDIS_PORT'),
19
+ username: AppEnv.string('WS_REDIS_USERNAME', { optional: true }),
20
+ password: AppEnv.string('WS_REDIS_PASSWORD', { optional: true }),
21
+ tls: AppEnv.isProduction ? {} : undefined,
22
+ maxRetriesPerRequest: null,
23
+ })
24
+ })
25
+ }
26
+
27
+ `;
28
+ }
29
+ }
@@ -0,0 +1,3 @@
1
+ import { Env } from '@rvoh/dream';
2
+ const EnvInternal = new Env();
3
+ export default EnvInternal;
@@ -0,0 +1,3 @@
1
+ export { default as PsychicApplicationWebsockets } from './psychic-application-websockets/index.js';
2
+ export { default as Cable } from './cable/index.js';
3
+ export { default as Ws } from './cable/ws.js';
@@ -0,0 +1,12 @@
1
+ let _psychicAppWebsockets = undefined;
2
+ export function cachePsychicApplicationWebsockets(psychicAppWebsockets) {
3
+ _psychicAppWebsockets = psychicAppWebsockets;
4
+ }
5
+ export function getCachedPsychicApplicationWebsockets() {
6
+ return _psychicAppWebsockets;
7
+ }
8
+ export function getCachedPsychicApplicationWebsocketsOrFail() {
9
+ if (!_psychicAppWebsockets)
10
+ throw new Error('must call `cachePsychicApplicationWebsockets` before loading cached psychic application websockets');
11
+ return _psychicAppWebsockets;
12
+ }
@@ -0,0 +1,76 @@
1
+ import Cable from '../cable/index.js';
2
+ import { cachePsychicApplicationWebsockets, getCachedPsychicApplicationWebsocketsOrFail } from './cache.js';
3
+ export default class PsychicApplicationWebsockets {
4
+ static async init(psychicApp, cb) {
5
+ const psychicWsApp = new PsychicApplicationWebsockets(psychicApp);
6
+ await cb(psychicWsApp);
7
+ psychicApp.on('server:shutdown', async (psychicServer) => {
8
+ const cable = psychicServer.$attached.cable;
9
+ await cable?.stop();
10
+ });
11
+ psychicApp.override('server:start', async (psychicServer, { port, withFrontEndClient, frontEndPort }) => {
12
+ const cable = new Cable(psychicServer.expressApp, psychicWsApp);
13
+ await cable.start(port, {
14
+ withFrontEndClient,
15
+ frontEndPort,
16
+ });
17
+ psychicServer.attach('cable', cable);
18
+ return cable.httpServer;
19
+ });
20
+ cachePsychicApplicationWebsockets(psychicWsApp);
21
+ return psychicWsApp;
22
+ }
23
+ /**
24
+ * Returns the cached psychic application if it has been set.
25
+ * If it has not been set, an exception is raised.
26
+ *
27
+ * The psychic application can be set by calling PsychicApplication#init
28
+ */
29
+ static getOrFail() {
30
+ return getCachedPsychicApplicationWebsocketsOrFail();
31
+ }
32
+ psychicApp;
33
+ static log(...args) {
34
+ const psychicWebsocketsApp = this.getOrFail();
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
36
+ return psychicWebsocketsApp.psychicApp.constructor.log(...args);
37
+ }
38
+ constructor(psychicApp) {
39
+ this.psychicApp = psychicApp;
40
+ }
41
+ _websocketOptions;
42
+ get websocketOptions() {
43
+ return this._websocketOptions;
44
+ }
45
+ _hooks = {
46
+ wsStart: [],
47
+ wsConnect: [],
48
+ };
49
+ get hooks() {
50
+ return this._hooks;
51
+ }
52
+ on(hookEventType, cb) {
53
+ switch (hookEventType) {
54
+ case 'ws:start':
55
+ this._hooks.wsStart.push(cb);
56
+ break;
57
+ case 'ws:connect':
58
+ this._hooks.wsConnect.push(cb);
59
+ break;
60
+ default:
61
+ throw new Error(`unrecognized event provided to PsychicApplicationWebsockets#on: ${hookEventType}`);
62
+ }
63
+ }
64
+ set(option, value) {
65
+ switch (option) {
66
+ case 'websockets':
67
+ this._websocketOptions = {
68
+ ...value,
69
+ subConnection: value?.connection?.duplicate(),
70
+ };
71
+ break;
72
+ default:
73
+ throw new Error(`Unhandled option type passed to PsychicApplicationWebsockets#set: ${option}`);
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,24 @@
1
+ import { Application } from 'express';
2
+ import * as http from 'http';
3
+ import * as socketio from 'socket.io';
4
+ import PsychicApplicationWebsockets from '../psychic-application-websockets/index.js';
5
+ export default class Cable {
6
+ app: Application;
7
+ io: socketio.Server | undefined;
8
+ httpServer: http.Server;
9
+ private config;
10
+ private redisConnections;
11
+ constructor(app: Application, config: PsychicApplicationWebsockets);
12
+ connect(): void;
13
+ start(port?: number, { withFrontEndClient, frontEndPort, }?: {
14
+ withFrontEndClient?: boolean;
15
+ frontEndPort?: number;
16
+ }): Promise<void>;
17
+ stop(): Promise<void>;
18
+ listen({ port, withFrontEndClient, frontEndPort, }: {
19
+ port: number | string;
20
+ withFrontEndClient: boolean;
21
+ frontEndPort: number;
22
+ }): Promise<unknown>;
23
+ bindToRedis(): void;
24
+ }
@@ -0,0 +1,2 @@
1
+ import { IdType } from '@rvoh/dream';
2
+ export default function redisWsKey(userId: IdType, redisKeyPrefix: string): string;
@@ -0,0 +1,25 @@
1
+ import { Dream, IdType } from '@rvoh/dream';
2
+ import { Emitter } from '@socket.io/redis-emitter';
3
+ import { Socket } from 'socket.io';
4
+ export default class Ws<AllowedPaths extends readonly string[]> {
5
+ allowedPaths: AllowedPaths & readonly string[];
6
+ io: Emitter;
7
+ private redisClient;
8
+ private booted;
9
+ private namespace;
10
+ private redisKeyPrefix;
11
+ static register(socket: Socket, id: IdType | Dream, redisKeyPrefix?: string): Promise<void>;
12
+ constructor(allowedPaths: AllowedPaths & readonly string[], { namespace, redisKeyPrefix, }?: {
13
+ namespace?: string;
14
+ redisKeyPrefix?: string;
15
+ });
16
+ boot(): void;
17
+ emit<T extends Ws<AllowedPaths>, const P extends AllowedPaths[number]>(this: T, id: IdType | Dream, path: P, data?: any): Promise<void>;
18
+ findSocketIds(id: IdType): Promise<string[]>;
19
+ private redisKey;
20
+ }
21
+ export declare class InvalidWsPathError extends Error {
22
+ private invalidPath;
23
+ constructor(invalidPath: string);
24
+ get message(): string;
25
+ }
@@ -0,0 +1,3 @@
1
+ export default class MissingWsRedisConnection extends Error {
2
+ get message(): string;
3
+ }
@@ -0,0 +1,6 @@
1
+ import { Env } from '@rvoh/dream';
2
+ declare const EnvInternal: Env<{
3
+ string: "NODE_ENV" | "PSYCHIC_CORE_DEVELOPMENT";
4
+ boolean: "DEBUG" | "PSYCHIC_CORE_DEVELOPMENT" | "PSYCHIC_DANGEROUSLY_PERMIT_WS_EXCEPTIONS";
5
+ }, "NODE_ENV" | "PSYCHIC_CORE_DEVELOPMENT", never, "PSYCHIC_CORE_DEVELOPMENT" | "DEBUG" | "PSYCHIC_DANGEROUSLY_PERMIT_WS_EXCEPTIONS">;
6
+ export default EnvInternal;
@@ -0,0 +1,3 @@
1
+ export { default as PsychicApplicationWebsockets } from './psychic-application-websockets/index.js';
2
+ export { default as Cable } from './cable/index.js';
3
+ export { default as Ws } from './cable/ws.js';
@@ -0,0 +1,4 @@
1
+ import PsychicApplicationWebsockets from './index.js';
2
+ export declare function cachePsychicApplicationWebsockets(psychicAppWebsockets: PsychicApplicationWebsockets): void;
3
+ export declare function getCachedPsychicApplicationWebsockets(): PsychicApplicationWebsockets | undefined;
4
+ export declare function getCachedPsychicApplicationWebsocketsOrFail(): PsychicApplicationWebsockets;
@@ -0,0 +1,35 @@
1
+ import { PsychicApplication } from '@rvoh/psychic';
2
+ import { Cluster, Redis } from 'ioredis';
3
+ import { Socket, Server as SocketServer } from 'socket.io';
4
+ export default class PsychicApplicationWebsockets {
5
+ static init(psychicApp: PsychicApplication, cb: (app: PsychicApplicationWebsockets) => void | Promise<void>): Promise<PsychicApplicationWebsockets>;
6
+ /**
7
+ * Returns the cached psychic application if it has been set.
8
+ * If it has not been set, an exception is raised.
9
+ *
10
+ * The psychic application can be set by calling PsychicApplication#init
11
+ */
12
+ static getOrFail(): PsychicApplicationWebsockets;
13
+ psychicApp: PsychicApplication;
14
+ static log(...args: Parameters<typeof PsychicApplication.log>): void;
15
+ constructor(psychicApp: PsychicApplication);
16
+ private _websocketOptions;
17
+ get websocketOptions(): PsychicWebsocketOptions & {
18
+ subConnection?: RedisOrRedisClusterConnection;
19
+ };
20
+ private _hooks;
21
+ get hooks(): PsychicApplicationWebsocketsHooks;
22
+ on<T extends PsychicWebsocketsHookEventType>(hookEventType: T, cb: T extends 'ws:start' ? (server: SocketServer) => void | Promise<void> : T extends 'ws:connect' ? (socket: Socket) => void | Promise<void> : never): void;
23
+ set<Opt extends PsychicApplicationWebsocketsOption>(option: Opt, value: unknown): void;
24
+ }
25
+ interface PsychicWebsocketOptions {
26
+ connection: Redis;
27
+ }
28
+ export type PsychicApplicationWebsocketsOption = 'websockets';
29
+ export type PsychicWebsocketsHookEventType = 'ws:start' | 'ws:connect';
30
+ export interface PsychicApplicationWebsocketsHooks {
31
+ wsStart: ((server: SocketServer) => void | Promise<void>)[];
32
+ wsConnect: ((socket: Socket) => void | Promise<void>)[];
33
+ }
34
+ export type RedisOrRedisClusterConnection = Redis | Cluster;
35
+ export {};
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@rvoh/psychic-websockets",
4
+ "description": "Websocket system for Psychic applications",
5
+ "version": "0.2.0",
6
+ "author": "RVOHealth",
7
+ "repository": "https://github.com/rvohealth/psychic-websockets.git",
8
+ "license": "MIT",
9
+ "main": "./dist/cjs/src/index.js",
10
+ "module": "./dist/esm/src/index.js",
11
+ "types": "./dist/types/src/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/types/src/index.d.ts",
15
+ "import": "./dist/esm/src/index.js",
16
+ "require": "./dist/cjs/src/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist/**/*"
21
+ ],
22
+ "scripts": {
23
+ "client": "yarn --cwd=./client start",
24
+ "psy": "yarn psyts",
25
+ "psyjs": "node ./dist/test-app/src/cli/index.js",
26
+ "psyts": "node --experimental-specifier-resolution=node --import ./bin/esm.js ./test-app/src/cli/index.ts",
27
+ "build": "echo \"building cjs...\" && rm -rf dist && npx tsc -p ./tsconfig.cjs.build.json && echo \"building esm...\" && npx tsc -p ./tsconfig.esm.build.json",
28
+ "uspec": "vitest --config ./spec/unit/vite.config.ts",
29
+ "fspec": "vitest run --config=./spec/features/vite.config.ts",
30
+ "format": "yarn run prettier . --write",
31
+ "lint": "yarn run eslint --no-warn-ignored \"src/**/*.ts\" && yarn run prettier . --check",
32
+ "dev": "NODE_ENV=development WORKER_COUNT=0 ts-node --transpile-only ./test-app/main.ts",
33
+ "prepack": "yarn build"
34
+ },
35
+ "peerDependencies": {
36
+ "@rvoh/dream": "*",
37
+ "@rvoh/psychic": "*",
38
+ "@socket.io/redis-adapter": "*",
39
+ "@socket.io/redis-emitter": "*",
40
+ "ioredis": "*",
41
+ "socket.io": "*",
42
+ "socket.io-adapter": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@eslint/js": "=9.0.0",
46
+ "@rvoh/dream": "^0.29.1",
47
+ "@rvoh/dream-spec-helpers": "^0.1.0",
48
+ "@rvoh/psychic": "^0.24.0",
49
+ "@rvoh/psychic-spec-helpers": "^0.2.0",
50
+ "@socket.io/redis-adapter": "^8.3.0",
51
+ "@socket.io/redis-emitter": "^5.1.0",
52
+ "@types/express": "^4",
53
+ "@types/luxon": "^3.4.2",
54
+ "@types/node": "^22.5.1",
55
+ "@types/pg": "^8",
56
+ "@types/supertest": "^6.0.2",
57
+ "bullmq": "^5.12.12",
58
+ "eslint": "^9.9.1",
59
+ "express": "^4.21.2",
60
+ "ioredis": "^5.4.1",
61
+ "kysely": "^0.27.5",
62
+ "kysely-codegen": "^0.17.0",
63
+ "luxon": "^3.5.0",
64
+ "luxon-jest-matchers": "^0.1.14",
65
+ "pg": "^8.13.1",
66
+ "prettier": "^3.3.3",
67
+ "puppeteer": "^24.4.0",
68
+ "socket.io": "^4.8.1",
69
+ "socket.io-adapter": "^2.5.5",
70
+ "socket.io-client": "^4.8.1",
71
+ "supertest": "^7.0.0",
72
+ "ts-node": "^10.9.2",
73
+ "tslib": "^2.7.0",
74
+ "typedoc": "^0.26.6",
75
+ "typescript": "^5.5.4",
76
+ "typescript-eslint": "=7.18.0",
77
+ "vitest": "^3.0.8"
78
+ },
79
+ "packageManager": "yarn@4.4.1"
80
+ }