@cronvello/sdk 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/dist/index.js ADDED
@@ -0,0 +1,899 @@
1
+ // src/internal/errors.ts
2
+ var CronvelloError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "CronvelloError";
6
+ Object.setPrototypeOf(this, new.target.prototype);
7
+ }
8
+ };
9
+ var CronvelloApiError = class extends CronvelloError {
10
+ /** HTTP status code. */
11
+ status;
12
+ /** Machine-readable error code from the API body, when present. */
13
+ code;
14
+ /** The method + path that failed, e.g. `POST /v1/jobs`. */
15
+ endpoint;
16
+ /** Parsed response body (best effort). */
17
+ body;
18
+ /** Seconds to wait before retrying, parsed from `Retry-After` / rate-limit payload (429 only). */
19
+ retryAfterSeconds;
20
+ constructor(args) {
21
+ super(`[${args.status}] ${args.endpoint}: ${args.message}`);
22
+ this.name = "CronvelloApiError";
23
+ this.status = args.status;
24
+ this.code = args.code;
25
+ this.endpoint = args.endpoint;
26
+ this.body = args.body;
27
+ this.retryAfterSeconds = args.retryAfterSeconds;
28
+ Object.setPrototypeOf(this, new.target.prototype);
29
+ }
30
+ get isRateLimited() {
31
+ return this.status === 429;
32
+ }
33
+ get isAuthError() {
34
+ return this.status === 401 || this.status === 403;
35
+ }
36
+ get isNotFound() {
37
+ return this.status === 404;
38
+ }
39
+ };
40
+ var CronvelloNetworkError = class extends CronvelloError {
41
+ endpoint;
42
+ cause;
43
+ constructor(endpoint, cause) {
44
+ super(`Network error calling ${endpoint}: ${describe(cause)}`);
45
+ this.name = "CronvelloNetworkError";
46
+ this.endpoint = endpoint;
47
+ this.cause = cause;
48
+ Object.setPrototypeOf(this, new.target.prototype);
49
+ }
50
+ };
51
+ var CronvelloConfigError = class extends CronvelloError {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "CronvelloConfigError";
55
+ Object.setPrototypeOf(this, new.target.prototype);
56
+ }
57
+ };
58
+ function describe(cause) {
59
+ if (cause instanceof Error) return cause.message;
60
+ return String(cause);
61
+ }
62
+
63
+ // src/internal/http.ts
64
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
65
+ var Transport = class {
66
+ baseUrl;
67
+ apiKey;
68
+ timeoutMs;
69
+ maxRetries;
70
+ fetchImpl;
71
+ defaultHeaders;
72
+ onRequest;
73
+ constructor(opts) {
74
+ if (!opts.apiKey) throw new CronvelloConfigError("apiKey is required");
75
+ if (!opts.baseUrl) throw new CronvelloConfigError("baseUrl is required");
76
+ const resolvedFetch = opts.fetch ?? globalThis.fetch;
77
+ if (!resolvedFetch) {
78
+ throw new CronvelloConfigError(
79
+ "No global fetch found. Use Node 18+ or pass a `fetch` implementation in the client options."
80
+ );
81
+ }
82
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
83
+ this.apiKey = opts.apiKey;
84
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
85
+ this.maxRetries = opts.maxRetries ?? 2;
86
+ this.fetchImpl = resolvedFetch;
87
+ this.defaultHeaders = opts.defaultHeaders ?? {};
88
+ this.onRequest = opts.onRequest;
89
+ }
90
+ async request(opts) {
91
+ const url = this.buildUrl(opts.path, opts.query);
92
+ const endpoint = `${opts.method} ${opts.path}`;
93
+ const headers = {
94
+ authorization: `Bearer ${this.apiKey}`,
95
+ accept: "application/json",
96
+ ...this.defaultHeaders
97
+ };
98
+ let payload;
99
+ if (opts.body !== void 0) {
100
+ headers["content-type"] = "application/json";
101
+ payload = JSON.stringify(opts.body);
102
+ }
103
+ if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
104
+ let lastError;
105
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
106
+ const startedAt = Date.now();
107
+ const { signal, cancel } = this.withTimeout(opts.signal);
108
+ try {
109
+ const res = await this.fetchImpl(url, {
110
+ method: opts.method,
111
+ headers,
112
+ ...payload !== void 0 ? { body: payload } : {},
113
+ signal
114
+ });
115
+ cancel();
116
+ const durationMs = Date.now() - startedAt;
117
+ this.onRequest?.({ method: opts.method, path: opts.path, status: res.status, attempt, durationMs });
118
+ const text = await res.text();
119
+ const parsed = text ? safeJson(text) : null;
120
+ if (res.ok) return parsed;
121
+ const retryAfter = parseRetryAfter(res, parsed);
122
+ if (RETRYABLE_STATUS.has(res.status) && attempt < this.maxRetries) {
123
+ await sleep(this.backoff(attempt, retryAfter));
124
+ continue;
125
+ }
126
+ throw new CronvelloApiError({
127
+ status: res.status,
128
+ endpoint,
129
+ message: extractMessage(parsed) ?? `Request failed`,
130
+ code: extractCode(parsed),
131
+ body: parsed,
132
+ ...retryAfter !== void 0 ? { retryAfterSeconds: retryAfter } : {}
133
+ });
134
+ } catch (err) {
135
+ cancel();
136
+ if (err instanceof CronvelloApiError) throw err;
137
+ lastError = err;
138
+ if (attempt < this.maxRetries) {
139
+ await sleep(this.backoff(attempt));
140
+ continue;
141
+ }
142
+ throw new CronvelloNetworkError(endpoint, err);
143
+ }
144
+ }
145
+ throw new CronvelloNetworkError(endpoint, lastError);
146
+ }
147
+ buildUrl(path, query) {
148
+ const base = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
149
+ if (!query) return base;
150
+ const params = Object.entries(query).filter(([, v]) => v !== void 0).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
151
+ return params.length ? `${base}?${params.join("&")}` : base;
152
+ }
153
+ withTimeout(external) {
154
+ const controller = new AbortController();
155
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
156
+ const onExternalAbort = () => controller.abort();
157
+ if (external) {
158
+ if (external.aborted) controller.abort();
159
+ else external.addEventListener("abort", onExternalAbort, { once: true });
160
+ }
161
+ return {
162
+ signal: controller.signal,
163
+ cancel: () => {
164
+ clearTimeout(timer);
165
+ external?.removeEventListener("abort", onExternalAbort);
166
+ }
167
+ };
168
+ }
169
+ backoff(attempt, retryAfterSeconds) {
170
+ if (retryAfterSeconds !== void 0) return Math.min(retryAfterSeconds * 1e3, 3e4);
171
+ const base = Math.min(300 * 2 ** attempt, 5e3);
172
+ return Math.floor(Math.random() * base);
173
+ }
174
+ };
175
+ function sleep(ms) {
176
+ return new Promise((resolve) => setTimeout(resolve, ms));
177
+ }
178
+ function safeJson(text) {
179
+ try {
180
+ return JSON.parse(text);
181
+ } catch {
182
+ return text;
183
+ }
184
+ }
185
+ function extractMessage(body) {
186
+ if (body && typeof body === "object" && "message" in body) {
187
+ const m = body.message;
188
+ if (typeof m === "string") return m;
189
+ }
190
+ return void 0;
191
+ }
192
+ function extractCode(body) {
193
+ if (body && typeof body === "object" && "code" in body) {
194
+ const c = body.code;
195
+ if (typeof c === "string") return c;
196
+ }
197
+ return void 0;
198
+ }
199
+ function parseRetryAfter(res, body) {
200
+ const header = res.headers.get("retry-after");
201
+ if (header) {
202
+ const n = Number(header);
203
+ if (Number.isFinite(n)) return n;
204
+ }
205
+ if (body && typeof body === "object" && "data" in body) {
206
+ const data = body.data;
207
+ if (data && typeof data.retryAfterSeconds === "number") return data.retryAfterSeconds;
208
+ }
209
+ return void 0;
210
+ }
211
+
212
+ // src/client/client.ts
213
+ var CRONVELLO_DEFAULT_BASE_URL = "https://api.cronvello.com";
214
+ var CronvelloClient = class {
215
+ transport;
216
+ /** The resolved base URL in use. */
217
+ baseUrl;
218
+ /** Job container operations. */
219
+ jobs;
220
+ /** Scheduled task operations. */
221
+ tasks;
222
+ /** Execution-history (run) operations. */
223
+ runs;
224
+ /** Account identity + usage. */
225
+ account;
226
+ constructor(options) {
227
+ if (!options || !options.apiKey) {
228
+ throw new CronvelloConfigError("CronvelloClient requires an `apiKey` (crn_live_\u2026).");
229
+ }
230
+ this.baseUrl = (options.baseUrl ?? CRONVELLO_DEFAULT_BASE_URL).replace(/\/+$/, "");
231
+ this.transport = new Transport({
232
+ baseUrl: this.baseUrl,
233
+ apiKey: options.apiKey,
234
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
235
+ ...options.maxRetries !== void 0 ? { maxRetries: options.maxRetries } : {},
236
+ ...options.fetch ? { fetch: options.fetch } : {},
237
+ ...options.onRequest ? { onRequest: options.onRequest } : {},
238
+ defaultHeaders: { "user-agent": "cronvello-sdk" }
239
+ });
240
+ this.jobs = new JobsResource(this.transport);
241
+ this.tasks = new TasksResource(this.transport);
242
+ this.runs = new RunsResource(this.transport);
243
+ this.account = new AccountResource(this.transport);
244
+ }
245
+ /**
246
+ * Atomically reconcile a whole code registry server-side (`PUT /v1/registry`): one job
247
+ * container + its tasks, created/updated/pruned/started in a single call. This is what
248
+ * `defineCronvello().sync()` prefers; the high-level API builds the body for you.
249
+ */
250
+ reconcileRegistry(body, opts) {
251
+ return this.transport.request({
252
+ method: "PUT",
253
+ path: "/v1/registry",
254
+ body,
255
+ ...opts?.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}
256
+ });
257
+ }
258
+ /**
259
+ * Escape hatch for endpoints not yet wrapped by a typed method (heartbeat monitors,
260
+ * maintenance windows, DLQ, notification channels, audit log, API-key self-service …).
261
+ * `T` is the response shape you expect.
262
+ */
263
+ request(method, path, opts) {
264
+ return this.transport.request({
265
+ method,
266
+ path,
267
+ ...opts?.query ? { query: opts.query } : {},
268
+ ...opts?.body !== void 0 ? { body: opts.body } : {},
269
+ ...opts?.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}
270
+ });
271
+ }
272
+ };
273
+ var JobsResource = class {
274
+ constructor(t) {
275
+ this.t = t;
276
+ }
277
+ t;
278
+ list() {
279
+ return this.t.request({ method: "GET", path: "/v1/jobs" });
280
+ }
281
+ get(jobId) {
282
+ return this.t.request({ method: "GET", path: `/v1/jobs/${enc(jobId)}` });
283
+ }
284
+ create(body, opts) {
285
+ return this.t.request({ method: "POST", path: "/v1/jobs", body, ...opts?.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} });
286
+ }
287
+ update(jobId, body) {
288
+ return this.t.request({ method: "PATCH", path: `/v1/jobs/${enc(jobId)}`, body });
289
+ }
290
+ delete(jobId) {
291
+ return this.t.request({ method: "DELETE", path: `/v1/jobs/${enc(jobId)}` });
292
+ }
293
+ start(jobId) {
294
+ return this.t.request({ method: "POST", path: `/v1/jobs/${enc(jobId)}/start` });
295
+ }
296
+ stop(jobId) {
297
+ return this.t.request({ method: "POST", path: `/v1/jobs/${enc(jobId)}/stop` });
298
+ }
299
+ listTasks(jobId) {
300
+ return this.t.request({ method: "GET", path: `/v1/jobs/${enc(jobId)}/tasks` });
301
+ }
302
+ createTask(jobId, body, opts) {
303
+ return this.t.request({ method: "POST", path: `/v1/jobs/${enc(jobId)}/tasks`, body, ...opts?.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} });
304
+ }
305
+ };
306
+ var TasksResource = class {
307
+ constructor(t) {
308
+ this.t = t;
309
+ }
310
+ t;
311
+ /** Account-wide paginated task table. */
312
+ list(query) {
313
+ return this.t.request({ method: "GET", path: "/v1/tasks", ...query ? { query } : {} });
314
+ }
315
+ get(taskId) {
316
+ return this.t.request({ method: "GET", path: `/v1/tasks/${enc(taskId)}` });
317
+ }
318
+ update(taskId, body) {
319
+ return this.t.request({ method: "PATCH", path: `/v1/tasks/${enc(taskId)}`, body });
320
+ }
321
+ delete(taskId) {
322
+ return this.t.request({ method: "DELETE", path: `/v1/tasks/${enc(taskId)}` });
323
+ }
324
+ start(taskId) {
325
+ return this.t.request({ method: "POST", path: `/v1/tasks/${enc(taskId)}/start` });
326
+ }
327
+ stop(taskId) {
328
+ return this.t.request({ method: "POST", path: `/v1/tasks/${enc(taskId)}/stop` });
329
+ }
330
+ /** Trigger a one-off manual execution (does not change the schedule). */
331
+ runNow(taskId) {
332
+ return this.t.request({ method: "POST", path: `/v1/tasks/${enc(taskId)}/run` });
333
+ }
334
+ listRuns(taskId, query) {
335
+ return this.t.request({ method: "GET", path: `/v1/tasks/${enc(taskId)}/runs`, ...query ? { query } : {} });
336
+ }
337
+ };
338
+ var RunsResource = class {
339
+ constructor(t) {
340
+ this.t = t;
341
+ }
342
+ t;
343
+ /** Account-wide paginated activity feed. */
344
+ list(query) {
345
+ return this.t.request({ method: "GET", path: "/v1/runs", ...query ? { query } : {} });
346
+ }
347
+ get(runId) {
348
+ return this.t.request({ method: "GET", path: `/v1/runs/${enc(runId)}` });
349
+ }
350
+ };
351
+ var AccountResource = class {
352
+ constructor(t) {
353
+ this.t = t;
354
+ }
355
+ t;
356
+ me() {
357
+ return this.t.request({ method: "GET", path: "/v1/me" });
358
+ }
359
+ usage() {
360
+ return this.t.request({ method: "GET", path: "/v1/usage" });
361
+ }
362
+ };
363
+ function enc(segment) {
364
+ return encodeURIComponent(segment);
365
+ }
366
+
367
+ // src/registry/verify.ts
368
+ var encoder = new TextEncoder();
369
+ function parseBearer(headerValue2) {
370
+ if (!headerValue2) return null;
371
+ const match = /^bearer\s+(.+)$/i.exec(headerValue2.trim());
372
+ return match ? match[1].trim() : null;
373
+ }
374
+ function timingSafeEqual(a, b) {
375
+ const ab = encoder.encode(a);
376
+ const bb = encoder.encode(b);
377
+ const len = Math.max(ab.length, bb.length);
378
+ let diff = ab.length ^ bb.length;
379
+ for (let i = 0; i < len; i++) {
380
+ diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
381
+ }
382
+ return diff === 0;
383
+ }
384
+ async function signHmacSha256(payload, secret) {
385
+ const key = await crypto.subtle.importKey(
386
+ "raw",
387
+ encoder.encode(secret),
388
+ { name: "HMAC", hash: "SHA-256" },
389
+ false,
390
+ ["sign"]
391
+ );
392
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
393
+ const hex = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
394
+ return `sha256=${hex}`;
395
+ }
396
+
397
+ // src/registry/dispatch.ts
398
+ function createDispatcher(state) {
399
+ const log = state.logger;
400
+ async function handle(req) {
401
+ if (req.method.toUpperCase() !== "POST") {
402
+ return resp(405, { ok: false, error: "Method not allowed" });
403
+ }
404
+ const token = parseBearer(req.authorization);
405
+ if (!token || !timingSafeEqual(token, state.dispatchSecret)) {
406
+ log?.warn?.("[cronvello] dispatch rejected: bad or missing bearer token");
407
+ return resp(401, { ok: false, error: "Unauthorized" });
408
+ }
409
+ let body;
410
+ try {
411
+ const parsed = req.rawBody ? JSON.parse(req.rawBody) : {};
412
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
413
+ return resp(400, { ok: false, error: "Body must be a JSON object" });
414
+ }
415
+ body = parsed;
416
+ } catch {
417
+ return resp(400, { ok: false, error: "Invalid JSON body" });
418
+ }
419
+ const key = typeof body["job"] === "string" ? body["job"] : null;
420
+ if (!key) {
421
+ return resp(400, { ok: false, error: "Missing 'job' key in body" });
422
+ }
423
+ const job = state.jobs.get(key);
424
+ if (!job) {
425
+ log?.warn?.(`[cronvello] dispatch for unknown job '${key}'`);
426
+ return resp(404, { ok: false, error: `Unknown job: ${key}` });
427
+ }
428
+ const callback = readCallback(body["_callback"]);
429
+ const isAsync = callback !== null;
430
+ const ctx = {
431
+ key,
432
+ schedule: typeof body["schedule"] === "string" ? body["schedule"] : "",
433
+ payload: job.config.payload ?? {},
434
+ body,
435
+ headers: req.headers,
436
+ isAsync,
437
+ signal: req.signal
438
+ };
439
+ if (callback) {
440
+ const work = runAndReportCallback(job, ctx, callback, state.dispatchSecret, log);
441
+ if (req.waitUntil) req.waitUntil(work);
442
+ return resp(202, { ok: true, job: key, accepted: true });
443
+ }
444
+ const startedAt = Date.now();
445
+ try {
446
+ const result = await job.config.handler(ctx);
447
+ log?.debug?.(`[cronvello] job '${key}' completed in ${Date.now() - startedAt}ms`);
448
+ return resp(200, { ok: true, job: key, result: result ?? null });
449
+ } catch (err) {
450
+ const message = err instanceof Error ? err.message : String(err);
451
+ log?.error?.(`[cronvello] job '${key}' failed: ${message}`, { error: message });
452
+ return resp(500, { ok: false, job: key, error: message });
453
+ }
454
+ }
455
+ return { handle };
456
+ }
457
+ async function runAndReportCallback(job, ctx, callback, secret, log) {
458
+ const startedAt = Date.now();
459
+ let payload;
460
+ try {
461
+ const result = await job.config.handler(ctx);
462
+ payload = {
463
+ runId: callback.runId,
464
+ success: true,
465
+ durationMs: Date.now() - startedAt,
466
+ result: result ?? null
467
+ };
468
+ } catch (err) {
469
+ payload = {
470
+ runId: callback.runId,
471
+ success: false,
472
+ durationMs: Date.now() - startedAt,
473
+ error: err instanceof Error ? err.message : String(err)
474
+ };
475
+ }
476
+ try {
477
+ const bodyStr = JSON.stringify(payload);
478
+ const header = callback.expectedSignatureHeader || "X-Webhook-Signature";
479
+ const signature = await signHmacSha256(bodyStr, secret);
480
+ const res = await fetch(callback.url, {
481
+ method: "POST",
482
+ headers: { "content-type": "application/json", [header]: signature },
483
+ body: bodyStr
484
+ });
485
+ if (!res.ok) {
486
+ log?.warn?.(`[cronvello] callback POST for job '${job.key}' returned ${res.status}`);
487
+ }
488
+ } catch (err) {
489
+ log?.error?.(`[cronvello] failed to deliver callback for job '${job.key}'`, {
490
+ error: err instanceof Error ? err.message : String(err)
491
+ });
492
+ }
493
+ }
494
+ function readCallback(value) {
495
+ if (!value || typeof value !== "object") return null;
496
+ const v = value;
497
+ if (typeof v["url"] !== "string" || typeof v["runId"] !== "string") return null;
498
+ return {
499
+ url: v["url"],
500
+ runId: v["runId"],
501
+ ...typeof v["expectedSignatureHeader"] === "string" ? { expectedSignatureHeader: v["expectedSignatureHeader"] } : {},
502
+ ...typeof v["timeoutMs"] === "number" ? { timeoutMs: v["timeoutMs"] } : {}
503
+ };
504
+ }
505
+ function resp(status, body) {
506
+ return { status, body };
507
+ }
508
+
509
+ // src/registry/reconcile.ts
510
+ async function reconcile(input, options = {}) {
511
+ const { client, appName, dispatchUrl, dispatchSecret, defaultTimeZone, jobs } = input;
512
+ const prune = options.prune ?? true;
513
+ const dryRun = options.dryRun ?? false;
514
+ const rotateSecret = options.rotateSecret ?? false;
515
+ const { job: container, created: jobCreated } = await ensureContainer(client, appName, input.appDescription, dryRun);
516
+ const jobId = container?.id ?? "(dry-run)";
517
+ const changes = [];
518
+ const existingTasks = container && !jobCreated ? await client.jobs.listTasks(container.id) : [];
519
+ const byKey = new Map(existingTasks.map((t) => [t.name, t]));
520
+ const registryKeys = new Set(jobs.map((j) => j.key));
521
+ for (const job of jobs) {
522
+ const enabled = job.config.enabled ?? true;
523
+ const existing = byKey.get(job.key);
524
+ const desired = buildDesiredTask({ job, dispatchUrl, defaultTimeZone });
525
+ if (!enabled) {
526
+ if (existing && existing.status === "ACTIVE" && !dryRun) await client.tasks.stop(existing.id);
527
+ changes.push({ key: job.key, action: "skipped", taskId: existing?.id ?? null, reason: "disabled in registry" });
528
+ continue;
529
+ }
530
+ if (!existing) {
531
+ if (dryRun || !container) {
532
+ changes.push({ key: job.key, action: "created", taskId: null });
533
+ continue;
534
+ }
535
+ const createBody = { ...desired, targetToken: dispatchSecret };
536
+ const task = await client.jobs.createTask(container.id, createBody, { idempotencyKey: `cv-create-${appName}-${job.key}` });
537
+ await client.tasks.start(task.id);
538
+ changes.push({ key: job.key, action: "created", taskId: task.id });
539
+ continue;
540
+ }
541
+ const { patch, changedFields } = diffTask(existing, desired, { rotateSecret, dispatchSecret });
542
+ const needsStart = existing.status === "DISABLED";
543
+ if (changedFields.length === 0 && !needsStart) {
544
+ changes.push({ key: job.key, action: "unchanged", taskId: existing.id });
545
+ continue;
546
+ }
547
+ if (!dryRun) {
548
+ if (changedFields.length > 0) await client.tasks.update(existing.id, patch);
549
+ if (needsStart) await client.tasks.start(existing.id);
550
+ }
551
+ changes.push({
552
+ key: job.key,
553
+ action: changedFields.length > 0 ? "updated" : "unchanged",
554
+ taskId: existing.id,
555
+ ...changedFields.length > 0 ? { changedFields } : {},
556
+ ...needsStart ? { reason: "re-activated" } : {}
557
+ });
558
+ }
559
+ if (prune) {
560
+ for (const task of existingTasks) {
561
+ if (registryKeys.has(task.name)) continue;
562
+ if (!dryRun) await client.tasks.delete(task.id);
563
+ changes.push({ key: task.name, action: "deleted", taskId: task.id });
564
+ }
565
+ }
566
+ return summarize(jobId, appName, jobCreated, changes);
567
+ }
568
+ async function ensureContainer(client, appName, description, dryRun) {
569
+ const jobs = await client.jobs.list();
570
+ const found = jobs.find((j) => j.name === appName);
571
+ if (found) return { job: found, created: false };
572
+ if (dryRun) return { job: null, created: true };
573
+ const created = await client.jobs.create(
574
+ { name: appName, ...description ? { description } : {} },
575
+ { idempotencyKey: `cv-job-${appName}` }
576
+ );
577
+ return { job: created, created: true };
578
+ }
579
+ function buildDesiredTask(args) {
580
+ const { job, dispatchUrl, defaultTimeZone } = args;
581
+ const cfg = job.config;
582
+ const requestBody = JSON.stringify({ job: job.key, ...cfg.payload ?? {} });
583
+ const body = {
584
+ name: job.key,
585
+ schedule: cfg.schedule,
586
+ timeZone: cfg.timeZone ?? defaultTimeZone,
587
+ targetUrl: dispatchUrl,
588
+ method: "POST",
589
+ requestBody
590
+ };
591
+ if (cfg.description !== void 0) body.description = cfg.description;
592
+ if (cfg.urgency !== void 0) body.urgency = cfg.urgency;
593
+ if (cfg.maxRetries !== void 0) body.maxRetries = cfg.maxRetries;
594
+ if (cfg.executionMode !== void 0) body.executionMode = cfg.executionMode;
595
+ if (cfg.callbackTimeoutMs !== void 0) body.callbackTimeoutMs = cfg.callbackTimeoutMs;
596
+ if (cfg.allowConcurrentRuns !== void 0) body.allowConcurrentRuns = cfg.allowConcurrentRuns;
597
+ if (cfg.successCriteria !== void 0) body.successCriteria = cfg.successCriteria;
598
+ return body;
599
+ }
600
+ function diffTask(existing, desired, opts) {
601
+ const patch = {};
602
+ const changed = [];
603
+ const set = (field, value) => {
604
+ patch[field] = value;
605
+ changed.push(field);
606
+ };
607
+ if (existing.schedule !== desired.schedule) set("schedule", desired.schedule);
608
+ if (desired.timeZone !== void 0 && existing.timeZone !== desired.timeZone) set("timeZone", desired.timeZone);
609
+ if (existing.targetUrl !== desired.targetUrl) set("targetUrl", desired.targetUrl);
610
+ if (existing.method !== (desired.method ?? "POST")) set("method", desired.method ?? "POST");
611
+ if (!jsonEqual(existing.requestBody, desired.requestBody)) set("requestBody", desired.requestBody ?? null);
612
+ if (desired.description !== void 0 && (existing.description ?? void 0) !== desired.description) {
613
+ set("description", desired.description);
614
+ }
615
+ if (desired.urgency !== void 0 && existing.urgency !== desired.urgency) set("urgency", desired.urgency);
616
+ if (desired.maxRetries !== void 0 && existing.maxRetries !== desired.maxRetries) set("maxRetries", desired.maxRetries);
617
+ if (desired.executionMode !== void 0 && existing.executionMode !== desired.executionMode) {
618
+ set("executionMode", desired.executionMode);
619
+ }
620
+ if (desired.callbackTimeoutMs !== void 0 && existing.callbackTimeoutMs !== desired.callbackTimeoutMs) {
621
+ set("callbackTimeoutMs", desired.callbackTimeoutMs);
622
+ }
623
+ if (desired.allowConcurrentRuns !== void 0 && existing.allowConcurrentRuns !== desired.allowConcurrentRuns) {
624
+ set("allowConcurrentRuns", desired.allowConcurrentRuns);
625
+ }
626
+ if (desired.successCriteria !== void 0 && !deepEqual(existing.successCriteria, desired.successCriteria)) {
627
+ set("successCriteria", desired.successCriteria);
628
+ }
629
+ if (opts.rotateSecret || !existing.hasTargetToken) set("targetToken", opts.dispatchSecret);
630
+ return { patch, changedFields: changed };
631
+ }
632
+ function jsonEqual(a, b) {
633
+ if (a == null || b == null) return a == b;
634
+ try {
635
+ return deepEqual(JSON.parse(a), JSON.parse(b));
636
+ } catch {
637
+ return a === b;
638
+ }
639
+ }
640
+ function deepEqual(a, b) {
641
+ if (a === b) return true;
642
+ if (a == null || b == null) return a === b;
643
+ if (typeof a !== "object" || typeof b !== "object") return false;
644
+ const ak = Object.keys(a).sort();
645
+ const bk = Object.keys(b).sort();
646
+ if (ak.length !== bk.length) return false;
647
+ for (let i = 0; i < ak.length; i++) {
648
+ if (ak[i] !== bk[i]) return false;
649
+ if (!deepEqual(a[ak[i]], b[bk[i]])) return false;
650
+ }
651
+ return true;
652
+ }
653
+ function summarize(jobId, jobName, jobCreated, changes) {
654
+ const count = (a) => changes.filter((c) => c.action === a).length;
655
+ return {
656
+ jobId,
657
+ jobName,
658
+ jobCreated,
659
+ created: count("created"),
660
+ updated: count("updated"),
661
+ unchanged: count("unchanged"),
662
+ deleted: count("deleted"),
663
+ skipped: count("skipped"),
664
+ changes
665
+ };
666
+ }
667
+
668
+ // src/adapters/express.ts
669
+ function expressHandler(app) {
670
+ return async (req, res) => {
671
+ const rawBody = await readBody(req);
672
+ const result = await app.handle({
673
+ method: req.method ?? "POST",
674
+ authorization: headerValue(req.headers["authorization"]),
675
+ rawBody,
676
+ headers: flattenHeaders(req.headers)
677
+ });
678
+ res.status(result.status).json(result.body);
679
+ };
680
+ }
681
+ async function readBody(req) {
682
+ const body = req.body;
683
+ if (body !== void 0 && body !== null) {
684
+ if (typeof body === "string") return body;
685
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(body)) return body.toString("utf8");
686
+ if (typeof body === "object") return JSON.stringify(body);
687
+ }
688
+ if (typeof req.on !== "function") return "";
689
+ return new Promise((resolve) => {
690
+ let data = "";
691
+ req.on("data", (chunk) => {
692
+ data += typeof chunk === "string" ? chunk : String(chunk ?? "");
693
+ });
694
+ req.on("end", () => resolve(data));
695
+ req.on("error", () => resolve(data));
696
+ });
697
+ }
698
+ function headerValue(value) {
699
+ if (Array.isArray(value)) return value[0];
700
+ return value;
701
+ }
702
+ function flattenHeaders(headers) {
703
+ const out = {};
704
+ for (const [k, v] of Object.entries(headers)) {
705
+ if (v === void 0) continue;
706
+ out[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
707
+ }
708
+ return out;
709
+ }
710
+
711
+ // src/adapters/next.ts
712
+ function nextHandler(app) {
713
+ return async (req) => {
714
+ const rawBody = await req.text();
715
+ const headers = {};
716
+ req.headers.forEach?.((value, key) => {
717
+ headers[key.toLowerCase()] = value;
718
+ });
719
+ const result = await app.handle({
720
+ method: req.method,
721
+ authorization: req.headers.get("authorization") ?? void 0,
722
+ rawBody,
723
+ headers
724
+ });
725
+ return new Response(JSON.stringify(result.body), {
726
+ status: result.status,
727
+ headers: { "content-type": "application/json" }
728
+ });
729
+ };
730
+ }
731
+
732
+ // src/registry/define.ts
733
+ var DEFAULT_DISPATCH_PATH = "/cronvello/dispatch";
734
+ var DEFAULT_TIME_ZONE = "Europe/Berlin";
735
+ function defineCronvello(config) {
736
+ validateConfig(config);
737
+ const jobs = normalizeJobs(config.jobs);
738
+ const dispatchPath = normalizePath(config.dispatchPath ?? DEFAULT_DISPATCH_PATH);
739
+ const dispatchUrl = joinUrl(config.appUrl, dispatchPath);
740
+ const defaultTimeZone = config.timeZone ?? DEFAULT_TIME_ZONE;
741
+ const client = new CronvelloClient({
742
+ apiKey: config.apiKey,
743
+ baseUrl: config.baseUrl ?? CRONVELLO_DEFAULT_BASE_URL,
744
+ ...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
745
+ ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
746
+ ...config.fetch ? { fetch: config.fetch } : {}
747
+ });
748
+ const dispatcher = createDispatcher({
749
+ jobs,
750
+ dispatchSecret: config.dispatchSecret,
751
+ logger: config.logger
752
+ });
753
+ const app = {
754
+ client,
755
+ jobs,
756
+ appName: config.appName,
757
+ dispatchPath,
758
+ dispatchUrl,
759
+ async sync(options) {
760
+ const opts = options ?? {};
761
+ if (!opts.dryRun) {
762
+ try {
763
+ const res = await client.reconcileRegistry(
764
+ buildRegistryRequest(config, dispatchUrl, defaultTimeZone, [...jobs.values()], opts)
765
+ );
766
+ return {
767
+ jobId: res.job.id,
768
+ jobName: res.job.name,
769
+ jobCreated: res.jobCreated,
770
+ created: res.created,
771
+ updated: res.updated,
772
+ unchanged: res.unchanged,
773
+ deleted: res.deleted,
774
+ skipped: res.skipped,
775
+ changes: res.changes.map((c) => ({
776
+ key: c.key,
777
+ action: c.action,
778
+ taskId: c.taskId,
779
+ ...c.changedFields ? { changedFields: c.changedFields } : {}
780
+ })),
781
+ tasks: res.tasks
782
+ };
783
+ } catch (e) {
784
+ if (!(e instanceof CronvelloApiError) || e.status !== 404 && e.status !== 405) throw e;
785
+ }
786
+ }
787
+ return reconcile(
788
+ {
789
+ client,
790
+ appName: config.appName,
791
+ dispatchUrl,
792
+ dispatchSecret: config.dispatchSecret,
793
+ defaultTimeZone,
794
+ jobs: [...jobs.values()]
795
+ },
796
+ opts
797
+ );
798
+ },
799
+ async run(key) {
800
+ if (!jobs.has(key)) {
801
+ throw new CronvelloConfigError(`Unknown job '${key}'. Known: ${[...jobs.keys()].join(", ") || "(none)"}`);
802
+ }
803
+ const containers = await client.jobs.list();
804
+ const container = containers.find((j) => j.name === config.appName);
805
+ if (!container) {
806
+ throw new CronvelloConfigError(`App '${config.appName}' is not synced yet \u2014 call sync() first.`);
807
+ }
808
+ const tasks = await client.jobs.listTasks(container.id);
809
+ const task = tasks.find((t) => t.name === key);
810
+ if (!task) {
811
+ throw new CronvelloConfigError(`Job '${key}' has no task yet \u2014 call sync() first.`);
812
+ }
813
+ return client.tasks.runNow(task.id);
814
+ },
815
+ handle: dispatcher.handle,
816
+ expressHandler: () => expressHandler(app),
817
+ nextHandler: () => nextHandler(app),
818
+ keys: () => [...jobs.keys()]
819
+ };
820
+ return app;
821
+ }
822
+ function validateConfig(config) {
823
+ if (!config) throw new CronvelloConfigError("defineCronvello requires a config object.");
824
+ if (!config.appName || !config.appName.trim()) throw new CronvelloConfigError("`appName` is required.");
825
+ if (!config.appUrl || !/^https?:\/\//i.test(config.appUrl)) {
826
+ throw new CronvelloConfigError("`appUrl` must be an absolute http(s) URL (the public URL of THIS app).");
827
+ }
828
+ if (!config.apiKey) throw new CronvelloConfigError("`apiKey` (crn_live_\u2026) is required.");
829
+ if (!config.dispatchSecret || config.dispatchSecret.length < 16) {
830
+ throw new CronvelloConfigError("`dispatchSecret` is required and must be at least 16 chars (use a random 32-byte value).");
831
+ }
832
+ if (!config.jobs) throw new CronvelloConfigError("`jobs` is required.");
833
+ }
834
+ function normalizeJobs(input) {
835
+ const map = /* @__PURE__ */ new Map();
836
+ const entries = Array.isArray(input) ? input.map(({ key, ...rest }) => ({ key, config: rest })) : Object.entries(input).map(([key, config]) => ({ key, config }));
837
+ for (const { key, config } of entries) {
838
+ const trimmed = (key ?? "").trim();
839
+ if (!trimmed) throw new CronvelloConfigError("Every job needs a non-empty key.");
840
+ if (trimmed.length > 255) throw new CronvelloConfigError(`Job key '${trimmed}' exceeds 255 characters.`);
841
+ if (map.has(trimmed)) throw new CronvelloConfigError(`Duplicate job key '${trimmed}'.`);
842
+ if (!config || typeof config.handler !== "function") {
843
+ throw new CronvelloConfigError(`Job '${trimmed}' is missing a handler function.`);
844
+ }
845
+ if (!config.schedule || !config.schedule.trim()) {
846
+ throw new CronvelloConfigError(`Job '${trimmed}' is missing a schedule.`);
847
+ }
848
+ map.set(trimmed, { key: trimmed, config });
849
+ }
850
+ if (map.size === 0) throw new CronvelloConfigError("At least one job is required.");
851
+ return map;
852
+ }
853
+ function normalizePath(path) {
854
+ const trimmed = path.trim();
855
+ return trimmed.startsWith("/") ? trimmed.replace(/\/+$/, "") || "/" : `/${trimmed.replace(/\/+$/, "")}`;
856
+ }
857
+ function joinUrl(base, path) {
858
+ return `${base.replace(/\/+$/, "")}${path}`;
859
+ }
860
+ function buildRegistryRequest(config, dispatchUrl, defaultTimeZone, jobs, opts) {
861
+ const tasks = jobs.map((j) => {
862
+ const cfg = j.config;
863
+ const task = {
864
+ key: j.key,
865
+ schedule: cfg.schedule,
866
+ targetUrl: dispatchUrl,
867
+ targetToken: config.dispatchSecret,
868
+ method: "POST",
869
+ timeZone: cfg.timeZone ?? defaultTimeZone,
870
+ requestBody: JSON.stringify({ job: j.key, ...cfg.payload ?? {} })
871
+ };
872
+ if (cfg.description !== void 0) task.description = cfg.description;
873
+ if (cfg.urgency !== void 0) task.urgency = cfg.urgency;
874
+ if (cfg.maxRetries !== void 0) task.maxRetries = cfg.maxRetries;
875
+ if (cfg.executionMode !== void 0) task.executionMode = cfg.executionMode;
876
+ if (cfg.callbackTimeoutMs !== void 0) task.callbackTimeoutMs = cfg.callbackTimeoutMs;
877
+ if (cfg.allowConcurrentRuns !== void 0) task.allowConcurrentRuns = cfg.allowConcurrentRuns;
878
+ if (cfg.successCriteria !== void 0) task.successCriteria = cfg.successCriteria;
879
+ if (cfg.enabled !== void 0) task.enabled = cfg.enabled;
880
+ return task;
881
+ });
882
+ return {
883
+ appName: config.appName,
884
+ tasks,
885
+ prune: opts.prune ?? true,
886
+ rotateSecret: opts.rotateSecret ?? false
887
+ };
888
+ }
889
+
890
+ // src/index.ts
891
+ function generateDispatchSecret(bytes = 32) {
892
+ const buf = new Uint8Array(bytes);
893
+ crypto.getRandomValues(buf);
894
+ return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
895
+ }
896
+
897
+ export { CRONVELLO_DEFAULT_BASE_URL, CronvelloApiError, CronvelloClient, CronvelloConfigError, CronvelloError, CronvelloNetworkError, defineCronvello, generateDispatchSecret };
898
+ //# sourceMappingURL=index.js.map
899
+ //# sourceMappingURL=index.js.map