@supernovae-st/nika-client 0.63.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 +661 -0
- package/README.md +263 -0
- package/dist/index.cjs +675 -0
- package/dist/index.d.cts +274 -0
- package/dist/index.d.ts +274 -0
- package/dist/index.js +639 -0
- package/package.json +44 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Jobs: () => Jobs,
|
|
24
|
+
Nika: () => Nika,
|
|
25
|
+
NikaAPIError: () => NikaAPIError,
|
|
26
|
+
NikaConnectionError: () => NikaConnectionError,
|
|
27
|
+
NikaError: () => NikaError,
|
|
28
|
+
NikaJobCancelledError: () => NikaJobCancelledError,
|
|
29
|
+
NikaJobError: () => NikaJobError,
|
|
30
|
+
NikaTimeoutError: () => NikaTimeoutError,
|
|
31
|
+
Workflows: () => Workflows,
|
|
32
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/errors.ts
|
|
37
|
+
var NikaError = class extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "NikaError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var NikaAPIError = class extends NikaError {
|
|
44
|
+
status;
|
|
45
|
+
body;
|
|
46
|
+
requestId;
|
|
47
|
+
constructor(message, status, body, requestId) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "NikaAPIError";
|
|
50
|
+
this.status = status;
|
|
51
|
+
this.body = body;
|
|
52
|
+
this.requestId = requestId;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var NikaConnectionError = class extends NikaError {
|
|
56
|
+
cause;
|
|
57
|
+
constructor(message, cause) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "NikaConnectionError";
|
|
60
|
+
this.cause = cause;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var NikaTimeoutError = class extends NikaError {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "NikaTimeoutError";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var NikaJobError = class extends NikaError {
|
|
70
|
+
job;
|
|
71
|
+
exitCode;
|
|
72
|
+
constructor(job) {
|
|
73
|
+
super(`Job ${job.job_id} ${job.status}: ${job.output ?? "unknown error"}`);
|
|
74
|
+
this.name = "NikaJobError";
|
|
75
|
+
this.job = job;
|
|
76
|
+
this.exitCode = job.exit_code;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var NikaJobCancelledError = class extends NikaJobError {
|
|
80
|
+
constructor(job) {
|
|
81
|
+
super(job);
|
|
82
|
+
this.name = "NikaJobCancelledError";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/lib/semaphore.ts
|
|
87
|
+
var Semaphore = class {
|
|
88
|
+
constructor(max) {
|
|
89
|
+
this.max = max;
|
|
90
|
+
}
|
|
91
|
+
max;
|
|
92
|
+
current = 0;
|
|
93
|
+
queue = [];
|
|
94
|
+
async acquire() {
|
|
95
|
+
if (this.current < this.max) {
|
|
96
|
+
this.current++;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve) => this.queue.push(resolve));
|
|
100
|
+
}
|
|
101
|
+
release() {
|
|
102
|
+
const next = this.queue.shift();
|
|
103
|
+
if (next) {
|
|
104
|
+
next();
|
|
105
|
+
} else {
|
|
106
|
+
this.current--;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// src/lib/api-client.ts
|
|
112
|
+
var ApiClient = class {
|
|
113
|
+
url;
|
|
114
|
+
timeout;
|
|
115
|
+
retries;
|
|
116
|
+
logger;
|
|
117
|
+
token;
|
|
118
|
+
_fetch;
|
|
119
|
+
semaphore;
|
|
120
|
+
constructor(url, token, timeout, retries, fetchFn, concurrency, logger) {
|
|
121
|
+
this.url = url;
|
|
122
|
+
this.token = token;
|
|
123
|
+
this.timeout = timeout;
|
|
124
|
+
this.retries = retries;
|
|
125
|
+
this._fetch = fetchFn;
|
|
126
|
+
this.semaphore = new Semaphore(concurrency);
|
|
127
|
+
this.logger = logger;
|
|
128
|
+
}
|
|
129
|
+
// ── Standard request: auth + retry + timeout ──────────────
|
|
130
|
+
async request(path, init) {
|
|
131
|
+
await this.semaphore.acquire();
|
|
132
|
+
try {
|
|
133
|
+
return await this._request(path, init);
|
|
134
|
+
} finally {
|
|
135
|
+
this.semaphore.release();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async _request(path, init) {
|
|
139
|
+
const url = `${this.url}${path}`;
|
|
140
|
+
const headers = {
|
|
141
|
+
"Authorization": `Bearer ${this.token}`,
|
|
142
|
+
...init?.headers ?? {}
|
|
143
|
+
};
|
|
144
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
147
|
+
let abortListener;
|
|
148
|
+
if (init?.signal) {
|
|
149
|
+
if (init.signal.aborted) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
throw new NikaConnectionError("Request aborted by caller");
|
|
152
|
+
}
|
|
153
|
+
abortListener = () => controller.abort(init.signal.reason);
|
|
154
|
+
init.signal.addEventListener("abort", abortListener, { once: true });
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
this.logger?.debug(`${init?.method ?? "GET"} ${path} (attempt ${attempt + 1})`);
|
|
158
|
+
const res = await this._fetch(url, {
|
|
159
|
+
...init,
|
|
160
|
+
headers,
|
|
161
|
+
signal: controller.signal
|
|
162
|
+
});
|
|
163
|
+
if (res.ok) {
|
|
164
|
+
this.logger?.debug(`${res.status} ${path}`);
|
|
165
|
+
return res;
|
|
166
|
+
}
|
|
167
|
+
const body = await res.text().catch(() => "");
|
|
168
|
+
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
169
|
+
if ((res.status === 429 || res.status >= 500) && attempt < this.retries) {
|
|
170
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
171
|
+
const parsed = retryAfter ? parseInt(retryAfter, 10) : NaN;
|
|
172
|
+
const delay = Number.isFinite(parsed) ? parsed * 1e3 : 1e3 * (attempt + 1);
|
|
173
|
+
this.logger?.warn(`${res.status} ${path} \u2014 retrying in ${delay}ms`);
|
|
174
|
+
await sleep(delay, init?.signal ?? void 0);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
throw new NikaAPIError(
|
|
178
|
+
`${res.status} ${res.statusText}: ${body}`.trim(),
|
|
179
|
+
res.status,
|
|
180
|
+
body,
|
|
181
|
+
requestId
|
|
182
|
+
);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof NikaAPIError) throw err;
|
|
185
|
+
if (err instanceof NikaConnectionError) throw err;
|
|
186
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
187
|
+
if (init?.signal?.aborted) {
|
|
188
|
+
throw new NikaConnectionError("Request aborted by caller");
|
|
189
|
+
}
|
|
190
|
+
throw new NikaTimeoutError(`Request timed out after ${this.timeout}ms`);
|
|
191
|
+
}
|
|
192
|
+
if (err instanceof TypeError) {
|
|
193
|
+
throw new NikaConnectionError(`Connection failed: ${err.message}`, err);
|
|
194
|
+
}
|
|
195
|
+
throw err;
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
if (abortListener && init?.signal) {
|
|
199
|
+
init.signal.removeEventListener("abort", abortListener);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw new NikaAPIError("Max retries exceeded", 503, "");
|
|
204
|
+
}
|
|
205
|
+
// ── JSON shorthand ────────────────────────────────────────
|
|
206
|
+
async json(path, init) {
|
|
207
|
+
const res = await this.request(path, init);
|
|
208
|
+
return res.json();
|
|
209
|
+
}
|
|
210
|
+
// ── SSE connect: auth but no retry/timeout (stream has its own idle timeout) ──
|
|
211
|
+
async connectSSE(path, signal, extraHeaders) {
|
|
212
|
+
const url = `${this.url}${path}`;
|
|
213
|
+
const res = await this._fetch(url, {
|
|
214
|
+
headers: {
|
|
215
|
+
"Authorization": `Bearer ${this.token}`,
|
|
216
|
+
"Accept": "text/event-stream",
|
|
217
|
+
...extraHeaders
|
|
218
|
+
},
|
|
219
|
+
signal
|
|
220
|
+
});
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const body = await res.text().catch(() => "");
|
|
223
|
+
throw new NikaAPIError(
|
|
224
|
+
`SSE connection failed: ${res.status} ${res.statusText}`,
|
|
225
|
+
res.status,
|
|
226
|
+
body
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return res;
|
|
230
|
+
}
|
|
231
|
+
// ── Health fetch: no auth, with timeout ───────────────────
|
|
232
|
+
async fetchHealth() {
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
235
|
+
try {
|
|
236
|
+
return await this._fetch(`${this.url}/health`, {
|
|
237
|
+
signal: controller.signal
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
241
|
+
throw new NikaTimeoutError(`Health check timed out after ${this.timeout}ms`);
|
|
242
|
+
}
|
|
243
|
+
if (err instanceof TypeError) {
|
|
244
|
+
throw new NikaConnectionError(`Health check failed: ${err.message}`, err);
|
|
245
|
+
}
|
|
246
|
+
throw err;
|
|
247
|
+
} finally {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
function sleep(ms, signal) {
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
if (signal?.aborted) {
|
|
255
|
+
reject(new NikaConnectionError("Request aborted by caller"));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const timer = setTimeout(resolve, ms);
|
|
259
|
+
signal?.addEventListener("abort", () => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
reject(new NikaConnectionError("Request aborted by caller"));
|
|
262
|
+
}, { once: true });
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/lib/polling.ts
|
|
267
|
+
async function pollUntilDone(fetchStatus, opts) {
|
|
268
|
+
const deadline = Date.now() + opts.timeout;
|
|
269
|
+
let delay = opts.interval;
|
|
270
|
+
while (true) {
|
|
271
|
+
if (opts.signal?.aborted) {
|
|
272
|
+
throw new NikaConnectionError("Poll aborted by caller");
|
|
273
|
+
}
|
|
274
|
+
const job = await fetchStatus();
|
|
275
|
+
if (job.status === "completed") return job;
|
|
276
|
+
if (job.status === "failed") throw new NikaJobError(job);
|
|
277
|
+
if (job.status === "cancelled") throw new NikaJobCancelledError(job);
|
|
278
|
+
if (Date.now() + delay > deadline) {
|
|
279
|
+
throw new NikaTimeoutError(`Job ${job.job_id} timed out after ${opts.timeout}ms`);
|
|
280
|
+
}
|
|
281
|
+
await sleep2(delay, opts.signal);
|
|
282
|
+
delay = Math.min(delay * opts.backoff, 1e4);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function sleep2(ms, signal) {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
if (signal?.aborted) {
|
|
288
|
+
reject(new NikaConnectionError("Poll aborted by caller"));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const timer = setTimeout(resolve, ms);
|
|
292
|
+
signal?.addEventListener("abort", () => {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
reject(new NikaConnectionError("Poll aborted by caller"));
|
|
295
|
+
}, { once: true });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/lib/streaming.ts
|
|
300
|
+
var DEFAULT_IDLE_TIMEOUT = 6e4;
|
|
301
|
+
async function* streamEvents(client, jobId, options) {
|
|
302
|
+
const maxReconnects = options?.maxReconnects ?? 3;
|
|
303
|
+
const reconnectDelay = options?.reconnectDelay ?? 1e3;
|
|
304
|
+
let lastEventId;
|
|
305
|
+
let reconnects = 0;
|
|
306
|
+
while (true) {
|
|
307
|
+
try {
|
|
308
|
+
const extraHeaders = {};
|
|
309
|
+
if (lastEventId) extraHeaders["Last-Event-Id"] = lastEventId;
|
|
310
|
+
yield* streamOnce(client, jobId, options, extraHeaders, (id) => {
|
|
311
|
+
lastEventId = id;
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (options?.signal?.aborted) throw err;
|
|
316
|
+
if (err instanceof NikaTimeoutError) throw err;
|
|
317
|
+
if (err instanceof NikaError && !(err instanceof NikaConnectionError)) throw err;
|
|
318
|
+
if (reconnects >= maxReconnects) throw err;
|
|
319
|
+
reconnects++;
|
|
320
|
+
await sleep3(reconnectDelay * reconnects, options?.signal);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function* streamOnce(client, jobId, options, extraHeaders, onEventId) {
|
|
326
|
+
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT;
|
|
327
|
+
const res = await client.connectSSE(
|
|
328
|
+
`/v1/events/${jobId}`,
|
|
329
|
+
options?.signal,
|
|
330
|
+
extraHeaders
|
|
331
|
+
);
|
|
332
|
+
if (!res.body) {
|
|
333
|
+
throw new NikaError("SSE response has no body");
|
|
334
|
+
}
|
|
335
|
+
const reader = res.body.getReader();
|
|
336
|
+
const decoder = new TextDecoder();
|
|
337
|
+
let buffer = "";
|
|
338
|
+
let receivedTerminal = false;
|
|
339
|
+
let timedOut = false;
|
|
340
|
+
let idleTimer;
|
|
341
|
+
function resetIdleTimer() {
|
|
342
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
343
|
+
idleTimer = setTimeout(() => {
|
|
344
|
+
timedOut = true;
|
|
345
|
+
reader.cancel("idle timeout").catch(() => {
|
|
346
|
+
});
|
|
347
|
+
}, idleTimeout);
|
|
348
|
+
}
|
|
349
|
+
resetIdleTimer();
|
|
350
|
+
try {
|
|
351
|
+
while (true) {
|
|
352
|
+
const { done, value } = await reader.read();
|
|
353
|
+
if (done) {
|
|
354
|
+
if (timedOut) {
|
|
355
|
+
throw new NikaTimeoutError(
|
|
356
|
+
`SSE stream idle for ${idleTimeout}ms \u2014 connection assumed dead`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (!receivedTerminal) {
|
|
360
|
+
throw new NikaConnectionError(
|
|
361
|
+
"SSE stream closed without terminal event (completed/failed/cancelled)"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
resetIdleTimer();
|
|
367
|
+
buffer += decoder.decode(value, { stream: true });
|
|
368
|
+
const parts = buffer.split("\n\n");
|
|
369
|
+
buffer = parts.pop();
|
|
370
|
+
for (const part of parts) {
|
|
371
|
+
const lines = part.split("\n");
|
|
372
|
+
let data;
|
|
373
|
+
let eventId;
|
|
374
|
+
for (const line of lines) {
|
|
375
|
+
if (line.startsWith("data:")) {
|
|
376
|
+
data = line[5] === " " ? line.slice(6) : line.slice(5);
|
|
377
|
+
}
|
|
378
|
+
if (line.startsWith("id:")) {
|
|
379
|
+
eventId = line[3] === " " ? line.slice(4) : line.slice(3);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (eventId) {
|
|
383
|
+
onEventId(eventId);
|
|
384
|
+
}
|
|
385
|
+
if (data) {
|
|
386
|
+
try {
|
|
387
|
+
const event = JSON.parse(data);
|
|
388
|
+
yield event;
|
|
389
|
+
if (event.type === "completed" || event.type === "failed" || event.type === "cancelled") {
|
|
390
|
+
receivedTerminal = true;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} finally {
|
|
399
|
+
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
400
|
+
await reader.cancel().catch(() => {
|
|
401
|
+
});
|
|
402
|
+
reader.releaseLock();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function sleep3(ms, signal) {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
if (signal?.aborted) {
|
|
408
|
+
reject(new NikaConnectionError("Request aborted by caller"));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const timer = setTimeout(resolve, ms);
|
|
412
|
+
signal?.addEventListener("abort", () => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
reject(new NikaConnectionError("Request aborted by caller"));
|
|
415
|
+
}, { once: true });
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/resources/jobs.ts
|
|
420
|
+
var Jobs = class {
|
|
421
|
+
constructor(api, poll) {
|
|
422
|
+
this.api = api;
|
|
423
|
+
this.poll = poll;
|
|
424
|
+
}
|
|
425
|
+
api;
|
|
426
|
+
poll;
|
|
427
|
+
/** Submit a workflow and return immediately with job ID. */
|
|
428
|
+
async submit(workflow, inputs, options) {
|
|
429
|
+
return this.api.json("/v1/run", {
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: { "Content-Type": "application/json" },
|
|
432
|
+
body: JSON.stringify({
|
|
433
|
+
workflow,
|
|
434
|
+
...inputs !== void 0 ? { inputs } : {},
|
|
435
|
+
...options?.resumeFrom ? { resume_from: options.resumeFrom } : {}
|
|
436
|
+
}),
|
|
437
|
+
signal: options?.signal
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/** Get current job status. */
|
|
441
|
+
async status(jobId) {
|
|
442
|
+
return this.api.json(`/v1/status/${jobId}`);
|
|
443
|
+
}
|
|
444
|
+
/** Cancel a running job. */
|
|
445
|
+
async cancel(jobId) {
|
|
446
|
+
return this.api.json(`/v1/cancel/${jobId}`, { method: "POST" });
|
|
447
|
+
}
|
|
448
|
+
/** Run a workflow and wait for completion (polling). */
|
|
449
|
+
async run(workflow, inputs, options) {
|
|
450
|
+
const { job_id } = await this.submit(workflow, inputs, options);
|
|
451
|
+
return pollUntilDone(
|
|
452
|
+
() => this.status(job_id),
|
|
453
|
+
{
|
|
454
|
+
interval: this.poll.pollInterval,
|
|
455
|
+
timeout: this.poll.pollTimeout,
|
|
456
|
+
backoff: this.poll.pollBackoff,
|
|
457
|
+
signal: options?.signal
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
/** Stream job events via SSE (AsyncIterable). */
|
|
462
|
+
stream(jobId, options) {
|
|
463
|
+
return streamEvents(this.api, jobId, options);
|
|
464
|
+
}
|
|
465
|
+
/** List artifacts for a job. */
|
|
466
|
+
async artifacts(jobId) {
|
|
467
|
+
const res = await this.api.json(`/v1/jobs/${jobId}/artifacts`);
|
|
468
|
+
return res.artifacts;
|
|
469
|
+
}
|
|
470
|
+
/** Download a specific artifact as string. */
|
|
471
|
+
async artifact(jobId, name) {
|
|
472
|
+
const res = await this.api.request(
|
|
473
|
+
`/v1/jobs/${jobId}/artifacts/${encodeURIComponent(name)}`
|
|
474
|
+
);
|
|
475
|
+
return res.text();
|
|
476
|
+
}
|
|
477
|
+
/** Download a specific artifact as parsed JSON. */
|
|
478
|
+
async artifactJson(jobId, name) {
|
|
479
|
+
const res = await this.api.request(
|
|
480
|
+
`/v1/jobs/${jobId}/artifacts/${encodeURIComponent(name)}`
|
|
481
|
+
);
|
|
482
|
+
return res.json();
|
|
483
|
+
}
|
|
484
|
+
/** Download a specific artifact as raw bytes. */
|
|
485
|
+
async artifactBinary(jobId, name) {
|
|
486
|
+
const res = await this.api.request(
|
|
487
|
+
`/v1/jobs/${jobId}/artifacts/${encodeURIComponent(name)}`
|
|
488
|
+
);
|
|
489
|
+
const buffer = await res.arrayBuffer();
|
|
490
|
+
return new Uint8Array(buffer);
|
|
491
|
+
}
|
|
492
|
+
/** Stream an artifact as a ReadableStream (for large files). */
|
|
493
|
+
async artifactStream(jobId, name) {
|
|
494
|
+
const res = await this.api.request(
|
|
495
|
+
`/v1/jobs/${jobId}/artifacts/${encodeURIComponent(name)}`
|
|
496
|
+
);
|
|
497
|
+
if (!res.body) {
|
|
498
|
+
throw new NikaError("Artifact response has no body");
|
|
499
|
+
}
|
|
500
|
+
return res.body;
|
|
501
|
+
}
|
|
502
|
+
/** Run workflow, wait, and collect all non-binary artifacts into a map. */
|
|
503
|
+
async runAndCollect(workflow, inputs, options) {
|
|
504
|
+
const job = await this.run(workflow, inputs, options);
|
|
505
|
+
const artifactList = await this.artifacts(job.job_id);
|
|
506
|
+
const downloadable = artifactList.filter((art) => art.format !== "binary");
|
|
507
|
+
const batchSize = 6;
|
|
508
|
+
const entries = [];
|
|
509
|
+
for (let i = 0; i < downloadable.length; i += batchSize) {
|
|
510
|
+
const batch = downloadable.slice(i, i + batchSize);
|
|
511
|
+
const results = await Promise.all(
|
|
512
|
+
batch.map(async (art) => {
|
|
513
|
+
if (art.format === "json") {
|
|
514
|
+
return [art.name, await this.artifactJson(job.job_id, art.name)];
|
|
515
|
+
}
|
|
516
|
+
return [art.name, await this.artifact(job.job_id, art.name)];
|
|
517
|
+
})
|
|
518
|
+
);
|
|
519
|
+
entries.push(...results);
|
|
520
|
+
}
|
|
521
|
+
return Object.fromEntries(entries);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/resources/workflows.ts
|
|
526
|
+
var Workflows = class {
|
|
527
|
+
constructor(api) {
|
|
528
|
+
this.api = api;
|
|
529
|
+
}
|
|
530
|
+
api;
|
|
531
|
+
/**
|
|
532
|
+
* List all workflows. Auto-paginates if server supports it.
|
|
533
|
+
* For large lists, use `listPage()` for manual pagination.
|
|
534
|
+
*/
|
|
535
|
+
async list() {
|
|
536
|
+
const all = [];
|
|
537
|
+
let after;
|
|
538
|
+
const MAX_PAGES = 1e3;
|
|
539
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
540
|
+
const params = new URLSearchParams();
|
|
541
|
+
params.set("limit", "200");
|
|
542
|
+
if (after) params.set("after", after);
|
|
543
|
+
const res = await this.api.json(
|
|
544
|
+
`/v1/workflows?${params}`
|
|
545
|
+
);
|
|
546
|
+
all.push(...res.workflows);
|
|
547
|
+
if (!res.has_more || res.workflows.length === 0) break;
|
|
548
|
+
after = res.workflows[res.workflows.length - 1].name;
|
|
549
|
+
}
|
|
550
|
+
return all;
|
|
551
|
+
}
|
|
552
|
+
/** List a single page of workflows (manual pagination). */
|
|
553
|
+
async listPage(options) {
|
|
554
|
+
const params = new URLSearchParams();
|
|
555
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
556
|
+
if (options?.after) params.set("after", options.after);
|
|
557
|
+
const qs = params.toString();
|
|
558
|
+
return this.api.json(
|
|
559
|
+
`/v1/workflows${qs ? `?${qs}` : ""}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
/** Reload workflows from disk and return the refreshed list. */
|
|
563
|
+
async reload() {
|
|
564
|
+
const res = await this.api.json("/v1/reload", {
|
|
565
|
+
method: "POST"
|
|
566
|
+
});
|
|
567
|
+
return res.workflows;
|
|
568
|
+
}
|
|
569
|
+
/** Get the raw YAML source of a workflow. */
|
|
570
|
+
async source(name) {
|
|
571
|
+
const res = await this.api.request(
|
|
572
|
+
`/v1/workflows/${encodeURIComponent(name)}/source`
|
|
573
|
+
);
|
|
574
|
+
return res.text();
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// src/webhook.ts
|
|
579
|
+
async function verifyWebhookSignature(payload, signatureHeader, secret, tolerance = 300) {
|
|
580
|
+
const parts = signatureHeader.split(",");
|
|
581
|
+
const timestampStr = parts.find((p) => p.startsWith("t="))?.slice(2);
|
|
582
|
+
const signature = parts.find((p) => p.startsWith("v1="))?.slice(3);
|
|
583
|
+
if (!timestampStr || !signature) return false;
|
|
584
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
585
|
+
if (!Number.isFinite(timestamp)) return false;
|
|
586
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
587
|
+
if (Math.abs(now - timestamp) > tolerance) return false;
|
|
588
|
+
const signedPayload = `${timestampStr}.${payload}`;
|
|
589
|
+
const key = await crypto.subtle.importKey(
|
|
590
|
+
"raw",
|
|
591
|
+
new TextEncoder().encode(secret),
|
|
592
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
593
|
+
false,
|
|
594
|
+
["sign"]
|
|
595
|
+
);
|
|
596
|
+
const sig = await crypto.subtle.sign(
|
|
597
|
+
"HMAC",
|
|
598
|
+
key,
|
|
599
|
+
new TextEncoder().encode(signedPayload)
|
|
600
|
+
);
|
|
601
|
+
const expected = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
602
|
+
if (expected.length !== signature.length) return false;
|
|
603
|
+
let result = 0;
|
|
604
|
+
for (let i = 0; i < expected.length; i++) {
|
|
605
|
+
result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
606
|
+
}
|
|
607
|
+
return result === 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/index.ts
|
|
611
|
+
var Nika = class {
|
|
612
|
+
/** Job operations: submit, status, cancel, run, stream, artifacts. */
|
|
613
|
+
jobs;
|
|
614
|
+
/** Workflow operations: list, reload. */
|
|
615
|
+
workflows;
|
|
616
|
+
api;
|
|
617
|
+
constructor(config) {
|
|
618
|
+
if (!config.url.startsWith("http://") && !config.url.startsWith("https://")) {
|
|
619
|
+
throw new TypeError(`NikaConfig.url must be an http(s) URL, got: ${config.url}`);
|
|
620
|
+
}
|
|
621
|
+
if (!config.token) {
|
|
622
|
+
throw new TypeError("NikaConfig.token must not be empty");
|
|
623
|
+
}
|
|
624
|
+
this.api = new ApiClient(
|
|
625
|
+
config.url.replace(/\/$/, ""),
|
|
626
|
+
config.token,
|
|
627
|
+
config.timeout ?? 3e4,
|
|
628
|
+
config.retries ?? 2,
|
|
629
|
+
config.fetch ?? globalThis.fetch.bind(globalThis),
|
|
630
|
+
config.concurrency ?? 24,
|
|
631
|
+
config.logger
|
|
632
|
+
);
|
|
633
|
+
this.jobs = new Jobs(this.api, {
|
|
634
|
+
pollInterval: config.pollInterval ?? 2e3,
|
|
635
|
+
pollTimeout: config.pollTimeout ?? 3e5,
|
|
636
|
+
pollBackoff: config.pollBackoff ?? 1.5
|
|
637
|
+
});
|
|
638
|
+
this.workflows = new Workflows(this.api);
|
|
639
|
+
}
|
|
640
|
+
/** Health check (no auth required, uses timeout). */
|
|
641
|
+
async health() {
|
|
642
|
+
const res = await this.api.fetchHealth();
|
|
643
|
+
if (!res.ok) {
|
|
644
|
+
const body = await res.text().catch(() => "");
|
|
645
|
+
throw new NikaAPIError(
|
|
646
|
+
`Health check failed: ${res.status} ${body}`.trim(),
|
|
647
|
+
res.status,
|
|
648
|
+
body
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return res.json();
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Verify a webhook signature from nika serve.
|
|
655
|
+
*
|
|
656
|
+
* @param payload — raw request body string
|
|
657
|
+
* @param signature — value of X-Nika-Signature header
|
|
658
|
+
* @param secret — shared webhook secret (NIKA_WEBHOOK_SECRET)
|
|
659
|
+
* @param tolerance — max age in seconds (default: 300)
|
|
660
|
+
*/
|
|
661
|
+
static verifyWebhook = verifyWebhookSignature;
|
|
662
|
+
};
|
|
663
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
664
|
+
0 && (module.exports = {
|
|
665
|
+
Jobs,
|
|
666
|
+
Nika,
|
|
667
|
+
NikaAPIError,
|
|
668
|
+
NikaConnectionError,
|
|
669
|
+
NikaError,
|
|
670
|
+
NikaJobCancelledError,
|
|
671
|
+
NikaJobError,
|
|
672
|
+
NikaTimeoutError,
|
|
673
|
+
Workflows,
|
|
674
|
+
verifyWebhookSignature
|
|
675
|
+
});
|