@evumi/berth 0.0.0-stage → 0.0.3

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/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # @evumi/berth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Berth 项目 Queues(队列)和 Alarms(定时器)的客户端,用法照 Cloudflare Queues 和 Durable Object alarms。也负责处理平台推给应用的请求(队列批次、定时器、Cron HTTP 目标),并校验签名。零运行时依赖,支持 Node ≥ 18、Bun、Deno 和 edge 运行时。
4
+
5
+ 项目开了 `queues` 或 `alarms` 能力后,Berth 会给容器注入 `BERTH_CONFIG_URL`、`BERTH_CONFIG_TOKEN` 和 `BERTH_SIGNING_SECRET`。环境是请求来源的那个环境:预览环境只会用到自己的队列和定时器。
6
+
7
+ ## 发消息(producer)
8
+
9
+ ```ts
10
+ import { queue } from '@evumi/berth';
11
+
12
+ const EMAILS = queue('emails'); // 放在模块顶层即可:环境变量到发请求时才读
13
+
14
+ await EMAILS.send({ to: 'a@example.com', template: 'welcome' });
15
+ await EMAILS.send({ to: 'b@example.com' }, { delaySeconds: 600, dedupId: 'welcome:42' }); // 24 小时内同一 dedupId 只存一次
16
+ await EMAILS.send('plain text', { contentType: 'text' });
17
+ await EMAILS.send(new Uint8Array(png)); // 自动用 bytes
18
+
19
+ const { ids } = await EMAILS.sendBatch([{ body: { n: 1 } }, { body: { n: 2 }, delaySeconds: 30 }], { delaySeconds: 5 });
20
+ ```
21
+
22
+ `contentType` 是 `json`(默认,任意 JSON 值)、`text`(字符串)或 `bytes`(`Uint8Array` / `ArrayBuffer`)。`send` 返回 `{ id, deduplicated }`。
23
+
24
+ 自己建客户端:
25
+
26
+ ```ts
27
+ import { createClient } from '@evumi/berth';
28
+
29
+ const berth = createClient({ url: process.env.BERTH_CONFIG_URL, token: process.env.BERTH_CONFIG_TOKEN, timeoutMs: 10_000 });
30
+ const EMAILS = berth.queue('emails');
31
+ const trialEnd = berth.alarm('trial-end:42');
32
+ ```
33
+
34
+ ## 接收推送(consumer)
35
+
36
+ 队列的消费者(push 模式)、定时器的目标服务和 Cron HTTP 目标都指向同一个处理器即可,靠 `Berth-Source` 区分。`createHandler` 会先用原始请求体校验 `Berth-Signature`(默认读 `BERTH_SIGNING_SECRET`,失败回 401),再调用对应函数:
37
+
38
+ ```ts
39
+ import { createHandler } from '@evumi/berth';
40
+
41
+ export const handler = createHandler({
42
+ async queue(batch, env) {
43
+ for (const m of batch.messages) {
44
+ try {
45
+ await sendEmail(m.body); // m.id, m.timestamp (Date), m.attempts, m.contentType
46
+ m.ack();
47
+ } catch {
48
+ m.retry({ delaySeconds: 60 }); // 不传则按队列退避
49
+ }
50
+ }
51
+ // 没 ack 也没 retry 的消息在函数正常返回后算确认;也可以 batch.ackAll() / batch.retryAll({ delaySeconds })
52
+ },
53
+ async alarm(info) {
54
+ // info.key, info.scheduledTime (ms), info.data, info.retryCount, info.isRetry
55
+ await endTrial(info.data);
56
+ },
57
+ async scheduled(controller) {
58
+ // controller.cron, controller.name, controller.scheduledTime (ms)
59
+ await cleanup();
60
+ },
61
+ });
62
+ ```
63
+
64
+ 函数抛异常时回 500,平台会重试:队列是整批重试,定时器最多重试 6 次。某个来源没有对应函数时回 404。`env` 是 `process.env` 的快照;第三个参数 `ctx.waitUntil(promise)` 会让响应等这些 promise 结束。
65
+
66
+ Next.js(App Router)、Hono、Bun、Deno:处理器就是 `(request: Request) => Promise<Response>`:
67
+
68
+ ```ts
69
+ // app/api/berth/route.ts
70
+ import { handler } from '@/lib/berth';
71
+ export const POST = handler;
72
+ ```
73
+
74
+ Node `http` / Express:用 `toNodeHandler`。它直接读原始请求流,不依赖 body parser。前面如果已经用了 `express.raw()`,它会用 `req.body`(Buffer 或字符串)。不要把它挂在 `express.json()` 后面:
75
+
76
+ ```ts
77
+ import express from 'express';
78
+ import { toNodeHandler } from '@evumi/berth';
79
+ import { handler } from './berth.js';
80
+
81
+ const app = express();
82
+ app.post('/berth', toNodeHandler(handler)); // 放在 app.use(express.json()) 之前
83
+ ```
84
+
85
+ 只想自己校验签名时用 `verifySignature(secret, header, rawBody, { toleranceSeconds = 300 })`。它是同步的,返回布尔值,不抛异常。`checkSignature(...)` 返回失败原因:`'missing' | 'malformed' | 'expired' | 'mismatch'`,通过时返回 `null`。
86
+
87
+ ## 拉取(pull 队列)
88
+
89
+ 和 Cloudflare 的 HTTP pull 一样:
90
+
91
+ ```ts
92
+ import { queue } from '@evumi/berth';
93
+
94
+ const JOBS = queue('jobs');
95
+ for (;;) {
96
+ const { messages } = await JOBS.pull({ batchSize: 10, visibilityTimeoutMs: 60_000 });
97
+ if (messages.length === 0) {
98
+ await new Promise((r) => setTimeout(r, 1000));
99
+ continue;
100
+ }
101
+ const acks: string[] = [];
102
+ const retries: { leaseId: string; delaySeconds?: number }[] = [];
103
+ for (const m of messages) {
104
+ try {
105
+ await handle(m.body); // m.id, m.leaseId, m.attempts, m.timestamp (ms), m.contentType
106
+ acks.push(m.leaseId);
107
+ } catch {
108
+ retries.push({ leaseId: m.leaseId, delaySeconds: 30 });
109
+ }
110
+ }
111
+ await JOBS.ack({ acks, retries }); // → { acked, retried, dead }
112
+ }
113
+ ```
114
+
115
+ `visibilityTimeoutMs` 内没 ack 的消息会换一个新 `leaseId` 再发一次。过期的 lease 会被忽略。
116
+
117
+ ## 定时器(Alarms)
118
+
119
+ 每个 key 一个,作用范围是整个环境,和 Durable Object 的 `storage.setAlarm` 一样:再设一次就是改时间,删掉就是取消。
120
+
121
+ ```ts
122
+ import { alarm } from '@evumi/berth';
123
+
124
+ const trialEnd = alarm(`trial-end:${userId}`);
125
+ await trialEnd.setAlarm(Date.now() + 14 * 86_400_000, { data: { userId } }); // ms 或 Date;最多 365 天后
126
+ const when = await trialEnd.getAlarm(); // ms,没有则为 null
127
+ const pending = await trialEnd.get(); // { key, scheduledTime, data } 或 null
128
+ await trialEnd.deleteAlarm(); // 没有也不报错
129
+ ```
130
+
131
+ ## 错误
132
+
133
+ 请求失败抛 `BerthError`,带 `status`、`code`、`message`(429 时还有 `retryAfterMs`)。`code` 取服务器返回的值:`unauthorized`、`capability_disabled`、`not_found`、`bad_request`、`conflict`、`too_large`、`rate_limited`、`quota_exceeded`、`unavailable`、`queue_full`。客户端自己的 `code`:`timeout`、`network`、`invalid_response`、`missing_env`。错误信息里不含令牌。
134
+
135
+ ## 开发
136
+
137
+ ```sh
138
+ pnpm install
139
+ pnpm typecheck
140
+ pnpm test # 先构建,再跑 vitest(含 sdk/testdata 的共享签名向量)
141
+ ```
@@ -0,0 +1,9 @@
1
+ /** How a message body travels: a JSON value, a string, or bytes (base64 on the wire). */
2
+ export type ContentType = 'json' | 'text' | 'bytes';
3
+ /** body + content_type for the wire. Bytes bodies are sent as bytes whatever contentType says. */
4
+ export declare function encodeBody(body: unknown, contentType?: ContentType): {
5
+ body: unknown;
6
+ content_type?: ContentType;
7
+ };
8
+ /** The body as the app sees it: JSON value, string, or Uint8Array. */
9
+ export declare function decodeBody(body: unknown, contentType: string | undefined): unknown;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodeBody = encodeBody;
4
+ exports.decodeBody = decodeBody;
5
+ const errors_js_1 = require("./errors.js");
6
+ const util_js_1 = require("./util.js");
7
+ const isBytes = (v) => v instanceof Uint8Array || v instanceof ArrayBuffer;
8
+ /** body + content_type for the wire. Bytes bodies are sent as bytes whatever contentType says. */
9
+ function encodeBody(body, contentType) {
10
+ if (isBytes(body))
11
+ return { body: (0, util_js_1.toBase64)(body instanceof Uint8Array ? body : new Uint8Array(body)), content_type: 'bytes' };
12
+ if (body === undefined)
13
+ throw new errors_js_1.BerthError('bad_request', 'body is required');
14
+ switch (contentType) {
15
+ case undefined:
16
+ case 'json':
17
+ return contentType ? { body, content_type: 'json' } : { body };
18
+ case 'text':
19
+ if (typeof body !== 'string')
20
+ throw new errors_js_1.BerthError('bad_request', 'contentType "text" needs a string body');
21
+ return { body, content_type: 'text' };
22
+ case 'bytes':
23
+ throw new errors_js_1.BerthError('bad_request', 'contentType "bytes" needs a Uint8Array or ArrayBuffer body');
24
+ default:
25
+ throw new errors_js_1.BerthError('bad_request', `unsupported contentType ${JSON.stringify(contentType)}`);
26
+ }
27
+ }
28
+ /** The body as the app sees it: JSON value, string, or Uint8Array. */
29
+ function decodeBody(body, contentType) {
30
+ if (contentType !== 'bytes')
31
+ return body;
32
+ if (typeof body !== 'string')
33
+ throw new errors_js_1.BerthError('invalid_response', 'bytes body is not a base64 string');
34
+ return (0, util_js_1.fromBase64)(body);
35
+ }
@@ -0,0 +1,111 @@
1
+ import { type ContentType } from './body.js';
2
+ export type { ContentType } from './body.js';
3
+ export interface SendOptions {
4
+ /** json (default; any JSON value), text (a string) or bytes (Uint8Array/ArrayBuffer). Bytes bodies pick bytes themselves. */
5
+ contentType?: ContentType;
6
+ /** Delay before the first delivery, 0–604800. */
7
+ delaySeconds?: number;
8
+ /** The same dedupId on the same queue within 24 h stores nothing and returns the first id. */
9
+ dedupId?: string;
10
+ }
11
+ export interface SendResult {
12
+ id: string;
13
+ /** The dedupId was seen within 24 h: id is the first message's and nothing was stored. */
14
+ deduplicated: boolean;
15
+ }
16
+ export interface BatchMessage extends SendOptions {
17
+ body: unknown;
18
+ }
19
+ export interface PullOptions {
20
+ /** 1–100, default 10. */
21
+ batchSize?: number;
22
+ /** A message not acknowledged within this is handed out again with a new leaseId. 1000–43200000, default 30000. */
23
+ visibilityTimeoutMs?: number;
24
+ }
25
+ export interface PulledMessage<T = unknown> {
26
+ id: string;
27
+ /** Pass it to ack(). */
28
+ leaseId: string;
29
+ /** JSON value (json), string (text) or Uint8Array (bytes). */
30
+ body: T;
31
+ contentType: ContentType;
32
+ /** Starts at 1. */
33
+ attempts: number;
34
+ /** Send time, Unix milliseconds. */
35
+ timestamp: number;
36
+ }
37
+ export interface AckRequest {
38
+ acks?: string[];
39
+ /** Without delaySeconds the queue's backoff applies. */
40
+ retries?: {
41
+ leaseId: string;
42
+ delaySeconds?: number;
43
+ }[];
44
+ }
45
+ export interface AckResult {
46
+ acked: number;
47
+ retried: number;
48
+ /** Out of attempts: moved to the dead-letter list. */
49
+ dead: number;
50
+ }
51
+ /** A queue producer (and pull consumer), like a Workers Queue binding. */
52
+ export interface Queue {
53
+ readonly name: string;
54
+ send(body: unknown, opts?: SendOptions): Promise<SendResult>;
55
+ /** Up to 100 messages; validation is all or nothing. opts.delaySeconds applies to messages without their own. */
56
+ sendBatch(messages: BatchMessage[], opts?: {
57
+ delaySeconds?: number;
58
+ }): Promise<{
59
+ ids: string[];
60
+ items: SendResult[];
61
+ }>;
62
+ /** Pull queues only. Maybe empty. */
63
+ pull<T = unknown>(opts?: PullOptions): Promise<{
64
+ messages: PulledMessage<T>[];
65
+ }>;
66
+ ack(req: AckRequest): Promise<AckResult>;
67
+ }
68
+ /** A named alarm of the environment, like a Durable Object's storage alarm. */
69
+ export interface Alarm {
70
+ readonly key: string;
71
+ /** Sets (or moves) the alarm. scheduledTime: a Date or Unix milliseconds; a past time fires at once. */
72
+ setAlarm(scheduledTime: number | Date, opts?: {
73
+ data?: unknown;
74
+ }): Promise<void>;
75
+ /** The scheduled time in Unix milliseconds, or null when none is pending. */
76
+ getAlarm(): Promise<number | null>;
77
+ /** The pending alarm with its data, or null. */
78
+ get<T = unknown>(): Promise<{
79
+ key: string;
80
+ scheduledTime: number;
81
+ data: T;
82
+ } | null>;
83
+ /** Cancels the alarm; fine when there is none. */
84
+ deleteAlarm(): Promise<void>;
85
+ }
86
+ export type FetchLike = (url: string, init: {
87
+ method: string;
88
+ headers: Record<string, string>;
89
+ body?: string;
90
+ signal: AbortSignal;
91
+ }) => Promise<Response>;
92
+ export interface ClientOptions {
93
+ /** Base URL without /v1. Default: env BERTH_CONFIG_URL, read at each request. */
94
+ url?: string;
95
+ /** Bearer token. Default: env BERTH_CONFIG_TOKEN, read at each request. */
96
+ token?: string;
97
+ /** Per request. Default 10000. */
98
+ timeoutMs?: number;
99
+ /** Custom fetch. Default: globalThis.fetch. */
100
+ fetch?: FetchLike;
101
+ }
102
+ export interface Client {
103
+ queue(name: string): Queue;
104
+ alarm(key: string): Alarm;
105
+ }
106
+ /** A client for queues and alarms. URL and token default to the environment, read lazily. */
107
+ export declare function createClient(opts?: ClientOptions): Client;
108
+ /** A queue of this environment, using BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN (read at each request). */
109
+ export declare const queue: (name: string) => Queue;
110
+ /** A named alarm of this environment, using BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN (read at each request). */
111
+ export declare const alarm: (key: string) => Alarm;
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.alarm = exports.queue = void 0;
4
+ exports.createClient = createClient;
5
+ const errors_js_1 = require("./errors.js");
6
+ const body_js_1 = require("./body.js");
7
+ const util_js_1 = require("./util.js");
8
+ function wireMessage(body, o) {
9
+ const out = (0, body_js_1.encodeBody)(body, o.contentType);
10
+ if (o.delaySeconds !== undefined)
11
+ out.delay_seconds = o.delaySeconds;
12
+ if (o.dedupId !== undefined)
13
+ out.dedup_id = o.dedupId;
14
+ return out;
15
+ }
16
+ /** Scheduled time from the wire: Unix ms, or an RFC 3339 string. */
17
+ function toMs(v) {
18
+ if (typeof v === 'number')
19
+ return v;
20
+ const t = typeof v === 'string' ? Date.parse(v) : Number.NaN;
21
+ if (Number.isNaN(t))
22
+ throw new errors_js_1.BerthError('invalid_response', 'scheduled_time is neither Unix ms nor RFC 3339');
23
+ return t;
24
+ }
25
+ const seg = (s) => encodeURIComponent(s);
26
+ /** A client for queues and alarms. URL and token default to the environment, read lazily. */
27
+ function createClient(opts = {}) {
28
+ const timeoutMs = opts.timeoutMs ?? 10_000;
29
+ async function request(method, path, body) {
30
+ const url = opts.url ?? (0, util_js_1.env)('BERTH_CONFIG_URL');
31
+ const token = opts.token ?? (0, util_js_1.env)('BERTH_CONFIG_TOKEN');
32
+ if (!url || !token)
33
+ throw new errors_js_1.BerthError('missing_env', 'BERTH_CONFIG_URL and BERTH_CONFIG_TOKEN are not set');
34
+ const base = url.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1';
35
+ const f = opts.fetch ?? globalThis.fetch;
36
+ if (!f)
37
+ throw new errors_js_1.BerthError('network', 'no fetch available: pass one in createClient({ fetch })');
38
+ const headers = { authorization: `Bearer ${token}`, accept: 'application/json' };
39
+ if (body !== undefined)
40
+ headers['content-type'] = 'application/json';
41
+ const ctrl = new AbortController();
42
+ let timedOut = false;
43
+ const timer = setTimeout(() => ((timedOut = true), ctrl.abort()), timeoutMs);
44
+ timer.unref?.();
45
+ let status;
46
+ let text;
47
+ let retryAfter;
48
+ try {
49
+ const res = await f(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctrl.signal });
50
+ status = res.status;
51
+ retryAfter = res.headers.get('retry-after');
52
+ text = await res.text();
53
+ }
54
+ catch (e) {
55
+ if (timedOut)
56
+ throw new errors_js_1.BerthError('timeout', `request timed out after ${timeoutMs} ms`);
57
+ throw new errors_js_1.BerthError('network', `request failed: ${e?.message ?? e}`, { cause: e });
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ if (status < 200 || status > 299)
63
+ throw (0, errors_js_1.responseError)(status, text, retryAfter);
64
+ if (status === 204 || text === '')
65
+ return undefined;
66
+ try {
67
+ return JSON.parse(text);
68
+ }
69
+ catch (e) {
70
+ throw new errors_js_1.BerthError('invalid_response', 'response is not JSON', { status, cause: e });
71
+ }
72
+ }
73
+ function queue(name) {
74
+ const p = `/queues/${seg(name)}/messages`;
75
+ return {
76
+ name,
77
+ async send(body, o = {}) {
78
+ return request('POST', p, wireMessage(body, o));
79
+ },
80
+ async sendBatch(messages, o = {}) {
81
+ const req = {
82
+ messages: messages.map((m) => {
83
+ if (m === null || typeof m !== 'object' || !('body' in m))
84
+ throw new errors_js_1.BerthError('bad_request', 'a batch message is { body, contentType?, delaySeconds?, dedupId? }');
85
+ return wireMessage(m.body, m);
86
+ }),
87
+ };
88
+ if (o.delaySeconds !== undefined)
89
+ req.delay_seconds = o.delaySeconds;
90
+ const r = await request('POST', `${p}/batch`, req);
91
+ const items = r?.items ?? [];
92
+ return { ids: items.map((i) => i.id), items };
93
+ },
94
+ async pull(o = {}) {
95
+ const req = {};
96
+ if (o.batchSize !== undefined)
97
+ req.batch_size = o.batchSize;
98
+ if (o.visibilityTimeoutMs !== undefined)
99
+ req.visibility_timeout_ms = o.visibilityTimeoutMs;
100
+ const r = await request('POST', `${p}/pull`, req);
101
+ return {
102
+ messages: (r?.messages ?? []).map((m) => {
103
+ const contentType = (m.content_type || 'json');
104
+ return {
105
+ id: String(m.id),
106
+ leaseId: String(m.lease_id),
107
+ body: (0, body_js_1.decodeBody)(m.body, contentType),
108
+ contentType,
109
+ attempts: Number(m.attempts ?? 0),
110
+ timestamp: Number(m.timestamp_ms ?? 0),
111
+ };
112
+ }),
113
+ };
114
+ },
115
+ async ack(req) {
116
+ const body = {
117
+ acks: (req.acks ?? []).map((lease_id) => ({ lease_id })),
118
+ retries: (req.retries ?? []).map((r) => (r.delaySeconds === undefined ? { lease_id: r.leaseId } : { lease_id: r.leaseId, delay_seconds: r.delaySeconds })),
119
+ };
120
+ return request('POST', `${p}/ack`, body);
121
+ },
122
+ };
123
+ }
124
+ function alarm(key) {
125
+ const p = `/alarms/${seg(key)}`;
126
+ const get = async () => {
127
+ try {
128
+ const a = await request('GET', p);
129
+ return { key: a.key, scheduledTime: toMs(a.scheduled_time), data: a.data };
130
+ }
131
+ catch (e) {
132
+ if (e instanceof errors_js_1.BerthError && e.status === 404 && e.code === 'not_found')
133
+ return null;
134
+ throw e;
135
+ }
136
+ };
137
+ return {
138
+ key,
139
+ async setAlarm(scheduledTime, o = {}) {
140
+ const ms = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime;
141
+ if (typeof ms !== 'number' || !Number.isFinite(ms))
142
+ throw new errors_js_1.BerthError('bad_request', 'scheduledTime must be a Date or Unix milliseconds');
143
+ const body = { scheduled_time: Math.round(ms) };
144
+ if (o.data !== undefined)
145
+ body.data = o.data;
146
+ await request('PUT', p, body);
147
+ },
148
+ async getAlarm() {
149
+ return (await get())?.scheduledTime ?? null;
150
+ },
151
+ get,
152
+ async deleteAlarm() {
153
+ await request('DELETE', p);
154
+ },
155
+ };
156
+ }
157
+ return { queue, alarm };
158
+ }
159
+ const defaultClient = createClient();
160
+ /** A queue of this environment, using BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN (read at each request). */
161
+ const queue = (name) => defaultClient.queue(name);
162
+ exports.queue = queue;
163
+ /** A named alarm of this environment, using BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN (read at each request). */
164
+ const alarm = (key) => defaultClient.alarm(key);
165
+ exports.alarm = alarm;
@@ -0,0 +1,19 @@
1
+ /** Error codes. Server codes come from the response body; the last four are raised by the client. */
2
+ export type ErrorCode = 'unauthorized' | 'capability_disabled' | 'not_found' | 'bad_request' | 'conflict' | 'too_large' | 'rate_limited' | 'quota_exceeded' | 'unavailable' | 'queue_full' | 'timeout' | 'network' | 'invalid_response' | 'missing_env' | (string & {});
3
+ export declare class BerthError extends Error {
4
+ name: string;
5
+ readonly code: ErrorCode;
6
+ /** HTTP status, when the error came from a response. */
7
+ readonly status?: number;
8
+ /** Parsed Retry-After, for 429 responses. */
9
+ readonly retryAfterMs?: number;
10
+ constructor(code: ErrorCode, message: string, extra?: {
11
+ status?: number;
12
+ retryAfterMs?: number;
13
+ cause?: unknown;
14
+ });
15
+ }
16
+ /** Builds an error from a non-2xx response body of the form {"error":{"code","message"}}. */
17
+ export declare function responseError(status: number, body: string, retryAfter: string | null): BerthError;
18
+ /** Retry-After is either delta-seconds or an HTTP date. */
19
+ export declare function parseRetryAfter(v: string | null): number | undefined;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BerthError = void 0;
4
+ exports.responseError = responseError;
5
+ exports.parseRetryAfter = parseRetryAfter;
6
+ class BerthError extends Error {
7
+ name = 'BerthError';
8
+ code;
9
+ /** HTTP status, when the error came from a response. */
10
+ status;
11
+ /** Parsed Retry-After, for 429 responses. */
12
+ retryAfterMs;
13
+ constructor(code, message, extra = {}) {
14
+ super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
15
+ this.code = code;
16
+ this.status = extra.status;
17
+ this.retryAfterMs = extra.retryAfterMs;
18
+ }
19
+ }
20
+ exports.BerthError = BerthError;
21
+ const statusCodes = {
22
+ 400: 'bad_request',
23
+ 401: 'unauthorized',
24
+ 403: 'unauthorized',
25
+ 404: 'not_found',
26
+ 409: 'conflict',
27
+ 413: 'too_large',
28
+ 429: 'rate_limited',
29
+ 507: 'queue_full',
30
+ };
31
+ /** Builds an error from a non-2xx response body of the form {"error":{"code","message"}}. */
32
+ function responseError(status, body, retryAfter) {
33
+ let code = statusCodes[status] ?? (status >= 500 ? 'unavailable' : 'bad_request');
34
+ let message = `request failed with HTTP ${status}`;
35
+ try {
36
+ const e = JSON.parse(body)?.error;
37
+ if (typeof e?.code === 'string' && e.code)
38
+ code = e.code;
39
+ if (typeof e?.message === 'string' && e.message)
40
+ message = e.message;
41
+ }
42
+ catch {
43
+ // Non-JSON error body (proxy page etc.): keep the generic message.
44
+ }
45
+ return new BerthError(code, message, { status, retryAfterMs: status === 429 ? parseRetryAfter(retryAfter) : undefined });
46
+ }
47
+ /** Retry-After is either delta-seconds or an HTTP date. */
48
+ function parseRetryAfter(v) {
49
+ if (!v)
50
+ return undefined;
51
+ const s = Number(v);
52
+ if (Number.isFinite(s))
53
+ return Math.max(0, s * 1000);
54
+ const t = Date.parse(v);
55
+ return Number.isNaN(t) ? undefined : Math.max(0, t - Date.now());
56
+ }
@@ -0,0 +1,83 @@
1
+ import { type ContentType } from './body.js';
2
+ export type Env = Record<string, string | undefined>;
3
+ /** One message of a pushed batch, like a Workers Queue Message. */
4
+ export interface Message<T = unknown> {
5
+ readonly id: string;
6
+ /** Send time. */
7
+ readonly timestamp: Date;
8
+ /** JSON value (json), string (text) or Uint8Array (bytes). */
9
+ readonly body: T;
10
+ readonly contentType: ContentType;
11
+ /** Starts at 1. */
12
+ readonly attempts: number;
13
+ /** Acknowledge this message even if the batch is retried. */
14
+ ack(): void;
15
+ /** Retry this message (after delaySeconds, else with the backoff). */
16
+ retry(opts?: {
17
+ delaySeconds?: number;
18
+ }): void;
19
+ }
20
+ /** A pushed batch, like a Workers MessageBatch. Messages not acked or retried are acknowledged when the handler returns. */
21
+ export interface MessageBatch<T = unknown> {
22
+ readonly queue: string;
23
+ readonly messages: readonly Message<T>[];
24
+ ackAll(): void;
25
+ retryAll(opts?: {
26
+ delaySeconds?: number;
27
+ }): void;
28
+ }
29
+ export interface AlarmInfo<T = unknown> {
30
+ key: string;
31
+ /** Unix milliseconds. */
32
+ scheduledTime: number;
33
+ data: T;
34
+ retryCount: number;
35
+ isRetry: boolean;
36
+ }
37
+ /** A Cron HTTP target call, like a Workers ScheduledController. */
38
+ export interface ScheduledController {
39
+ cron: string;
40
+ /** Unix milliseconds. */
41
+ scheduledTime: number;
42
+ name: string;
43
+ /** Accepted for Workers compatibility; does nothing. */
44
+ noRetry(): void;
45
+ }
46
+ export interface ExecutionContext {
47
+ /** The response waits for these (failures are ignored). */
48
+ waitUntil(p: Promise<unknown>): void;
49
+ }
50
+ export interface Handlers {
51
+ queue?(batch: MessageBatch<any>, env: Env, ctx: ExecutionContext): unknown | Promise<unknown>;
52
+ alarm?(alarm: AlarmInfo<any>, env: Env, ctx: ExecutionContext): unknown | Promise<unknown>;
53
+ scheduled?(controller: ScheduledController, env: Env, ctx: ExecutionContext): unknown | Promise<unknown>;
54
+ /** Default: env BERTH_SIGNING_SECRET, read at each request. */
55
+ secret?: string;
56
+ /** Default 300. */
57
+ toleranceSeconds?: number;
58
+ }
59
+ export type FetchHandler = (request: Request) => Promise<Response>;
60
+ /**
61
+ * A Fetch API handler for everything the platform pushes: queue batches, alarms and Cron HTTP
62
+ * targets, told apart by Berth-Source. It verifies Berth-Signature (401 when it fails), calls the
63
+ * matching handler and answers the platform: 200 with the batch settlement, 204 for an alarm or
64
+ * cron call, 500 when the handler throws (the platform retries), 404 when there is no handler.
65
+ */
66
+ export declare function createHandler(handlers: Handlers): FetchHandler;
67
+ /** The parts of node:http's IncomingMessage (or an Express request) toNodeHandler uses. */
68
+ export interface NodeRequest extends AsyncIterable<unknown> {
69
+ method?: string;
70
+ url?: string;
71
+ headers: Record<string, string | string[] | undefined>;
72
+ /** Set by body parsers. A Buffer or string is used as the raw body; anything else is ignored. */
73
+ body?: unknown;
74
+ readableEnded?: boolean;
75
+ }
76
+ /** The parts of node:http's ServerResponse used. */
77
+ export interface NodeResponse {
78
+ statusCode: number;
79
+ setHeader(name: string, value: string): unknown;
80
+ end(chunk?: Uint8Array | string): unknown;
81
+ }
82
+ /** Adapts a Fetch handler to node:http / Express: `app.post('/berth', toNodeHandler(handler))`. */
83
+ export declare function toNodeHandler(handler: FetchHandler): (req: NodeRequest, res: NodeResponse) => Promise<void>;