@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/LICENSE +21 -0
- package/README.md +206 -0
- package/dist/analytics-script.d.ts +11 -0
- package/dist/analytics.d.ts +89 -0
- package/dist/analytics.js +224 -0
- package/dist/analytics.min.js +3 -0
- package/dist/cache.d.ts +91 -0
- package/dist/cache.js +277 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +9 -0
- package/dist/cron.d.ts +166 -0
- package/dist/cron.js +190 -0
- package/dist/queue.d.ts +279 -0
- package/dist/queue.js +358 -0
- package/dist/ratelimit.d.ts +89 -0
- package/dist/ratelimit.js +244 -0
- package/package.json +63 -0
- package/src/analytics.ts +311 -0
- package/src/cache.ts +191 -0
- package/src/config.ts +76 -0
- package/src/cron.ts +339 -0
- package/src/queue.ts +461 -0
- package/src/ratelimit.ts +150 -0
package/src/queue.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// Message queues through mybase, modeled on SQS: standard and FIFO queues, visibility
|
|
2
|
+
// timeouts, long polling, delays, batching, dead-letter queues. Messages live in the mybase
|
|
3
|
+
// server's Postgres, so they survive restarts.
|
|
4
|
+
//
|
|
5
|
+
// const queue = createQueueClient({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
|
|
6
|
+
// await queue.send("emails", { to: "a@example.com" });
|
|
7
|
+
// queue.consume("emails", async (message) => sendEmail(message.json()));
|
|
8
|
+
//
|
|
9
|
+
// Delivery is at least once: a message whose handler fails (or whose visibility timeout runs
|
|
10
|
+
// out) is delivered again. Make handlers safe to repeat.
|
|
11
|
+
import { MybaseApiError, parseDelay } from "./cron";
|
|
12
|
+
|
|
13
|
+
export { MybaseApiError };
|
|
14
|
+
|
|
15
|
+
/** Queue names: unique per project, safe in URLs. */
|
|
16
|
+
export const QUEUE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
17
|
+
export const QUEUE_NAME_RULES = "Use 1–80 lowercase letters, digits, `_`, or `-`, starting with a letter or digit.";
|
|
18
|
+
|
|
19
|
+
export const QUEUE_LIMITS = {
|
|
20
|
+
/** Messages per send, receive, delete, or visibility batch. */
|
|
21
|
+
batch: 100,
|
|
22
|
+
/** All bodies and attributes of one send together. */
|
|
23
|
+
batchBytes: 1024 * 1024,
|
|
24
|
+
maxMessageBytes: { min: 1024, max: 256 * 1024, default: 256 * 1024 },
|
|
25
|
+
visibilityTimeoutMs: { min: 0, max: 12 * 3_600_000, default: 30_000 },
|
|
26
|
+
delayMs: { min: 0, max: 15 * 60_000, default: 0 },
|
|
27
|
+
retentionMs: { min: 60_000, max: 14 * 86_400_000, default: 4 * 86_400_000 },
|
|
28
|
+
/** Long polling: how long a receive waits for a message to arrive. */
|
|
29
|
+
waitMs: { min: 0, max: 20_000, default: 0 },
|
|
30
|
+
maxReceives: { min: 1, max: 1000 },
|
|
31
|
+
attributes: 10,
|
|
32
|
+
/** FIFO group and deduplication ids: printable ASCII. */
|
|
33
|
+
idLength: 128,
|
|
34
|
+
/** A FIFO send with a deduplication id seen this recently is accepted but not enqueued. */
|
|
35
|
+
dedupWindowMs: 5 * 60_000,
|
|
36
|
+
queuesPerProject: 1000,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
export type QueueAttributes = Record<string, string | number | boolean>;
|
|
40
|
+
|
|
41
|
+
/** Where a queue is defined: dashboard/CLI, mybase.config.ts (`mybase queue sync`), or app code. */
|
|
42
|
+
export type QueueSource = "dashboard" | "code" | "api";
|
|
43
|
+
|
|
44
|
+
export interface DeadLetterSettings {
|
|
45
|
+
/** Another queue in the project, of the same kind (standard or FIFO). */
|
|
46
|
+
queue: string;
|
|
47
|
+
/** Receives after which a message moves there instead of being delivered again. */
|
|
48
|
+
maxReceives: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A queue's settings, as the API returns them. */
|
|
52
|
+
export interface QueueSettings {
|
|
53
|
+
/** FIFO: strict order within each message group, exactly-once enqueue (deduplication). Fixed at creation. */
|
|
54
|
+
fifo: boolean;
|
|
55
|
+
/** How long a received message stays hidden from other consumers. */
|
|
56
|
+
visibilityTimeoutMs: number;
|
|
57
|
+
/** How long new messages wait before they can be received. */
|
|
58
|
+
delayMs: number;
|
|
59
|
+
/** How long a message is kept before it's deleted unreceived. */
|
|
60
|
+
retentionMs: number;
|
|
61
|
+
maxMessageBytes: number;
|
|
62
|
+
/** Default long-poll wait for receives that don't set one. */
|
|
63
|
+
receiveWaitMs: number;
|
|
64
|
+
/** FIFO only: deduplicate by a SHA-256 of the body when a send has no `dedupId`. */
|
|
65
|
+
contentDedup: boolean;
|
|
66
|
+
deadLetter: DeadLetterSettings | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface QueueCounts {
|
|
70
|
+
/** Ready to receive. */
|
|
71
|
+
visible: number;
|
|
72
|
+
/** Received, not yet deleted, still within the visibility timeout. */
|
|
73
|
+
inFlight: number;
|
|
74
|
+
/** Waiting out a delay. */
|
|
75
|
+
delayed: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Queue extends QueueSettings {
|
|
79
|
+
name: string;
|
|
80
|
+
source: QueueSource;
|
|
81
|
+
/** Approximate. */
|
|
82
|
+
counts: QueueCounts;
|
|
83
|
+
/** Sent time of the oldest message, or null when empty. */
|
|
84
|
+
oldestMessageAt: string | null;
|
|
85
|
+
/** Queues that send their dead letters here. */
|
|
86
|
+
deadLetterFor: string[];
|
|
87
|
+
createdAt: string;
|
|
88
|
+
updatedAt: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create or change a queue. Durations are ms or `"30s"`, `"5m"`, `"4d"`; `…Ms` fields take ms.
|
|
93
|
+
* Absent fields keep their current value (or the default, on create).
|
|
94
|
+
*/
|
|
95
|
+
export interface QueueSettingsInput {
|
|
96
|
+
fifo?: boolean;
|
|
97
|
+
visibilityTimeout?: number | string;
|
|
98
|
+
delay?: number | string;
|
|
99
|
+
retention?: number | string;
|
|
100
|
+
receiveWait?: number | string;
|
|
101
|
+
maxMessageBytes?: number;
|
|
102
|
+
contentDedup?: boolean;
|
|
103
|
+
deadLetter?: DeadLetterSettings | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CreateQueueRequest extends QueueSettingsInput {
|
|
107
|
+
name: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One message in a send. */
|
|
111
|
+
export interface SendMessageEntry {
|
|
112
|
+
/** Yours, echoed in the result to match entries up. Default: the entry's index. */
|
|
113
|
+
id?: string;
|
|
114
|
+
body: string;
|
|
115
|
+
/** Standard queues only; FIFO queues use the queue's delay. */
|
|
116
|
+
delayMs?: number;
|
|
117
|
+
/** FIFO: required. Messages with the same group are delivered in order, one batch at a time. */
|
|
118
|
+
groupId?: string;
|
|
119
|
+
/** FIFO: a repeat within 5 minutes is accepted but not enqueued again. Required unless the queue has `contentDedup`. */
|
|
120
|
+
dedupId?: string;
|
|
121
|
+
attributes?: QueueAttributes;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SendMessagesRequest {
|
|
125
|
+
messages: SendMessageEntry[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SentMessage {
|
|
129
|
+
id: string;
|
|
130
|
+
messageId: string;
|
|
131
|
+
/** FIFO: position in the queue, increasing. Null for standard queues. */
|
|
132
|
+
sequence: string | null;
|
|
133
|
+
/** FIFO: the dedup id was seen in the last 5 minutes; `messageId` is the original's. */
|
|
134
|
+
duplicate: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface BatchFailure {
|
|
138
|
+
/** The entry's `id`, or its receipt for deletes and visibility changes. */
|
|
139
|
+
id: string;
|
|
140
|
+
error: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SendMessagesResponse {
|
|
144
|
+
successful: SentMessage[];
|
|
145
|
+
failed: BatchFailure[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ReceiveMessagesRequest {
|
|
149
|
+
/** 1–100. Default 1. */
|
|
150
|
+
max?: number;
|
|
151
|
+
/** Long polling: wait up to this long (0–20000 ms) for a message. Default: the queue's `receiveWaitMs`. */
|
|
152
|
+
waitMs?: number;
|
|
153
|
+
/** Overrides the queue's visibility timeout for these messages. */
|
|
154
|
+
visibilityTimeoutMs?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A received message. Delete it with `receipt` once handled, or it's delivered again. */
|
|
158
|
+
export interface QueueMessage {
|
|
159
|
+
messageId: string;
|
|
160
|
+
/** Proves this receive; changes on every delivery. Only the latest one works. */
|
|
161
|
+
receipt: string;
|
|
162
|
+
body: string;
|
|
163
|
+
attributes: QueueAttributes;
|
|
164
|
+
groupId: string | null;
|
|
165
|
+
dedupId: string | null;
|
|
166
|
+
sequence: string | null;
|
|
167
|
+
/** Times this message has been received, this time included. */
|
|
168
|
+
receiveCount: number;
|
|
169
|
+
sentAt: string;
|
|
170
|
+
firstReceivedAt: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ReceiveMessagesResponse {
|
|
174
|
+
messages: QueueMessage[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface DeleteMessagesRequest {
|
|
178
|
+
receipts: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface VisibilityEntry {
|
|
182
|
+
receipt: string;
|
|
183
|
+
visibilityTimeoutMs: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ChangeVisibilityRequest {
|
|
187
|
+
entries: VisibilityEntry[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Deletes and visibility changes: how many applied, and which receipts didn't (stale, or the message is gone). */
|
|
191
|
+
export interface BatchResult {
|
|
192
|
+
done: number;
|
|
193
|
+
failed: BatchFailure[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Moves a dead-letter queue's messages back. Without `to`, each goes back to the queue it came from. */
|
|
197
|
+
export interface RedriveRequest {
|
|
198
|
+
to?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface RedriveResponse {
|
|
202
|
+
moved: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- client -----------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export interface QueueClientOptions {
|
|
208
|
+
/** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
|
|
209
|
+
apiUrl: string;
|
|
210
|
+
project: string;
|
|
211
|
+
/** A mybase API key (`mb_…`) with `queue:write` for this project (`queue:read` is enough to list and get queues). Server-side only. */
|
|
212
|
+
apiKey: string;
|
|
213
|
+
/** How long to wait for mybase on calls other than long polls, in ms. Default 10000. */
|
|
214
|
+
timeoutMs?: number;
|
|
215
|
+
/** Default `globalThis.fetch`. */
|
|
216
|
+
fetch?: typeof fetch;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface SendOptions {
|
|
220
|
+
/** Standard queues: hide the message this long first (ms or `"30s"`, up to 15 minutes). */
|
|
221
|
+
delay?: number | string;
|
|
222
|
+
groupId?: string;
|
|
223
|
+
dedupId?: string;
|
|
224
|
+
attributes?: QueueAttributes;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface SendBatchEntry extends SendOptions {
|
|
228
|
+
id?: string;
|
|
229
|
+
/** Strings are sent as is; anything else as JSON. */
|
|
230
|
+
body: unknown;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface ReceiveOptions {
|
|
234
|
+
max?: number;
|
|
235
|
+
/** Long poll: ms or `"20s"`, up to 20 seconds. */
|
|
236
|
+
wait?: number | string;
|
|
237
|
+
visibilityTimeout?: number | string;
|
|
238
|
+
signal?: AbortSignal;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A received message, with its body parsed on demand. */
|
|
242
|
+
export interface ReceivedMessage extends QueueMessage {
|
|
243
|
+
/** `JSON.parse(body)`. */
|
|
244
|
+
json<T = unknown>(): T;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface ConsumeOptions {
|
|
248
|
+
/** Messages per receive, 1–100. Default 10. */
|
|
249
|
+
batchSize?: number;
|
|
250
|
+
/** Handlers running at once. Default `batchSize`. FIFO groups always run one message at a time, in order. */
|
|
251
|
+
concurrency?: number;
|
|
252
|
+
/** Long poll per receive. Default 20s. */
|
|
253
|
+
wait?: number | string;
|
|
254
|
+
/** Default: the queue's. Set it longer than your slowest handler. */
|
|
255
|
+
visibilityTimeout?: number | string;
|
|
256
|
+
/**
|
|
257
|
+
* After a handler throws: make the message visible again after this long. Default: leave it
|
|
258
|
+
* hidden until its visibility timeout runs out.
|
|
259
|
+
*/
|
|
260
|
+
retryDelay?: number | string;
|
|
261
|
+
/** Called when a handler throws or mybase can't be reached. Default `console.error`. */
|
|
262
|
+
onError?: (error: unknown, message?: ReceivedMessage) => void;
|
|
263
|
+
/** Stops the consumer, like `stop()`. */
|
|
264
|
+
signal?: AbortSignal;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface Consumer {
|
|
268
|
+
/** Stops receiving, waits for running handlers, and deletes what they finished. */
|
|
269
|
+
stop(): Promise<void>;
|
|
270
|
+
/** Settles when the consumer has stopped. */
|
|
271
|
+
done: Promise<void>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface QueueClient {
|
|
275
|
+
send(queue: string, body: unknown, options?: SendOptions): Promise<SentMessage>;
|
|
276
|
+
/** Up to 100 messages. Entries fail one by one: check `failed`. */
|
|
277
|
+
sendBatch(queue: string, entries: SendBatchEntry[]): Promise<SendMessagesResponse>;
|
|
278
|
+
receive(queue: string, options?: ReceiveOptions): Promise<ReceivedMessage[]>;
|
|
279
|
+
/** Deletes received messages by receipt. Resolves to how many were deleted. */
|
|
280
|
+
delete(queue: string, receipts: string | string[]): Promise<BatchResult>;
|
|
281
|
+
/** Extends (or ends, with 0) the visibility timeout of a received message. */
|
|
282
|
+
changeVisibility(queue: string, receipt: string, timeout: number | string): Promise<void>;
|
|
283
|
+
changeVisibilityBatch(queue: string, entries: { receipt: string; timeout: number | string }[]): Promise<BatchResult>;
|
|
284
|
+
/** Calls `handler` for each message, deleting it when the handler resolves. */
|
|
285
|
+
consume(queue: string, handler: (message: ReceivedMessage) => unknown, options?: ConsumeOptions): Consumer;
|
|
286
|
+
|
|
287
|
+
listQueues(): Promise<Queue[]>;
|
|
288
|
+
getQueue(queue: string): Promise<Queue>;
|
|
289
|
+
createQueue(queue: string, settings?: QueueSettingsInput): Promise<Queue>;
|
|
290
|
+
updateQueue(queue: string, settings: QueueSettingsInput): Promise<Queue>;
|
|
291
|
+
deleteQueue(queue: string): Promise<void>;
|
|
292
|
+
/** Deletes every message in the queue. */
|
|
293
|
+
purge(queue: string): Promise<void>;
|
|
294
|
+
/** Moves a dead-letter queue's messages back to their source queues (or `to`). */
|
|
295
|
+
redrive(queue: string, options?: RedriveRequest): Promise<RedriveResponse>;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Strings as is, everything else as JSON. */
|
|
299
|
+
export const encodeBody = (body: unknown): string => (typeof body === "string" ? body : JSON.stringify(body));
|
|
300
|
+
|
|
301
|
+
const ms = (value: number | string | undefined) => (value === undefined ? undefined : parseDelay(value));
|
|
302
|
+
|
|
303
|
+
function received(message: QueueMessage): ReceivedMessage {
|
|
304
|
+
return { ...message, json: <T>() => JSON.parse(message.body) as T };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function toEntry(entry: SendBatchEntry, index: number): SendMessageEntry {
|
|
308
|
+
return {
|
|
309
|
+
id: entry.id ?? String(index),
|
|
310
|
+
body: encodeBody(entry.body),
|
|
311
|
+
delayMs: ms(entry.delay),
|
|
312
|
+
groupId: entry.groupId,
|
|
313
|
+
dedupId: entry.dedupId,
|
|
314
|
+
attributes: entry.attributes,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const sleep = (msToWait: number, signal: AbortSignal) =>
|
|
319
|
+
new Promise<void>((resolve) => {
|
|
320
|
+
if (signal.aborted || msToWait <= 0) return resolve();
|
|
321
|
+
const timer = setTimeout(done, msToWait);
|
|
322
|
+
signal.addEventListener("abort", done, { once: true });
|
|
323
|
+
function done() {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
signal.removeEventListener("abort", done);
|
|
326
|
+
resolve();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
export function createQueueClient(options: QueueClientOptions): QueueClient {
|
|
331
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
332
|
+
const base = `${options.apiUrl.replace(/\/+$/, "")}/queue/${encodeURIComponent(options.project)}/queues`;
|
|
333
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
334
|
+
const path = (queue: string, rest = "") => `${base}/${encodeURIComponent(queue)}${rest}`;
|
|
335
|
+
|
|
336
|
+
async function call<T>(method: string, url: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
|
337
|
+
const res = await doFetch(url, {
|
|
338
|
+
method,
|
|
339
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
340
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
341
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
|
342
|
+
});
|
|
343
|
+
if (res.status === 204) return undefined as T;
|
|
344
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
345
|
+
if (!res.ok) throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
|
|
346
|
+
return data as T;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const client: QueueClient = {
|
|
350
|
+
async send(queue, body, opts = {}) {
|
|
351
|
+
const result = await client.sendBatch(queue, [{ ...opts, body }]);
|
|
352
|
+
const failure = result.failed[0];
|
|
353
|
+
if (failure) throw new MybaseApiError(failure.error, 400);
|
|
354
|
+
return result.successful[0]!;
|
|
355
|
+
},
|
|
356
|
+
sendBatch: (queue, entries) => call<SendMessagesResponse>("POST", path(queue, "/messages"), { messages: entries.map(toEntry) } satisfies SendMessagesRequest),
|
|
357
|
+
async receive(queue, opts = {}) {
|
|
358
|
+
const waitMs = ms(opts.wait);
|
|
359
|
+
const body: ReceiveMessagesRequest = { max: opts.max, waitMs, visibilityTimeoutMs: ms(opts.visibilityTimeout) };
|
|
360
|
+
// A long poll can take up to 20 s on the server: give it room before timing out.
|
|
361
|
+
const timeout = AbortSignal.timeout((waitMs ?? QUEUE_LIMITS.waitMs.max) + timeoutMs);
|
|
362
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
|
|
363
|
+
const { messages } = await call<ReceiveMessagesResponse>("POST", path(queue, "/receive"), body, signal);
|
|
364
|
+
return messages.map(received);
|
|
365
|
+
},
|
|
366
|
+
delete: (queue, receipts) => call<BatchResult>("POST", path(queue, "/delete"), { receipts: [receipts].flat() } satisfies DeleteMessagesRequest),
|
|
367
|
+
async changeVisibility(queue, receipt, timeout) {
|
|
368
|
+
const result = await client.changeVisibilityBatch(queue, [{ receipt, timeout }]);
|
|
369
|
+
const failure = result.failed[0];
|
|
370
|
+
if (failure) throw new MybaseApiError(failure.error, 400);
|
|
371
|
+
},
|
|
372
|
+
changeVisibilityBatch: (queue, entries) =>
|
|
373
|
+
call<BatchResult>("POST", path(queue, "/visibility"), {
|
|
374
|
+
entries: entries.map((e) => ({ receipt: e.receipt, visibilityTimeoutMs: parseDelay(e.timeout) })),
|
|
375
|
+
} satisfies ChangeVisibilityRequest),
|
|
376
|
+
consume: (queue, handler, opts) => consume(client, queue, handler, opts),
|
|
377
|
+
|
|
378
|
+
listQueues: () => call<Queue[]>("GET", base),
|
|
379
|
+
getQueue: (queue) => call<Queue>("GET", path(queue)),
|
|
380
|
+
createQueue: (queue, settings = {}) => call<Queue>("POST", base, { ...settings, name: queue } satisfies CreateQueueRequest),
|
|
381
|
+
updateQueue: (queue, settings) => call<Queue>("PATCH", path(queue), settings),
|
|
382
|
+
deleteQueue: (queue) => call<void>("DELETE", path(queue)),
|
|
383
|
+
purge: (queue) => call<void>("POST", path(queue, "/purge")),
|
|
384
|
+
redrive: (queue, opts = {}) => call<RedriveResponse>("POST", path(queue, "/redrive"), opts),
|
|
385
|
+
};
|
|
386
|
+
return client;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function consume(client: QueueClient, queue: string, handler: (message: ReceivedMessage) => unknown, opts: ConsumeOptions = {}): Consumer {
|
|
390
|
+
const batchSize = opts.batchSize ?? 10;
|
|
391
|
+
const concurrency = Math.max(1, opts.concurrency ?? batchSize);
|
|
392
|
+
const wait = opts.wait ?? QUEUE_LIMITS.waitMs.max;
|
|
393
|
+
const retryDelay = ms(opts.retryDelay);
|
|
394
|
+
const onError = opts.onError ?? ((err: unknown) => console.error("[mybase queue]", err));
|
|
395
|
+
const stopper = new AbortController();
|
|
396
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, stopper.signal]) : stopper.signal;
|
|
397
|
+
|
|
398
|
+
async function handle(message: ReceivedMessage): Promise<boolean> {
|
|
399
|
+
try {
|
|
400
|
+
await handler(message);
|
|
401
|
+
return true;
|
|
402
|
+
} catch (err) {
|
|
403
|
+
onError(err, message);
|
|
404
|
+
if (retryDelay !== undefined) await client.changeVisibility(queue, message.receipt, retryDelay).catch((e) => onError(e, message));
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Runs one batch: groups in order (stopping a group at its first failure), the rest in parallel. */
|
|
410
|
+
async function runBatch(messages: ReceivedMessage[]): Promise<string[]> {
|
|
411
|
+
const lanes = new Map<string, ReceivedMessage[]>();
|
|
412
|
+
for (const m of messages) {
|
|
413
|
+
const lane = m.groupId === null ? `m:${m.messageId}` : `g:${m.groupId}`;
|
|
414
|
+
lanes.set(lane, [...(lanes.get(lane) ?? []), m]);
|
|
415
|
+
}
|
|
416
|
+
const done: string[] = [];
|
|
417
|
+
const queued = [...lanes.values()];
|
|
418
|
+
const worker = async () => {
|
|
419
|
+
for (let lane = queued.shift(); lane; lane = queued.shift()) {
|
|
420
|
+
for (const m of lane) {
|
|
421
|
+
if (!(await handle(m))) break;
|
|
422
|
+
done.push(m.receipt);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, queued.length) }, worker));
|
|
427
|
+
return done;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const done = (async () => {
|
|
431
|
+
let backoff = 1000;
|
|
432
|
+
while (!signal.aborted) {
|
|
433
|
+
let messages: ReceivedMessage[];
|
|
434
|
+
const started = Date.now();
|
|
435
|
+
try {
|
|
436
|
+
messages = await client.receive(queue, { max: batchSize, wait, visibilityTimeout: opts.visibilityTimeout, signal });
|
|
437
|
+
backoff = 1000;
|
|
438
|
+
// An empty answer that came back at once (no long poll): don't spin.
|
|
439
|
+
if (messages.length === 0) await sleep(1000 - (Date.now() - started), signal);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
if (signal.aborted) break;
|
|
442
|
+
onError(err);
|
|
443
|
+
// 4xx (wrong key, no such queue) won't fix itself quickly either; back off the same way.
|
|
444
|
+
await sleep(backoff, signal);
|
|
445
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (messages.length === 0) continue;
|
|
449
|
+
const finished = await runBatch(messages);
|
|
450
|
+
if (finished.length > 0) await client.delete(queue, finished).catch((err) => onError(err));
|
|
451
|
+
}
|
|
452
|
+
})();
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
stop() {
|
|
456
|
+
stopper.abort();
|
|
457
|
+
return done;
|
|
458
|
+
},
|
|
459
|
+
done,
|
|
460
|
+
};
|
|
461
|
+
}
|
package/src/ratelimit.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Rate limiting through mybase. The API keeps the counters in its Valkey server, so every
|
|
2
|
+
// instance of your app (and every serverless invocation) shares one limit.
|
|
3
|
+
//
|
|
4
|
+
// const limiter = createRateLimiter({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
|
|
5
|
+
// const { success } = await limiter.limit("login", ip);
|
|
6
|
+
//
|
|
7
|
+
// Limits are either named rules (defined on the dashboard or in mybase.config.ts and synced with
|
|
8
|
+
// `mybase ratelimit sync`) or inline: `limiter.limit({ limit: 10, window: "1m" }, userId)`.
|
|
9
|
+
import { MybaseApiError, parseDelay } from "./cron";
|
|
10
|
+
|
|
11
|
+
export { MybaseApiError };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `sliding`: smooth, a weighted count over the current and previous window (default).
|
|
15
|
+
* `fixed`: a plain counter per window; allows bursts at window edges.
|
|
16
|
+
* `token`: a bucket of `burst` tokens (default `limit`), refilled at `limit` per `window`.
|
|
17
|
+
*/
|
|
18
|
+
export const RATELIMIT_ALGORITHMS = ["sliding", "fixed", "token"] as const;
|
|
19
|
+
export type RateLimitAlgorithm = (typeof RATELIMIT_ALGORITHMS)[number];
|
|
20
|
+
|
|
21
|
+
export const RATELIMIT_LIMITS = {
|
|
22
|
+
limit: { min: 1, max: 1_000_000 },
|
|
23
|
+
windowMs: { min: 1000, max: 30 * 86_400_000 },
|
|
24
|
+
idLength: 256,
|
|
25
|
+
cost: { min: 1, max: 1_000_000 },
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** A limit defined inline, per call. Same fields as a rule. */
|
|
29
|
+
export interface InlineLimit {
|
|
30
|
+
limit: number;
|
|
31
|
+
/** Milliseconds, or `"10s"`, `"1m"`, `"1h"`, `"1d"`. */
|
|
32
|
+
window: number | string;
|
|
33
|
+
algorithm?: RateLimitAlgorithm;
|
|
34
|
+
/** Token bucket only: capacity. Default `limit`. */
|
|
35
|
+
burst?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Body of `POST /ratelimit/<project>/limit`. Give `rule` or the inline fields. */
|
|
39
|
+
export interface RateLimitRequest {
|
|
40
|
+
/** What's being limited: a user id, an IP, an API key. Up to 256 chars. */
|
|
41
|
+
id: string;
|
|
42
|
+
rule?: string;
|
|
43
|
+
limit?: number;
|
|
44
|
+
/** In ms. */
|
|
45
|
+
windowMs?: number;
|
|
46
|
+
algorithm?: RateLimitAlgorithm;
|
|
47
|
+
burst?: number;
|
|
48
|
+
/** Units this request uses. Default 1. */
|
|
49
|
+
cost?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RateLimitResult {
|
|
53
|
+
/** Whether the request is within the limit. */
|
|
54
|
+
success: boolean;
|
|
55
|
+
limit: number;
|
|
56
|
+
/** Units left in this window (or tokens left in the bucket). */
|
|
57
|
+
remaining: number;
|
|
58
|
+
/** When the limit fully resets, in unix ms. */
|
|
59
|
+
reset: number;
|
|
60
|
+
/** Set when mybase couldn't be reached and the limiter failed open or closed. */
|
|
61
|
+
error?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RateLimiterOptions {
|
|
65
|
+
/** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
|
|
66
|
+
apiUrl: string;
|
|
67
|
+
project: string;
|
|
68
|
+
/** A mybase API key (`mb_…`) with `ratelimit:write` for this project. Server-side only. */
|
|
69
|
+
apiKey: string;
|
|
70
|
+
/**
|
|
71
|
+
* What to answer when mybase can't be reached or times out. Default `false`: allow the request
|
|
72
|
+
* (fail open), so an outage doesn't take your app down with it. Set `true` for routes where
|
|
73
|
+
* letting traffic through is worse (sign-in, password reset).
|
|
74
|
+
*/
|
|
75
|
+
failClosed?: boolean;
|
|
76
|
+
/** How long to wait for mybase, in ms. Default 1000. */
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
/** Called when mybase can't be reached, before failing open or closed. */
|
|
79
|
+
onError?: (error: unknown) => void;
|
|
80
|
+
/** Default `globalThis.fetch`. */
|
|
81
|
+
fetch?: typeof fetch;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface LimitOptions {
|
|
85
|
+
/** Units this request uses. Default 1. */
|
|
86
|
+
cost?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RateLimiter {
|
|
90
|
+
/** Counts one request (or `cost` units) for `id` against a named rule or an inline limit. */
|
|
91
|
+
limit(rule: string | InlineLimit, id: string, options?: LimitOptions): Promise<RateLimitResult>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `"10s"` / `"1m"` / ms → ms, checked against the allowed window range. */
|
|
95
|
+
export function parseWindow(value: number | string): number {
|
|
96
|
+
const ms = parseDelay(value);
|
|
97
|
+
const { min, max } = RATELIMIT_LIMITS.windowMs;
|
|
98
|
+
if (ms < min || ms > max) throw new RangeError(`window must be from 1s to 30d, got "${value}"`);
|
|
99
|
+
return ms;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Headers to send with a 429 (or any response): `RateLimit-*` and `Retry-After`. */
|
|
103
|
+
export function rateLimitHeaders(result: RateLimitResult, now = Date.now()): Record<string, string> {
|
|
104
|
+
const resetSec = Math.max(0, Math.ceil((result.reset - now) / 1000));
|
|
105
|
+
const headers: Record<string, string> = {
|
|
106
|
+
"RateLimit-Limit": String(result.limit),
|
|
107
|
+
"RateLimit-Remaining": String(result.remaining),
|
|
108
|
+
"RateLimit-Reset": String(resetSec),
|
|
109
|
+
};
|
|
110
|
+
if (!result.success) headers["Retry-After"] = String(resetSec);
|
|
111
|
+
return headers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createRateLimiter(options: RateLimiterOptions): RateLimiter {
|
|
115
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
116
|
+
const url = `${options.apiUrl.replace(/\/+$/, "")}/ratelimit/${encodeURIComponent(options.project)}/limit`;
|
|
117
|
+
const timeoutMs = options.timeoutMs ?? 1000;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
async limit(rule, id, { cost } = {}) {
|
|
121
|
+
// Bad arguments are bugs in the caller: throw, don't fail open.
|
|
122
|
+
const body: RateLimitRequest =
|
|
123
|
+
typeof rule === "string"
|
|
124
|
+
? { id, rule, cost }
|
|
125
|
+
: { id, limit: rule.limit, windowMs: parseWindow(rule.window), algorithm: rule.algorithm, burst: rule.burst, cost };
|
|
126
|
+
let res: Response;
|
|
127
|
+
try {
|
|
128
|
+
res = await doFetch(url, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return unavailable(error);
|
|
136
|
+
}
|
|
137
|
+
const data = (await res.json().catch(() => ({}))) as RateLimitResult & { error?: string };
|
|
138
|
+
if (res.ok) return { success: data.success, limit: data.limit, remaining: data.remaining, reset: data.reset };
|
|
139
|
+
const error = new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
|
|
140
|
+
// 5xx (Valkey down, API restarting): an outage. 4xx: a wrong key, an unknown rule, bad input.
|
|
141
|
+
if (res.status >= 500) return unavailable(error);
|
|
142
|
+
throw error;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function unavailable(error: unknown): RateLimitResult {
|
|
147
|
+
options.onError?.(error);
|
|
148
|
+
return { success: !options.failClosed, limit: 0, remaining: 0, reset: Date.now(), error };
|
|
149
|
+
}
|
|
150
|
+
}
|