@roko-app/instant-admin 1.0.67-roko.2 → 1.0.67-roko.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/__tests__/src/alarms.test.ts +268 -0
- package/dist/commonjs/alarms.d.ts +138 -0
- package/dist/commonjs/alarms.d.ts.map +1 -0
- package/dist/commonjs/alarms.js +208 -0
- package/dist/commonjs/alarms.js.map +1 -0
- package/dist/commonjs/index.d.ts +4 -1
- package/dist/commonjs/index.d.ts.map +1 -1
- package/dist/commonjs/index.js +12 -1
- package/dist/commonjs/index.js.map +1 -1
- package/dist/esm/alarms.d.ts +138 -0
- package/dist/esm/alarms.d.ts.map +1 -0
- package/dist/esm/alarms.js +202 -0
- package/dist/esm/alarms.js.map +1 -0
- package/dist/esm/index.d.ts +4 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +11 -1
- package/dist/esm/index.js.map +1 -1
- package/package.json +4 -4
- package/src/alarms.ts +342 -0
- package/src/index.ts +28 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { Alarms, parseAlarmEvent, toAlarmTime } from '../../src/alarms';
|
|
3
|
+
|
|
4
|
+
type Call = { url: string; init: RequestInit | undefined };
|
|
5
|
+
|
|
6
|
+
const SERVER_ALARM = {
|
|
7
|
+
id: '11111111-1111-1111-1111-111111111111',
|
|
8
|
+
key: 'effort:1',
|
|
9
|
+
fireAt: '2026-11-13T10:00:00Z',
|
|
10
|
+
url: 'https://example.com/api/alarms',
|
|
11
|
+
payload: { taskId: '1' },
|
|
12
|
+
status: 'pending',
|
|
13
|
+
attempts: 0,
|
|
14
|
+
lastAttempt: null,
|
|
15
|
+
nextAttemptAfter: null,
|
|
16
|
+
firedAt: null,
|
|
17
|
+
createdAt: '2026-11-13T09:00:00Z',
|
|
18
|
+
updatedAt: '2026-11-13T09:00:00Z',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const EVENT_BODY = JSON.stringify({
|
|
22
|
+
type: 'alarm',
|
|
23
|
+
id: SERVER_ALARM.id,
|
|
24
|
+
appId: 'app-1',
|
|
25
|
+
key: 'effort:1',
|
|
26
|
+
fireAt: SERVER_ALARM.fireAt,
|
|
27
|
+
attempt: 2,
|
|
28
|
+
payload: { taskId: '1' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeAlarms(response: unknown) {
|
|
32
|
+
const calls: Call[] = [];
|
|
33
|
+
const verified: string[] = [];
|
|
34
|
+
const alarms = new Alarms({
|
|
35
|
+
apiURI: 'https://db.example.com',
|
|
36
|
+
appId: 'app-1',
|
|
37
|
+
headers: () => ({ authorization: 'Bearer admin' }),
|
|
38
|
+
jsonFetch: async (url, init) => {
|
|
39
|
+
calls.push({ url, init });
|
|
40
|
+
return response;
|
|
41
|
+
},
|
|
42
|
+
verifySignature: async (signatureHeader, body) => {
|
|
43
|
+
verified.push(signatureHeader);
|
|
44
|
+
if (signatureHeader === 'bad') {
|
|
45
|
+
throw new Error('Instant Signature did not validate');
|
|
46
|
+
}
|
|
47
|
+
return typeof body === 'function' ? body() : body;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
return { alarms, calls, verified };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('set', () => {
|
|
54
|
+
test('PUTs the alarm and returns the stored one', async () => {
|
|
55
|
+
const { alarms, calls } = makeAlarms({ alarm: SERVER_ALARM });
|
|
56
|
+
|
|
57
|
+
const alarm = await alarms.set({
|
|
58
|
+
key: 'effort:1',
|
|
59
|
+
fireAt: new Date('2026-11-13T10:00:00Z'),
|
|
60
|
+
url: 'https://example.com/api/alarms',
|
|
61
|
+
payload: { taskId: '1' },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(alarm).toEqual(SERVER_ALARM);
|
|
65
|
+
expect(calls).toHaveLength(1);
|
|
66
|
+
expect(calls[0].url).toBe(
|
|
67
|
+
'https://db.example.com/admin/alarms?app_id=app-1',
|
|
68
|
+
);
|
|
69
|
+
expect(calls[0].init?.method).toBe('PUT');
|
|
70
|
+
expect(calls[0].init?.headers).toEqual({ authorization: 'Bearer admin' });
|
|
71
|
+
expect(JSON.parse(String(calls[0].init?.body))).toEqual({
|
|
72
|
+
key: 'effort:1',
|
|
73
|
+
fireAt: Date.parse('2026-11-13T10:00:00Z'),
|
|
74
|
+
url: 'https://example.com/api/alarms',
|
|
75
|
+
payload: { taskId: '1' },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('sends a null payload when none is given', async () => {
|
|
80
|
+
const { alarms, calls } = makeAlarms({ alarm: SERVER_ALARM });
|
|
81
|
+
|
|
82
|
+
await alarms.set({ key: 'k', fireAt: 5, url: 'https://example.com/a' });
|
|
83
|
+
|
|
84
|
+
expect(JSON.parse(String(calls[0].init?.body))).toEqual({
|
|
85
|
+
key: 'k',
|
|
86
|
+
fireAt: 5,
|
|
87
|
+
url: 'https://example.com/a',
|
|
88
|
+
payload: null,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('rejects an answer that is not an alarm', async () => {
|
|
93
|
+
const { alarms } = makeAlarms({ alarm: { ...SERVER_ALARM, status: 'x' } });
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
alarms.set({ key: 'k', fireAt: 5, url: 'https://example.com/a' }),
|
|
97
|
+
).rejects.toThrow('Invalid alarm: unknown status.');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('get', () => {
|
|
102
|
+
test('asks for the key and returns the alarm', async () => {
|
|
103
|
+
const { alarms, calls } = makeAlarms({ alarm: SERVER_ALARM });
|
|
104
|
+
|
|
105
|
+
expect(await alarms.get('effort:1')).toEqual(SERVER_ALARM);
|
|
106
|
+
expect(calls[0].url).toBe(
|
|
107
|
+
'https://db.example.com/admin/alarms?app_id=app-1&key=effort%3A1',
|
|
108
|
+
);
|
|
109
|
+
expect(calls[0].init?.method).toBe('GET');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('returns null when there is no alarm', async () => {
|
|
113
|
+
const { alarms } = makeAlarms({ alarm: null });
|
|
114
|
+
|
|
115
|
+
expect(await alarms.get('nope')).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('list', () => {
|
|
120
|
+
test('passes the filters and returns the alarms', async () => {
|
|
121
|
+
const { alarms, calls } = makeAlarms({ alarms: [SERVER_ALARM] });
|
|
122
|
+
|
|
123
|
+
const list = await alarms.list({
|
|
124
|
+
status: 'pending',
|
|
125
|
+
limit: 10,
|
|
126
|
+
after: new Date('2026-11-13T09:00:00Z'),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(list).toEqual([SERVER_ALARM]);
|
|
130
|
+
const url = new URL(calls[0].url);
|
|
131
|
+
expect(url.pathname).toBe('/admin/alarms');
|
|
132
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
133
|
+
app_id: 'app-1',
|
|
134
|
+
status: 'pending',
|
|
135
|
+
limit: '10',
|
|
136
|
+
after: '2026-11-13T09:00:00.000Z',
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('sends no filter by default', async () => {
|
|
141
|
+
const { alarms, calls } = makeAlarms({ alarms: [] });
|
|
142
|
+
|
|
143
|
+
expect(await alarms.list()).toEqual([]);
|
|
144
|
+
expect(calls[0].url).toBe(
|
|
145
|
+
'https://db.example.com/admin/alarms?app_id=app-1',
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('delete', () => {
|
|
151
|
+
test('DELETEs the key and reports whether there was an alarm', async () => {
|
|
152
|
+
const deleted = makeAlarms({ deleted: true });
|
|
153
|
+
expect(await deleted.alarms.delete('effort:1')).toBe(true);
|
|
154
|
+
expect(deleted.calls[0].init?.method).toBe('DELETE');
|
|
155
|
+
expect(deleted.calls[0].url).toBe(
|
|
156
|
+
'https://db.example.com/admin/alarms?app_id=app-1&key=effort%3A1',
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const missing = makeAlarms({ deleted: false });
|
|
160
|
+
expect(await missing.alarms.delete('effort:1')).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('validate', () => {
|
|
165
|
+
test('returns the alarm of a verified body', async () => {
|
|
166
|
+
const { alarms, verified } = makeAlarms(null);
|
|
167
|
+
|
|
168
|
+
const event = await alarms.validate('sig', EVENT_BODY);
|
|
169
|
+
|
|
170
|
+
expect(verified).toEqual(['sig']);
|
|
171
|
+
expect(event).toEqual({
|
|
172
|
+
type: 'alarm',
|
|
173
|
+
id: SERVER_ALARM.id,
|
|
174
|
+
appId: 'app-1',
|
|
175
|
+
key: 'effort:1',
|
|
176
|
+
fireAt: SERVER_ALARM.fireAt,
|
|
177
|
+
attempt: 2,
|
|
178
|
+
payload: { taskId: '1' },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('throws when the signature is wrong', async () => {
|
|
183
|
+
const { alarms } = makeAlarms(null);
|
|
184
|
+
|
|
185
|
+
await expect(alarms.validate('bad', EVENT_BODY)).rejects.toThrow(
|
|
186
|
+
'Instant Signature did not validate',
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('throws when the alarm was set by another app', async () => {
|
|
191
|
+
const { alarms } = makeAlarms(null);
|
|
192
|
+
const foreign = JSON.stringify({
|
|
193
|
+
...JSON.parse(EVENT_BODY),
|
|
194
|
+
appId: 'app-2',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await expect(alarms.validate('sig', foreign)).rejects.toThrow(
|
|
198
|
+
'Alarm belongs to another app',
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('throws when a signed body is not an alarm', async () => {
|
|
203
|
+
const { alarms } = makeAlarms(null);
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
alarms.validate('sig', JSON.stringify({ payloadUrl: 'x', token: 'y' })),
|
|
207
|
+
).rejects.toThrow('Invalid alarm body');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('validateRequest', () => {
|
|
212
|
+
test('reads the header and the raw body from the request', async () => {
|
|
213
|
+
const { alarms, verified } = makeAlarms(null);
|
|
214
|
+
const req = new Request('https://example.com/api/alarms', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Instant-Signature': 'sig' },
|
|
217
|
+
body: EVENT_BODY,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const event = await alarms.validateRequest(req);
|
|
221
|
+
|
|
222
|
+
expect(verified).toEqual(['sig']);
|
|
223
|
+
expect(event.key).toBe('effort:1');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('throws when the header is missing', async () => {
|
|
227
|
+
const { alarms } = makeAlarms(null);
|
|
228
|
+
const req = new Request('https://example.com/api/alarms', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
body: EVENT_BODY,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await expect(alarms.validateRequest(req)).rejects.toThrow(
|
|
234
|
+
'Request is missing Instant-Signature header',
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('helpers', () => {
|
|
240
|
+
test('toAlarmTime turns a Date into milliseconds and keeps the rest', () => {
|
|
241
|
+
expect(toAlarmTime(new Date(5))).toBe(5);
|
|
242
|
+
expect(toAlarmTime(7)).toBe(7);
|
|
243
|
+
expect(toAlarmTime('2026-11-13T10:00:00Z')).toBe('2026-11-13T10:00:00Z');
|
|
244
|
+
expect(() => toAlarmTime(new Date('nope'))).toThrow('Invalid alarm time');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('parseAlarmEvent defaults a missing payload and attempt', () => {
|
|
248
|
+
expect(
|
|
249
|
+
parseAlarmEvent(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
type: 'alarm',
|
|
252
|
+
id: 'i',
|
|
253
|
+
appId: 'a',
|
|
254
|
+
key: 'k',
|
|
255
|
+
fireAt: 't',
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
).toEqual({
|
|
259
|
+
type: 'alarm',
|
|
260
|
+
id: 'i',
|
|
261
|
+
appId: 'a',
|
|
262
|
+
key: 'k',
|
|
263
|
+
fireAt: 't',
|
|
264
|
+
attempt: 1,
|
|
265
|
+
payload: null,
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Where an alarm is in its life. */
|
|
2
|
+
export type AlarmStatus =
|
|
3
|
+
/** Waits for its time. */
|
|
4
|
+
'pending'
|
|
5
|
+
/** Being delivered right now. */
|
|
6
|
+
| 'processing'
|
|
7
|
+
/** A delivery failed; it will be retried. */
|
|
8
|
+
| 'error'
|
|
9
|
+
/** Delivered. */
|
|
10
|
+
| 'fired'
|
|
11
|
+
/** Gave up after the last retry, or the endpoint answered 410 Gone. */
|
|
12
|
+
| 'failed';
|
|
13
|
+
/** The last delivery attempt of an alarm. */
|
|
14
|
+
export type AlarmAttempt = {
|
|
15
|
+
/** When the attempt was made (ISO 8601). */
|
|
16
|
+
at: string | null;
|
|
17
|
+
/** True if the endpoint answered with a 2xx status. */
|
|
18
|
+
success: boolean;
|
|
19
|
+
/** HTTP status of the answer, if there was one. */
|
|
20
|
+
statusCode: number | null;
|
|
21
|
+
/** Kind of failure when there was no answer, e.g. `timeout` or `dns`. */
|
|
22
|
+
errorType: string | null;
|
|
23
|
+
errorMessage: string | null;
|
|
24
|
+
};
|
|
25
|
+
/** An alarm as the server stores it. */
|
|
26
|
+
export type AlarmInfo = {
|
|
27
|
+
/** Changes every time the key is set again; also the `Idempotency-Key` of its deliveries. */
|
|
28
|
+
id: string;
|
|
29
|
+
/** Your name for the alarm, unique per app. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** When it fires (ISO 8601). */
|
|
32
|
+
fireAt: string;
|
|
33
|
+
/** The https url that is called. */
|
|
34
|
+
url: string;
|
|
35
|
+
/** Sent back to you, unchanged, when the alarm fires. */
|
|
36
|
+
payload: Record<string, unknown> | null;
|
|
37
|
+
status: AlarmStatus;
|
|
38
|
+
/** How many deliveries were attempted. */
|
|
39
|
+
attempts: number;
|
|
40
|
+
lastAttempt: AlarmAttempt | null;
|
|
41
|
+
/** When the next retry happens, while `status` is `error`. */
|
|
42
|
+
nextAttemptAfter: string | null;
|
|
43
|
+
firedAt: string | null;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
};
|
|
47
|
+
/** The body POSTed to the alarm's url when it fires. */
|
|
48
|
+
export type AlarmEvent = {
|
|
49
|
+
type: 'alarm';
|
|
50
|
+
id: string;
|
|
51
|
+
appId: string;
|
|
52
|
+
key: string;
|
|
53
|
+
/** The time the alarm was set for (ISO 8601). */
|
|
54
|
+
fireAt: string;
|
|
55
|
+
/** 1 for the first delivery, 2 for the first retry, and so on. */
|
|
56
|
+
attempt: number;
|
|
57
|
+
payload: Record<string, unknown> | null;
|
|
58
|
+
};
|
|
59
|
+
export type SetAlarmParams = {
|
|
60
|
+
/** Your name for the alarm. Setting the same key again replaces the alarm. */
|
|
61
|
+
key: string;
|
|
62
|
+
/** When to fire: a Date, milliseconds since the epoch, or an ISO 8601 string. A time in the past fires at once. */
|
|
63
|
+
fireAt: Date | number | string;
|
|
64
|
+
/** A public https url. It receives a POST signed with `Instant-Signature`. */
|
|
65
|
+
url: string;
|
|
66
|
+
/** A JSON object (64 KB at most) sent back to you when the alarm fires. */
|
|
67
|
+
payload?: Record<string, unknown> | null;
|
|
68
|
+
};
|
|
69
|
+
export type ListAlarmsParams = {
|
|
70
|
+
/** Keep one status only. */
|
|
71
|
+
status?: AlarmStatus;
|
|
72
|
+
/** Page size, 100 by default, 1000 at most. */
|
|
73
|
+
limit?: number;
|
|
74
|
+
/** Continue after this `fireAt` (the last alarm of the previous page). */
|
|
75
|
+
after?: Date | number | string;
|
|
76
|
+
};
|
|
77
|
+
export type AlarmValidateOpts = {
|
|
78
|
+
receivedAt?: Date | null;
|
|
79
|
+
/** Oldest signature accepted, in seconds. Default 300. */
|
|
80
|
+
tolerance?: number | null;
|
|
81
|
+
};
|
|
82
|
+
type AlarmsDeps = {
|
|
83
|
+
apiURI: string;
|
|
84
|
+
appId: string;
|
|
85
|
+
/** Builds the authorized request headers; throws without an admin token. */
|
|
86
|
+
headers: () => Record<string, string>;
|
|
87
|
+
jsonFetch: (input: string, init?: RequestInit) => Promise<unknown>;
|
|
88
|
+
/** Checks an `Instant-Signature` header and returns the verified body text. */
|
|
89
|
+
verifySignature: (signatureHeader: string, body: string | (() => Promise<string>), opts?: AlarmValidateOpts | null) => Promise<string>;
|
|
90
|
+
};
|
|
91
|
+
/** Reads a verified alarm body. Throws if it is not an alarm. */
|
|
92
|
+
export declare function parseAlarmEvent(bodyText: string): AlarmEvent;
|
|
93
|
+
/** Turns a Date / epoch milliseconds / ISO string into what the server takes. */
|
|
94
|
+
export declare function toAlarmTime(time: Date | number | string): number | string;
|
|
95
|
+
/**
|
|
96
|
+
* Alarms: ask the server to call your url at a given time.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* // When work starts, ask to be called if it is not done in one hour.
|
|
100
|
+
* await db.alarms.set({
|
|
101
|
+
* key: `effort:${taskId}`,
|
|
102
|
+
* fireAt: Date.now() + 60 * 60 * 1000,
|
|
103
|
+
* url: 'https://example.com/api/alarms',
|
|
104
|
+
* payload: { taskId },
|
|
105
|
+
* });
|
|
106
|
+
*
|
|
107
|
+
* // In the route behind that url.
|
|
108
|
+
* export async function POST(req: Request) {
|
|
109
|
+
* const alarm = await db.alarms.validateRequest(req);
|
|
110
|
+
* await pauseTask(alarm.payload);
|
|
111
|
+
* return new Response('ok');
|
|
112
|
+
* }
|
|
113
|
+
*/
|
|
114
|
+
export declare class Alarms {
|
|
115
|
+
#private;
|
|
116
|
+
constructor(deps: AlarmsDeps);
|
|
117
|
+
/**
|
|
118
|
+
* Creates the alarm for `key`, or replaces the one that exists. A replaced
|
|
119
|
+
* alarm starts over with a new id, even if it had already fired.
|
|
120
|
+
*/
|
|
121
|
+
set: (params: SetAlarmParams) => Promise<AlarmInfo>;
|
|
122
|
+
/** Returns the alarm for `key`, or null if there is none. */
|
|
123
|
+
get: (key: string) => Promise<AlarmInfo | null>;
|
|
124
|
+
/** Lists the app's alarms, soonest first. */
|
|
125
|
+
list: (params?: ListAlarmsParams) => Promise<AlarmInfo[]>;
|
|
126
|
+
/** Deletes the alarm for `key`. Returns false if there was none. */
|
|
127
|
+
delete: (key: string) => Promise<boolean>;
|
|
128
|
+
/**
|
|
129
|
+
* Verifies the `Instant-Signature` header of an alarm delivery against its
|
|
130
|
+
* raw body and returns the alarm. Throws if the signature is wrong or too
|
|
131
|
+
* old, if the body is not an alarm, or if the alarm belongs to another app.
|
|
132
|
+
*/
|
|
133
|
+
validate: (signatureHeader: string, body: string | (() => Promise<string>), opts?: AlarmValidateOpts | null) => Promise<AlarmEvent>;
|
|
134
|
+
/** Same as {@link validate}, reading the header and body from a `Request`. */
|
|
135
|
+
validateRequest: (req: Request, opts?: AlarmValidateOpts | null) => Promise<AlarmEvent>;
|
|
136
|
+
}
|
|
137
|
+
export {};
|
|
138
|
+
//# sourceMappingURL=alarms.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alarms.d.ts","sourceRoot":"","sources":["../../src/alarms.ts"],"names":[],"mappings":"AAKA,qCAAqC;AACrC,MAAM,MAAM,WAAW;AACrB,0BAA0B;AACxB,SAAS;AACX,iCAAiC;GAC/B,YAAY;AACd,6CAA6C;GAC3C,OAAO;AACT,iBAAiB;GACf,OAAO;AACT,uEAAuE;GACrE,QAAQ,CAAC;AAEb,6CAA6C;AAC7C,MAAM,MAAM,YAAY,GAAG;IACzB,4CAA4C;IAC5C,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,uDAAuD;IACvD,OAAO,EAAE,OAAO,CAAC;IACjB,mDAAmD;IACnD,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,yEAAyE;IACzE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B,CAAC;AAEF,wCAAwC;AACxC,MAAM,MAAM,SAAS,GAAG;IACtB,6FAA6F;IAC7F,EAAE,EAAE,MAAM,CAAC;IACX,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,MAAM,EAAE,WAAW,CAAC;IACpB,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,YAAY,GAAG,IAAI,CAAC;IACjC,8DAA8D;IAC9D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,OAAO,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;IACZ,mHAAmH;IACnH,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;IAC/B,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1C,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,4BAA4B;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,+CAA+C;IAC/C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,KAAK,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,UAAU,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAEF,KAAK,UAAU,GAAG;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,OAAO,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE,+EAA+E;IAC/E,eAAe,EAAE,CACf,eAAe,EAAE,MAAM,EACvB,IAAI,EAAE,MAAM,GAAG,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,EACtC,IAAI,CAAC,EAAE,iBAAiB,GAAG,IAAI,KAC5B,OAAO,CAAC,MAAM,CAAC,CAAC;CACtB,CAAC;AA0EF,iEAAiE;AACjE,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAgB5D;AAED,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CASzE;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,MAAM;;gBAGL,IAAI,EAAE,UAAU;IAS5B;;;OAGG;IACH,GAAG,GAAU,QAAQ,cAAc,KAAG,OAAO,CAAC,SAAS,CAAC,CAYtD;IAEF,6DAA6D;IAC7D,GAAG,GAAU,KAAK,MAAM,KAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAOlD;IAEF,6CAA6C;IAC7C,IAAI,GAAU,SAAQ,gBAAqB,KAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAmBhE;IAEF,oEAAoE;IACpE,MAAM,GAAU,KAAK,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC,CAM5C;IAEF;;;;OAIG;IACH,QAAQ,GACN,iBAAiB,MAAM,EACvB,MAAM,MAAM,GAAG,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,EACtC,OAAO,iBAAiB,GAAG,IAAI,KAC9B,OAAO,CAAC,UAAU,CAAC,CAapB;IAEF,8EAA8E;IAC9E,eAAe,GACb,KAAK,OAAO,EACZ,OAAO,iBAAiB,GAAG,IAAI,KAC9B,OAAO,CAAC,UAAU,CAAC,CAMpB;CACH"}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Roko DB: alarms. An alarm asks the server to POST a signed JSON body to a
|
|
3
|
+
// url at a given time. One alarm per key; setting a key again replaces it.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.Alarms = void 0;
|
|
6
|
+
exports.parseAlarmEvent = parseAlarmEvent;
|
|
7
|
+
exports.toAlarmTime = toAlarmTime;
|
|
8
|
+
// #endregion
|
|
9
|
+
// #region Parsing
|
|
10
|
+
const ALARM_STATUSES = [
|
|
11
|
+
'pending',
|
|
12
|
+
'processing',
|
|
13
|
+
'error',
|
|
14
|
+
'fired',
|
|
15
|
+
'failed',
|
|
16
|
+
];
|
|
17
|
+
function isRecord(x) {
|
|
18
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
19
|
+
}
|
|
20
|
+
function isAlarmStatus(x) {
|
|
21
|
+
return ALARM_STATUSES.some((status) => status === x);
|
|
22
|
+
}
|
|
23
|
+
function stringOrNull(x) {
|
|
24
|
+
return typeof x === 'string' ? x : null;
|
|
25
|
+
}
|
|
26
|
+
function numberOrNull(x) {
|
|
27
|
+
return typeof x === 'number' ? x : null;
|
|
28
|
+
}
|
|
29
|
+
function requiredString(source, field) {
|
|
30
|
+
const value = source[field];
|
|
31
|
+
if (typeof value !== 'string') {
|
|
32
|
+
throw new Error(`Invalid alarm: expected a string in \`${field}\`.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function parseAttempt(x) {
|
|
37
|
+
if (!isRecord(x)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
at: stringOrNull(x.at),
|
|
42
|
+
success: x.success === true,
|
|
43
|
+
statusCode: numberOrNull(x.statusCode),
|
|
44
|
+
errorType: stringOrNull(x.errorType),
|
|
45
|
+
errorMessage: stringOrNull(x.errorMessage),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function parseAlarm(x) {
|
|
49
|
+
if (!isRecord(x)) {
|
|
50
|
+
throw new Error('Invalid alarm: expected an object.');
|
|
51
|
+
}
|
|
52
|
+
if (!isAlarmStatus(x.status)) {
|
|
53
|
+
throw new Error('Invalid alarm: unknown status.');
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
id: requiredString(x, 'id'),
|
|
57
|
+
key: requiredString(x, 'key'),
|
|
58
|
+
fireAt: requiredString(x, 'fireAt'),
|
|
59
|
+
url: requiredString(x, 'url'),
|
|
60
|
+
payload: isRecord(x.payload) ? x.payload : null,
|
|
61
|
+
status: x.status,
|
|
62
|
+
attempts: numberOrNull(x.attempts) ?? 0,
|
|
63
|
+
lastAttempt: parseAttempt(x.lastAttempt),
|
|
64
|
+
nextAttemptAfter: stringOrNull(x.nextAttemptAfter),
|
|
65
|
+
firedAt: stringOrNull(x.firedAt),
|
|
66
|
+
createdAt: requiredString(x, 'createdAt'),
|
|
67
|
+
updatedAt: requiredString(x, 'updatedAt'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Reads a verified alarm body. Throws if it is not an alarm. */
|
|
71
|
+
function parseAlarmEvent(bodyText) {
|
|
72
|
+
const body = JSON.parse(bodyText);
|
|
73
|
+
if (!isRecord(body) || body.type !== 'alarm') {
|
|
74
|
+
throw new Error('Invalid alarm body, expected an object with type "alarm".');
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
type: 'alarm',
|
|
78
|
+
id: requiredString(body, 'id'),
|
|
79
|
+
appId: requiredString(body, 'appId'),
|
|
80
|
+
key: requiredString(body, 'key'),
|
|
81
|
+
fireAt: requiredString(body, 'fireAt'),
|
|
82
|
+
attempt: numberOrNull(body.attempt) ?? 1,
|
|
83
|
+
payload: isRecord(body.payload) ? body.payload : null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Turns a Date / epoch milliseconds / ISO string into what the server takes. */
|
|
87
|
+
function toAlarmTime(time) {
|
|
88
|
+
if (time instanceof Date) {
|
|
89
|
+
const ms = time.getTime();
|
|
90
|
+
if (Number.isNaN(ms)) {
|
|
91
|
+
throw new Error('Invalid alarm time: the Date is not valid.');
|
|
92
|
+
}
|
|
93
|
+
return ms;
|
|
94
|
+
}
|
|
95
|
+
return time;
|
|
96
|
+
}
|
|
97
|
+
// #endregion
|
|
98
|
+
// #region Alarms
|
|
99
|
+
/**
|
|
100
|
+
* Alarms: ask the server to call your url at a given time.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* // When work starts, ask to be called if it is not done in one hour.
|
|
104
|
+
* await db.alarms.set({
|
|
105
|
+
* key: `effort:${taskId}`,
|
|
106
|
+
* fireAt: Date.now() + 60 * 60 * 1000,
|
|
107
|
+
* url: 'https://example.com/api/alarms',
|
|
108
|
+
* payload: { taskId },
|
|
109
|
+
* });
|
|
110
|
+
*
|
|
111
|
+
* // In the route behind that url.
|
|
112
|
+
* export async function POST(req: Request) {
|
|
113
|
+
* const alarm = await db.alarms.validateRequest(req);
|
|
114
|
+
* await pauseTask(alarm.payload);
|
|
115
|
+
* return new Response('ok');
|
|
116
|
+
* }
|
|
117
|
+
*/
|
|
118
|
+
class Alarms {
|
|
119
|
+
#deps;
|
|
120
|
+
constructor(deps) {
|
|
121
|
+
this.#deps = deps;
|
|
122
|
+
}
|
|
123
|
+
#url(query) {
|
|
124
|
+
const qs = new URLSearchParams({ app_id: this.#deps.appId, ...query });
|
|
125
|
+
return `${this.#deps.apiURI}/admin/alarms?${qs.toString()}`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Creates the alarm for `key`, or replaces the one that exists. A replaced
|
|
129
|
+
* alarm starts over with a new id, even if it had already fired.
|
|
130
|
+
*/
|
|
131
|
+
set = async (params) => {
|
|
132
|
+
const res = await this.#deps.jsonFetch(this.#url(), {
|
|
133
|
+
method: 'PUT',
|
|
134
|
+
headers: this.#deps.headers(),
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
key: params.key,
|
|
137
|
+
fireAt: toAlarmTime(params.fireAt),
|
|
138
|
+
url: params.url,
|
|
139
|
+
payload: params.payload ?? null,
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
return parseAlarm(isRecord(res) ? res.alarm : null);
|
|
143
|
+
};
|
|
144
|
+
/** Returns the alarm for `key`, or null if there is none. */
|
|
145
|
+
get = async (key) => {
|
|
146
|
+
const res = await this.#deps.jsonFetch(this.#url({ key }), {
|
|
147
|
+
method: 'GET',
|
|
148
|
+
headers: this.#deps.headers(),
|
|
149
|
+
});
|
|
150
|
+
const alarm = isRecord(res) ? res.alarm : null;
|
|
151
|
+
return alarm == null ? null : parseAlarm(alarm);
|
|
152
|
+
};
|
|
153
|
+
/** Lists the app's alarms, soonest first. */
|
|
154
|
+
list = async (params = {}) => {
|
|
155
|
+
const query = {};
|
|
156
|
+
if (params.status) {
|
|
157
|
+
query.status = params.status;
|
|
158
|
+
}
|
|
159
|
+
if (params.limit !== undefined) {
|
|
160
|
+
query.limit = String(params.limit);
|
|
161
|
+
}
|
|
162
|
+
if (params.after !== undefined) {
|
|
163
|
+
const after = toAlarmTime(params.after);
|
|
164
|
+
query.after =
|
|
165
|
+
typeof after === 'number' ? new Date(after).toISOString() : after;
|
|
166
|
+
}
|
|
167
|
+
const res = await this.#deps.jsonFetch(this.#url(query), {
|
|
168
|
+
method: 'GET',
|
|
169
|
+
headers: this.#deps.headers(),
|
|
170
|
+
});
|
|
171
|
+
const alarms = isRecord(res) ? res.alarms : null;
|
|
172
|
+
return Array.isArray(alarms) ? alarms.map(parseAlarm) : [];
|
|
173
|
+
};
|
|
174
|
+
/** Deletes the alarm for `key`. Returns false if there was none. */
|
|
175
|
+
delete = async (key) => {
|
|
176
|
+
const res = await this.#deps.jsonFetch(this.#url({ key }), {
|
|
177
|
+
method: 'DELETE',
|
|
178
|
+
headers: this.#deps.headers(),
|
|
179
|
+
});
|
|
180
|
+
return isRecord(res) && res.deleted === true;
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Verifies the `Instant-Signature` header of an alarm delivery against its
|
|
184
|
+
* raw body and returns the alarm. Throws if the signature is wrong or too
|
|
185
|
+
* old, if the body is not an alarm, or if the alarm belongs to another app.
|
|
186
|
+
*/
|
|
187
|
+
validate = async (signatureHeader, body, opts) => {
|
|
188
|
+
const bodyText = await this.#deps.verifySignature(signatureHeader, body, opts);
|
|
189
|
+
const event = parseAlarmEvent(bodyText);
|
|
190
|
+
// The signing key is the server's, shared by every app on it: without
|
|
191
|
+
// this check another app could aim its own alarm at this endpoint.
|
|
192
|
+
if (event.appId !== this.#deps.appId) {
|
|
193
|
+
throw new Error('Alarm belongs to another app');
|
|
194
|
+
}
|
|
195
|
+
return event;
|
|
196
|
+
};
|
|
197
|
+
/** Same as {@link validate}, reading the header and body from a `Request`. */
|
|
198
|
+
validateRequest = async (req, opts) => {
|
|
199
|
+
const signatureHeader = req.headers.get('instant-signature');
|
|
200
|
+
if (!signatureHeader) {
|
|
201
|
+
throw new Error('Request is missing Instant-Signature header');
|
|
202
|
+
}
|
|
203
|
+
return this.validate(signatureHeader, () => req.text(), opts);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
exports.Alarms = Alarms;
|
|
207
|
+
// #endregion
|
|
208
|
+
//# sourceMappingURL=alarms.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alarms.js","sourceRoot":"","sources":["../../src/alarms.ts"],"names":[],"mappings":"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","sourcesContent":["// Roko DB: alarms. An alarm asks the server to POST a signed JSON body to a\n// url at a given time. One alarm per key; setting a key again replaces it.\n\n// #region Types\n\n/** Where an alarm is in its life. */\nexport type AlarmStatus =\n /** Waits for its time. */\n | 'pending'\n /** Being delivered right now. */\n | 'processing'\n /** A delivery failed; it will be retried. */\n | 'error'\n /** Delivered. */\n | 'fired'\n /** Gave up after the last retry, or the endpoint answered 410 Gone. */\n | 'failed';\n\n/** The last delivery attempt of an alarm. */\nexport type AlarmAttempt = {\n /** When the attempt was made (ISO 8601). */\n at: string | null;\n /** True if the endpoint answered with a 2xx status. */\n success: boolean;\n /** HTTP status of the answer, if there was one. */\n statusCode: number | null;\n /** Kind of failure when there was no answer, e.g. `timeout` or `dns`. */\n errorType: string | null;\n errorMessage: string | null;\n};\n\n/** An alarm as the server stores it. */\nexport type AlarmInfo = {\n /** Changes every time the key is set again; also the `Idempotency-Key` of its deliveries. */\n id: string;\n /** Your name for the alarm, unique per app. */\n key: string;\n /** When it fires (ISO 8601). */\n fireAt: string;\n /** The https url that is called. */\n url: string;\n /** Sent back to you, unchanged, when the alarm fires. */\n payload: Record<string, unknown> | null;\n status: AlarmStatus;\n /** How many deliveries were attempted. */\n attempts: number;\n lastAttempt: AlarmAttempt | null;\n /** When the next retry happens, while `status` is `error`. */\n nextAttemptAfter: string | null;\n firedAt: string | null;\n createdAt: string;\n updatedAt: string;\n};\n\n/** The body POSTed to the alarm's url when it fires. */\nexport type AlarmEvent = {\n type: 'alarm';\n id: string;\n appId: string;\n key: string;\n /** The time the alarm was set for (ISO 8601). */\n fireAt: string;\n /** 1 for the first delivery, 2 for the first retry, and so on. */\n attempt: number;\n payload: Record<string, unknown> | null;\n};\n\nexport type SetAlarmParams = {\n /** Your name for the alarm. Setting the same key again replaces the alarm. */\n key: string;\n /** When to fire: a Date, milliseconds since the epoch, or an ISO 8601 string. A time in the past fires at once. */\n fireAt: Date | number | string;\n /** A public https url. It receives a POST signed with `Instant-Signature`. */\n url: string;\n /** A JSON object (64 KB at most) sent back to you when the alarm fires. */\n payload?: Record<string, unknown> | null;\n};\n\nexport type ListAlarmsParams = {\n /** Keep one status only. */\n status?: AlarmStatus;\n /** Page size, 100 by default, 1000 at most. */\n limit?: number;\n /** Continue after this `fireAt` (the last alarm of the previous page). */\n after?: Date | number | string;\n};\n\nexport type AlarmValidateOpts = {\n receivedAt?: Date | null;\n /** Oldest signature accepted, in seconds. Default 300. */\n tolerance?: number | null;\n};\n\ntype AlarmsDeps = {\n apiURI: string;\n appId: string;\n /** Builds the authorized request headers; throws without an admin token. */\n headers: () => Record<string, string>;\n jsonFetch: (input: string, init?: RequestInit) => Promise<unknown>;\n /** Checks an `Instant-Signature` header and returns the verified body text. */\n verifySignature: (\n signatureHeader: string,\n body: string | (() => Promise<string>),\n opts?: AlarmValidateOpts | null,\n ) => Promise<string>;\n};\n\n// #endregion\n\n// #region Parsing\n\nconst ALARM_STATUSES: readonly AlarmStatus[] = [\n 'pending',\n 'processing',\n 'error',\n 'fired',\n 'failed',\n];\n\nfunction isRecord(x: unknown): x is Record<string, unknown> {\n return typeof x === 'object' && x !== null && !Array.isArray(x);\n}\n\nfunction isAlarmStatus(x: unknown): x is AlarmStatus {\n return ALARM_STATUSES.some((status) => status === x);\n}\n\nfunction stringOrNull(x: unknown): string | null {\n return typeof x === 'string' ? x : null;\n}\n\nfunction numberOrNull(x: unknown): number | null {\n return typeof x === 'number' ? x : null;\n}\n\nfunction requiredString(source: Record<string, unknown>, field: string) {\n const value = source[field];\n if (typeof value !== 'string') {\n throw new Error(`Invalid alarm: expected a string in \\`${field}\\`.`);\n }\n return value;\n}\n\nfunction parseAttempt(x: unknown): AlarmAttempt | null {\n if (!isRecord(x)) {\n return null;\n }\n return {\n at: stringOrNull(x.at),\n success: x.success === true,\n statusCode: numberOrNull(x.statusCode),\n errorType: stringOrNull(x.errorType),\n errorMessage: stringOrNull(x.errorMessage),\n };\n}\n\nfunction parseAlarm(x: unknown): AlarmInfo {\n if (!isRecord(x)) {\n throw new Error('Invalid alarm: expected an object.');\n }\n if (!isAlarmStatus(x.status)) {\n throw new Error('Invalid alarm: unknown status.');\n }\n return {\n id: requiredString(x, 'id'),\n key: requiredString(x, 'key'),\n fireAt: requiredString(x, 'fireAt'),\n url: requiredString(x, 'url'),\n payload: isRecord(x.payload) ? x.payload : null,\n status: x.status,\n attempts: numberOrNull(x.attempts) ?? 0,\n lastAttempt: parseAttempt(x.lastAttempt),\n nextAttemptAfter: stringOrNull(x.nextAttemptAfter),\n firedAt: stringOrNull(x.firedAt),\n createdAt: requiredString(x, 'createdAt'),\n updatedAt: requiredString(x, 'updatedAt'),\n };\n}\n\n/** Reads a verified alarm body. Throws if it is not an alarm. */\nexport function parseAlarmEvent(bodyText: string): AlarmEvent {\n const body: unknown = JSON.parse(bodyText);\n if (!isRecord(body) || body.type !== 'alarm') {\n throw new Error(\n 'Invalid alarm body, expected an object with type \"alarm\".',\n );\n }\n return {\n type: 'alarm',\n id: requiredString(body, 'id'),\n appId: requiredString(body, 'appId'),\n key: requiredString(body, 'key'),\n fireAt: requiredString(body, 'fireAt'),\n attempt: numberOrNull(body.attempt) ?? 1,\n payload: isRecord(body.payload) ? body.payload : null,\n };\n}\n\n/** Turns a Date / epoch milliseconds / ISO string into what the server takes. */\nexport function toAlarmTime(time: Date | number | string): number | string {\n if (time instanceof Date) {\n const ms = time.getTime();\n if (Number.isNaN(ms)) {\n throw new Error('Invalid alarm time: the Date is not valid.');\n }\n return ms;\n }\n return time;\n}\n\n// #endregion\n\n// #region Alarms\n\n/**\n * Alarms: ask the server to call your url at a given time.\n *\n * @example\n * // When work starts, ask to be called if it is not done in one hour.\n * await db.alarms.set({\n * key: `effort:${taskId}`,\n * fireAt: Date.now() + 60 * 60 * 1000,\n * url: 'https://example.com/api/alarms',\n * payload: { taskId },\n * });\n *\n * // In the route behind that url.\n * export async function POST(req: Request) {\n * const alarm = await db.alarms.validateRequest(req);\n * await pauseTask(alarm.payload);\n * return new Response('ok');\n * }\n */\nexport class Alarms {\n #deps: AlarmsDeps;\n\n constructor(deps: AlarmsDeps) {\n this.#deps = deps;\n }\n\n #url(query?: Record<string, string>): string {\n const qs = new URLSearchParams({ app_id: this.#deps.appId, ...query });\n return `${this.#deps.apiURI}/admin/alarms?${qs.toString()}`;\n }\n\n /**\n * Creates the alarm for `key`, or replaces the one that exists. A replaced\n * alarm starts over with a new id, even if it had already fired.\n */\n set = async (params: SetAlarmParams): Promise<AlarmInfo> => {\n const res = await this.#deps.jsonFetch(this.#url(), {\n method: 'PUT',\n headers: this.#deps.headers(),\n body: JSON.stringify({\n key: params.key,\n fireAt: toAlarmTime(params.fireAt),\n url: params.url,\n payload: params.payload ?? null,\n }),\n });\n return parseAlarm(isRecord(res) ? res.alarm : null);\n };\n\n /** Returns the alarm for `key`, or null if there is none. */\n get = async (key: string): Promise<AlarmInfo | null> => {\n const res = await this.#deps.jsonFetch(this.#url({ key }), {\n method: 'GET',\n headers: this.#deps.headers(),\n });\n const alarm = isRecord(res) ? res.alarm : null;\n return alarm == null ? null : parseAlarm(alarm);\n };\n\n /** Lists the app's alarms, soonest first. */\n list = async (params: ListAlarmsParams = {}): Promise<AlarmInfo[]> => {\n const query: Record<string, string> = {};\n if (params.status) {\n query.status = params.status;\n }\n if (params.limit !== undefined) {\n query.limit = String(params.limit);\n }\n if (params.after !== undefined) {\n const after = toAlarmTime(params.after);\n query.after =\n typeof after === 'number' ? new Date(after).toISOString() : after;\n }\n const res = await this.#deps.jsonFetch(this.#url(query), {\n method: 'GET',\n headers: this.#deps.headers(),\n });\n const alarms = isRecord(res) ? res.alarms : null;\n return Array.isArray(alarms) ? alarms.map(parseAlarm) : [];\n };\n\n /** Deletes the alarm for `key`. Returns false if there was none. */\n delete = async (key: string): Promise<boolean> => {\n const res = await this.#deps.jsonFetch(this.#url({ key }), {\n method: 'DELETE',\n headers: this.#deps.headers(),\n });\n return isRecord(res) && res.deleted === true;\n };\n\n /**\n * Verifies the `Instant-Signature` header of an alarm delivery against its\n * raw body and returns the alarm. Throws if the signature is wrong or too\n * old, if the body is not an alarm, or if the alarm belongs to another app.\n */\n validate = async (\n signatureHeader: string,\n body: string | (() => Promise<string>),\n opts?: AlarmValidateOpts | null,\n ): Promise<AlarmEvent> => {\n const bodyText = await this.#deps.verifySignature(\n signatureHeader,\n body,\n opts,\n );\n const event = parseAlarmEvent(bodyText);\n // The signing key is the server's, shared by every app on it: without\n // this check another app could aim its own alarm at this endpoint.\n if (event.appId !== this.#deps.appId) {\n throw new Error('Alarm belongs to another app');\n }\n return event;\n };\n\n /** Same as {@link validate}, reading the header and body from a `Request`. */\n validateRequest = async (\n req: Request,\n opts?: AlarmValidateOpts | null,\n ): Promise<AlarmEvent> => {\n const signatureHeader = req.headers.get('instant-signature');\n if (!signatureHeader) {\n throw new Error('Request is missing Instant-Signature header');\n }\n return this.validate(signatureHeader, () => req.text(), opts);\n };\n}\n\n// #endregion\n"]}
|