@sockethub/data-layer 1.0.0-alpha.4 → 1.0.0-alpha.5

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/src/job-queue.ts CHANGED
@@ -1,148 +1,227 @@
1
- import Queue from 'bull';
2
- import crypto from '@sockethub/crypto';
3
- import {
4
- JobDataDecrypted,
5
- JobDataEncrypted, JobDecrypted,
6
- JobEncrypted,
7
- RedisConfig
8
- } from "./types";
9
- import debug, {Debugger} from 'debug';
10
- import EventEmitter from "events";
11
- import {IActivityStream} from "@sockethub/schemas";
1
+ import { type Job, Queue, QueueEvents, Worker } from "bullmq";
2
+ import debug, { type Debugger } from "debug";
12
3
 
13
- interface JobHandler {
14
- (job: JobDataDecrypted, done: CallableFunction)
15
- }
4
+ import type { ActivityStream } from "@sockethub/schemas";
16
5
 
17
- export default class JobQueue extends EventEmitter {
18
- readonly uid: string;
19
- private readonly bull: Queue;
20
- private readonly debug: Debugger;
21
- private readonly secret: string;
22
- private handler: JobHandler;
23
- private counter = 0;
6
+ import { JobBase, createIORedisConnection } from "./job-base.js";
7
+ import type { JobDataEncrypted, JobDecrypted, RedisConfig } from "./types.js";
24
8
 
25
- constructor(instanceId: string, sessionId: string, secret: string, redisConfig: RedisConfig) {
26
- super();
27
- this.bull = new Queue(instanceId + sessionId, { redis: redisConfig });
28
- this.uid = `sockethub:data-layer:job-queue:${instanceId}:${sessionId}`;
29
- this.secret = secret;
30
- this.debug = debug(this.uid);
9
+ export async function verifyJobQueue(config: RedisConfig): Promise<void> {
10
+ const log = debug("sockethub:data-layer:queue");
11
+ return new Promise((resolve, reject) => {
12
+ const worker = new Worker(
13
+ "connectiontest",
14
+ async (job) => {
15
+ if (job.name !== "foo" || job.data?.foo !== "bar") {
16
+ reject(
17
+ "Worker received invalid job data during JobQueue connection test",
18
+ );
19
+ }
20
+ job.data.test = "touched by worker";
21
+ },
22
+ {
23
+ connection: createIORedisConnection(config),
24
+ },
25
+ );
26
+ worker.on("completed", async (job: Job) => {
27
+ if (job.name !== "foo" || job.data?.test !== "touched by worker") {
28
+ reject(
29
+ "Worker job completed unsuccessfully during JobQueue connection test",
30
+ );
31
+ }
32
+ log("connection verified");
33
+ await queue.close();
34
+ await worker.close();
35
+ resolve();
36
+ });
37
+ worker.on("error", (err) => {
38
+ log(
39
+ `connection verification worker error received ${err.toString()}`,
40
+ );
41
+ reject(err);
42
+ });
43
+ const queue = new Queue("connectiontest", {
44
+ connection: createIORedisConnection(config),
45
+ });
46
+ queue.on("error", (err) => {
47
+ log(
48
+ `connection verification queue error received ${err.toString()}`,
49
+ );
50
+ reject(err);
51
+ });
52
+ queue.add(
53
+ "foo",
54
+ { foo: "bar" },
55
+ { removeOnComplete: true, removeOnFail: true },
56
+ );
57
+ });
58
+ }
31
59
 
32
- this.debug('initialized');
33
- }
60
+ /**
61
+ * Redis-backed job queue for managing ActivityStreams message processing.
62
+ *
63
+ * Creates isolated queues per platform instance and session, providing reliable
64
+ * message delivery and processing coordination between Sockethub server and
65
+ * platform workers.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * const queue = new JobQueue('irc-platform', 'session123', secret, redisConfig);
70
+ * await queue.add('socket-id', activityStreamMessage);
71
+ * ```
72
+ */
73
+ export class JobQueue extends JobBase {
74
+ readonly uid: string;
75
+ protected queue: Queue;
76
+ protected events: QueueEvents;
77
+ private readonly debug: Debugger;
78
+ private counter = 0;
79
+ private initialized = false;
34
80
 
35
- async add(socketId: string, msg: IActivityStream): Promise<JobDataEncrypted> {
36
- const job = this.createJob(socketId, msg);
37
- const isPaused = await this.bull.isPaused();
38
- if (isPaused) {
39
- this.bull.emit('failed', job, 'queue closed');
40
- return undefined;
81
+ /**
82
+ * Creates a new JobQueue instance.
83
+ *
84
+ * @param instanceId - Unique identifier for the platform instance
85
+ * @param sessionId - Client session identifier for queue isolation
86
+ * @param secret - 32-character encryption secret for message security
87
+ * @param redisConfig - Redis connection configuration
88
+ */
89
+ constructor(
90
+ instanceId: string,
91
+ sessionId: string,
92
+ secret: string,
93
+ redisConfig: RedisConfig,
94
+ ) {
95
+ super(secret);
96
+ this.uid = `sockethub:data-layer:queue:${instanceId}:${sessionId}`;
97
+ this.debug = debug(this.uid);
98
+ this.init(redisConfig);
41
99
  }
42
- this.debug(`adding ${job.title} ${msg.type}`);
43
- this.bull.add(job);
44
- return job;
45
- }
46
100
 
47
- initResultEvents() {
48
- this.bull.on('global:completed', async (jobId: string, result: string) => {
49
- const r = result ? JSON.parse(result) : "";
50
- const job = await this.getJob(jobId);
51
- if (job) {
52
- this.debug(`completed ${job.data.title} ${job.data.msg.type}`);
53
- this.emit('global:completed', job.data, r);
54
- await job.remove();
55
- }
56
- });
57
- this.bull.on('global:error', async (jobId: string, result: string) => {
58
- this.debug("unknown queue error", jobId, result);
59
- });
60
- this.bull.on('global:failed', async (jobId, result: string) => {
61
- const job = await this.getJob(jobId);
62
- if (job) {
63
- this.debug(`failed ${job.data.title} ${job.data.msg.type}`);
64
- this.emit('global:failed', job.data, result);
65
- await job.remove();
66
- }
67
- });
68
- this.bull.on('failed', (job: JobDataEncrypted, result: string) => {
69
- // locally failed jobs (eg. due to paused queue)
70
- const unencryptedJobData: JobDataDecrypted = {
71
- title: job.title,
72
- msg: this.decryptActivityStream(job.msg),
73
- sessionId: job.sessionId
74
- };
75
- this.debug(`failed ${unencryptedJobData.title} ${unencryptedJobData.msg.type}`);
76
- this.emit('global:failed', unencryptedJobData, result);
77
- });
78
- }
101
+ protected init(redisConfig: RedisConfig) {
102
+ if (this.initialized) {
103
+ throw new Error(`JobQueue already initialized for ${this.uid}`);
104
+ }
105
+ this.initialized = true;
79
106
 
80
- async getJob(jobId: string): Promise<JobDecrypted> {
81
- const job = await this.bull.getJob(jobId);
82
- if (job) {
83
- job.data = this.decryptJobData(job);
84
- try {
85
- delete job.data.msg.sessionSecret;
86
- } catch (e) {
87
- // this property should never be exposed externally
88
- }
89
- }
90
- return job;
91
- }
107
+ // BullMQ v5+ prohibits colons in queue names, so replace with dashes
108
+ // while keeping uid with colons for debug namespace convention
109
+ const queueName = this.uid.replace(/:/g, "-");
110
+ // Let BullMQ create its own connections for better lifecycle management
111
+ this.queue = new Queue(queueName, {
112
+ connection: redisConfig,
113
+ });
114
+ this.events = new QueueEvents(queueName, {
115
+ connection: redisConfig,
116
+ });
92
117
 
93
- onJob(handler: JobHandler): void {
94
- this.handler = handler;
95
- this.bull.process(this.jobHandler.bind(this));
96
- }
118
+ this.events.on("completed", async ({ jobId, returnvalue }) => {
119
+ const job = await this.getJob(jobId);
120
+ if (!job) {
121
+ this.debug(`completed job ${jobId} (already removed)`);
122
+ return;
123
+ }
124
+ this.debug(`completed ${job.data.title} ${job.data.msg.type}`);
125
+ this.emit("completed", job.data, returnvalue);
126
+ });
97
127
 
98
- async pause() {
99
- await this.bull.pause();
100
- this.debug('paused');
101
- }
128
+ this.events.on("failed", async ({ jobId, failedReason }) => {
129
+ const job = await this.getJob(jobId);
130
+ if (!job) {
131
+ this.debug(
132
+ `failed job ${jobId} (already removed): ${failedReason}`,
133
+ );
134
+ return;
135
+ }
136
+ this.debug(
137
+ `failed ${job.data.title} ${job.data.msg.type}: ${failedReason}`,
138
+ );
139
+ this.emit("failed", job.data, failedReason);
140
+ });
141
+ this.debug("initialized");
142
+ }
102
143
 
103
- async resume() {
104
- await this.bull.resume();
105
- this.debug('resumed');
106
- }
144
+ /**
145
+ * Adds an ActivityStreams message to the job queue for processing.
146
+ *
147
+ * @param socketId - Socket.IO connection identifier for response routing
148
+ * @param msg - ActivityStreams message to be processed by platform worker
149
+ * @returns Promise resolving to encrypted job data
150
+ * @throws Error if queue is closed or Redis connection fails
151
+ */
152
+ async add(
153
+ socketId: string,
154
+ msg: ActivityStream,
155
+ ): Promise<JobDataEncrypted> {
156
+ const job = this.createJob(socketId, msg);
157
+ if (await this.queue.isPaused()) {
158
+ // this.queue.emit("error", new Error("queue closed"));
159
+ this.debug(
160
+ `failed to add ${job.title} ${msg.type} to queue: queue closed`,
161
+ );
162
+ throw new Error("queue closed");
163
+ }
164
+ await this.queue.add(job.title, job, {
165
+ // Auto-remove jobs after 5 minutes to prevent Redis memory buildup.
166
+ // Jobs only need to exist long enough for event handlers to look them up
167
+ // by jobId and send results to clients (typically < 1 second).
168
+ removeOnComplete: { age: 300 }, // 5 minutes in seconds
169
+ removeOnFail: { age: 300 },
170
+ });
171
+ this.debug(`added ${job.title} ${msg.type} to queue`);
172
+ return job;
173
+ }
107
174
 
108
- async shutdown() {
109
- this.debug('shutdown');
110
- const isPaused = await this.bull.isPaused(true);
111
- if (!isPaused) {
112
- await this.bull.pause();
175
+ /**
176
+ * Pauses job processing. New jobs can still be added but won't be processed.
177
+ */
178
+ async pause() {
179
+ await this.queue.pause();
180
+ this.debug("paused");
113
181
  }
114
- await this.bull.obliterate({ force: true });
115
- await this.bull.removeAllListeners();
116
- }
117
182
 
118
- private createJob(socketId: string, msg): JobDataEncrypted {
119
- const title = `${msg.context}-${(msg.id) ? msg.id : this.counter++}`;
120
- return {
121
- title: title,
122
- sessionId: socketId,
123
- msg: crypto.encrypt(msg, this.secret)
124
- };
125
- }
183
+ /**
184
+ * Resumes job processing after being paused.
185
+ */
186
+ async resume() {
187
+ await this.queue.resume();
188
+ this.debug("resumed");
189
+ }
126
190
 
127
- private jobHandler(encryptedJob: JobEncrypted, done: CallableFunction): void {
128
- const job = this.decryptJobData(encryptedJob);
129
- this.debug(`handling ${job.title} ${job.msg.type}`);
130
- this.handler(job, done);
131
- }
191
+ /**
192
+ * Gracefully shuts down the queue, cleaning up all resources and connections.
193
+ */
194
+ async shutdown() {
195
+ this.removeAllListeners();
196
+ this.queue.removeAllListeners();
197
+ if (!(await this.queue.isPaused())) {
198
+ await this.queue.pause();
199
+ }
200
+ await this.queue.obliterate({ force: true });
201
+ await this.queue.close();
202
+ await this.events.close();
203
+ }
132
204
 
133
- /**
134
- * @param job
135
- * @private
136
- */
137
- private decryptJobData(job: JobEncrypted): JobDataDecrypted {
138
- return {
139
- title: job.data.title,
140
- msg: this.decryptActivityStream(job.data.msg),
141
- sessionId: job.data.sessionId
142
- };
143
- }
205
+ private async getJob(jobId: string): Promise<JobDecrypted> {
206
+ const job = await this.queue.getJob(jobId);
207
+ if (job) {
208
+ job.data = this.decryptJobData(job);
209
+ try {
210
+ // biome-ignore lint/performance/noDelete: <explanation>
211
+ delete job.data.msg.sessionSecret;
212
+ } catch (e) {
213
+ // this property should never be exposed externally
214
+ }
215
+ }
216
+ return job;
217
+ }
144
218
 
145
- private decryptActivityStream(msg: string): IActivityStream {
146
- return crypto.decrypt(msg, this.secret);
147
- }
148
- }
219
+ private createJob(socketId: string, msg: ActivityStream): JobDataEncrypted {
220
+ const title = `${msg.context}-${msg.id ? msg.id : this.counter++}`;
221
+ return {
222
+ title: title,
223
+ sessionId: socketId,
224
+ msg: this.encryptActivityStream(msg),
225
+ };
226
+ }
227
+ }
@@ -0,0 +1,128 @@
1
+ import { expect } from "chai";
2
+ import * as sinon from "sinon";
3
+
4
+ import { JobWorker } from "./index";
5
+
6
+ describe("JobWorker", () => {
7
+ let MockBull, jobWorker, cryptoMocks, sandbox;
8
+
9
+ beforeEach(() => {
10
+ sandbox = sinon.createSandbox();
11
+ cryptoMocks = {
12
+ objectHash: sandbox.stub(),
13
+ decrypt: sandbox.stub(),
14
+ encrypt: sandbox.stub(),
15
+ hash: sandbox.stub(),
16
+ };
17
+ MockBull = sandbox.stub().returns({
18
+ add: sandbox.stub(),
19
+ getJob: sandbox.stub(),
20
+ removeAllListeners: sandbox.stub(),
21
+ pause: sandbox.stub(),
22
+ resume: sandbox.stub(),
23
+ isPaused: sandbox.stub(),
24
+ obliterate: sandbox.stub(),
25
+ isRunning: sandbox.stub(),
26
+ close: sandbox.stub(),
27
+ emit: sandbox.stub(),
28
+ disconnect: sandbox.stub(),
29
+ on: sandbox.stub().callsArgWith(1, "a job id", "a result string"),
30
+ });
31
+
32
+ class TestJobWorker extends JobWorker {
33
+ init() {
34
+ this.redisConnection = MockBull();
35
+ this.worker = MockBull(
36
+ this.uid,
37
+ this.jobHandler.bind(this),
38
+ this.redisConnection,
39
+ );
40
+ }
41
+ initCrypto() {
42
+ this.crypto = cryptoMocks;
43
+ }
44
+ }
45
+ jobWorker = new TestJobWorker(
46
+ "a parent id",
47
+ "a session id",
48
+ "secret is 32 char long like this",
49
+ {
50
+ url: "redis config",
51
+ },
52
+ );
53
+ jobWorker.emit = sandbox.stub();
54
+ });
55
+
56
+ afterEach(() => {
57
+ sinon.restore();
58
+ sandbox.reset();
59
+ });
60
+
61
+ it("returns a valid JobWorker object", () => {
62
+ expect(typeof jobWorker).to.equal("object");
63
+ expect(jobWorker.uid).to.equal(
64
+ `sockethub:data-layer:worker:a parent id:a session id`,
65
+ );
66
+ expect(typeof jobWorker.onJob).to.equal("function");
67
+ expect(typeof jobWorker.shutdown).to.equal("function");
68
+ });
69
+
70
+ describe("onJob", () => {
71
+ it("queues the handler", () => {
72
+ jobWorker.onJob(() => {
73
+ throw new Error("This handler should never be called");
74
+ });
75
+ sinon.assert.notCalled(jobWorker.worker.on);
76
+ });
77
+ });
78
+
79
+ describe("jobHandler", () => {
80
+ it("calls handler as expected", async () => {
81
+ cryptoMocks.decrypt.returns("an unencrypted message");
82
+ const encryptedJob = {
83
+ data: {
84
+ title: "a title",
85
+ msg: "an encrypted message",
86
+ sessionId: "a socket id",
87
+ },
88
+ };
89
+ jobWorker.onJob((job) => {
90
+ const decryptedData = encryptedJob.data;
91
+ decryptedData.msg = "an unencrypted message";
92
+ expect(job).to.eql(decryptedData);
93
+ decryptedData.msg += " handled";
94
+ return decryptedData;
95
+ });
96
+ const result = await jobWorker.jobHandler(encryptedJob);
97
+ expect(result.msg).to.eql("an unencrypted message handled");
98
+ });
99
+ });
100
+
101
+ describe("decryptJobData", () => {
102
+ it("decrypts and returns expected object", () => {
103
+ cryptoMocks.decrypt.returnsArg(0);
104
+ const jobData = {
105
+ data: {
106
+ title: "foo",
107
+ msg: "encryptedjobdata",
108
+ sessionId: "foobar",
109
+ },
110
+ };
111
+ const secret = "secretstring";
112
+ expect(jobWorker.decryptJobData(jobData, secret)).to.be.eql(
113
+ jobData.data,
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("decryptActivityStream", () => {
119
+ it("decrypts and returns expected object", () => {
120
+ cryptoMocks.decrypt.returnsArg(0);
121
+ const jobData = "encryptedjobdata";
122
+ const secret = "secretstring";
123
+ expect(jobWorker.decryptActivityStream(jobData, secret)).to.be.eql(
124
+ jobData,
125
+ );
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,97 @@
1
+ import { Worker } from "bullmq";
2
+ import debug, { type Debugger } from "debug";
3
+
4
+ import { JobBase, createIORedisConnection } from "./job-base.js";
5
+ import type { JobEncrypted, JobHandler, RedisConfig } from "./types.js";
6
+
7
+ /**
8
+ * Worker for processing jobs from a Redis queue within platform child processes.
9
+ *
10
+ * Connects to the same queue as its corresponding JobQueue instance and processes
11
+ * jobs using a platform-specific handler function. Provides automatic decryption
12
+ * of job data and error handling.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const worker = new JobWorker('irc-platform', 'session123', secret, redisConfig);
17
+ * worker.onJob(async (job) => {
18
+ * // Process the decrypted ActivityStreams message
19
+ * return await processMessage(job.msg);
20
+ * });
21
+ * ```
22
+ */
23
+ export class JobWorker extends JobBase {
24
+ readonly uid: string;
25
+ protected worker: Worker;
26
+ protected handler: JobHandler;
27
+ private readonly debug: Debugger;
28
+ private readonly redisConfig: RedisConfig;
29
+ private readonly queueId: string;
30
+ private initialized = false;
31
+
32
+ /**
33
+ * Creates a new JobWorker instance.
34
+ *
35
+ * @param instanceId - Must match the instanceId of the corresponding JobQueue
36
+ * @param sessionId - Must match the sessionId of the corresponding JobQueue
37
+ * @param secret - 32-character encryption secret, must match JobQueue secret
38
+ * @param redisConfig - Redis connection configuration
39
+ */
40
+ constructor(
41
+ instanceId: string,
42
+ sessionId: string,
43
+ secret: string,
44
+ redisConfig: RedisConfig,
45
+ ) {
46
+ super(secret);
47
+ this.uid = `sockethub:data-layer:worker:${instanceId}:${sessionId}`;
48
+ this.queueId = `sockethub:data-layer:queue:${instanceId}:${sessionId}`;
49
+ this.debug = debug(this.uid);
50
+ this.redisConfig = redisConfig;
51
+ }
52
+
53
+ protected init() {
54
+ if (this.initialized) {
55
+ throw new Error(`JobWorker already initialized for ${this.uid}`);
56
+ }
57
+ this.initialized = true;
58
+ // BullMQ v5+ prohibits colons in queue names, so replace with dashes
59
+ // while keeping queueId with colons for debug namespace convention
60
+ const queueName = this.queueId.replace(/:/g, "-");
61
+ // Let BullMQ create its own connection for better lifecycle management
62
+ this.worker = new Worker(queueName, this.jobHandler.bind(this), {
63
+ connection: this.redisConfig,
64
+ // Prevent infinite retry loops when platform child process crashes mid-job.
65
+ // If worker disappears (crash/disconnect), job becomes "stalled" and retries
66
+ // up to maxStalledCount times (with default 30s interval) before failing permanently.
67
+ maxStalledCount: 3,
68
+ });
69
+ this.debug("initialized");
70
+ }
71
+
72
+ /**
73
+ * Registers a job handler function and starts processing jobs from the queue.
74
+ *
75
+ * @param handler - Function that processes decrypted job data and returns results
76
+ */
77
+ onJob(handler: JobHandler): void {
78
+ this.handler = handler;
79
+ this.init();
80
+ }
81
+
82
+ /**
83
+ * Gracefully shuts down the worker, stopping job processing and cleaning up connections.
84
+ */
85
+ async shutdown() {
86
+ await this.worker.pause();
87
+ this.removeAllListeners();
88
+ this.worker.removeAllListeners();
89
+ await this.worker.close();
90
+ }
91
+
92
+ protected async jobHandler(encryptedJob: JobEncrypted) {
93
+ const job = this.decryptJobData(encryptedJob);
94
+ this.debug(`handling ${job.title} ${job.msg.type}`);
95
+ return await this.handler(job);
96
+ }
97
+ }
package/src/types.ts CHANGED
@@ -1,37 +1,35 @@
1
- import {IActivityStream} from "@sockethub/schemas";
1
+ import type {
2
+ ActivityStream,
3
+ InternalActivityStream,
4
+ } from "@sockethub/schemas";
2
5
 
3
- export type RedisConfigUrl = string;
4
-
5
- export interface RedisConfigProps {
6
- host: string,
7
- port: string
8
- }
9
-
10
- export type RedisConfig = RedisConfigProps | RedisConfigUrl;
6
+ export type RedisConfig = {
7
+ url: string;
8
+ };
11
9
 
12
10
  export interface JobDataEncrypted {
13
- title?: string;
14
- msg: string;
15
- sessionId: string;
11
+ title?: string;
12
+ msg: string;
13
+ sessionId: string;
16
14
  }
17
15
 
18
16
  export interface JobDataDecrypted {
19
- title?: string;
20
- msg: IActivityStream;
21
- sessionId: string;
17
+ title?: string;
18
+ msg: InternalActivityStream;
19
+ sessionId: string;
22
20
  }
23
21
 
24
22
  export interface JobEncrypted {
25
- data: JobDataEncrypted,
26
- remove?: {
27
- (): void;
28
- };
23
+ data: JobDataEncrypted;
24
+ remove?: () => void;
29
25
  }
30
26
 
31
27
  export interface JobDecrypted {
32
- data: JobDataDecrypted,
33
- remove?: {
34
- (): void;
35
- };
28
+ data: JobDataDecrypted;
29
+ remove?: () => void;
30
+ returnvalue: unknown;
36
31
  }
37
32
 
33
+ export type JobHandler = (
34
+ job: JobDataDecrypted,
35
+ ) => Promise<string | undefined | ActivityStream>;
package/typedoc.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "entryPoints": ["./src/index.ts"],
3
+ "out": "./docs",
4
+ "readme": "none",
5
+ "includeVersion": true,
6
+ "exclude": ["**/*.test.ts", "**/*.spec.ts", "**/node_modules/**"],
7
+ "skipErrorChecking": true,
8
+ "compilerOptions": {
9
+ "skipLibCheck": true
10
+ }
11
+ }