bunqueue-client 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 +21 -0
- package/README.md +165 -0
- package/dist/connection-types.d.ts +24 -0
- package/dist/connection-types.js +2 -0
- package/dist/connection.d.ts +44 -0
- package/dist/connection.js +229 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +25 -0
- package/dist/flow-types.d.ts +34 -0
- package/dist/flow-types.js +2 -0
- package/dist/flow.d.ts +31 -0
- package/dist/flow.js +186 -0
- package/dist/frame.d.ts +16 -0
- package/dist/frame.js +57 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +12 -0
- package/dist/job.d.ts +56 -0
- package/dist/job.js +151 -0
- package/dist/queue-admin.d.ts +63 -0
- package/dist/queue-admin.js +143 -0
- package/dist/queue-control.d.ts +44 -0
- package/dist/queue-control.js +85 -0
- package/dist/queue-query.d.ts +58 -0
- package/dist/queue-query.js +165 -0
- package/dist/queue.d.ts +50 -0
- package/dist/queue.js +77 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.js +50 -0
- package/dist/worker-base.d.ts +51 -0
- package/dist/worker-base.js +122 -0
- package/dist/worker-types.d.ts +27 -0
- package/dist/worker-types.js +5 -0
- package/dist/worker.d.ts +20 -0
- package/dist/worker.js +151 -0
- package/package.json +64 -0
- package/src/connection-types.ts +21 -0
- package/src/connection.ts +242 -0
- package/src/errors.ts +26 -0
- package/src/flow-types.ts +40 -0
- package/src/flow.ts +236 -0
- package/src/frame.ts +62 -0
- package/src/index.ts +41 -0
- package/src/job.ts +194 -0
- package/src/queue-admin.ts +213 -0
- package/src/queue-control.ts +145 -0
- package/src/queue-query.ts +220 -0
- package/src/queue.ts +112 -0
- package/src/types.ts +133 -0
- package/src/worker-base.ts +132 -0
- package/src/worker-types.ts +32 -0
- package/src/worker.ts +174 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCP connection to a bunqueue server — cross-runtime (Node.js, Bun, Deno).
|
|
3
|
+
*
|
|
4
|
+
* Requests carry a `reqId` string; the server echoes it back, enabling
|
|
5
|
+
* pipelining (many in-flight commands per socket). Uses only `node:`
|
|
6
|
+
* builtins (net/tls), which Node, Bun and Deno all support.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { connect as netConnect, type Socket } from 'node:net';
|
|
11
|
+
import { type ConnectionOptions as TlsConnectOptions, connect as tlsConnect } from 'node:tls';
|
|
12
|
+
import { pack, unpack } from 'msgpackr';
|
|
13
|
+
import type {
|
|
14
|
+
Command,
|
|
15
|
+
ConnectionOptions,
|
|
16
|
+
Pending,
|
|
17
|
+
Response,
|
|
18
|
+
TlsOption,
|
|
19
|
+
} from './connection-types.js';
|
|
20
|
+
import { AuthError, CommandError, CommandTimeoutError, ConnectionClosedError } from './errors.js';
|
|
21
|
+
import { compact, FrameParser, frame, PROTOCOL_VERSION } from './frame.js';
|
|
22
|
+
|
|
23
|
+
export type { Command, ConnectionOptions, Response, TlsOption } from './connection-types.js';
|
|
24
|
+
|
|
25
|
+
/** A single pipelined TCP connection to a bunqueue server. */
|
|
26
|
+
export class Connection {
|
|
27
|
+
readonly host: string;
|
|
28
|
+
readonly port: number;
|
|
29
|
+
readonly token: string | undefined;
|
|
30
|
+
readonly tls: TlsOption;
|
|
31
|
+
readonly connectTimeoutMs: number;
|
|
32
|
+
readonly commandTimeoutMs: number;
|
|
33
|
+
|
|
34
|
+
private socket: Socket | null = null;
|
|
35
|
+
private connected = false;
|
|
36
|
+
private closed = false;
|
|
37
|
+
private connecting: Promise<void> | null = null;
|
|
38
|
+
private pending = new Map<string, Pending>();
|
|
39
|
+
private reqCounter = 0;
|
|
40
|
+
private parser = new FrameParser();
|
|
41
|
+
|
|
42
|
+
constructor(options: ConnectionOptions = {}) {
|
|
43
|
+
this.host = options.host ?? 'localhost';
|
|
44
|
+
this.port = options.port ?? 6789;
|
|
45
|
+
this.token = options.token;
|
|
46
|
+
this.tls = options.tls;
|
|
47
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 5000;
|
|
48
|
+
this.commandTimeoutMs = options.commandTimeoutMs ?? 10_000;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get isConnected(): boolean {
|
|
52
|
+
return this.connected;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Open the socket (and authenticate) if not already connected. */
|
|
56
|
+
async connect(): Promise<void> {
|
|
57
|
+
if (this.connected) return;
|
|
58
|
+
if (this.closed) throw new ConnectionClosedError('connection closed by client');
|
|
59
|
+
if (this.connecting) return this.connecting;
|
|
60
|
+
this.connecting = this.doConnect().finally(() => {
|
|
61
|
+
this.connecting = null;
|
|
62
|
+
});
|
|
63
|
+
return this.connecting;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async doConnect(): Promise<void> {
|
|
67
|
+
const socket = await this.openSocket();
|
|
68
|
+
socket.setNoDelay(true);
|
|
69
|
+
this.parser.clear();
|
|
70
|
+
this.socket = socket;
|
|
71
|
+
|
|
72
|
+
socket.on('data', (chunk: Buffer) => this.handleData(chunk));
|
|
73
|
+
socket.on('error', () => this.teardown());
|
|
74
|
+
socket.on('close', () => this.teardown());
|
|
75
|
+
|
|
76
|
+
this.connected = true;
|
|
77
|
+
|
|
78
|
+
if (this.token) {
|
|
79
|
+
try {
|
|
80
|
+
await this.call({ cmd: 'Auth', token: this.token });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
this.teardown();
|
|
83
|
+
if (err instanceof CommandError) throw new AuthError(err.message);
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private openSocket(): Promise<Socket> {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
socket.destroy();
|
|
96
|
+
reject(new ConnectionClosedError(`connect timeout to ${this.host}:${this.port}`));
|
|
97
|
+
}, this.connectTimeoutMs);
|
|
98
|
+
|
|
99
|
+
const onError = (err: Error) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
reject(new ConnectionClosedError(`connect failed: ${err.message}`));
|
|
104
|
+
};
|
|
105
|
+
const onReady = () => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
socket.off('error', onError);
|
|
110
|
+
resolve(socket);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let socket: Socket;
|
|
114
|
+
if (this.tls) {
|
|
115
|
+
const tlsOpts: TlsConnectOptions = { host: this.host, port: this.port };
|
|
116
|
+
if (typeof this.tls === 'object') {
|
|
117
|
+
if (this.tls.caFile) tlsOpts.ca = readFileSync(this.tls.caFile);
|
|
118
|
+
if (this.tls.rejectUnauthorized === false) tlsOpts.rejectUnauthorized = false;
|
|
119
|
+
}
|
|
120
|
+
socket = tlsConnect(tlsOpts, onReady);
|
|
121
|
+
} else {
|
|
122
|
+
socket = netConnect({ host: this.host, port: this.port }, onReady);
|
|
123
|
+
}
|
|
124
|
+
socket.on('error', onError);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Send a command and await its response. Rejects with CommandError when
|
|
130
|
+
* the server answers ok=false. Reconnects lazily if the link was lost.
|
|
131
|
+
*/
|
|
132
|
+
async call(command: Command, timeoutMs?: number): Promise<Response> {
|
|
133
|
+
if (!this.connected) await this.connect();
|
|
134
|
+
const socket = this.socket;
|
|
135
|
+
if (!socket) throw new ConnectionClosedError('not connected');
|
|
136
|
+
|
|
137
|
+
this.reqCounter = (this.reqCounter + 1) & 0x7fffffff;
|
|
138
|
+
const reqId = String(this.reqCounter);
|
|
139
|
+
const payload = pack({ ...compact(command), reqId });
|
|
140
|
+
|
|
141
|
+
return new Promise<Response>((resolve, reject) => {
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
this.pending.delete(reqId);
|
|
144
|
+
reject(new CommandTimeoutError(`no response for ${command.cmd} within timeout`));
|
|
145
|
+
}, timeoutMs ?? this.commandTimeoutMs);
|
|
146
|
+
|
|
147
|
+
this.pending.set(reqId, {
|
|
148
|
+
resolve: (response) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
reject(new CommandError(String(response.error ?? 'unknown server error')));
|
|
152
|
+
} else {
|
|
153
|
+
resolve(response);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
reject: (err) => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
reject(err);
|
|
159
|
+
},
|
|
160
|
+
timer,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
socket.write(frame(payload), (err) => {
|
|
164
|
+
if (err) {
|
|
165
|
+
const entry = this.pending.get(reqId);
|
|
166
|
+
this.pending.delete(reqId);
|
|
167
|
+
entry?.reject(new ConnectionClosedError(`send failed: ${err.message}`));
|
|
168
|
+
this.teardown();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Ping the server; returns true when it answers pong. */
|
|
175
|
+
async ping(): Promise<boolean> {
|
|
176
|
+
try {
|
|
177
|
+
const response = await this.call({ cmd: 'Ping' });
|
|
178
|
+
const data = response.data as Record<string, unknown> | undefined;
|
|
179
|
+
return data?.pong === true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Protocol negotiation; returns server name/version/protocolVersion. */
|
|
186
|
+
hello(): Promise<Response> {
|
|
187
|
+
return this.call({
|
|
188
|
+
cmd: 'Hello',
|
|
189
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
190
|
+
capabilities: ['pipelining'],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Close permanently; in-flight commands reject. */
|
|
195
|
+
close(): void {
|
|
196
|
+
this.closed = true;
|
|
197
|
+
this.teardown();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private handleData(chunk: Buffer): void {
|
|
201
|
+
let frames: Buffer[];
|
|
202
|
+
try {
|
|
203
|
+
frames = this.parser.addData(chunk);
|
|
204
|
+
} catch {
|
|
205
|
+
this.teardown();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const framePayload of frames) {
|
|
209
|
+
let message: unknown;
|
|
210
|
+
try {
|
|
211
|
+
message = unpack(framePayload);
|
|
212
|
+
} catch {
|
|
213
|
+
continue; // skip unparseable frame; a desynced stream dies via socket error
|
|
214
|
+
}
|
|
215
|
+
if (typeof message !== 'object' || message === null) continue;
|
|
216
|
+
const response = message as Response;
|
|
217
|
+
const reqId = response.reqId;
|
|
218
|
+
if (reqId === undefined || reqId === null) continue; // server-push unsupported
|
|
219
|
+
const entry = this.pending.get(String(reqId));
|
|
220
|
+
if (entry) {
|
|
221
|
+
this.pending.delete(String(reqId));
|
|
222
|
+
entry.resolve(response);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private teardown(): void {
|
|
228
|
+
this.connected = false;
|
|
229
|
+
const socket = this.socket;
|
|
230
|
+
this.socket = null;
|
|
231
|
+
if (socket) {
|
|
232
|
+
socket.removeAllListeners();
|
|
233
|
+
socket.destroy();
|
|
234
|
+
}
|
|
235
|
+
const pending = this.pending;
|
|
236
|
+
this.pending = new Map();
|
|
237
|
+
for (const entry of pending.values()) {
|
|
238
|
+
clearTimeout(entry.timer);
|
|
239
|
+
entry.reject(new ConnectionClosedError('connection lost'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Exception hierarchy for the bunqueue cross-runtime SDK. */
|
|
2
|
+
|
|
3
|
+
export class BunqueueError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = new.target.name;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The TCP connection is closed or was lost mid-command. */
|
|
11
|
+
export class ConnectionClosedError extends BunqueueError {}
|
|
12
|
+
|
|
13
|
+
/** No response received for a command within the timeout. */
|
|
14
|
+
export class CommandTimeoutError extends BunqueueError {}
|
|
15
|
+
|
|
16
|
+
/** The server answered ok=false; message carries the server error. */
|
|
17
|
+
export class CommandError extends BunqueueError {}
|
|
18
|
+
|
|
19
|
+
/** Authentication with the server failed. */
|
|
20
|
+
export class AuthError extends BunqueueError {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Throw inside a Worker processor to fail the job terminally: skips all
|
|
24
|
+
* remaining retry attempts and sends the job straight to the DLQ.
|
|
25
|
+
*/
|
|
26
|
+
export class UnrecoverableError extends BunqueueError {}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** FlowProducer types. */
|
|
2
|
+
|
|
3
|
+
import type { Connection, TlsOption } from './connection.js';
|
|
4
|
+
import type { Job } from './job.js';
|
|
5
|
+
import type { JobOptions } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface FlowJob<T = unknown> {
|
|
8
|
+
name: string;
|
|
9
|
+
queueName: string;
|
|
10
|
+
data?: T;
|
|
11
|
+
opts?: JobOptions;
|
|
12
|
+
children?: FlowJob<T>[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface JobNode<T = unknown> {
|
|
16
|
+
job: Job<T>;
|
|
17
|
+
children?: JobNode<T>[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FlowStep<T = unknown> {
|
|
21
|
+
name: string;
|
|
22
|
+
queueName: string;
|
|
23
|
+
data?: T;
|
|
24
|
+
opts?: JobOptions;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FlowProducerOptions {
|
|
28
|
+
host?: string;
|
|
29
|
+
port?: number;
|
|
30
|
+
token?: string;
|
|
31
|
+
tls?: TlsOption;
|
|
32
|
+
connection?: Connection;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GetFlowOptions {
|
|
36
|
+
id: string;
|
|
37
|
+
queueName?: string;
|
|
38
|
+
depth?: number;
|
|
39
|
+
maxChildren?: number;
|
|
40
|
+
}
|
package/src/flow.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlowProducer: parent/children job trees, chains and fan-in flows.
|
|
3
|
+
* Mirrors the official client's TCP flow logic (bottom-up creation,
|
|
4
|
+
* UpdateParent fix-up, rollback via Cancel on failure).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Connection } from './connection.js';
|
|
8
|
+
import type {
|
|
9
|
+
FlowJob,
|
|
10
|
+
FlowProducerOptions,
|
|
11
|
+
FlowStep,
|
|
12
|
+
GetFlowOptions,
|
|
13
|
+
JobNode,
|
|
14
|
+
} from './flow-types.js';
|
|
15
|
+
import { compact } from './frame.js';
|
|
16
|
+
import { Job } from './job.js';
|
|
17
|
+
import { type JobOptions, jobPayload, wireJobOptions } from './types.js';
|
|
18
|
+
|
|
19
|
+
export class FlowProducer {
|
|
20
|
+
readonly connection: Connection;
|
|
21
|
+
private readonly ownsConnection: boolean;
|
|
22
|
+
|
|
23
|
+
constructor(opts: FlowProducerOptions = {}) {
|
|
24
|
+
this.connection =
|
|
25
|
+
opts.connection ??
|
|
26
|
+
new Connection({ host: opts.host, port: opts.port, token: opts.token, tls: opts.tls });
|
|
27
|
+
this.ownsConnection = opts.connection === undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Add a flow tree. Children are created (and processed) BEFORE their parent. */
|
|
31
|
+
async add<T = unknown>(flow: FlowJob<T>): Promise<JobNode<T>> {
|
|
32
|
+
const created: string[] = [];
|
|
33
|
+
try {
|
|
34
|
+
return await this.addNode(flow, null, created);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
await this.rollback(created);
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async addBulk<T = unknown>(flows: FlowJob<T>[]): Promise<JobNode<T>[]> {
|
|
42
|
+
const created: string[] = [];
|
|
43
|
+
const results: JobNode<T>[] = [];
|
|
44
|
+
try {
|
|
45
|
+
for (const flow of flows) {
|
|
46
|
+
results.push(await this.addNode(flow, null, created));
|
|
47
|
+
}
|
|
48
|
+
return results;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
await this.rollback(created);
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fetch a flow tree starting from a job id (recursive over childrenIds). */
|
|
56
|
+
getFlow<T = unknown>(opts: GetFlowOptions): Promise<JobNode<T> | null> {
|
|
57
|
+
return this.fetchNode<T>(opts.id, opts.depth ?? Number.POSITIVE_INFINITY, opts.maxChildren);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Add a sequential chain: step[0] → step[1] → ... via dependsOn. */
|
|
61
|
+
async addChain<T = unknown>(steps: FlowStep<T>[]): Promise<{ jobIds: string[] }> {
|
|
62
|
+
if (steps.length === 0) return { jobIds: [] };
|
|
63
|
+
const jobIds: string[] = [];
|
|
64
|
+
let prevId: string | null = null;
|
|
65
|
+
try {
|
|
66
|
+
for (const step of steps) {
|
|
67
|
+
const data = compact({
|
|
68
|
+
...jobPayload(step.name, step.data),
|
|
69
|
+
__flowParentId: prevId ?? undefined,
|
|
70
|
+
});
|
|
71
|
+
const response = await this.connection.call(
|
|
72
|
+
compact({
|
|
73
|
+
cmd: 'PUSH',
|
|
74
|
+
queue: step.queueName,
|
|
75
|
+
data,
|
|
76
|
+
...wireJobOptions(step.opts),
|
|
77
|
+
dependsOn: prevId ? [prevId] : undefined,
|
|
78
|
+
}) as { cmd: string }
|
|
79
|
+
);
|
|
80
|
+
const id = String(response.id);
|
|
81
|
+
jobIds.push(id);
|
|
82
|
+
prevId = id;
|
|
83
|
+
}
|
|
84
|
+
return { jobIds };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
await this.rollback(jobIds);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parallel jobs converging into a final fan-in job. */
|
|
92
|
+
async addBulkThen<T = unknown>(
|
|
93
|
+
parallel: FlowStep<T>[],
|
|
94
|
+
final: FlowStep<T>
|
|
95
|
+
): Promise<{ parallelIds: string[]; finalId: string }> {
|
|
96
|
+
const parallelIds: string[] = [];
|
|
97
|
+
try {
|
|
98
|
+
const results = await Promise.allSettled(
|
|
99
|
+
parallel.map(async (step) => {
|
|
100
|
+
const response = await this.connection.call(
|
|
101
|
+
compact({
|
|
102
|
+
cmd: 'PUSH',
|
|
103
|
+
queue: step.queueName,
|
|
104
|
+
data: jobPayload(step.name, step.data),
|
|
105
|
+
...wireJobOptions(step.opts),
|
|
106
|
+
}) as { cmd: string }
|
|
107
|
+
);
|
|
108
|
+
return String(response.id);
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
const errors: unknown[] = [];
|
|
112
|
+
for (const r of results) {
|
|
113
|
+
if (r.status === 'fulfilled') parallelIds.push(r.value);
|
|
114
|
+
else errors.push(r.reason);
|
|
115
|
+
}
|
|
116
|
+
if (errors.length > 0) {
|
|
117
|
+
await this.rollback(parallelIds);
|
|
118
|
+
throw errors[0] instanceof Error ? errors[0] : new Error(String(errors[0]));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const finalData = {
|
|
122
|
+
...jobPayload(final.name, final.data),
|
|
123
|
+
__flowParentIds: parallelIds,
|
|
124
|
+
};
|
|
125
|
+
const finalId = await this.pushWithParent(
|
|
126
|
+
final.queueName,
|
|
127
|
+
finalData,
|
|
128
|
+
final.opts,
|
|
129
|
+
null,
|
|
130
|
+
parallelIds
|
|
131
|
+
);
|
|
132
|
+
return { parallelIds, finalId };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
await this.rollback(parallelIds);
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
close(): void {
|
|
140
|
+
if (this.ownsConnection) this.connection.close();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ----------------------------------------------------------------- internals
|
|
144
|
+
|
|
145
|
+
private async addNode<T>(
|
|
146
|
+
node: FlowJob<T>,
|
|
147
|
+
parentRef: { id: string; queue: string } | null,
|
|
148
|
+
created: string[]
|
|
149
|
+
): Promise<JobNode<T>> {
|
|
150
|
+
const childNodes: JobNode<T>[] = [];
|
|
151
|
+
const childIds: string[] = [];
|
|
152
|
+
|
|
153
|
+
if (node.children && node.children.length > 0) {
|
|
154
|
+
const tempParent = { id: 'pending', queue: node.queueName };
|
|
155
|
+
const results = await Promise.all(
|
|
156
|
+
node.children.map((child) => this.addNode(child, tempParent, created))
|
|
157
|
+
);
|
|
158
|
+
for (const childNode of results) {
|
|
159
|
+
childNodes.push(childNode);
|
|
160
|
+
childIds.push(childNode.job.id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const jobData: Record<string, unknown> = jobPayload(node.name, node.data);
|
|
165
|
+
if (parentRef) {
|
|
166
|
+
jobData.__parentId = parentRef.id;
|
|
167
|
+
jobData.__parentQueue = parentRef.queue;
|
|
168
|
+
}
|
|
169
|
+
if (childIds.length > 0) jobData.__childrenIds = childIds;
|
|
170
|
+
|
|
171
|
+
const id = await this.pushWithParent(node.queueName, jobData, node.opts, parentRef, childIds);
|
|
172
|
+
created.push(id);
|
|
173
|
+
|
|
174
|
+
const job = new Job<T>({ id, queue: node.queueName, data: jobData }, this.connection);
|
|
175
|
+
return { job, children: childNodes.length > 0 ? childNodes : undefined };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async pushWithParent(
|
|
179
|
+
queue: string,
|
|
180
|
+
data: Record<string, unknown>,
|
|
181
|
+
opts: JobOptions | undefined,
|
|
182
|
+
parentRef: { id: string; queue: string } | null,
|
|
183
|
+
childIds: string[]
|
|
184
|
+
): Promise<string> {
|
|
185
|
+
const response = await this.connection.call(
|
|
186
|
+
compact({
|
|
187
|
+
cmd: 'PUSH',
|
|
188
|
+
queue,
|
|
189
|
+
data,
|
|
190
|
+
...wireJobOptions(opts),
|
|
191
|
+
parentId: parentRef?.id,
|
|
192
|
+
childrenIds: childIds.length > 0 ? childIds : undefined,
|
|
193
|
+
dependsOn: childIds.length > 0 ? childIds : undefined,
|
|
194
|
+
}) as { cmd: string }
|
|
195
|
+
);
|
|
196
|
+
const parentJobId = String(response.id);
|
|
197
|
+
|
|
198
|
+
// fix up children with the real parent id (like the official client)
|
|
199
|
+
for (const childId of childIds) {
|
|
200
|
+
await this.connection.call({ cmd: 'UpdateParent', childId, parentId: parentJobId });
|
|
201
|
+
}
|
|
202
|
+
return parentJobId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async fetchNode<T>(
|
|
206
|
+
id: string,
|
|
207
|
+
depth: number,
|
|
208
|
+
maxChildren?: number
|
|
209
|
+
): Promise<JobNode<T> | null> {
|
|
210
|
+
const response = await this.connection.call({ cmd: 'GetJob', id });
|
|
211
|
+
const raw = response.job as Record<string, unknown> | null;
|
|
212
|
+
if (!raw) return null;
|
|
213
|
+
const job = new Job<T>(raw, this.connection);
|
|
214
|
+
if (depth <= 0 || job.childrenIds.length === 0) return { job };
|
|
215
|
+
|
|
216
|
+
const limit = maxChildren ?? job.childrenIds.length;
|
|
217
|
+
const children: JobNode<T>[] = [];
|
|
218
|
+
for (const childId of job.childrenIds.slice(0, limit)) {
|
|
219
|
+
const child = await this.fetchNode<T>(childId, depth - 1, maxChildren);
|
|
220
|
+
if (child) children.push(child);
|
|
221
|
+
}
|
|
222
|
+
return { job, children: children.length > 0 ? children : undefined };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async rollback(jobIds: string[]): Promise<void> {
|
|
226
|
+
await Promise.all(
|
|
227
|
+
jobIds.map(async (id) => {
|
|
228
|
+
try {
|
|
229
|
+
await this.connection.call({ cmd: 'Cancel', id });
|
|
230
|
+
} catch {
|
|
231
|
+
// ignore cleanup errors
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/frame.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire framing for the bunqueue TCP protocol: 4-byte big-endian length
|
|
3
|
+
* prefix + msgpack payload. Runtime-neutral (plain Buffer operations).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ConnectionClosedError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export const PROTOCOL_VERSION = 1;
|
|
9
|
+
export const MAX_FRAME_SIZE = 64 * 1024 * 1024; // mirror server-side limit
|
|
10
|
+
|
|
11
|
+
/** Drop undefined-valued keys so the msgpack frame stays minimal. */
|
|
12
|
+
export function compact<T extends Record<string, unknown>>(obj: T): T {
|
|
13
|
+
const out: Record<string, unknown> = {};
|
|
14
|
+
for (const key in obj) {
|
|
15
|
+
if (obj[key] !== undefined) out[key] = obj[key];
|
|
16
|
+
}
|
|
17
|
+
return out as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Frame a msgpack payload with the 4-byte big-endian length prefix. */
|
|
21
|
+
export function frame(payload: Uint8Array): Buffer {
|
|
22
|
+
const framed = Buffer.allocUnsafe(4 + payload.length);
|
|
23
|
+
framed.writeUInt32BE(payload.length, 0);
|
|
24
|
+
framed.set(payload, 4);
|
|
25
|
+
return framed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Incremental length-prefixed frame parser (O(total bytes) via offset cursor). */
|
|
29
|
+
export class FrameParser {
|
|
30
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
31
|
+
|
|
32
|
+
addData(data: Buffer): Buffer[] {
|
|
33
|
+
const buffer = this.buffer.length === 0 ? data : Buffer.concat([this.buffer, data]);
|
|
34
|
+
const frames: Buffer[] = [];
|
|
35
|
+
let offset = 0;
|
|
36
|
+
|
|
37
|
+
while (buffer.length - offset >= 4) {
|
|
38
|
+
const length = buffer.readUInt32BE(offset);
|
|
39
|
+
if (length > MAX_FRAME_SIZE) {
|
|
40
|
+
this.buffer = Buffer.alloc(0);
|
|
41
|
+
throw new ConnectionClosedError(`frame size ${length} exceeds maximum`);
|
|
42
|
+
}
|
|
43
|
+
if (buffer.length - offset < 4 + length) break;
|
|
44
|
+
// explicit copy so frames never alias the retained tail
|
|
45
|
+
frames.push(Buffer.from(buffer.subarray(offset + 4, offset + 4 + length)));
|
|
46
|
+
offset += 4 + length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (offset >= buffer.length) {
|
|
50
|
+
this.buffer = Buffer.alloc(0);
|
|
51
|
+
} else if (offset === 0) {
|
|
52
|
+
this.buffer = buffer;
|
|
53
|
+
} else {
|
|
54
|
+
this.buffer = Buffer.from(buffer.subarray(offset));
|
|
55
|
+
}
|
|
56
|
+
return frames;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clear(): void {
|
|
60
|
+
this.buffer = Buffer.alloc(0);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bunqueue-client — cross-runtime TypeScript SDK for bunqueue (TCP mode).
|
|
3
|
+
* Works on Node.js ≥20, Bun and Deno ≥2 (uses only node: builtins + msgpackr).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type { Command, ConnectionOptions, Response, TlsOption } from './connection.js';
|
|
7
|
+
export { Connection } from './connection.js';
|
|
8
|
+
export {
|
|
9
|
+
AuthError,
|
|
10
|
+
BunqueueError,
|
|
11
|
+
CommandError,
|
|
12
|
+
CommandTimeoutError,
|
|
13
|
+
ConnectionClosedError,
|
|
14
|
+
UnrecoverableError,
|
|
15
|
+
} from './errors.js';
|
|
16
|
+
export { FlowProducer } from './flow.js';
|
|
17
|
+
export type {
|
|
18
|
+
FlowJob,
|
|
19
|
+
FlowProducerOptions,
|
|
20
|
+
FlowStep,
|
|
21
|
+
GetFlowOptions,
|
|
22
|
+
JobNode,
|
|
23
|
+
} from './flow-types.js';
|
|
24
|
+
export { MAX_FRAME_SIZE, PROTOCOL_VERSION } from './frame.js';
|
|
25
|
+
export type { JobRaw } from './job.js';
|
|
26
|
+
export { Job } from './job.js';
|
|
27
|
+
export type { BulkJobEntry, QueueOptions } from './queue.js';
|
|
28
|
+
export { Queue } from './queue.js';
|
|
29
|
+
export type { SchedulerOptions } from './queue-admin.js';
|
|
30
|
+
export type {
|
|
31
|
+
BackoffOptions,
|
|
32
|
+
DeduplicationOptions,
|
|
33
|
+
JobCounts,
|
|
34
|
+
JobOptions,
|
|
35
|
+
JobStateName,
|
|
36
|
+
RepeatOptions,
|
|
37
|
+
} from './types.js';
|
|
38
|
+
export { Worker } from './worker.js';
|
|
39
|
+
export type { Processor, WorkerOptions } from './worker-types.js';
|
|
40
|
+
|
|
41
|
+
export const __version__ = '0.1.0';
|