@mybase-dev/sdk 1.0.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/cron.js ADDED
@@ -0,0 +1,190 @@
1
+ // src/config.ts
2
+ function defineConfig(config) {
3
+ return config;
4
+ }
5
+ var DEFAULT_CRON_PATH = "/api/cron";
6
+
7
+ // src/cron.ts
8
+ var SIGNATURE_HEADER = "X-Mybase-Signature";
9
+ var JOB_HEADER = "X-Mybase-Job";
10
+ var RUN_HEADER = "X-Mybase-Run";
11
+
12
+ class CronVerificationError extends Error {
13
+ name = "CronVerificationError";
14
+ }
15
+ var encoder = new TextEncoder;
16
+ async function hmacHex(secret, message) {
17
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
18
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
19
+ return Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
20
+ }
21
+ function safeEqual(a, b) {
22
+ if (a.length !== b.length)
23
+ return false;
24
+ let diff = 0;
25
+ for (let i = 0;i < a.length; i++)
26
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
27
+ return diff === 0;
28
+ }
29
+ async function signCronRequest(secret, body, t = Math.floor(Date.now() / 1000)) {
30
+ return `t=${t},v1=${await hmacHex(secret, `${t}.${body}`)}`;
31
+ }
32
+ async function verifySigned(signature, secret, toleranceSec, message) {
33
+ if (!signature)
34
+ throw new CronVerificationError(`missing ${SIGNATURE_HEADER} header`);
35
+ let t;
36
+ const sigs = [];
37
+ for (const part of signature.split(",")) {
38
+ const [k, v] = part.trim().split("=", 2);
39
+ if (k === "t" && v && /^\d+$/.test(v))
40
+ t = Number(v);
41
+ else if (k === "v1" && v)
42
+ sigs.push(v);
43
+ }
44
+ if (t === undefined || sigs.length === 0)
45
+ throw new CronVerificationError(`malformed ${SIGNATURE_HEADER} header`);
46
+ if (Math.abs(Date.now() / 1000 - t) > toleranceSec)
47
+ throw new CronVerificationError("signature timestamp outside tolerance");
48
+ const secrets = typeof secret === "string" ? [secret] : secret;
49
+ if (secrets.length === 0 || secrets.some((s) => !s))
50
+ throw new CronVerificationError("no cron secret configured");
51
+ for (const s of secrets) {
52
+ const expected = await hmacHex(s, message(t));
53
+ if (sigs.some((sig) => safeEqual(sig, expected)))
54
+ return;
55
+ }
56
+ throw new CronVerificationError("signature does not match");
57
+ }
58
+ async function verifyCronSignature({ body, signature, secret, toleranceSec = 300 }) {
59
+ await verifySigned(signature, secret, toleranceSec, (t) => `${t}.${body}`);
60
+ }
61
+ async function verifyCronRequest(req, secret, options = {}) {
62
+ const body = await req.text();
63
+ await verifyCronSignature({ body, signature: req.headers.get(SIGNATURE_HEADER), secret, ...options });
64
+ let run;
65
+ try {
66
+ run = JSON.parse(body);
67
+ } catch {
68
+ throw new CronVerificationError("body is not JSON");
69
+ }
70
+ if (typeof run?.job !== "string" || typeof run.runId !== "string")
71
+ throw new CronVerificationError("body is not a cron run");
72
+ return run;
73
+ }
74
+ var reply = (body, status) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
75
+ function cronHandler(options) {
76
+ const jobs = options.jobs;
77
+ return async (req) => {
78
+ if (req.method !== "POST")
79
+ return reply({ error: "method not allowed" }, 405);
80
+ let run;
81
+ try {
82
+ run = await verifyCronRequest(req, options.secret, { toleranceSec: options.toleranceSec });
83
+ } catch (err) {
84
+ if (err instanceof CronVerificationError)
85
+ return reply({ error: err.message }, 401);
86
+ throw err;
87
+ }
88
+ const job = Object.hasOwn(jobs, run.job) ? jobs[run.job] : undefined;
89
+ if (!job)
90
+ return reply({ error: `no handler for job "${run.job}"` }, 404);
91
+ try {
92
+ await job(run);
93
+ } catch (err) {
94
+ if (options.onError)
95
+ options.onError(err, run);
96
+ else
97
+ console.error(`mybase cron job "${run.job}" failed`, err);
98
+ return reply({ error: "job failed" }, 500);
99
+ }
100
+ return reply({ ok: true }, 200);
101
+ };
102
+ }
103
+
104
+ class MybaseApiError extends Error {
105
+ status;
106
+ name = "MybaseApiError";
107
+ constructor(message, status) {
108
+ super(message);
109
+ this.status = status;
110
+ }
111
+ }
112
+ var UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 };
113
+ function parseDelay(value) {
114
+ if (typeof value === "number") {
115
+ if (!Number.isFinite(value) || value < 0)
116
+ throw new RangeError("delay must be a non-negative number of ms");
117
+ return Math.round(value);
118
+ }
119
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(value);
120
+ if (!m)
121
+ throw new RangeError(`invalid delay "${value}". Use ms, or a number with ms, s, m, h, or d (e.g. "15m")`);
122
+ return Math.round(Number(m[1]) * UNITS[m[2]]);
123
+ }
124
+ function createCronClient(options) {
125
+ const doFetch = options.fetch ?? globalThis.fetch;
126
+ const apiUrl = options.apiUrl.replace(/\/+$/, "");
127
+ const base = `/cron/${encodeURIComponent(options.project)}/tasks`;
128
+ async function call(method, path, body) {
129
+ const raw = body === undefined ? "" : JSON.stringify(body);
130
+ const res = await doFetch(`${apiUrl}${path}`, {
131
+ method,
132
+ body: body === undefined ? undefined : raw,
133
+ headers: {
134
+ "Content-Type": "application/json",
135
+ Authorization: `Bearer ${options.apiKey}`
136
+ }
137
+ });
138
+ if (res.status === 404 && method !== "POST")
139
+ return null;
140
+ const data = await res.json().catch(() => ({}));
141
+ if (!res.ok)
142
+ throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
143
+ return data;
144
+ }
145
+ const byId = (id) => `${base}/${encodeURIComponent(id)}`;
146
+ const byKey = (key) => `${base}/by-key/${encodeURIComponent(key)}`;
147
+ return {
148
+ async schedule(job, opts = {}) {
149
+ if (opts.at !== undefined && opts.in !== undefined)
150
+ throw new TypeError("give `at` or `in`, not both");
151
+ let url = opts.url;
152
+ if (!url) {
153
+ if (!options.baseUrl)
154
+ throw new TypeError("set `baseUrl` on the client, or `url` on the task");
155
+ url = `${options.baseUrl.replace(/\/+$/, "")}/${(opts.path ?? DEFAULT_CRON_PATH).replace(/^\/+/, "")}`;
156
+ }
157
+ const at = opts.at === undefined ? undefined : new Date(opts.at);
158
+ if (at && Number.isNaN(at.getTime()))
159
+ throw new RangeError(`invalid date "${String(opts.at)}"`);
160
+ const req = {
161
+ job,
162
+ url,
163
+ runAt: at?.toISOString(),
164
+ delayMs: opts.in === undefined ? undefined : parseDelay(opts.in),
165
+ key: opts.key,
166
+ payload: opts.payload,
167
+ retries: opts.retries,
168
+ timeoutMs: opts.timeoutMs
169
+ };
170
+ return await call("POST", base, req);
171
+ },
172
+ get: (id) => call("GET", byId(id)),
173
+ getByKey: (key) => call("GET", byKey(key)),
174
+ cancel: (id) => call("DELETE", byId(id)),
175
+ cancelByKey: (key) => call("DELETE", byKey(key))
176
+ };
177
+ }
178
+ export {
179
+ CronVerificationError,
180
+ JOB_HEADER,
181
+ MybaseApiError,
182
+ RUN_HEADER,
183
+ SIGNATURE_HEADER,
184
+ createCronClient,
185
+ cronHandler,
186
+ parseDelay,
187
+ signCronRequest,
188
+ verifyCronRequest,
189
+ verifyCronSignature
190
+ };
@@ -0,0 +1,279 @@
1
+ import { MybaseApiError } from "./cron";
2
+ export { MybaseApiError };
3
+ /** Queue names: unique per project, safe in URLs. */
4
+ export declare const QUEUE_NAME_PATTERN: RegExp;
5
+ export declare const QUEUE_NAME_RULES = "Use 1\u201380 lowercase letters, digits, `_`, or `-`, starting with a letter or digit.";
6
+ export declare const QUEUE_LIMITS: {
7
+ /** Messages per send, receive, delete, or visibility batch. */
8
+ readonly batch: 100;
9
+ /** All bodies and attributes of one send together. */
10
+ readonly batchBytes: number;
11
+ readonly maxMessageBytes: {
12
+ readonly min: 1024;
13
+ readonly max: number;
14
+ readonly default: number;
15
+ };
16
+ readonly visibilityTimeoutMs: {
17
+ readonly min: 0;
18
+ readonly max: number;
19
+ readonly default: 30000;
20
+ };
21
+ readonly delayMs: {
22
+ readonly min: 0;
23
+ readonly max: number;
24
+ readonly default: 0;
25
+ };
26
+ readonly retentionMs: {
27
+ readonly min: 60000;
28
+ readonly max: number;
29
+ readonly default: number;
30
+ };
31
+ /** Long polling: how long a receive waits for a message to arrive. */
32
+ readonly waitMs: {
33
+ readonly min: 0;
34
+ readonly max: 20000;
35
+ readonly default: 0;
36
+ };
37
+ readonly maxReceives: {
38
+ readonly min: 1;
39
+ readonly max: 1000;
40
+ };
41
+ readonly attributes: 10;
42
+ /** FIFO group and deduplication ids: printable ASCII. */
43
+ readonly idLength: 128;
44
+ /** A FIFO send with a deduplication id seen this recently is accepted but not enqueued. */
45
+ readonly dedupWindowMs: number;
46
+ readonly queuesPerProject: 1000;
47
+ };
48
+ export type QueueAttributes = Record<string, string | number | boolean>;
49
+ /** Where a queue is defined: dashboard/CLI, mybase.config.ts (`mybase queue sync`), or app code. */
50
+ export type QueueSource = "dashboard" | "code" | "api";
51
+ export interface DeadLetterSettings {
52
+ /** Another queue in the project, of the same kind (standard or FIFO). */
53
+ queue: string;
54
+ /** Receives after which a message moves there instead of being delivered again. */
55
+ maxReceives: number;
56
+ }
57
+ /** A queue's settings, as the API returns them. */
58
+ export interface QueueSettings {
59
+ /** FIFO: strict order within each message group, exactly-once enqueue (deduplication). Fixed at creation. */
60
+ fifo: boolean;
61
+ /** How long a received message stays hidden from other consumers. */
62
+ visibilityTimeoutMs: number;
63
+ /** How long new messages wait before they can be received. */
64
+ delayMs: number;
65
+ /** How long a message is kept before it's deleted unreceived. */
66
+ retentionMs: number;
67
+ maxMessageBytes: number;
68
+ /** Default long-poll wait for receives that don't set one. */
69
+ receiveWaitMs: number;
70
+ /** FIFO only: deduplicate by a SHA-256 of the body when a send has no `dedupId`. */
71
+ contentDedup: boolean;
72
+ deadLetter: DeadLetterSettings | null;
73
+ }
74
+ export interface QueueCounts {
75
+ /** Ready to receive. */
76
+ visible: number;
77
+ /** Received, not yet deleted, still within the visibility timeout. */
78
+ inFlight: number;
79
+ /** Waiting out a delay. */
80
+ delayed: number;
81
+ }
82
+ export interface Queue extends QueueSettings {
83
+ name: string;
84
+ source: QueueSource;
85
+ /** Approximate. */
86
+ counts: QueueCounts;
87
+ /** Sent time of the oldest message, or null when empty. */
88
+ oldestMessageAt: string | null;
89
+ /** Queues that send their dead letters here. */
90
+ deadLetterFor: string[];
91
+ createdAt: string;
92
+ updatedAt: string;
93
+ }
94
+ /**
95
+ * Create or change a queue. Durations are ms or `"30s"`, `"5m"`, `"4d"`; `…Ms` fields take ms.
96
+ * Absent fields keep their current value (or the default, on create).
97
+ */
98
+ export interface QueueSettingsInput {
99
+ fifo?: boolean;
100
+ visibilityTimeout?: number | string;
101
+ delay?: number | string;
102
+ retention?: number | string;
103
+ receiveWait?: number | string;
104
+ maxMessageBytes?: number;
105
+ contentDedup?: boolean;
106
+ deadLetter?: DeadLetterSettings | null;
107
+ }
108
+ export interface CreateQueueRequest extends QueueSettingsInput {
109
+ name: string;
110
+ }
111
+ /** One message in a send. */
112
+ export interface SendMessageEntry {
113
+ /** Yours, echoed in the result to match entries up. Default: the entry's index. */
114
+ id?: string;
115
+ body: string;
116
+ /** Standard queues only; FIFO queues use the queue's delay. */
117
+ delayMs?: number;
118
+ /** FIFO: required. Messages with the same group are delivered in order, one batch at a time. */
119
+ groupId?: string;
120
+ /** FIFO: a repeat within 5 minutes is accepted but not enqueued again. Required unless the queue has `contentDedup`. */
121
+ dedupId?: string;
122
+ attributes?: QueueAttributes;
123
+ }
124
+ export interface SendMessagesRequest {
125
+ messages: SendMessageEntry[];
126
+ }
127
+ export interface SentMessage {
128
+ id: string;
129
+ messageId: string;
130
+ /** FIFO: position in the queue, increasing. Null for standard queues. */
131
+ sequence: string | null;
132
+ /** FIFO: the dedup id was seen in the last 5 minutes; `messageId` is the original's. */
133
+ duplicate: boolean;
134
+ }
135
+ export interface BatchFailure {
136
+ /** The entry's `id`, or its receipt for deletes and visibility changes. */
137
+ id: string;
138
+ error: string;
139
+ }
140
+ export interface SendMessagesResponse {
141
+ successful: SentMessage[];
142
+ failed: BatchFailure[];
143
+ }
144
+ export interface ReceiveMessagesRequest {
145
+ /** 1–100. Default 1. */
146
+ max?: number;
147
+ /** Long polling: wait up to this long (0–20000 ms) for a message. Default: the queue's `receiveWaitMs`. */
148
+ waitMs?: number;
149
+ /** Overrides the queue's visibility timeout for these messages. */
150
+ visibilityTimeoutMs?: number;
151
+ }
152
+ /** A received message. Delete it with `receipt` once handled, or it's delivered again. */
153
+ export interface QueueMessage {
154
+ messageId: string;
155
+ /** Proves this receive; changes on every delivery. Only the latest one works. */
156
+ receipt: string;
157
+ body: string;
158
+ attributes: QueueAttributes;
159
+ groupId: string | null;
160
+ dedupId: string | null;
161
+ sequence: string | null;
162
+ /** Times this message has been received, this time included. */
163
+ receiveCount: number;
164
+ sentAt: string;
165
+ firstReceivedAt: string;
166
+ }
167
+ export interface ReceiveMessagesResponse {
168
+ messages: QueueMessage[];
169
+ }
170
+ export interface DeleteMessagesRequest {
171
+ receipts: string[];
172
+ }
173
+ export interface VisibilityEntry {
174
+ receipt: string;
175
+ visibilityTimeoutMs: number;
176
+ }
177
+ export interface ChangeVisibilityRequest {
178
+ entries: VisibilityEntry[];
179
+ }
180
+ /** Deletes and visibility changes: how many applied, and which receipts didn't (stale, or the message is gone). */
181
+ export interface BatchResult {
182
+ done: number;
183
+ failed: BatchFailure[];
184
+ }
185
+ /** Moves a dead-letter queue's messages back. Without `to`, each goes back to the queue it came from. */
186
+ export interface RedriveRequest {
187
+ to?: string;
188
+ }
189
+ export interface RedriveResponse {
190
+ moved: number;
191
+ }
192
+ export interface QueueClientOptions {
193
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
194
+ apiUrl: string;
195
+ project: string;
196
+ /** A mybase API key (`mb_…`) with `queue:write` for this project (`queue:read` is enough to list and get queues). Server-side only. */
197
+ apiKey: string;
198
+ /** How long to wait for mybase on calls other than long polls, in ms. Default 10000. */
199
+ timeoutMs?: number;
200
+ /** Default `globalThis.fetch`. */
201
+ fetch?: typeof fetch;
202
+ }
203
+ export interface SendOptions {
204
+ /** Standard queues: hide the message this long first (ms or `"30s"`, up to 15 minutes). */
205
+ delay?: number | string;
206
+ groupId?: string;
207
+ dedupId?: string;
208
+ attributes?: QueueAttributes;
209
+ }
210
+ export interface SendBatchEntry extends SendOptions {
211
+ id?: string;
212
+ /** Strings are sent as is; anything else as JSON. */
213
+ body: unknown;
214
+ }
215
+ export interface ReceiveOptions {
216
+ max?: number;
217
+ /** Long poll: ms or `"20s"`, up to 20 seconds. */
218
+ wait?: number | string;
219
+ visibilityTimeout?: number | string;
220
+ signal?: AbortSignal;
221
+ }
222
+ /** A received message, with its body parsed on demand. */
223
+ export interface ReceivedMessage extends QueueMessage {
224
+ /** `JSON.parse(body)`. */
225
+ json<T = unknown>(): T;
226
+ }
227
+ export interface ConsumeOptions {
228
+ /** Messages per receive, 1–100. Default 10. */
229
+ batchSize?: number;
230
+ /** Handlers running at once. Default `batchSize`. FIFO groups always run one message at a time, in order. */
231
+ concurrency?: number;
232
+ /** Long poll per receive. Default 20s. */
233
+ wait?: number | string;
234
+ /** Default: the queue's. Set it longer than your slowest handler. */
235
+ visibilityTimeout?: number | string;
236
+ /**
237
+ * After a handler throws: make the message visible again after this long. Default: leave it
238
+ * hidden until its visibility timeout runs out.
239
+ */
240
+ retryDelay?: number | string;
241
+ /** Called when a handler throws or mybase can't be reached. Default `console.error`. */
242
+ onError?: (error: unknown, message?: ReceivedMessage) => void;
243
+ /** Stops the consumer, like `stop()`. */
244
+ signal?: AbortSignal;
245
+ }
246
+ export interface Consumer {
247
+ /** Stops receiving, waits for running handlers, and deletes what they finished. */
248
+ stop(): Promise<void>;
249
+ /** Settles when the consumer has stopped. */
250
+ done: Promise<void>;
251
+ }
252
+ export interface QueueClient {
253
+ send(queue: string, body: unknown, options?: SendOptions): Promise<SentMessage>;
254
+ /** Up to 100 messages. Entries fail one by one: check `failed`. */
255
+ sendBatch(queue: string, entries: SendBatchEntry[]): Promise<SendMessagesResponse>;
256
+ receive(queue: string, options?: ReceiveOptions): Promise<ReceivedMessage[]>;
257
+ /** Deletes received messages by receipt. Resolves to how many were deleted. */
258
+ delete(queue: string, receipts: string | string[]): Promise<BatchResult>;
259
+ /** Extends (or ends, with 0) the visibility timeout of a received message. */
260
+ changeVisibility(queue: string, receipt: string, timeout: number | string): Promise<void>;
261
+ changeVisibilityBatch(queue: string, entries: {
262
+ receipt: string;
263
+ timeout: number | string;
264
+ }[]): Promise<BatchResult>;
265
+ /** Calls `handler` for each message, deleting it when the handler resolves. */
266
+ consume(queue: string, handler: (message: ReceivedMessage) => unknown, options?: ConsumeOptions): Consumer;
267
+ listQueues(): Promise<Queue[]>;
268
+ getQueue(queue: string): Promise<Queue>;
269
+ createQueue(queue: string, settings?: QueueSettingsInput): Promise<Queue>;
270
+ updateQueue(queue: string, settings: QueueSettingsInput): Promise<Queue>;
271
+ deleteQueue(queue: string): Promise<void>;
272
+ /** Deletes every message in the queue. */
273
+ purge(queue: string): Promise<void>;
274
+ /** Moves a dead-letter queue's messages back to their source queues (or `to`). */
275
+ redrive(queue: string, options?: RedriveRequest): Promise<RedriveResponse>;
276
+ }
277
+ /** Strings as is, everything else as JSON. */
278
+ export declare const encodeBody: (body: unknown) => string;
279
+ export declare function createQueueClient(options: QueueClientOptions): QueueClient;