@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 +140 -2
- package/dist/cjs/body.d.ts +9 -0
- package/dist/cjs/body.js +35 -0
- package/dist/cjs/client.d.ts +111 -0
- package/dist/cjs/client.js +165 -0
- package/dist/cjs/errors.d.ts +19 -0
- package/dist/cjs/errors.js +56 -0
- package/dist/cjs/handler.d.ts +83 -0
- package/dist/cjs/handler.js +168 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +15 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/sha256.d.ts +5 -0
- package/dist/cjs/sha256.js +98 -0
- package/dist/cjs/signature.d.ts +20 -0
- package/dist/cjs/signature.js +74 -0
- package/dist/cjs/util.d.ts +3 -0
- package/dist/cjs/util.js +27 -0
- package/dist/esm/body.d.ts +9 -0
- package/dist/esm/body.js +31 -0
- package/dist/esm/client.d.ts +111 -0
- package/dist/esm/client.js +159 -0
- package/dist/esm/errors.d.ts +19 -0
- package/dist/esm/errors.js +50 -0
- package/dist/esm/handler.d.ts +83 -0
- package/dist/esm/handler.js +164 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/sha256.d.ts +5 -0
- package/dist/esm/sha256.js +93 -0
- package/dist/esm/signature.d.ts +20 -0
- package/dist/esm/signature.js +70 -0
- package/dist/esm/util.d.ts +3 -0
- package/dist/esm/util.js +22 -0
- package/package.json +46 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { BerthError, responseError } from './errors.js';
|
|
2
|
+
import { decodeBody, encodeBody } from './body.js';
|
|
3
|
+
import { env } from './util.js';
|
|
4
|
+
function wireMessage(body, o) {
|
|
5
|
+
const out = encodeBody(body, o.contentType);
|
|
6
|
+
if (o.delaySeconds !== undefined)
|
|
7
|
+
out.delay_seconds = o.delaySeconds;
|
|
8
|
+
if (o.dedupId !== undefined)
|
|
9
|
+
out.dedup_id = o.dedupId;
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** Scheduled time from the wire: Unix ms, or an RFC 3339 string. */
|
|
13
|
+
function toMs(v) {
|
|
14
|
+
if (typeof v === 'number')
|
|
15
|
+
return v;
|
|
16
|
+
const t = typeof v === 'string' ? Date.parse(v) : Number.NaN;
|
|
17
|
+
if (Number.isNaN(t))
|
|
18
|
+
throw new BerthError('invalid_response', 'scheduled_time is neither Unix ms nor RFC 3339');
|
|
19
|
+
return t;
|
|
20
|
+
}
|
|
21
|
+
const seg = (s) => encodeURIComponent(s);
|
|
22
|
+
/** A client for queues and alarms. URL and token default to the environment, read lazily. */
|
|
23
|
+
export function createClient(opts = {}) {
|
|
24
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
25
|
+
async function request(method, path, body) {
|
|
26
|
+
const url = opts.url ?? env('BERTH_CONFIG_URL');
|
|
27
|
+
const token = opts.token ?? env('BERTH_CONFIG_TOKEN');
|
|
28
|
+
if (!url || !token)
|
|
29
|
+
throw new BerthError('missing_env', 'BERTH_CONFIG_URL and BERTH_CONFIG_TOKEN are not set');
|
|
30
|
+
const base = url.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1';
|
|
31
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
32
|
+
if (!f)
|
|
33
|
+
throw new BerthError('network', 'no fetch available: pass one in createClient({ fetch })');
|
|
34
|
+
const headers = { authorization: `Bearer ${token}`, accept: 'application/json' };
|
|
35
|
+
if (body !== undefined)
|
|
36
|
+
headers['content-type'] = 'application/json';
|
|
37
|
+
const ctrl = new AbortController();
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
const timer = setTimeout(() => ((timedOut = true), ctrl.abort()), timeoutMs);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
let status;
|
|
42
|
+
let text;
|
|
43
|
+
let retryAfter;
|
|
44
|
+
try {
|
|
45
|
+
const res = await f(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctrl.signal });
|
|
46
|
+
status = res.status;
|
|
47
|
+
retryAfter = res.headers.get('retry-after');
|
|
48
|
+
text = await res.text();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (timedOut)
|
|
52
|
+
throw new BerthError('timeout', `request timed out after ${timeoutMs} ms`);
|
|
53
|
+
throw new BerthError('network', `request failed: ${e?.message ?? e}`, { cause: e });
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
if (status < 200 || status > 299)
|
|
59
|
+
throw responseError(status, text, retryAfter);
|
|
60
|
+
if (status === 204 || text === '')
|
|
61
|
+
return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
throw new BerthError('invalid_response', 'response is not JSON', { status, cause: e });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function queue(name) {
|
|
70
|
+
const p = `/queues/${seg(name)}/messages`;
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
async send(body, o = {}) {
|
|
74
|
+
return request('POST', p, wireMessage(body, o));
|
|
75
|
+
},
|
|
76
|
+
async sendBatch(messages, o = {}) {
|
|
77
|
+
const req = {
|
|
78
|
+
messages: messages.map((m) => {
|
|
79
|
+
if (m === null || typeof m !== 'object' || !('body' in m))
|
|
80
|
+
throw new BerthError('bad_request', 'a batch message is { body, contentType?, delaySeconds?, dedupId? }');
|
|
81
|
+
return wireMessage(m.body, m);
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
if (o.delaySeconds !== undefined)
|
|
85
|
+
req.delay_seconds = o.delaySeconds;
|
|
86
|
+
const r = await request('POST', `${p}/batch`, req);
|
|
87
|
+
const items = r?.items ?? [];
|
|
88
|
+
return { ids: items.map((i) => i.id), items };
|
|
89
|
+
},
|
|
90
|
+
async pull(o = {}) {
|
|
91
|
+
const req = {};
|
|
92
|
+
if (o.batchSize !== undefined)
|
|
93
|
+
req.batch_size = o.batchSize;
|
|
94
|
+
if (o.visibilityTimeoutMs !== undefined)
|
|
95
|
+
req.visibility_timeout_ms = o.visibilityTimeoutMs;
|
|
96
|
+
const r = await request('POST', `${p}/pull`, req);
|
|
97
|
+
return {
|
|
98
|
+
messages: (r?.messages ?? []).map((m) => {
|
|
99
|
+
const contentType = (m.content_type || 'json');
|
|
100
|
+
return {
|
|
101
|
+
id: String(m.id),
|
|
102
|
+
leaseId: String(m.lease_id),
|
|
103
|
+
body: decodeBody(m.body, contentType),
|
|
104
|
+
contentType,
|
|
105
|
+
attempts: Number(m.attempts ?? 0),
|
|
106
|
+
timestamp: Number(m.timestamp_ms ?? 0),
|
|
107
|
+
};
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
async ack(req) {
|
|
112
|
+
const body = {
|
|
113
|
+
acks: (req.acks ?? []).map((lease_id) => ({ lease_id })),
|
|
114
|
+
retries: (req.retries ?? []).map((r) => (r.delaySeconds === undefined ? { lease_id: r.leaseId } : { lease_id: r.leaseId, delay_seconds: r.delaySeconds })),
|
|
115
|
+
};
|
|
116
|
+
return request('POST', `${p}/ack`, body);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function alarm(key) {
|
|
121
|
+
const p = `/alarms/${seg(key)}`;
|
|
122
|
+
const get = async () => {
|
|
123
|
+
try {
|
|
124
|
+
const a = await request('GET', p);
|
|
125
|
+
return { key: a.key, scheduledTime: toMs(a.scheduled_time), data: a.data };
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
if (e instanceof BerthError && e.status === 404 && e.code === 'not_found')
|
|
129
|
+
return null;
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
key,
|
|
135
|
+
async setAlarm(scheduledTime, o = {}) {
|
|
136
|
+
const ms = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime;
|
|
137
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms))
|
|
138
|
+
throw new BerthError('bad_request', 'scheduledTime must be a Date or Unix milliseconds');
|
|
139
|
+
const body = { scheduled_time: Math.round(ms) };
|
|
140
|
+
if (o.data !== undefined)
|
|
141
|
+
body.data = o.data;
|
|
142
|
+
await request('PUT', p, body);
|
|
143
|
+
},
|
|
144
|
+
async getAlarm() {
|
|
145
|
+
return (await get())?.scheduledTime ?? null;
|
|
146
|
+
},
|
|
147
|
+
get,
|
|
148
|
+
async deleteAlarm() {
|
|
149
|
+
await request('DELETE', p);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { queue, alarm };
|
|
154
|
+
}
|
|
155
|
+
const defaultClient = createClient();
|
|
156
|
+
/** A queue of this environment, using BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN (read at each request). */
|
|
157
|
+
export const queue = (name) => defaultClient.queue(name);
|
|
158
|
+
/** A named alarm of this environment, using BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN (read at each request). */
|
|
159
|
+
export const alarm = (key) => defaultClient.alarm(key);
|
|
@@ -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,50 @@
|
|
|
1
|
+
export class BerthError extends Error {
|
|
2
|
+
name = 'BerthError';
|
|
3
|
+
code;
|
|
4
|
+
/** HTTP status, when the error came from a response. */
|
|
5
|
+
status;
|
|
6
|
+
/** Parsed Retry-After, for 429 responses. */
|
|
7
|
+
retryAfterMs;
|
|
8
|
+
constructor(code, message, extra = {}) {
|
|
9
|
+
super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = extra.status;
|
|
12
|
+
this.retryAfterMs = extra.retryAfterMs;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const statusCodes = {
|
|
16
|
+
400: 'bad_request',
|
|
17
|
+
401: 'unauthorized',
|
|
18
|
+
403: 'unauthorized',
|
|
19
|
+
404: 'not_found',
|
|
20
|
+
409: 'conflict',
|
|
21
|
+
413: 'too_large',
|
|
22
|
+
429: 'rate_limited',
|
|
23
|
+
507: 'queue_full',
|
|
24
|
+
};
|
|
25
|
+
/** Builds an error from a non-2xx response body of the form {"error":{"code","message"}}. */
|
|
26
|
+
export function responseError(status, body, retryAfter) {
|
|
27
|
+
let code = statusCodes[status] ?? (status >= 500 ? 'unavailable' : 'bad_request');
|
|
28
|
+
let message = `request failed with HTTP ${status}`;
|
|
29
|
+
try {
|
|
30
|
+
const e = JSON.parse(body)?.error;
|
|
31
|
+
if (typeof e?.code === 'string' && e.code)
|
|
32
|
+
code = e.code;
|
|
33
|
+
if (typeof e?.message === 'string' && e.message)
|
|
34
|
+
message = e.message;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Non-JSON error body (proxy page etc.): keep the generic message.
|
|
38
|
+
}
|
|
39
|
+
return new BerthError(code, message, { status, retryAfterMs: status === 429 ? parseRetryAfter(retryAfter) : undefined });
|
|
40
|
+
}
|
|
41
|
+
/** Retry-After is either delta-seconds or an HTTP date. */
|
|
42
|
+
export function parseRetryAfter(v) {
|
|
43
|
+
if (!v)
|
|
44
|
+
return undefined;
|
|
45
|
+
const s = Number(v);
|
|
46
|
+
if (Number.isFinite(s))
|
|
47
|
+
return Math.max(0, s * 1000);
|
|
48
|
+
const t = Date.parse(v);
|
|
49
|
+
return Number.isNaN(t) ? undefined : Math.max(0, t - Date.now());
|
|
50
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { decodeBody } from './body.js';
|
|
2
|
+
import { checkSignature } from './signature.js';
|
|
3
|
+
import { env as readEnv } from './util.js';
|
|
4
|
+
function snapshotEnv() {
|
|
5
|
+
const g = globalThis;
|
|
6
|
+
return { ...(g.process?.env ?? {}) };
|
|
7
|
+
}
|
|
8
|
+
const json = (status, v) => new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json' } });
|
|
9
|
+
const text = (status, s) => new Response(s, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
10
|
+
function makeBatch(raw) {
|
|
11
|
+
const settle = { acks: [], retries: [] };
|
|
12
|
+
const decided = new Set();
|
|
13
|
+
const delay = (o) => (o?.delaySeconds === undefined ? {} : { delaySeconds: o.delaySeconds });
|
|
14
|
+
const list = Array.isArray(raw.messages) ? raw.messages : [];
|
|
15
|
+
const messages = list.map((m) => {
|
|
16
|
+
const id = String(m.id);
|
|
17
|
+
const contentType = (m.contentType || 'json');
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
timestamp: new Date(Number(m.timestamp)),
|
|
21
|
+
body: decodeBody(m.body, contentType),
|
|
22
|
+
contentType,
|
|
23
|
+
attempts: Number(m.attempts ?? 1),
|
|
24
|
+
// The first call on a message wins, as in Workers.
|
|
25
|
+
ack() {
|
|
26
|
+
if (decided.has(id))
|
|
27
|
+
return;
|
|
28
|
+
decided.add(id);
|
|
29
|
+
settle.acks.push(id);
|
|
30
|
+
},
|
|
31
|
+
retry(o) {
|
|
32
|
+
if (decided.has(id))
|
|
33
|
+
return;
|
|
34
|
+
decided.add(id);
|
|
35
|
+
settle.retries.push({ id, ...delay(o) });
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
const batch = {
|
|
40
|
+
queue: String(raw.queue ?? ''),
|
|
41
|
+
messages,
|
|
42
|
+
ackAll() {
|
|
43
|
+
settle.ackAll = true;
|
|
44
|
+
delete settle.retryAll;
|
|
45
|
+
},
|
|
46
|
+
retryAll(o) {
|
|
47
|
+
settle.retryAll = delay(o);
|
|
48
|
+
delete settle.ackAll;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return { batch, settle };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A Fetch API handler for everything the platform pushes: queue batches, alarms and Cron HTTP
|
|
55
|
+
* targets, told apart by Berth-Source. It verifies Berth-Signature (401 when it fails), calls the
|
|
56
|
+
* matching handler and answers the platform: 200 with the batch settlement, 204 for an alarm or
|
|
57
|
+
* cron call, 500 when the handler throws (the platform retries), 404 when there is no handler.
|
|
58
|
+
*/
|
|
59
|
+
export function createHandler(handlers) {
|
|
60
|
+
return async (request) => {
|
|
61
|
+
const secret = handlers.secret ?? readEnv('BERTH_SIGNING_SECRET') ?? '';
|
|
62
|
+
const raw = new Uint8Array(await request.arrayBuffer());
|
|
63
|
+
if (!secret)
|
|
64
|
+
return text(401, 'BERTH_SIGNING_SECRET is not set');
|
|
65
|
+
const bad = checkSignature(secret, request.headers.get('Berth-Signature'), raw, { toleranceSeconds: handlers.toleranceSeconds });
|
|
66
|
+
if (bad)
|
|
67
|
+
return text(401, `invalid signature: ${bad}`);
|
|
68
|
+
let payload;
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(new TextDecoder().decode(raw)) ?? {};
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return text(400, 'body is not JSON');
|
|
74
|
+
}
|
|
75
|
+
const env = snapshotEnv();
|
|
76
|
+
const pending = [];
|
|
77
|
+
const ctx = { waitUntil: (p) => void pending.push(Promise.resolve(p).catch(() => { })) };
|
|
78
|
+
const source = request.headers.get('Berth-Source');
|
|
79
|
+
try {
|
|
80
|
+
switch (source) {
|
|
81
|
+
case 'queue': {
|
|
82
|
+
if (!handlers.queue)
|
|
83
|
+
return text(404, 'no queue handler');
|
|
84
|
+
const { batch, settle } = makeBatch(payload);
|
|
85
|
+
await handlers.queue(batch, env, ctx);
|
|
86
|
+
await Promise.all(pending);
|
|
87
|
+
return json(200, settle);
|
|
88
|
+
}
|
|
89
|
+
case 'alarm': {
|
|
90
|
+
if (!handlers.alarm)
|
|
91
|
+
return text(404, 'no alarm handler');
|
|
92
|
+
await handlers.alarm({
|
|
93
|
+
key: String(payload.key ?? ''),
|
|
94
|
+
scheduledTime: Number(payload.scheduledTime),
|
|
95
|
+
data: payload.data,
|
|
96
|
+
retryCount: Number(payload.retryCount ?? 0),
|
|
97
|
+
isRetry: Boolean(payload.isRetry),
|
|
98
|
+
}, env, ctx);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'cron': {
|
|
102
|
+
if (!handlers.scheduled)
|
|
103
|
+
return text(404, 'no scheduled handler');
|
|
104
|
+
await handlers.scheduled({ cron: String(payload.cron ?? ''), scheduledTime: Number(payload.scheduledTime), name: String(payload.name ?? ''), noRetry() { } }, env, ctx);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
return text(404, `unknown Berth-Source ${JSON.stringify(source)}`);
|
|
109
|
+
}
|
|
110
|
+
await Promise.all(pending);
|
|
111
|
+
return new Response(null, { status: 204 });
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
await Promise.all(pending);
|
|
115
|
+
return text(500, `handler failed: ${e?.message ?? e}`);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function readNodeBody(req) {
|
|
120
|
+
if (req.body instanceof Uint8Array)
|
|
121
|
+
return new Uint8Array(req.body); // Buffer, e.g. express.raw()
|
|
122
|
+
if (typeof req.body === 'string')
|
|
123
|
+
return new TextEncoder().encode(req.body);
|
|
124
|
+
if (req.readableEnded) {
|
|
125
|
+
throw new Error('the request body was already read by a parser; mount this handler before express.json() or use express.raw()');
|
|
126
|
+
}
|
|
127
|
+
const chunks = [];
|
|
128
|
+
for await (const c of req)
|
|
129
|
+
chunks.push(typeof c === 'string' ? new TextEncoder().encode(c) : c);
|
|
130
|
+
let len = 0;
|
|
131
|
+
for (const c of chunks)
|
|
132
|
+
len += c.length;
|
|
133
|
+
const out = new Uint8Array(len);
|
|
134
|
+
let off = 0;
|
|
135
|
+
for (const c of chunks) {
|
|
136
|
+
out.set(c, off);
|
|
137
|
+
off += c.length;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** Adapts a Fetch handler to node:http / Express: `app.post('/berth', toNodeHandler(handler))`. */
|
|
142
|
+
export function toNodeHandler(handler) {
|
|
143
|
+
return async (req, res) => {
|
|
144
|
+
let response;
|
|
145
|
+
try {
|
|
146
|
+
const body = await readNodeBody(req);
|
|
147
|
+
const headers = new Headers();
|
|
148
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
149
|
+
if (v === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
headers.set(k, Array.isArray(v) ? v.join(', ') : v);
|
|
152
|
+
}
|
|
153
|
+
const host = headers.get('host') ?? 'localhost';
|
|
154
|
+
const method = req.method ?? 'POST';
|
|
155
|
+
response = await handler(new Request(new URL(req.url ?? '/', `http://${host}`), { method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body }));
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
response = text(400, e?.message ?? String(e));
|
|
159
|
+
}
|
|
160
|
+
res.statusCode = response.status;
|
|
161
|
+
response.headers.forEach((v, k) => void res.setHeader(k, v));
|
|
162
|
+
res.end(new Uint8Array(await response.arrayBuffer()));
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { BerthError, type ErrorCode } from './errors.js';
|
|
2
|
+
export { type AckRequest, type AckResult, type Alarm, type BatchMessage, type Client, type ClientOptions, type ContentType, type FetchLike, type PulledMessage, type PullOptions, type Queue, type SendOptions, type SendResult, alarm, createClient, queue, } from './client.js';
|
|
3
|
+
export { type AlarmInfo, type Env, type ExecutionContext, type FetchHandler, type Handlers, type Message, type MessageBatch, type NodeRequest, type NodeResponse, type ScheduledController, createHandler, toNodeHandler, } from './handler.js';
|
|
4
|
+
export { type RawBody, type SignatureError, type VerifyOptions, checkSignature, verifySignature } from './signature.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** SHA-256 of the concatenation of parts. */
|
|
2
|
+
export declare function sha256(...parts: Uint8Array[]): Uint8Array;
|
|
3
|
+
/** HMAC-SHA256(key, concatenation of parts). */
|
|
4
|
+
export declare function hmacSha256(key: Uint8Array, ...parts: Uint8Array[]): Uint8Array;
|
|
5
|
+
export declare function toHex(b: Uint8Array): string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Synchronous HMAC-SHA256 in plain JS. verifySignature has to be synchronous and run in edge
|
|
2
|
+
// runtimes too, where node:crypto is absent and WebCrypto is async only. Bodies are at most
|
|
3
|
+
// 256 KiB, so speed does not matter much. Checked against node:crypto in the tests.
|
|
4
|
+
const K = new Uint32Array([
|
|
5
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
6
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
7
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
8
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
9
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
10
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
11
|
+
]);
|
|
12
|
+
/** SHA-256 of the concatenation of parts. */
|
|
13
|
+
export function sha256(...parts) {
|
|
14
|
+
let len = 0;
|
|
15
|
+
for (const p of parts)
|
|
16
|
+
len += p.length;
|
|
17
|
+
// Message + 0x80 + zero padding + 64-bit big-endian bit length, a multiple of 64 bytes.
|
|
18
|
+
const total = Math.ceil((len + 9) / 64) * 64;
|
|
19
|
+
const buf = new Uint8Array(total);
|
|
20
|
+
let off = 0;
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
buf.set(p, off);
|
|
23
|
+
off += p.length;
|
|
24
|
+
}
|
|
25
|
+
buf[len] = 0x80;
|
|
26
|
+
const view = new DataView(buf.buffer);
|
|
27
|
+
const bits = len * 8;
|
|
28
|
+
view.setUint32(total - 8, Math.floor(bits / 0x100000000));
|
|
29
|
+
view.setUint32(total - 4, bits >>> 0);
|
|
30
|
+
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
31
|
+
const w = new Uint32Array(64);
|
|
32
|
+
for (let i = 0; i < total; i += 64) {
|
|
33
|
+
for (let t = 0; t < 16; t++)
|
|
34
|
+
w[t] = view.getUint32(i + t * 4);
|
|
35
|
+
for (let t = 16; t < 64; t++) {
|
|
36
|
+
const a = w[t - 15];
|
|
37
|
+
const b = w[t - 2];
|
|
38
|
+
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
|
|
39
|
+
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
|
|
40
|
+
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) | 0;
|
|
41
|
+
}
|
|
42
|
+
let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
|
43
|
+
for (let t = 0; t < 64; t++) {
|
|
44
|
+
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
|
|
45
|
+
const ch = (e & f) ^ (~e & g);
|
|
46
|
+
const t1 = (hh + S1 + ch + K[t] + w[t]) | 0;
|
|
47
|
+
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
|
|
48
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
49
|
+
const t2 = (S0 + maj) | 0;
|
|
50
|
+
hh = g;
|
|
51
|
+
g = f;
|
|
52
|
+
f = e;
|
|
53
|
+
e = (d + t1) | 0;
|
|
54
|
+
d = c;
|
|
55
|
+
c = b;
|
|
56
|
+
b = a;
|
|
57
|
+
a = (t1 + t2) | 0;
|
|
58
|
+
}
|
|
59
|
+
h[0] = (h[0] + a) | 0;
|
|
60
|
+
h[1] = (h[1] + b) | 0;
|
|
61
|
+
h[2] = (h[2] + c) | 0;
|
|
62
|
+
h[3] = (h[3] + d) | 0;
|
|
63
|
+
h[4] = (h[4] + e) | 0;
|
|
64
|
+
h[5] = (h[5] + f) | 0;
|
|
65
|
+
h[6] = (h[6] + g) | 0;
|
|
66
|
+
h[7] = (h[7] + hh) | 0;
|
|
67
|
+
}
|
|
68
|
+
const out = new Uint8Array(32);
|
|
69
|
+
const ov = new DataView(out.buffer);
|
|
70
|
+
for (let i = 0; i < 8; i++)
|
|
71
|
+
ov.setUint32(i * 4, h[i]);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** HMAC-SHA256(key, concatenation of parts). */
|
|
75
|
+
export function hmacSha256(key, ...parts) {
|
|
76
|
+
const k = key.length > 64 ? sha256(key) : key;
|
|
77
|
+
const block = new Uint8Array(64);
|
|
78
|
+
block.set(k);
|
|
79
|
+
const ipad = new Uint8Array(64);
|
|
80
|
+
const opad = new Uint8Array(64);
|
|
81
|
+
for (let i = 0; i < 64; i++) {
|
|
82
|
+
ipad[i] = block[i] ^ 0x36;
|
|
83
|
+
opad[i] = block[i] ^ 0x5c;
|
|
84
|
+
}
|
|
85
|
+
return sha256(opad, sha256(ipad, ...parts));
|
|
86
|
+
}
|
|
87
|
+
const HEX = '0123456789abcdef';
|
|
88
|
+
export function toHex(b) {
|
|
89
|
+
let s = '';
|
|
90
|
+
for (const x of b)
|
|
91
|
+
s += HEX[x >> 4] + HEX[x & 15];
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Why a signature was rejected. */
|
|
2
|
+
export type SignatureError = 'missing' | 'malformed' | 'expired' | 'mismatch';
|
|
3
|
+
export interface VerifyOptions {
|
|
4
|
+
/** The receiver's clock: a Date or unix seconds. Default: now. */
|
|
5
|
+
now?: Date | number;
|
|
6
|
+
/** How far the signature's timestamp may be from now, both ways. Default 300. */
|
|
7
|
+
toleranceSeconds?: number;
|
|
8
|
+
}
|
|
9
|
+
export type RawBody = string | Uint8Array | ArrayBuffer;
|
|
10
|
+
/**
|
|
11
|
+
* Checks a Berth-Signature header (`t=<unix>,v1=<hex>`) against the raw request body and says
|
|
12
|
+
* why it fails, or null when it is valid. Synchronous; never throws. Several v1 values are
|
|
13
|
+
* accepted (one must match), so the secret can be rotated; unknown keys are ignored.
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkSignature(secret: string | Uint8Array, header: string | null | undefined, rawBody: RawBody, opts?: VerifyOptions): SignatureError | null;
|
|
16
|
+
/**
|
|
17
|
+
* Whether a Berth-Signature header is valid for the raw request body. Verify the bytes as they
|
|
18
|
+
* arrived, before any JSON parsing. Synchronous; never throws.
|
|
19
|
+
*/
|
|
20
|
+
export declare function verifySignature(secret: string | Uint8Array, header: string | null | undefined, rawBody: RawBody, opts?: VerifyOptions): boolean;
|