@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/dist/queue.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
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
|
+
|
|
179
|
+
// src/queue.ts
|
|
180
|
+
var QUEUE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
181
|
+
var QUEUE_NAME_RULES = "Use 1–80 lowercase letters, digits, `_`, or `-`, starting with a letter or digit.";
|
|
182
|
+
var QUEUE_LIMITS = {
|
|
183
|
+
batch: 100,
|
|
184
|
+
batchBytes: 1024 * 1024,
|
|
185
|
+
maxMessageBytes: { min: 1024, max: 256 * 1024, default: 256 * 1024 },
|
|
186
|
+
visibilityTimeoutMs: { min: 0, max: 12 * 3600000, default: 30000 },
|
|
187
|
+
delayMs: { min: 0, max: 15 * 60000, default: 0 },
|
|
188
|
+
retentionMs: { min: 60000, max: 14 * 86400000, default: 4 * 86400000 },
|
|
189
|
+
waitMs: { min: 0, max: 20000, default: 0 },
|
|
190
|
+
maxReceives: { min: 1, max: 1000 },
|
|
191
|
+
attributes: 10,
|
|
192
|
+
idLength: 128,
|
|
193
|
+
dedupWindowMs: 5 * 60000,
|
|
194
|
+
queuesPerProject: 1000
|
|
195
|
+
};
|
|
196
|
+
var encodeBody = (body) => typeof body === "string" ? body : JSON.stringify(body);
|
|
197
|
+
var ms = (value) => value === undefined ? undefined : parseDelay(value);
|
|
198
|
+
function received(message) {
|
|
199
|
+
return { ...message, json: () => JSON.parse(message.body) };
|
|
200
|
+
}
|
|
201
|
+
function toEntry(entry, index) {
|
|
202
|
+
return {
|
|
203
|
+
id: entry.id ?? String(index),
|
|
204
|
+
body: encodeBody(entry.body),
|
|
205
|
+
delayMs: ms(entry.delay),
|
|
206
|
+
groupId: entry.groupId,
|
|
207
|
+
dedupId: entry.dedupId,
|
|
208
|
+
attributes: entry.attributes
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
var sleep = (msToWait, signal) => new Promise((resolve) => {
|
|
212
|
+
if (signal.aborted || msToWait <= 0)
|
|
213
|
+
return resolve();
|
|
214
|
+
const timer = setTimeout(done, msToWait);
|
|
215
|
+
signal.addEventListener("abort", done, { once: true });
|
|
216
|
+
function done() {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
signal.removeEventListener("abort", done);
|
|
219
|
+
resolve();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
function createQueueClient(options) {
|
|
223
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
224
|
+
const base = `${options.apiUrl.replace(/\/+$/, "")}/queue/${encodeURIComponent(options.project)}/queues`;
|
|
225
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
226
|
+
const path = (queue, rest = "") => `${base}/${encodeURIComponent(queue)}${rest}`;
|
|
227
|
+
async function call(method, url, body, signal) {
|
|
228
|
+
const res = await doFetch(url, {
|
|
229
|
+
method,
|
|
230
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
231
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
232
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs)
|
|
233
|
+
});
|
|
234
|
+
if (res.status === 204)
|
|
235
|
+
return;
|
|
236
|
+
const data = await res.json().catch(() => ({}));
|
|
237
|
+
if (!res.ok)
|
|
238
|
+
throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
|
|
239
|
+
return data;
|
|
240
|
+
}
|
|
241
|
+
const client = {
|
|
242
|
+
async send(queue, body, opts = {}) {
|
|
243
|
+
const result = await client.sendBatch(queue, [{ ...opts, body }]);
|
|
244
|
+
const failure = result.failed[0];
|
|
245
|
+
if (failure)
|
|
246
|
+
throw new MybaseApiError(failure.error, 400);
|
|
247
|
+
return result.successful[0];
|
|
248
|
+
},
|
|
249
|
+
sendBatch: (queue, entries) => call("POST", path(queue, "/messages"), { messages: entries.map(toEntry) }),
|
|
250
|
+
async receive(queue, opts = {}) {
|
|
251
|
+
const waitMs = ms(opts.wait);
|
|
252
|
+
const body = { max: opts.max, waitMs, visibilityTimeoutMs: ms(opts.visibilityTimeout) };
|
|
253
|
+
const timeout = AbortSignal.timeout((waitMs ?? QUEUE_LIMITS.waitMs.max) + timeoutMs);
|
|
254
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
|
|
255
|
+
const { messages } = await call("POST", path(queue, "/receive"), body, signal);
|
|
256
|
+
return messages.map(received);
|
|
257
|
+
},
|
|
258
|
+
delete: (queue, receipts) => call("POST", path(queue, "/delete"), { receipts: [receipts].flat() }),
|
|
259
|
+
async changeVisibility(queue, receipt, timeout) {
|
|
260
|
+
const result = await client.changeVisibilityBatch(queue, [{ receipt, timeout }]);
|
|
261
|
+
const failure = result.failed[0];
|
|
262
|
+
if (failure)
|
|
263
|
+
throw new MybaseApiError(failure.error, 400);
|
|
264
|
+
},
|
|
265
|
+
changeVisibilityBatch: (queue, entries) => call("POST", path(queue, "/visibility"), {
|
|
266
|
+
entries: entries.map((e) => ({ receipt: e.receipt, visibilityTimeoutMs: parseDelay(e.timeout) }))
|
|
267
|
+
}),
|
|
268
|
+
consume: (queue, handler, opts) => consume(client, queue, handler, opts),
|
|
269
|
+
listQueues: () => call("GET", base),
|
|
270
|
+
getQueue: (queue) => call("GET", path(queue)),
|
|
271
|
+
createQueue: (queue, settings = {}) => call("POST", base, { ...settings, name: queue }),
|
|
272
|
+
updateQueue: (queue, settings) => call("PATCH", path(queue), settings),
|
|
273
|
+
deleteQueue: (queue) => call("DELETE", path(queue)),
|
|
274
|
+
purge: (queue) => call("POST", path(queue, "/purge")),
|
|
275
|
+
redrive: (queue, opts = {}) => call("POST", path(queue, "/redrive"), opts)
|
|
276
|
+
};
|
|
277
|
+
return client;
|
|
278
|
+
}
|
|
279
|
+
function consume(client, queue, handler, opts = {}) {
|
|
280
|
+
const batchSize = opts.batchSize ?? 10;
|
|
281
|
+
const concurrency = Math.max(1, opts.concurrency ?? batchSize);
|
|
282
|
+
const wait = opts.wait ?? QUEUE_LIMITS.waitMs.max;
|
|
283
|
+
const retryDelay = ms(opts.retryDelay);
|
|
284
|
+
const onError = opts.onError ?? ((err) => console.error("[mybase queue]", err));
|
|
285
|
+
const stopper = new AbortController;
|
|
286
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, stopper.signal]) : stopper.signal;
|
|
287
|
+
async function handle(message) {
|
|
288
|
+
try {
|
|
289
|
+
await handler(message);
|
|
290
|
+
return true;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
onError(err, message);
|
|
293
|
+
if (retryDelay !== undefined)
|
|
294
|
+
await client.changeVisibility(queue, message.receipt, retryDelay).catch((e) => onError(e, message));
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function runBatch(messages) {
|
|
299
|
+
const lanes = new Map;
|
|
300
|
+
for (const m of messages) {
|
|
301
|
+
const lane = m.groupId === null ? `m:${m.messageId}` : `g:${m.groupId}`;
|
|
302
|
+
lanes.set(lane, [...lanes.get(lane) ?? [], m]);
|
|
303
|
+
}
|
|
304
|
+
const done = [];
|
|
305
|
+
const queued = [...lanes.values()];
|
|
306
|
+
const worker = async () => {
|
|
307
|
+
for (let lane = queued.shift();lane; lane = queued.shift()) {
|
|
308
|
+
for (const m of lane) {
|
|
309
|
+
if (!await handle(m))
|
|
310
|
+
break;
|
|
311
|
+
done.push(m.receipt);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, queued.length) }, worker));
|
|
316
|
+
return done;
|
|
317
|
+
}
|
|
318
|
+
const done = (async () => {
|
|
319
|
+
let backoff = 1000;
|
|
320
|
+
while (!signal.aborted) {
|
|
321
|
+
let messages;
|
|
322
|
+
const started = Date.now();
|
|
323
|
+
try {
|
|
324
|
+
messages = await client.receive(queue, { max: batchSize, wait, visibilityTimeout: opts.visibilityTimeout, signal });
|
|
325
|
+
backoff = 1000;
|
|
326
|
+
if (messages.length === 0)
|
|
327
|
+
await sleep(1000 - (Date.now() - started), signal);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (signal.aborted)
|
|
330
|
+
break;
|
|
331
|
+
onError(err);
|
|
332
|
+
await sleep(backoff, signal);
|
|
333
|
+
backoff = Math.min(backoff * 2, 30000);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (messages.length === 0)
|
|
337
|
+
continue;
|
|
338
|
+
const finished = await runBatch(messages);
|
|
339
|
+
if (finished.length > 0)
|
|
340
|
+
await client.delete(queue, finished).catch((err) => onError(err));
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
return {
|
|
344
|
+
stop() {
|
|
345
|
+
stopper.abort();
|
|
346
|
+
return done;
|
|
347
|
+
},
|
|
348
|
+
done
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
export {
|
|
352
|
+
MybaseApiError,
|
|
353
|
+
QUEUE_LIMITS,
|
|
354
|
+
QUEUE_NAME_PATTERN,
|
|
355
|
+
QUEUE_NAME_RULES,
|
|
356
|
+
createQueueClient,
|
|
357
|
+
encodeBody
|
|
358
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { MybaseApiError } from "./cron";
|
|
2
|
+
export { MybaseApiError };
|
|
3
|
+
/**
|
|
4
|
+
* `sliding`: smooth, a weighted count over the current and previous window (default).
|
|
5
|
+
* `fixed`: a plain counter per window; allows bursts at window edges.
|
|
6
|
+
* `token`: a bucket of `burst` tokens (default `limit`), refilled at `limit` per `window`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RATELIMIT_ALGORITHMS: readonly ["sliding", "fixed", "token"];
|
|
9
|
+
export type RateLimitAlgorithm = (typeof RATELIMIT_ALGORITHMS)[number];
|
|
10
|
+
export declare const RATELIMIT_LIMITS: {
|
|
11
|
+
readonly limit: {
|
|
12
|
+
readonly min: 1;
|
|
13
|
+
readonly max: 1000000;
|
|
14
|
+
};
|
|
15
|
+
readonly windowMs: {
|
|
16
|
+
readonly min: 1000;
|
|
17
|
+
readonly max: number;
|
|
18
|
+
};
|
|
19
|
+
readonly idLength: 256;
|
|
20
|
+
readonly cost: {
|
|
21
|
+
readonly min: 1;
|
|
22
|
+
readonly max: 1000000;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** A limit defined inline, per call. Same fields as a rule. */
|
|
26
|
+
export interface InlineLimit {
|
|
27
|
+
limit: number;
|
|
28
|
+
/** Milliseconds, or `"10s"`, `"1m"`, `"1h"`, `"1d"`. */
|
|
29
|
+
window: number | string;
|
|
30
|
+
algorithm?: RateLimitAlgorithm;
|
|
31
|
+
/** Token bucket only: capacity. Default `limit`. */
|
|
32
|
+
burst?: number;
|
|
33
|
+
}
|
|
34
|
+
/** Body of `POST /ratelimit/<project>/limit`. Give `rule` or the inline fields. */
|
|
35
|
+
export interface RateLimitRequest {
|
|
36
|
+
/** What's being limited: a user id, an IP, an API key. Up to 256 chars. */
|
|
37
|
+
id: string;
|
|
38
|
+
rule?: string;
|
|
39
|
+
limit?: number;
|
|
40
|
+
/** In ms. */
|
|
41
|
+
windowMs?: number;
|
|
42
|
+
algorithm?: RateLimitAlgorithm;
|
|
43
|
+
burst?: number;
|
|
44
|
+
/** Units this request uses. Default 1. */
|
|
45
|
+
cost?: number;
|
|
46
|
+
}
|
|
47
|
+
export interface RateLimitResult {
|
|
48
|
+
/** Whether the request is within the limit. */
|
|
49
|
+
success: boolean;
|
|
50
|
+
limit: number;
|
|
51
|
+
/** Units left in this window (or tokens left in the bucket). */
|
|
52
|
+
remaining: number;
|
|
53
|
+
/** When the limit fully resets, in unix ms. */
|
|
54
|
+
reset: number;
|
|
55
|
+
/** Set when mybase couldn't be reached and the limiter failed open or closed. */
|
|
56
|
+
error?: unknown;
|
|
57
|
+
}
|
|
58
|
+
export interface RateLimiterOptions {
|
|
59
|
+
/** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
|
|
60
|
+
apiUrl: string;
|
|
61
|
+
project: string;
|
|
62
|
+
/** A mybase API key (`mb_…`) with `ratelimit:write` for this project. Server-side only. */
|
|
63
|
+
apiKey: string;
|
|
64
|
+
/**
|
|
65
|
+
* What to answer when mybase can't be reached or times out. Default `false`: allow the request
|
|
66
|
+
* (fail open), so an outage doesn't take your app down with it. Set `true` for routes where
|
|
67
|
+
* letting traffic through is worse (sign-in, password reset).
|
|
68
|
+
*/
|
|
69
|
+
failClosed?: boolean;
|
|
70
|
+
/** How long to wait for mybase, in ms. Default 1000. */
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
/** Called when mybase can't be reached, before failing open or closed. */
|
|
73
|
+
onError?: (error: unknown) => void;
|
|
74
|
+
/** Default `globalThis.fetch`. */
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
}
|
|
77
|
+
export interface LimitOptions {
|
|
78
|
+
/** Units this request uses. Default 1. */
|
|
79
|
+
cost?: number;
|
|
80
|
+
}
|
|
81
|
+
export interface RateLimiter {
|
|
82
|
+
/** Counts one request (or `cost` units) for `id` against a named rule or an inline limit. */
|
|
83
|
+
limit(rule: string | InlineLimit, id: string, options?: LimitOptions): Promise<RateLimitResult>;
|
|
84
|
+
}
|
|
85
|
+
/** `"10s"` / `"1m"` / ms → ms, checked against the allowed window range. */
|
|
86
|
+
export declare function parseWindow(value: number | string): number;
|
|
87
|
+
/** Headers to send with a 429 (or any response): `RateLimit-*` and `Retry-After`. */
|
|
88
|
+
export declare function rateLimitHeaders(result: RateLimitResult, now?: number): Record<string, string>;
|
|
89
|
+
export declare function createRateLimiter(options: RateLimiterOptions): RateLimiter;
|
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
|
|
179
|
+
// src/ratelimit.ts
|
|
180
|
+
var RATELIMIT_ALGORITHMS = ["sliding", "fixed", "token"];
|
|
181
|
+
var RATELIMIT_LIMITS = {
|
|
182
|
+
limit: { min: 1, max: 1e6 },
|
|
183
|
+
windowMs: { min: 1000, max: 30 * 86400000 },
|
|
184
|
+
idLength: 256,
|
|
185
|
+
cost: { min: 1, max: 1e6 }
|
|
186
|
+
};
|
|
187
|
+
function parseWindow(value) {
|
|
188
|
+
const ms = parseDelay(value);
|
|
189
|
+
const { min, max } = RATELIMIT_LIMITS.windowMs;
|
|
190
|
+
if (ms < min || ms > max)
|
|
191
|
+
throw new RangeError(`window must be from 1s to 30d, got "${value}"`);
|
|
192
|
+
return ms;
|
|
193
|
+
}
|
|
194
|
+
function rateLimitHeaders(result, now = Date.now()) {
|
|
195
|
+
const resetSec = Math.max(0, Math.ceil((result.reset - now) / 1000));
|
|
196
|
+
const headers = {
|
|
197
|
+
"RateLimit-Limit": String(result.limit),
|
|
198
|
+
"RateLimit-Remaining": String(result.remaining),
|
|
199
|
+
"RateLimit-Reset": String(resetSec)
|
|
200
|
+
};
|
|
201
|
+
if (!result.success)
|
|
202
|
+
headers["Retry-After"] = String(resetSec);
|
|
203
|
+
return headers;
|
|
204
|
+
}
|
|
205
|
+
function createRateLimiter(options) {
|
|
206
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
207
|
+
const url = `${options.apiUrl.replace(/\/+$/, "")}/ratelimit/${encodeURIComponent(options.project)}/limit`;
|
|
208
|
+
const timeoutMs = options.timeoutMs ?? 1000;
|
|
209
|
+
return {
|
|
210
|
+
async limit(rule, id, { cost } = {}) {
|
|
211
|
+
const body = typeof rule === "string" ? { id, rule, cost } : { id, limit: rule.limit, windowMs: parseWindow(rule.window), algorithm: rule.algorithm, burst: rule.burst, cost };
|
|
212
|
+
let res;
|
|
213
|
+
try {
|
|
214
|
+
res = await doFetch(url, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
body: JSON.stringify(body),
|
|
217
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
218
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return unavailable(error);
|
|
222
|
+
}
|
|
223
|
+
const data = await res.json().catch(() => ({}));
|
|
224
|
+
if (res.ok)
|
|
225
|
+
return { success: data.success, limit: data.limit, remaining: data.remaining, reset: data.reset };
|
|
226
|
+
const error = new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
|
|
227
|
+
if (res.status >= 500)
|
|
228
|
+
return unavailable(error);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
function unavailable(error) {
|
|
233
|
+
options.onError?.(error);
|
|
234
|
+
return { success: !options.failClosed, limit: 0, remaining: 0, reset: Date.now(), error };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
export {
|
|
238
|
+
MybaseApiError,
|
|
239
|
+
RATELIMIT_ALGORITHMS,
|
|
240
|
+
RATELIMIT_LIMITS,
|
|
241
|
+
createRateLimiter,
|
|
242
|
+
parseWindow,
|
|
243
|
+
rateLimitHeaders
|
|
244
|
+
};
|