@chronos.sh/sdk 0.0.1
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 +66 -0
- package/dist/index.cjs +552 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +323 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.js +544 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chronos Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @chronos.sh/sdk
|
|
2
|
+
|
|
3
|
+
Official Node.js SDK for [Chronos](https://chronos.sh) — reliable job scheduling as a service.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Chronos } from '@chronos.sh/sdk';
|
|
7
|
+
|
|
8
|
+
const chronos = new Chronos({ apiKey: process.env.CHRONOS_API_KEY! });
|
|
9
|
+
|
|
10
|
+
chronos.worker.handle('send-email', async (ctx) => {
|
|
11
|
+
await sendEmail(ctx.payload);
|
|
12
|
+
return { sent: true };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await chronos.worker.start();
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Zero dependencies. Runs anywhere with `fetch` — Node.js 20+, Bun, Deno.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @chronos.sh/sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { Chronos } from '@chronos.sh/sdk';
|
|
30
|
+
|
|
31
|
+
const chronos = new Chronos({ apiKey: process.env.CHRONOS_API_KEY! });
|
|
32
|
+
|
|
33
|
+
chronos.worker.handle<{ to: string }>('send-email', async (ctx) => {
|
|
34
|
+
await sendEmail(ctx.payload.to);
|
|
35
|
+
return { sent: true };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await chronos.worker.start();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`worker.start()` long-polls for jobs and dispatches them to registered handlers. It runs until you call `worker.stop()`.
|
|
42
|
+
|
|
43
|
+
## Graceful shutdown
|
|
44
|
+
|
|
45
|
+
The SDK does not install signal handlers — your application owns its process lifecycle. Wire `SIGTERM`/`SIGINT` to `worker.stop()` so in-flight jobs and their result reports finish before the process exits:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
let stopping = false;
|
|
49
|
+
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
|
50
|
+
process.on(sig, () => {
|
|
51
|
+
if (stopping) process.exit(1);
|
|
52
|
+
stopping = true;
|
|
53
|
+
chronos.worker.stop().then(() => process.exit(143));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A second signal force-exits as an escape hatch. This matters for any long-running worker: standalone scripts, Docker containers, systemd services, PM2, cloud VMs, or CI runners.
|
|
59
|
+
|
|
60
|
+
## Documentation
|
|
61
|
+
|
|
62
|
+
[Chronos documentation](https://chronos.sh/docs)
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
[MIT](LICENSE)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/errors.ts
|
|
3
|
+
/**
|
|
4
|
+
* Base class for all errors thrown by the Chronos SDK. Catch this in a
|
|
5
|
+
* single `catch` to handle any SDK failure generically; use the subclasses
|
|
6
|
+
* to branch on cause.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { Chronos, ChronosError } from '@chronos.sh/sdk';
|
|
11
|
+
*
|
|
12
|
+
* const chronos = new Chronos({ apiKey: 'chrns_...' });
|
|
13
|
+
* try {
|
|
14
|
+
* await chronos.worker.start();
|
|
15
|
+
* } catch (err) {
|
|
16
|
+
* if (err instanceof ChronosError) {
|
|
17
|
+
* console.error('Chronos failed:', err.message);
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
var ChronosError = class extends Error {
|
|
23
|
+
constructor(message, options) {
|
|
24
|
+
super(message, options);
|
|
25
|
+
this.name = "ChronosError";
|
|
26
|
+
if (options && !("cause" in this)) Object.defineProperty(this, "cause", {
|
|
27
|
+
value: options.cause,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when SDK options fail validation at `new Chronos({ ... })`. Covers
|
|
35
|
+
* `apiKey`, `baseUrl`, `pollWaitTimeSeconds`, and `retryDelayMs`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { Chronos, ChronosConfigError } from '@chronos.sh/sdk';
|
|
40
|
+
*
|
|
41
|
+
* try {
|
|
42
|
+
* const chronos = new Chronos({ apiKey: '' });
|
|
43
|
+
* } catch (err) {
|
|
44
|
+
* if (err instanceof ChronosConfigError) {
|
|
45
|
+
* console.error('Invalid Chronos config:', err.message);
|
|
46
|
+
* }
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
var ChronosConfigError = class extends ChronosError {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "ChronosConfigError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Thrown when the Chronos API responds with a non-2xx status or a
|
|
58
|
+
* `success: false` envelope. Carries HTTP `status`, the application
|
|
59
|
+
* `code`, parsed `body`, and the API's `X-Request-Id`.
|
|
60
|
+
*
|
|
61
|
+
* `instanceof ChronosApiError` means the server replied;
|
|
62
|
+
* network/transport failures throw {@link ChronosNetworkError} instead.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* import { ChronosApiError } from '@chronos.sh/sdk';
|
|
67
|
+
*
|
|
68
|
+
* function handleSdkError(err: unknown) {
|
|
69
|
+
* if (err instanceof ChronosApiError) {
|
|
70
|
+
* if (err.status === 401) return refreshAuth();
|
|
71
|
+
* console.error('API error', { status: err.status, requestId: err.requestId });
|
|
72
|
+
* }
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
var ChronosApiError = class extends ChronosError {
|
|
77
|
+
/** HTTP status code returned by the Chronos API. */
|
|
78
|
+
status;
|
|
79
|
+
/** Application-level error code from the response envelope, when present. */
|
|
80
|
+
code;
|
|
81
|
+
/** Full parsed response payload (envelope + data, or whatever the server returned). */
|
|
82
|
+
body;
|
|
83
|
+
/** Value of the `X-Request-Id` response header, when present. */
|
|
84
|
+
requestId;
|
|
85
|
+
constructor(message, options) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "ChronosApiError";
|
|
88
|
+
this.status = options.status;
|
|
89
|
+
this.code = options.code;
|
|
90
|
+
this.body = options.body;
|
|
91
|
+
this.requestId = options.requestId;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Thrown when the underlying `fetch` rejects before the server replies —
|
|
96
|
+
* DNS failure, TCP reset, connection refused, etc. The original error is
|
|
97
|
+
* available on `.cause`.
|
|
98
|
+
*
|
|
99
|
+
* Abort signals propagate unwrapped — `instanceof ChronosNetworkError`
|
|
100
|
+
* always means a real transport failure, not a graceful shutdown.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```ts
|
|
104
|
+
* import { ChronosNetworkError } from '@chronos.sh/sdk';
|
|
105
|
+
*
|
|
106
|
+
* function handleSdkError(err: unknown) {
|
|
107
|
+
* if (err instanceof ChronosNetworkError) {
|
|
108
|
+
* console.warn('Transport blip', { cause: err.cause });
|
|
109
|
+
* }
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
var ChronosNetworkError = class extends ChronosError {
|
|
114
|
+
constructor(message, options) {
|
|
115
|
+
super(message, options);
|
|
116
|
+
this.name = "ChronosNetworkError";
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Wraps an exception thrown by a user-supplied {@link ChronosHandler}. The
|
|
121
|
+
* original error is on `.cause`; `.message` is copied from the original so
|
|
122
|
+
* the SDK reports it to the API as the failure reason.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* import { ChronosHandlerError } from '@chronos.sh/sdk';
|
|
127
|
+
*
|
|
128
|
+
* if (err instanceof ChronosHandlerError) {
|
|
129
|
+
* console.error('Handler threw', err.cause);
|
|
130
|
+
* }
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
var ChronosHandlerError = class extends ChronosError {
|
|
134
|
+
constructor(message, options) {
|
|
135
|
+
super(message, options);
|
|
136
|
+
this.name = "ChronosHandlerError";
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/internal/logger.ts
|
|
141
|
+
const defaultLogger = {
|
|
142
|
+
debug: (message, meta) => logToConsole(console.debug, message, meta),
|
|
143
|
+
info: (message, meta) => logToConsole(console.info, message, meta),
|
|
144
|
+
warn: (message, meta) => logToConsole(console.warn, message, meta),
|
|
145
|
+
error: (message, meta) => logToConsole(console.error, message, meta)
|
|
146
|
+
};
|
|
147
|
+
function logToConsole(method, message, meta) {
|
|
148
|
+
if (meta) method(message, meta);
|
|
149
|
+
else method(message);
|
|
150
|
+
}
|
|
151
|
+
function validateApiKey(apiKey) {
|
|
152
|
+
const trimmed = apiKey?.trim();
|
|
153
|
+
if (!trimmed) throw new ChronosConfigError("Chronos apiKey is required");
|
|
154
|
+
return trimmed;
|
|
155
|
+
}
|
|
156
|
+
function validatePollWaitTime(seconds) {
|
|
157
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 20) throw new ChronosConfigError(`pollWaitTimeSeconds must be an integer between 0 and 20`);
|
|
158
|
+
}
|
|
159
|
+
function validateRetryDelayMs(ms) {
|
|
160
|
+
if (!Number.isFinite(ms) || ms < 0) throw new ChronosConfigError("retryDelayMs must be a non-negative number");
|
|
161
|
+
}
|
|
162
|
+
function normalizeHandlerName(name) {
|
|
163
|
+
const normalized = name.trim();
|
|
164
|
+
if (!normalized) throw new ChronosError("Handler name is required");
|
|
165
|
+
if (normalized.length > 255) throw new ChronosError(`Handler name must be 255 characters or fewer`);
|
|
166
|
+
return normalized;
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/client.ts
|
|
170
|
+
/** Default Chronos API base URL. */
|
|
171
|
+
const DEFAULT_BASE_URL = "https://api.chronos.sh";
|
|
172
|
+
var BaseClient = class {
|
|
173
|
+
apiKey;
|
|
174
|
+
baseUrl;
|
|
175
|
+
fetch;
|
|
176
|
+
logger;
|
|
177
|
+
headers;
|
|
178
|
+
constructor(options) {
|
|
179
|
+
this.apiKey = validateApiKey(options.apiKey);
|
|
180
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? "https://api.chronos.sh");
|
|
181
|
+
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
182
|
+
this.logger = options.logger ?? defaultLogger;
|
|
183
|
+
this.headers = {
|
|
184
|
+
"content-type": "application/json",
|
|
185
|
+
authorization: `Bearer ${this.apiKey}`
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async request(path, body, signal) {
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: this.headers,
|
|
194
|
+
body: JSON.stringify(body),
|
|
195
|
+
signal
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (signal?.aborted) throw err;
|
|
199
|
+
throw new ChronosNetworkError(`Chronos API request failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
200
|
+
}
|
|
201
|
+
const payload = await parseJsonResponse(response);
|
|
202
|
+
const envelope = isEnvelope(payload) ? payload : void 0;
|
|
203
|
+
if (!response.ok || envelope && !envelope.success) throw new ChronosApiError(apiErrorMessage(response, envelope), {
|
|
204
|
+
status: response.status,
|
|
205
|
+
code: typeof envelope?.code === "string" ? envelope.code : void 0,
|
|
206
|
+
body: payload,
|
|
207
|
+
requestId: response.headers.get("x-request-id") ?? void 0
|
|
208
|
+
});
|
|
209
|
+
if (!envelope) throw new ChronosError("Chronos API returned an invalid response");
|
|
210
|
+
return envelope.data;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
function normalizeBaseUrl(baseUrl) {
|
|
214
|
+
const normalized = baseUrl.trim().replace(/\/+$/, "");
|
|
215
|
+
if (!normalized) throw new ChronosConfigError("Chronos baseUrl is required");
|
|
216
|
+
return normalized;
|
|
217
|
+
}
|
|
218
|
+
async function parseJsonResponse(response) {
|
|
219
|
+
try {
|
|
220
|
+
return await response.json();
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function apiErrorMessage(response, envelope) {
|
|
226
|
+
if (typeof envelope?.message === "string" && envelope.message.trim()) return envelope.message;
|
|
227
|
+
return response.ok ? "Chronos API returned an invalid response" : `Chronos API request failed with status ${response.status}`;
|
|
228
|
+
}
|
|
229
|
+
function isEnvelope(value) {
|
|
230
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
231
|
+
return typeof value.success === "boolean";
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/worker.ts
|
|
235
|
+
/** Default worker long-poll wait time in seconds. Equal to the API maximum. */
|
|
236
|
+
const DEFAULT_POLL_WAIT_TIME_SECONDS = 20;
|
|
237
|
+
const DEFAULT_RETRY_DELAY_MS = 1e3;
|
|
238
|
+
const RESULT_REPORT_MAX_ATTEMPTS = 3;
|
|
239
|
+
const MAX_REPORTED_ERROR_LENGTH = 4096;
|
|
240
|
+
const DEFAULT_HANDLER_ERROR = "Chronos handler failed";
|
|
241
|
+
/**
|
|
242
|
+
* Long-poll worker. Claims jobs from the Chronos API, dispatches them to
|
|
243
|
+
* registered handlers, and reports results.
|
|
244
|
+
*
|
|
245
|
+
* Construct via `new Chronos({ apiKey }).worker` rather than directly.
|
|
246
|
+
*/
|
|
247
|
+
var Worker = class {
|
|
248
|
+
client;
|
|
249
|
+
pollWaitTimeSeconds;
|
|
250
|
+
retryDelayMs;
|
|
251
|
+
handlers = /* @__PURE__ */ new Map();
|
|
252
|
+
handlerNames = [];
|
|
253
|
+
startPromise;
|
|
254
|
+
pollController;
|
|
255
|
+
constructor(client, options) {
|
|
256
|
+
this.client = client;
|
|
257
|
+
this.pollWaitTimeSeconds = options.pollWaitTimeSeconds ?? 20;
|
|
258
|
+
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
259
|
+
validatePollWaitTime(this.pollWaitTimeSeconds);
|
|
260
|
+
validateRetryDelayMs(this.retryDelayMs);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Register a handler for a named job type. Invoked when the Chronos API
|
|
264
|
+
* claims a job whose `handler` field matches `name`. Names are trimmed and
|
|
265
|
+
* must be 1–255 characters.
|
|
266
|
+
*
|
|
267
|
+
* @param name - Handler name. Must match the schedule's `handler` on the API side.
|
|
268
|
+
* @param handler - Async function invoked with the job context. Return a
|
|
269
|
+
* plain object to record a result, or `undefined` for none.
|
|
270
|
+
* @returns The Worker, for chaining.
|
|
271
|
+
* @throws {ChronosError} If the name is invalid, already registered, or `handler` is not a function.
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* ```ts
|
|
275
|
+
* chronos.worker
|
|
276
|
+
* .handle('send-email', async (ctx) => ({ sent: true }))
|
|
277
|
+
* .handle('cleanup', async () => undefined);
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
handle(name, handler) {
|
|
281
|
+
const normalizedName = normalizeHandlerName(name);
|
|
282
|
+
if (this.handlers.has(normalizedName)) throw new ChronosError(`Handler "${normalizedName}" is already registered`);
|
|
283
|
+
if (typeof handler !== "function") throw new ChronosError(`Handler "${normalizedName}" must be a function`);
|
|
284
|
+
this.handlers.set(normalizedName, handler);
|
|
285
|
+
this.handlerNames.push(normalizedName);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Begin long-polling for jobs. The returned promise resolves when
|
|
290
|
+
* {@link Worker.stop} is called and any in-flight job completes.
|
|
291
|
+
*
|
|
292
|
+
* @throws {ChronosError} Synchronously, if no handlers are registered or the worker is already running.
|
|
293
|
+
*/
|
|
294
|
+
start() {
|
|
295
|
+
if (this.startPromise) throw new ChronosError("Chronos worker is already started");
|
|
296
|
+
if (this.handlers.size === 0) throw new ChronosError("Register at least one handler before starting Chronos");
|
|
297
|
+
this.pollController = new AbortController();
|
|
298
|
+
this.startPromise = this.runLoop().finally(() => {
|
|
299
|
+
this.startPromise = void 0;
|
|
300
|
+
this.pollController = void 0;
|
|
301
|
+
});
|
|
302
|
+
return this.startPromise;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Request graceful shutdown. The poll loop is aborted immediately; any
|
|
306
|
+
* in-flight handler and result-report are allowed to complete to preserve
|
|
307
|
+
* at-least-once delivery. Returns the same promise as the active
|
|
308
|
+
* {@link Worker.start}, or a resolved promise if the worker isn't running.
|
|
309
|
+
*/
|
|
310
|
+
stop() {
|
|
311
|
+
this.pollController?.abort();
|
|
312
|
+
return this.startPromise ?? Promise.resolve();
|
|
313
|
+
}
|
|
314
|
+
get isStopped() {
|
|
315
|
+
return this.pollController?.signal.aborted ?? true;
|
|
316
|
+
}
|
|
317
|
+
async runLoop() {
|
|
318
|
+
while (!this.isStopped) try {
|
|
319
|
+
const job = await this.claimJob();
|
|
320
|
+
if (job) await this.processJob(job);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (this.isStopped) break;
|
|
323
|
+
this.client.logger.error("Chronos poll loop error", { err: errorToLogValue(err) });
|
|
324
|
+
await sleep(this.retryDelayMs, this.pollController?.signal);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async claimJob() {
|
|
328
|
+
if (this.handlerNames.length === 0) throw new ChronosError("Cannot claim jobs without registered handlers");
|
|
329
|
+
return this.client.request("/v1/worker/jobs/claim", {
|
|
330
|
+
wait_time_seconds: this.pollWaitTimeSeconds,
|
|
331
|
+
handlers: this.handlerNames
|
|
332
|
+
}, this.pollController?.signal);
|
|
333
|
+
}
|
|
334
|
+
async processJob(job) {
|
|
335
|
+
const handler = this.handlers.get(job.handler);
|
|
336
|
+
if (!handler) return this.handleUnregisteredJob(job);
|
|
337
|
+
let handlerResult;
|
|
338
|
+
try {
|
|
339
|
+
handlerResult = await handler(createContext(job));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
const message = errorMessage(err);
|
|
342
|
+
const handlerErr = new ChronosHandlerError(message, { cause: err });
|
|
343
|
+
this.client.logger.error("Chronos handler failed", {
|
|
344
|
+
...jobLogMeta(job),
|
|
345
|
+
err: errorToLogValue(handlerErr)
|
|
346
|
+
});
|
|
347
|
+
await this.safeReportFailed(job, message);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
let result;
|
|
351
|
+
try {
|
|
352
|
+
result = normalizeHandlerResult(handlerResult);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
this.client.logger.error("Chronos handler returned invalid result", {
|
|
355
|
+
...jobLogMeta(job),
|
|
356
|
+
err: errorToLogValue(err)
|
|
357
|
+
});
|
|
358
|
+
await this.safeReportFailed(job, errorMessage(err));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
await this.reportCompleted(job.execution_id, result);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.logResultReportFailure(err, job, "completed");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async handleUnregisteredJob(job) {
|
|
368
|
+
const message = `Chronos SDK received job for unregistered handler "${job.handler}"`;
|
|
369
|
+
this.client.logger.error(message, jobLogMeta(job));
|
|
370
|
+
await this.safeReportFailed(job, message);
|
|
371
|
+
}
|
|
372
|
+
async safeReportFailed(job, message) {
|
|
373
|
+
try {
|
|
374
|
+
await this.reportFailed(job.execution_id, message);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
this.logResultReportFailure(err, job, "failed");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async reportCompleted(executionId, result) {
|
|
380
|
+
await this.reportResultWithRetry(executionId, {
|
|
381
|
+
status: "completed",
|
|
382
|
+
...result === void 0 ? {} : { result }
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async reportFailed(executionId, error) {
|
|
386
|
+
await this.reportResultWithRetry(executionId, {
|
|
387
|
+
status: "failed",
|
|
388
|
+
error: truncate(error, MAX_REPORTED_ERROR_LENGTH)
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async reportResultWithRetry(executionId, body) {
|
|
392
|
+
let lastErr;
|
|
393
|
+
for (let attempt = 1; attempt <= RESULT_REPORT_MAX_ATTEMPTS; attempt++) try {
|
|
394
|
+
await this.client.request(`/v1/worker/executions/${encodeURIComponent(executionId)}/result`, body);
|
|
395
|
+
return;
|
|
396
|
+
} catch (err) {
|
|
397
|
+
const apiErr = err instanceof ChronosApiError ? err : void 0;
|
|
398
|
+
if (apiErr?.status === 409) {
|
|
399
|
+
this.client.logger.warn("Chronos result discarded; execution already terminal", {
|
|
400
|
+
executionId,
|
|
401
|
+
status: body.status,
|
|
402
|
+
code: apiErr.code
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (apiErr && isTerminalReportError(apiErr.status)) throw apiErr;
|
|
407
|
+
lastErr = err;
|
|
408
|
+
if (attempt === RESULT_REPORT_MAX_ATTEMPTS) break;
|
|
409
|
+
this.client.logger.warn("Chronos result report failed; retrying", {
|
|
410
|
+
err: errorToLogValue(err),
|
|
411
|
+
executionId,
|
|
412
|
+
status: body.status,
|
|
413
|
+
attempt,
|
|
414
|
+
maxAttempts: RESULT_REPORT_MAX_ATTEMPTS
|
|
415
|
+
});
|
|
416
|
+
await sleep(this.retryDelayMs);
|
|
417
|
+
}
|
|
418
|
+
throw lastErr;
|
|
419
|
+
}
|
|
420
|
+
logResultReportFailure(err, job, status) {
|
|
421
|
+
const isTerminal = err instanceof ChronosApiError && isTerminalReportError(err.status);
|
|
422
|
+
this.client.logger.error(isTerminal ? "Chronos result report rejected by API" : "Chronos result report failed after retries", {
|
|
423
|
+
...jobLogMeta(job),
|
|
424
|
+
err: errorToLogValue(err),
|
|
425
|
+
status
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
function createContext(job) {
|
|
430
|
+
return {
|
|
431
|
+
jobId: job.job_id,
|
|
432
|
+
executionId: job.execution_id,
|
|
433
|
+
handler: job.handler,
|
|
434
|
+
payload: job.payload,
|
|
435
|
+
scheduledFor: new Date(job.scheduled_for),
|
|
436
|
+
attempt: job.attempt,
|
|
437
|
+
timeout: job.timeout,
|
|
438
|
+
schedule: job.schedule
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function jobLogMeta(job) {
|
|
442
|
+
return {
|
|
443
|
+
jobId: job.job_id,
|
|
444
|
+
executionId: job.execution_id,
|
|
445
|
+
handler: job.handler
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function normalizeHandlerResult(result) {
|
|
449
|
+
if (result === void 0) return;
|
|
450
|
+
if (!isPlainObject(result)) throw new ChronosError("Chronos handler result must be a plain object or undefined");
|
|
451
|
+
try {
|
|
452
|
+
JSON.stringify(result);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
throw new ChronosError(`Chronos handler result is not JSON-encodable: ${err instanceof Error ? err.message : "value is not JSON-encodable"}`);
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
function isPlainObject(value) {
|
|
459
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
460
|
+
const proto = Object.getPrototypeOf(value);
|
|
461
|
+
return proto === Object.prototype || proto === null;
|
|
462
|
+
}
|
|
463
|
+
function errorMessage(err) {
|
|
464
|
+
return (err instanceof Error ? err.message : String(err)).trim() || DEFAULT_HANDLER_ERROR;
|
|
465
|
+
}
|
|
466
|
+
function errorToLogValue(err) {
|
|
467
|
+
if (!(err instanceof Error)) return err;
|
|
468
|
+
const base = {
|
|
469
|
+
name: err.name,
|
|
470
|
+
message: err.message,
|
|
471
|
+
stack: err.stack
|
|
472
|
+
};
|
|
473
|
+
if (err instanceof ChronosApiError) return {
|
|
474
|
+
...base,
|
|
475
|
+
status: err.status,
|
|
476
|
+
code: err.code,
|
|
477
|
+
requestId: err.requestId
|
|
478
|
+
};
|
|
479
|
+
if (err instanceof ChronosNetworkError || err instanceof ChronosHandlerError) return {
|
|
480
|
+
...base,
|
|
481
|
+
cause: errorToLogValue(err.cause)
|
|
482
|
+
};
|
|
483
|
+
return base;
|
|
484
|
+
}
|
|
485
|
+
function truncate(value, maxLength) {
|
|
486
|
+
return value.length <= maxLength ? value : value.slice(0, maxLength);
|
|
487
|
+
}
|
|
488
|
+
const NON_TERMINAL_4XX = new Set([
|
|
489
|
+
408,
|
|
490
|
+
409,
|
|
491
|
+
429
|
|
492
|
+
]);
|
|
493
|
+
function isTerminalReportError(status) {
|
|
494
|
+
if (status === 200) return true;
|
|
495
|
+
if (NON_TERMINAL_4XX.has(status)) return false;
|
|
496
|
+
return status >= 400 && status < 500;
|
|
497
|
+
}
|
|
498
|
+
function sleep(ms, signal) {
|
|
499
|
+
if (signal?.aborted) return Promise.resolve();
|
|
500
|
+
return new Promise((resolve) => {
|
|
501
|
+
const timeout = setTimeout(done, ms);
|
|
502
|
+
function done() {
|
|
503
|
+
clearTimeout(timeout);
|
|
504
|
+
signal?.removeEventListener("abort", done);
|
|
505
|
+
resolve();
|
|
506
|
+
}
|
|
507
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/index.ts
|
|
512
|
+
/**
|
|
513
|
+
* The Chronos SDK client. Composes worker and (future) REST resource
|
|
514
|
+
* subclients from a single instance.
|
|
515
|
+
*
|
|
516
|
+
* @example
|
|
517
|
+
* ```ts
|
|
518
|
+
* import { Chronos } from '@chronos.sh/sdk';
|
|
519
|
+
*
|
|
520
|
+
* const chronos = new Chronos({ apiKey: process.env.CHRONOS_API_KEY! });
|
|
521
|
+
*
|
|
522
|
+
* chronos.worker.handle<{ to: string }>('send-email', async (ctx) => {
|
|
523
|
+
* await sendEmail(ctx.payload.to);
|
|
524
|
+
* return { sent: true };
|
|
525
|
+
* });
|
|
526
|
+
*
|
|
527
|
+
* await chronos.worker.start();
|
|
528
|
+
* ```
|
|
529
|
+
*/
|
|
530
|
+
var Chronos = class {
|
|
531
|
+
/** Long-poll worker for executing pull-mode jobs. */
|
|
532
|
+
worker;
|
|
533
|
+
/**
|
|
534
|
+
* @param options - Client configuration. Only `apiKey` is required.
|
|
535
|
+
* @throws {ChronosConfigError} If `apiKey` is missing or any option fails validation.
|
|
536
|
+
*/
|
|
537
|
+
constructor(options) {
|
|
538
|
+
const client = new BaseClient(options);
|
|
539
|
+
this.worker = new Worker(client, options);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
//#endregion
|
|
543
|
+
exports.Chronos = Chronos;
|
|
544
|
+
exports.ChronosApiError = ChronosApiError;
|
|
545
|
+
exports.ChronosConfigError = ChronosConfigError;
|
|
546
|
+
exports.ChronosError = ChronosError;
|
|
547
|
+
exports.ChronosHandlerError = ChronosHandlerError;
|
|
548
|
+
exports.ChronosNetworkError = ChronosNetworkError;
|
|
549
|
+
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
550
|
+
exports.DEFAULT_POLL_WAIT_TIME_SECONDS = DEFAULT_POLL_WAIT_TIME_SECONDS;
|
|
551
|
+
|
|
552
|
+
//# sourceMappingURL=index.cjs.map
|