@quatrain/worker 1.1.42 → 1.1.44

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.md ADDED
@@ -0,0 +1,15 @@
1
+ # LICENSE UPDATE NOTICE
2
+
3
+ As of 01/01/2026, Quatrain Core is licensed under the **GNU Affero General Public License v3.0 (AGPL v3)**.
4
+ Previous versions remain under the MIT License.
5
+
6
+ ## Why AGPL?
7
+
8
+ We believe in open collaboration for the development ecosystem. The AGPL ensures that any modification or deployment of this BaaS stack, including over a network, benefits the entire community.
9
+
10
+ ## Commercial Services & Enterprise Usage
11
+
12
+ We provide official deployment services, technical training, and certification for Quatrain Core.
13
+ For organizations requiring a non-copyleft license (commercial license) or custom proprietary integrations, please contact the copyright holder: **Quatrain Technologies**.
14
+
15
+ Copyright © 2024-2026 Quatrain Technologies. All Rights Reserved.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const Worker_1 = require("./Worker");
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const child_process_1 = require("child_process");
9
+ // Mock dependencies
10
+ jest.mock('axios');
11
+ jest.mock('child_process');
12
+ const mockedAxios = axios_1.default;
13
+ const mockedSpawn = child_process_1.spawn;
14
+ describe('Worker', () => {
15
+ beforeEach(() => {
16
+ // Clear all mocks before each test
17
+ jest.clearAllMocks();
18
+ // Reset Worker.endpoint
19
+ Worker_1.Worker.endpoint = '';
20
+ });
21
+ describe('endpoint property', () => {
22
+ it('should have an empty endpoint by default', () => {
23
+ expect(Worker_1.Worker.endpoint).toBe('');
24
+ });
25
+ it('should allow setting the endpoint', () => {
26
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
27
+ expect(Worker_1.Worker.endpoint).toBe('https://api.example.com/events');
28
+ });
29
+ });
30
+ describe('pushEvent', () => {
31
+ it('should return false when endpoint is not set', () => {
32
+ const result = Worker_1.Worker.pushEvent('test-event');
33
+ expect(result).toBe(false);
34
+ expect(mockedAxios.patch).not.toHaveBeenCalled();
35
+ });
36
+ it('should send event when endpoint is set', () => {
37
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
38
+ mockedAxios.patch.mockResolvedValue({
39
+ statusText: 'OK',
40
+ data: { success: true },
41
+ });
42
+ Worker_1.Worker.pushEvent('test-event', { foo: 'bar' });
43
+ expect(mockedAxios.patch).toHaveBeenCalledWith('https://api.example.com/events', expect.objectContaining({
44
+ event: 'test-event',
45
+ foo: 'bar',
46
+ }));
47
+ });
48
+ it('should include custom timestamp in payload', () => {
49
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
50
+ const customTs = 1234567890;
51
+ mockedAxios.patch.mockResolvedValue({
52
+ statusText: 'OK',
53
+ data: { success: true },
54
+ });
55
+ Worker_1.Worker.pushEvent('test-event', {}, customTs);
56
+ expect(mockedAxios.patch).toHaveBeenCalledWith('https://api.example.com/events', expect.objectContaining({
57
+ event: 'test-event',
58
+ ts: expect.any(Number),
59
+ }));
60
+ });
61
+ it('should include additional data in payload', () => {
62
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
63
+ mockedAxios.patch.mockResolvedValue({
64
+ statusText: 'OK',
65
+ data: { success: true },
66
+ });
67
+ const additionalData = {
68
+ userId: '123',
69
+ action: 'upload',
70
+ metadata: { size: 1024 },
71
+ };
72
+ Worker_1.Worker.pushEvent('custom-event', additionalData);
73
+ expect(mockedAxios.patch).toHaveBeenCalledWith('https://api.example.com/events', expect.objectContaining({
74
+ event: 'custom-event',
75
+ userId: '123',
76
+ action: 'upload',
77
+ metadata: { size: 1024 },
78
+ }));
79
+ });
80
+ });
81
+ describe('pushEventAsync', () => {
82
+ it('should return false when endpoint is not set', async () => {
83
+ const result = await Worker_1.Worker.pushEventAsync('test-event');
84
+ expect(result).toBe(false);
85
+ expect(mockedAxios.patch).not.toHaveBeenCalled();
86
+ });
87
+ it('should send event and return true on success', async () => {
88
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
89
+ mockedAxios.patch.mockResolvedValue({
90
+ statusText: 'OK',
91
+ data: { success: true },
92
+ });
93
+ const result = await Worker_1.Worker.pushEventAsync('test-event', {
94
+ foo: 'bar',
95
+ });
96
+ expect(result).toBe(true);
97
+ expect(mockedAxios.patch).toHaveBeenCalledWith('https://api.example.com/events', expect.objectContaining({
98
+ event: 'test-event',
99
+ foo: 'bar',
100
+ }));
101
+ });
102
+ it('should return false on axios error', async () => {
103
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
104
+ mockedAxios.patch.mockRejectedValue(new Error('Network error'));
105
+ const result = await Worker_1.Worker.pushEventAsync('test-event');
106
+ expect(result).toBe(false);
107
+ });
108
+ it('should handle non-OK status response', async () => {
109
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
110
+ mockedAxios.patch.mockResolvedValue({
111
+ statusText: 'Bad Request',
112
+ data: { error: 'Invalid payload' },
113
+ });
114
+ const result = await Worker_1.Worker.pushEventAsync('test-event');
115
+ expect(result).toBe(undefined);
116
+ });
117
+ it('should include custom timestamp', async () => {
118
+ Worker_1.Worker.endpoint = 'https://api.example.com/events';
119
+ const customTs = 9876543210;
120
+ mockedAxios.patch.mockResolvedValue({
121
+ statusText: 'OK',
122
+ data: { success: true },
123
+ });
124
+ await Worker_1.Worker.pushEventAsync('test-event', {}, customTs);
125
+ expect(mockedAxios.patch).toHaveBeenCalledWith('https://api.example.com/events', expect.objectContaining({
126
+ event: 'test-event',
127
+ ts: expect.any(Number),
128
+ }));
129
+ });
130
+ });
131
+ describe('execPromise', () => {
132
+ it('should execute command successfully', async () => {
133
+ const mockChild = {
134
+ stdout: {
135
+ on: jest.fn((event, callback) => {
136
+ if (event === 'data') {
137
+ // Simulate stdout data
138
+ setTimeout(() => callback(Buffer.from('Command output')), 10);
139
+ }
140
+ }),
141
+ },
142
+ stderr: {
143
+ on: jest.fn(),
144
+ },
145
+ on: jest.fn((event, callback) => {
146
+ if (event === 'close') {
147
+ // Simulate successful completion
148
+ setTimeout(() => callback(0), 20);
149
+ }
150
+ }),
151
+ };
152
+ mockedSpawn.mockReturnValue(mockChild);
153
+ await expect(Worker_1.Worker.execPromise('ls', ['-la'], '/tmp')).resolves.toBeUndefined();
154
+ expect(mockedSpawn).toHaveBeenCalledWith('ls', ['-la'], {
155
+ cwd: '/tmp',
156
+ });
157
+ });
158
+ it('should reject on command failure', async () => {
159
+ const mockChild = {
160
+ stdout: {
161
+ on: jest.fn(),
162
+ },
163
+ stderr: {
164
+ on: jest.fn((event, callback) => {
165
+ if (event === 'data') {
166
+ setTimeout(() => callback(Buffer.from('Error output')), 10);
167
+ }
168
+ }),
169
+ },
170
+ on: jest.fn((event, callback) => {
171
+ if (event === 'close') {
172
+ // Simulate failure with exit code 1
173
+ setTimeout(() => callback(1), 20);
174
+ }
175
+ }),
176
+ };
177
+ mockedSpawn.mockReturnValue(mockChild);
178
+ await expect(Worker_1.Worker.execPromise('invalid-command', [])).rejects.toThrow('Process failed and returned code: 1');
179
+ });
180
+ it('should use current working directory by default', async () => {
181
+ const mockChild = {
182
+ stdout: { on: jest.fn() },
183
+ stderr: { on: jest.fn() },
184
+ on: jest.fn((event, callback) => {
185
+ if (event === 'close') {
186
+ setTimeout(() => callback(0), 10);
187
+ }
188
+ }),
189
+ };
190
+ mockedSpawn.mockReturnValue(mockChild);
191
+ await Worker_1.Worker.execPromise('echo', ['test']);
192
+ expect(mockedSpawn).toHaveBeenCalledWith('echo', ['test'], {
193
+ cwd: process.cwd(),
194
+ });
195
+ });
196
+ it('should pass arguments to command', async () => {
197
+ const mockChild = {
198
+ stdout: { on: jest.fn() },
199
+ stderr: { on: jest.fn() },
200
+ on: jest.fn((event, callback) => {
201
+ if (event === 'close') {
202
+ setTimeout(() => callback(0), 10);
203
+ }
204
+ }),
205
+ };
206
+ mockedSpawn.mockReturnValue(mockChild);
207
+ const args = ['arg1', 'arg2', '--flag'];
208
+ await Worker_1.Worker.execPromise('command', args, '/custom/path');
209
+ expect(mockedSpawn).toHaveBeenCalledWith('command', args, {
210
+ cwd: '/custom/path',
211
+ });
212
+ });
213
+ });
214
+ describe('logger', () => {
215
+ it('should have a logger instance', () => {
216
+ expect(Worker_1.Worker.logger).toBeDefined();
217
+ });
218
+ });
219
+ });
package/package.json CHANGED
@@ -1,46 +1,45 @@
1
1
  {
2
- "name": "@quatrain/worker",
3
- "version": "1.1.42",
4
- "description": "Container Worker helpers",
5
- "main": "lib/index.js",
6
- "types": "lib/index.d.ts",
7
- "files": [
8
- "lib/",
9
- "README.md"
10
- ],
11
- "author": "Quatrain Développement SAS <developers@quatrain.com>",
12
- "license": "MIT",
13
- "devDependencies": {
14
- "@tsconfig/recommended": "^1.0.1",
15
- "@types/fluent-ffmpeg": "^2",
16
- "@types/fs-extra": "^11.0.4",
17
- "@types/jest": "^27.0.3",
18
- "@types/node": "^22.9.0",
19
- "@types/object-hash": "^3.0.6",
20
- "jest": "^27.4.7",
21
- "jest-node-exports-resolver": "^1.1.6",
22
- "trace-unhandled": "^2.0.1",
23
- "ts-jest": "^27.1.2",
24
- "ts-node": "^10.4.0",
25
- "typescript": "^5.1.5"
26
- },
27
- "dependencies": {
28
- "@quatrain/core": "^1.1.23",
29
- "@quatrain/queue": "^1.1.19",
30
- "axios": "^1.7.7",
31
- "fluent-ffmpeg": "^2.1.2",
32
- "fs-extra": "^11.2.0",
33
- "node-fetch-native": "^1.6.4"
34
- },
35
- "scripts": {
36
- "test-ci": "jest --runInBand",
37
- "build": "tsc",
38
- "wbuild": "tsc --watch",
39
- "bump-to": "yarn version",
40
- "hash": "node ../../bin/hashFolder.js",
41
- "hash:persist": "yarn hash > .hash_latest.txt",
42
- "hash:compare": "yarn hash > .hash_newest.txt && cmp -s .hash_latest.txt .hash_newest.txt",
43
- "publish": "yarn hash:compare || yarn publish:process",
44
- "publish:process": "yarn version patch && yarn build && yarn npm publish --access public && yarn hash:persist"
45
- }
46
- }
2
+ "name": "@quatrain/worker",
3
+ "version": "1.1.44",
4
+ "description": "Container Worker helpers",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "bun": "src/index.ts",
8
+ "files": [
9
+ "LICENSE.md",
10
+ "src/",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "author": "Quatrain Développement SAS <developers@quatrain.com>",
15
+ "license": "AGPL-3.0-only",
16
+ "devDependencies": {
17
+ "@tsconfig/recommended": "^1.0.1",
18
+ "@types/fluent-ffmpeg": "^2",
19
+ "@types/fs-extra": "^11.0.4",
20
+ "@types/jest": "^27.0.3",
21
+ "@types/node": "^22.9.0",
22
+ "@types/object-hash": "^3.0.6",
23
+ "jest": "^30.2.0",
24
+ "jest-node-exports-resolver": "^1.1.6",
25
+ "trace-unhandled": "^2.0.1",
26
+ "ts-jest": "^27.1.2",
27
+ "ts-node": "^10.4.0",
28
+ "typescript": "^5.1.5"
29
+ },
30
+ "dependencies": {
31
+ "@quatrain/core": "^1.1.43",
32
+ "@quatrain/queue": "^1.1.21",
33
+ "@quatrain/storage": "^1.1.24",
34
+ "axios": "^1.7.7",
35
+ "fluent-ffmpeg": "^2.1.2",
36
+ "fs-extra": "^11.2.0",
37
+ "node-fetch-native": "^1.6.4"
38
+ },
39
+ "scripts": {
40
+ "test-ci": "jest --runInBand",
41
+ "build": "tsc",
42
+ "wbuild": "tsc --watch",
43
+ "bump-to": "yarn version"
44
+ }
45
+ }
@@ -0,0 +1,186 @@
1
+ import fs, { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import axios from 'axios'
4
+ import { Worker } from './Worker'
5
+ import fetch from 'node-fetch-native'
6
+ import * as ffmpeg from 'fluent-ffmpeg'
7
+ import { FileType } from '@quatrain/storage'
8
+
9
+ export class FileSystem {
10
+ static prepare(folder: string) {
11
+ Worker.debug(`Setting up process folder ${folder}`)
12
+ this.removeFolder(folder)
13
+
14
+ fs.mkdirSync(folder)
15
+ fs.mkdirSync(folder + path.sep + 'images')
16
+ fs.mkdirSync(folder + path.sep + 'vecto')
17
+ }
18
+
19
+ static makeFolder(folder: string) {
20
+ fs.mkdirSync(folder)
21
+ }
22
+
23
+ static removeFolder(folder: string, recursively = true) {
24
+ if (fs.existsSync(folder)) {
25
+ fs.readdirSync(folder).forEach((element) => {
26
+ const item = path.join(folder, element)
27
+ if (fs.lstatSync(item).isDirectory()) {
28
+ if (recursively !== true) {
29
+ throw new Error(`Folder contains folder`)
30
+ }
31
+ this.removeFolder(item)
32
+ } else {
33
+ fs.unlinkSync(item)
34
+ }
35
+ })
36
+
37
+ fs.rmdirSync(folder)
38
+ }
39
+ }
40
+
41
+ static async downloadFile(url: string, filepath: string) {
42
+ try {
43
+ Worker.debug(`Downloading file at ${url} to ${filepath}`)
44
+ const writer = fs.createWriteStream(filepath)
45
+ const response = await axios.get(url, {
46
+ responseType: 'stream',
47
+ })
48
+
49
+ response.data.pipe(writer)
50
+
51
+ return new Promise((resolve, reject) => {
52
+ writer.on('finish', resolve)
53
+ writer.on('error', reject)
54
+ })
55
+ } catch (err) {
56
+ Worker.error(`Download error: ${(err as Error).message}`)
57
+ throw err
58
+ }
59
+ }
60
+
61
+ static safeString(name: string) {
62
+ return name.replace(/\s+/g, '_')
63
+ }
64
+
65
+ /**
66
+ * Upload file to public URL and return meta data
67
+ * @param filename string
68
+ * @param meta
69
+ * @param mime
70
+ * @returns Promise
71
+ */
72
+ static uploadFile(
73
+ filename: string,
74
+ meta: FileType,
75
+ mime = 'video/mp4'
76
+ ): Promise<FileType> {
77
+ // try to get more file metadata
78
+ meta = { ...meta, ...FileSystem.getInfo(filename) }
79
+ Worker.info(`Uploading file ${filename} to ${meta.uploadUrl}`)
80
+ const { size } = fs.statSync(filename)
81
+
82
+ if (size < 32 * 1024) {
83
+ return new Promise((resolve, reject) => {
84
+ fetch(meta.uploadUrl, {
85
+ method: 'PUT',
86
+ mode: 'cors',
87
+ duplex: 'half',
88
+ body: readFileSync(filename),
89
+ headers: {
90
+ 'Content-Type': meta.contentType || mime,
91
+ 'Content-length': String(size),
92
+ },
93
+ })
94
+ .then(() => resolve({ ...meta, size, uploadUrl: undefined }))
95
+ .catch((err) => {
96
+ Worker.error(
97
+ `An error occured while uploading ${filename}: ${err.message}`
98
+ )
99
+ reject(err)
100
+ })
101
+ })
102
+ }
103
+
104
+ return new Promise((resolve, reject) => {
105
+ try {
106
+ let done = 0 // total bytes uploaded
107
+ let prev = 0 // latest value of done stored when % done was displayed
108
+ const { size } = fs.statSync(filename)
109
+ const readStream = fs.createReadStream(filename)
110
+
111
+ const sizeMB = (size / (1024 * 1024)).toFixed(2)
112
+
113
+ readStream.on('data', (data) => {
114
+ done += data.length
115
+ if (done - prev >= size / 20) {
116
+ // only display message when 5% more has been uploaded
117
+ Worker.info(
118
+ `${((done / size) * 100).toFixed(2)}% - ${(
119
+ done /
120
+ (1024 * 1024)
121
+ ).toFixed(2)}MB / ${sizeMB}MB`
122
+ )
123
+ prev = done
124
+ }
125
+ })
126
+
127
+ Worker.info(`Uploading file ${filename} with size ${size} bytes`)
128
+ fetch(meta.uploadUrl, {
129
+ method: 'PUT',
130
+ mode: 'cors',
131
+ duplex: 'half',
132
+ body: readStream,
133
+ headers: {
134
+ 'Content-Type': meta.contentType || mime,
135
+ 'Content-length': String(size),
136
+ },
137
+ })
138
+ .then(() => resolve({ ...meta, size, uploadUrl: undefined }))
139
+ .catch((err) => {
140
+ Worker.error(
141
+ `An error occured while uploading ${filename}: ${err.message}`
142
+ )
143
+ reject(err)
144
+ })
145
+ } catch (err) {
146
+ Worker.error(err)
147
+ reject(err)
148
+ }
149
+ })
150
+ }
151
+
152
+ /**
153
+ * Return meta data on given file
154
+ */
155
+ static getInfo = (file: string): Promise<any> => {
156
+ if (file.endsWith('.mp4') || file.endsWith('.insv')) {
157
+ return new Promise((resolve, reject) =>
158
+ ffmpeg.ffprobe(file, (err: any, metadata: any) => {
159
+ if (err) {
160
+ Worker.error(err)
161
+ reject(err)
162
+ }
163
+ // Worker.debug(`ffprobe getInfo: ${JSON.stringify(metadata)}`)
164
+ const {
165
+ width,
166
+ height,
167
+ duration,
168
+ bit_rate: bitrate,
169
+ nb_frames: nbFramees,
170
+ } = metadata.streams[0]
171
+ const nb_frames: number = parseFloat(nbFramees as string)
172
+ const framerate = nb_frames / parseFloat(duration as string)
173
+ resolve({
174
+ width,
175
+ height,
176
+ framerate: parseInt(framerate.toFixed(0)),
177
+ duration: parseInt(duration as string),
178
+ bitrate,
179
+ })
180
+ })
181
+ )
182
+ }
183
+ // Handle other cases silently
184
+ return new Promise((resolve) => resolve({}))
185
+ }
186
+ }
package/src/Handler.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { Worker } from './Worker'
2
+
3
+ export const handler = async (
4
+ messageHandler: Function,
5
+ config: any,
6
+ adapters: any
7
+ ) => {
8
+ try {
9
+ Worker.info(
10
+ `Worker version ${require('../package.json').version} started in ${
11
+ config.MQ_MODE
12
+ } mode`
13
+ )
14
+ switch (config.MQ_MODE) {
15
+ case 'queue':
16
+ const { Queue } = require('@quatrain/queue')
17
+ Queue.addQueue(adapters.queue, 'default', true)
18
+ Queue.info(
19
+ `Connecting to amqp://${config.MQ_HOST}:${config.MQ_PORT}`
20
+ )
21
+ Queue.getQueue().listen(config.MQ_TOPIC, messageHandler, {
22
+ concurrency: config.MQ_CONCURRENCY,
23
+ gpu: config.MQ_GPU,
24
+ })
25
+ break
26
+
27
+ case 'cli':
28
+ case 'test':
29
+ Worker.warn(`Message received from CLI.`)
30
+ const json =
31
+ config.MQ_MODE === 'test'
32
+ ? require('../test.json')
33
+ : process.env.JSON
34
+
35
+ if (!json) {
36
+ throw new Error(`CLI call with missing environment variables`)
37
+ }
38
+
39
+ await messageHandler(json)
40
+ break
41
+
42
+ default:
43
+ Worker.error(`Unknown mode option: '${config.MQ_MODE}'`)
44
+ process.exit(1)
45
+ }
46
+ } catch (error) {
47
+ Worker.error((error as Error).message)
48
+ process.exit(1)
49
+ }
50
+ }
package/src/Helpers.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { Worker } from './Worker'
2
+ import path from 'path'
3
+
4
+ export class Helpers {
5
+ static FFMPEG = '/usr/bin/ffmpeg'
6
+
7
+ /**
8
+ * Generate a thubnail from a video file at given frame position
9
+ * @param videoPath path to video file
10
+ * @param frame frame to extract thumbnail from
11
+ * @param width width of thumbnail (4/3 ratio)
12
+ * @returns
13
+ */
14
+ static generateVideoThumbnail = async (
15
+ videoPath: string,
16
+ outputPath: string,
17
+ frame: number = 0,
18
+ width: number = 320
19
+ ) => {
20
+ const resolution = `${width}x${Math.ceil(width * 0.75)}`
21
+ const ffmpegParams = [
22
+ '-i',
23
+ videoPath,
24
+ '-vframes',
25
+ '1',
26
+ '-vf',
27
+ String.raw`select=gte(n\,${frame})`,
28
+ '-s',
29
+ resolution,
30
+ '-ss',
31
+ '1',
32
+ outputPath,
33
+ '-y',
34
+ ]
35
+
36
+ Worker.info(`Generating Thumbnail : ${outputPath}`)
37
+
38
+ return await Worker.execPromise(Helpers.FFMPEG, ffmpegParams)
39
+ }
40
+ }
@@ -0,0 +1,289 @@
1
+ import { Worker } from './Worker'
2
+ import axios from 'axios'
3
+ import { spawn } from 'child_process'
4
+
5
+ // Mock dependencies
6
+ jest.mock('axios')
7
+ jest.mock('child_process')
8
+
9
+ const mockedAxios = axios as jest.Mocked<typeof axios>
10
+ const mockedSpawn = spawn as jest.MockedFunction<typeof spawn>
11
+
12
+ describe('Worker', () => {
13
+ beforeEach(() => {
14
+ // Clear all mocks before each test
15
+ jest.clearAllMocks()
16
+
17
+ // Reset Worker.endpoint
18
+ Worker.endpoint = ''
19
+ })
20
+
21
+ describe('endpoint property', () => {
22
+ it('should have an empty endpoint by default', () => {
23
+ expect(Worker.endpoint).toBe('')
24
+ })
25
+
26
+ it('should allow setting the endpoint', () => {
27
+ Worker.endpoint = 'https://api.example.com/events'
28
+ expect(Worker.endpoint).toBe('https://api.example.com/events')
29
+ })
30
+ })
31
+
32
+ describe('pushEvent', () => {
33
+ it('should return false when endpoint is not set', () => {
34
+ const result = Worker.pushEvent('test-event')
35
+ expect(result).toBe(false)
36
+ expect(mockedAxios.patch).not.toHaveBeenCalled()
37
+ })
38
+
39
+ it('should send event when endpoint is set', () => {
40
+ Worker.endpoint = 'https://api.example.com/events'
41
+
42
+ mockedAxios.patch.mockResolvedValue({
43
+ statusText: 'OK',
44
+ data: { success: true },
45
+ })
46
+
47
+ Worker.pushEvent('test-event', { foo: 'bar' })
48
+
49
+ expect(mockedAxios.patch).toHaveBeenCalledWith(
50
+ 'https://api.example.com/events',
51
+ expect.objectContaining({
52
+ event: 'test-event',
53
+ foo: 'bar',
54
+ })
55
+ )
56
+ })
57
+
58
+ it('should include custom timestamp in payload', () => {
59
+ Worker.endpoint = 'https://api.example.com/events'
60
+ const customTs = 1234567890
61
+
62
+ mockedAxios.patch.mockResolvedValue({
63
+ statusText: 'OK',
64
+ data: { success: true },
65
+ })
66
+
67
+ Worker.pushEvent('test-event', {}, customTs)
68
+
69
+ expect(mockedAxios.patch).toHaveBeenCalledWith(
70
+ 'https://api.example.com/events',
71
+ expect.objectContaining({
72
+ event: 'test-event',
73
+ ts: expect.any(Number),
74
+ })
75
+ )
76
+ })
77
+
78
+ it('should include additional data in payload', () => {
79
+ Worker.endpoint = 'https://api.example.com/events'
80
+
81
+ mockedAxios.patch.mockResolvedValue({
82
+ statusText: 'OK',
83
+ data: { success: true },
84
+ })
85
+
86
+ const additionalData = {
87
+ userId: '123',
88
+ action: 'upload',
89
+ metadata: { size: 1024 },
90
+ }
91
+
92
+ Worker.pushEvent('custom-event', additionalData)
93
+
94
+ expect(mockedAxios.patch).toHaveBeenCalledWith(
95
+ 'https://api.example.com/events',
96
+ expect.objectContaining({
97
+ event: 'custom-event',
98
+ userId: '123',
99
+ action: 'upload',
100
+ metadata: { size: 1024 },
101
+ })
102
+ )
103
+ })
104
+ })
105
+
106
+ describe('pushEventAsync', () => {
107
+ it('should return false when endpoint is not set', async () => {
108
+ const result = await Worker.pushEventAsync('test-event')
109
+ expect(result).toBe(false)
110
+ expect(mockedAxios.patch).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('should send event and return true on success', async () => {
114
+ Worker.endpoint = 'https://api.example.com/events'
115
+
116
+ mockedAxios.patch.mockResolvedValue({
117
+ statusText: 'OK',
118
+ data: { success: true },
119
+ })
120
+
121
+ const result = await Worker.pushEventAsync('test-event', {
122
+ foo: 'bar',
123
+ })
124
+
125
+ expect(result).toBe(true)
126
+ expect(mockedAxios.patch).toHaveBeenCalledWith(
127
+ 'https://api.example.com/events',
128
+ expect.objectContaining({
129
+ event: 'test-event',
130
+ foo: 'bar',
131
+ })
132
+ )
133
+ })
134
+
135
+ it('should return false on axios error', async () => {
136
+ Worker.endpoint = 'https://api.example.com/events'
137
+
138
+ mockedAxios.patch.mockRejectedValue(new Error('Network error'))
139
+
140
+ const result = await Worker.pushEventAsync('test-event')
141
+
142
+ expect(result).toBe(false)
143
+ })
144
+
145
+ it('should handle non-OK status response', async () => {
146
+ Worker.endpoint = 'https://api.example.com/events'
147
+
148
+ mockedAxios.patch.mockResolvedValue({
149
+ statusText: 'Bad Request',
150
+ data: { error: 'Invalid payload' },
151
+ })
152
+
153
+ const result = await Worker.pushEventAsync('test-event')
154
+
155
+ expect(result).toBe(undefined)
156
+ })
157
+
158
+ it('should include custom timestamp', async () => {
159
+ Worker.endpoint = 'https://api.example.com/events'
160
+ const customTs = 9876543210
161
+
162
+ mockedAxios.patch.mockResolvedValue({
163
+ statusText: 'OK',
164
+ data: { success: true },
165
+ })
166
+
167
+ await Worker.pushEventAsync('test-event', {}, customTs)
168
+
169
+ expect(mockedAxios.patch).toHaveBeenCalledWith(
170
+ 'https://api.example.com/events',
171
+ expect.objectContaining({
172
+ event: 'test-event',
173
+ ts: expect.any(Number),
174
+ })
175
+ )
176
+ })
177
+ })
178
+
179
+ describe('execPromise', () => {
180
+ it('should execute command successfully', async () => {
181
+ const mockChild = {
182
+ stdout: {
183
+ on: jest.fn((event, callback) => {
184
+ if (event === 'data') {
185
+ // Simulate stdout data
186
+ setTimeout(
187
+ () => callback(Buffer.from('Command output')),
188
+ 10
189
+ )
190
+ }
191
+ }),
192
+ },
193
+ stderr: {
194
+ on: jest.fn(),
195
+ },
196
+ on: jest.fn((event, callback) => {
197
+ if (event === 'close') {
198
+ // Simulate successful completion
199
+ setTimeout(() => callback(0), 20)
200
+ }
201
+ }),
202
+ }
203
+
204
+ mockedSpawn.mockReturnValue(mockChild as any)
205
+
206
+ await expect(
207
+ Worker.execPromise('ls', ['-la'], '/tmp')
208
+ ).resolves.toBeUndefined()
209
+
210
+ expect(mockedSpawn).toHaveBeenCalledWith('ls', ['-la'], {
211
+ cwd: '/tmp',
212
+ })
213
+ })
214
+
215
+ it('should reject on command failure', async () => {
216
+ const mockChild = {
217
+ stdout: {
218
+ on: jest.fn(),
219
+ },
220
+ stderr: {
221
+ on: jest.fn((event, callback) => {
222
+ if (event === 'data') {
223
+ setTimeout(() => callback(Buffer.from('Error output')), 10)
224
+ }
225
+ }),
226
+ },
227
+ on: jest.fn((event, callback) => {
228
+ if (event === 'close') {
229
+ // Simulate failure with exit code 1
230
+ setTimeout(() => callback(1), 20)
231
+ }
232
+ }),
233
+ }
234
+
235
+ mockedSpawn.mockReturnValue(mockChild as any)
236
+
237
+ await expect(
238
+ Worker.execPromise('invalid-command', [])
239
+ ).rejects.toThrow('Process failed and returned code: 1')
240
+ })
241
+
242
+ it('should use current working directory by default', async () => {
243
+ const mockChild = {
244
+ stdout: { on: jest.fn() },
245
+ stderr: { on: jest.fn() },
246
+ on: jest.fn((event, callback) => {
247
+ if (event === 'close') {
248
+ setTimeout(() => callback(0), 10)
249
+ }
250
+ }),
251
+ }
252
+
253
+ mockedSpawn.mockReturnValue(mockChild as any)
254
+
255
+ await Worker.execPromise('echo', ['test'])
256
+
257
+ expect(mockedSpawn).toHaveBeenCalledWith('echo', ['test'], {
258
+ cwd: process.cwd(),
259
+ })
260
+ })
261
+
262
+ it('should pass arguments to command', async () => {
263
+ const mockChild = {
264
+ stdout: { on: jest.fn() },
265
+ stderr: { on: jest.fn() },
266
+ on: jest.fn((event, callback) => {
267
+ if (event === 'close') {
268
+ setTimeout(() => callback(0), 10)
269
+ }
270
+ }),
271
+ }
272
+
273
+ mockedSpawn.mockReturnValue(mockChild as any)
274
+
275
+ const args = ['arg1', 'arg2', '--flag']
276
+ await Worker.execPromise('command', args, '/custom/path')
277
+
278
+ expect(mockedSpawn).toHaveBeenCalledWith('command', args, {
279
+ cwd: '/custom/path',
280
+ })
281
+ })
282
+ })
283
+
284
+ describe('logger', () => {
285
+ it('should have a logger instance', () => {
286
+ expect(Worker.logger).toBeDefined()
287
+ })
288
+ })
289
+ })
package/src/Worker.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { Core } from '@quatrain/core'
2
+ import os from 'os'
3
+ import axios from 'axios'
4
+ import { spawn } from 'child_process'
5
+ import { HandlerParameters } from './types/HandlerParameters'
6
+
7
+ export class Worker extends Core {
8
+ static endpoint: string = ''
9
+ static readonly logger = this.addLogger('Worker')
10
+
11
+ /**
12
+ * Execute an external command in a promise
13
+ * @see https://stackoverflow.com/questions/46289682/how-to-wait-for-child-process-spawn-execution-with-async-await
14
+ * @see https://dzone.com/articles/understanding-execfile-spawn-exec-and-fork-in-node
15
+ * @param string command
16
+ * @param array args
17
+ * @return Promise
18
+ */
19
+ static readonly execPromise = (
20
+ command: string,
21
+ args: any[] = [],
22
+ cwd = process.cwd()
23
+ ): Promise<any> => {
24
+ try {
25
+ Worker.info(`Executing command ${command} in ${cwd} with arguments:`)
26
+ args.forEach((arg) => console.log(`\t${arg}`))
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(command, args, { cwd })
29
+
30
+ child.stdout.on('data', (data: Buffer) =>
31
+ Worker.debug(data.toString())
32
+ )
33
+
34
+ child.stderr.on('data', (data: Buffer) =>
35
+ Worker.debug(data.toString())
36
+ )
37
+
38
+ child.on('close', (code) => {
39
+ if (code !== 0) {
40
+ Worker.error(`Command execution failed with code: ${code}`)
41
+ reject(new Error(`Process failed and returned code: ${code}`))
42
+ } else {
43
+ Worker.info(`Command execution completed with code: ${code}`)
44
+ resolve(undefined)
45
+ }
46
+ })
47
+ })
48
+ } catch (err) {
49
+ Worker.error((err as Error).message)
50
+ throw err
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Push an event to the backend endpoint, if available
56
+ * @param event string
57
+ * @param data
58
+ * @param ts timestamp
59
+ * @returns boolean
60
+ */
61
+ static pushEvent(event: string, data = {}, ts = 0) {
62
+ if (!this.endpoint) {
63
+ Worker.warn(`Events endpoint is not set, can't send update!`)
64
+ return false
65
+ }
66
+
67
+ ts = ts === Date.now() ? Date.now() + 1 : Date.now()
68
+ const payload = {
69
+ event,
70
+ worker: `Container ${os.hostname}`,
71
+ os: `${os.type} ${os.release} (${os.platform} ${os.arch})`,
72
+ ...data,
73
+ ts,
74
+ }
75
+
76
+ Worker.debug('event payload', payload)
77
+
78
+ axios
79
+ .patch(Worker.endpoint, payload)
80
+ .then((res) => {
81
+ Worker.info(`Event pushed to backend: ${res.statusText}`)
82
+ Worker.info(res.data)
83
+ return true
84
+ })
85
+ .catch((err) => {
86
+ Worker.error(`Failed to push event to backend: ${err}`)
87
+ return false
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Async Push an event to the backend endpoint, if available
93
+ * @param event string
94
+ * @param data
95
+ * @param ts timestamp
96
+ * @returns boolean
97
+ */
98
+ static async pushEventAsync(event: string, data = {}, ts = 0) {
99
+ if (!this.endpoint) {
100
+ Worker.warn(`Events endpoint is not set, can't send update!`)
101
+ return false
102
+ }
103
+
104
+ try {
105
+ ts = ts === Date.now() ? Date.now() + 1 : Date.now()
106
+ const payload = {
107
+ event,
108
+ worker: `Container ${os.hostname}`,
109
+ os: `${os.type} ${os.release} (${os.platform} ${os.arch})`,
110
+ ...data,
111
+ ts,
112
+ }
113
+
114
+ const res = await axios.patch(Worker.endpoint, payload)
115
+
116
+ if (res.statusText === 'OK') {
117
+ Worker.info(`Event pushed to backend: ${res.statusText}`)
118
+ return true
119
+ }
120
+ } catch (err) {
121
+ Worker.error(`Failed to push event to backend: ${err}`)
122
+ return false
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Global handling function to process received messages
128
+ * @param messageHandler function
129
+ * @param config object
130
+ */
131
+ static readonly handler = async (
132
+ messageHandler: Function,
133
+ config: HandlerParameters
134
+ ) => {
135
+ try {
136
+ switch (config.mode) {
137
+ case 'queue':
138
+ const { Queue } = require('@quatrain/queue')
139
+ Queue.addQueue(config.queueAdapter, 'default', true)
140
+ Queue.getQueue().listen(config.topic, messageHandler, {
141
+ concurrency: config.concurrency,
142
+ gpu: config.gpu,
143
+ })
144
+ Queue.info(
145
+ `Connected and listening to ${config.topic}, ready to receive messages.`
146
+ )
147
+ break
148
+
149
+ case 'cli':
150
+ case 'test':
151
+ Worker.warn(`Message received from CLI.`)
152
+ const json =
153
+ config.mode === 'test'
154
+ ? require('../test.json')
155
+ : process.env.JSON
156
+
157
+ if (!json) {
158
+ throw new Error(`CLI call with missing environment variables`)
159
+ }
160
+
161
+ await messageHandler(json)
162
+ break
163
+
164
+ default:
165
+ Worker.error(`Unknown mode option: '${config.mode}'`)
166
+ process.exit(1)
167
+ }
168
+ } catch (error) {
169
+ Worker.error((error as Error).message)
170
+ process.exit(1)
171
+ }
172
+ }
173
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { Worker } from './Worker'
2
+ import { FileSystem } from './FileSystem'
3
+ import { Helpers } from './Helpers'
4
+
5
+ import { HandlerParameters } from './types/HandlerParameters'
6
+ import { MessagehandlerParameters } from './types/MessagehandlerParameters'
7
+ import { ModeEnum } from './types/ModeEnum'
8
+
9
+ export {
10
+ Worker,
11
+ FileSystem,
12
+ Helpers,
13
+ ModeEnum,
14
+ HandlerParameters,
15
+ MessagehandlerParameters,
16
+ }
@@ -0,0 +1,10 @@
1
+ import { ModeEnum } from './ModeEnum'
2
+ import { AbstractQueueAdapter } from '@quatrain/queue'
3
+
4
+ export interface HandlerParameters {
5
+ mode: string | typeof ModeEnum
6
+ topic?: string
7
+ concurrency?: number
8
+ queueAdapter?: AbstractQueueAdapter
9
+ gpu?: boolean
10
+ }
@@ -0,0 +1,4 @@
1
+ export interface MessagehandlerParameters {
2
+ concurrency?: number
3
+ gpu?: boolean
4
+ }
@@ -0,0 +1,5 @@
1
+ export enum ModeEnum {
2
+ MODE_CLI = 'cli',
3
+ MODE_QUEUE = 'queue',
4
+ MODE_TEST = 'test',
5
+ }
@@ -1,9 +0,0 @@
1
- import { ModeEnum } from './ModeEnum';
2
- import { AbstractQueueAdapter } from '@quatrain/queue';
3
- export interface HandlerParameters {
4
- mode: string | typeof ModeEnum;
5
- topic?: string;
6
- concurrency?: number;
7
- queueAdapter?: AbstractQueueAdapter;
8
- gpu?: boolean;
9
- }
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,5 +0,0 @@
1
- export declare enum ModeEnum {
2
- MODE_CLI = "cli",
3
- MODE_QUEUE = "queue",
4
- MODE_TEST = "test"
5
- }
@@ -1,9 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ModeEnum = void 0;
4
- var ModeEnum;
5
- (function (ModeEnum) {
6
- ModeEnum["MODE_CLI"] = "cli";
7
- ModeEnum["MODE_QUEUE"] = "queue";
8
- ModeEnum["MODE_TEST"] = "test";
9
- })(ModeEnum || (exports.ModeEnum = ModeEnum = {}));