@roko-app/instant-admin 1.0.67-roko.1 → 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
package/src/alarms.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Roko DB: alarms. An alarm asks the server to POST a signed JSON body to a
|
|
2
|
+
// url at a given time. One alarm per key; setting a key again replaces it.
|
|
3
|
+
|
|
4
|
+
// #region Types
|
|
5
|
+
|
|
6
|
+
/** Where an alarm is in its life. */
|
|
7
|
+
export type AlarmStatus =
|
|
8
|
+
/** Waits for its time. */
|
|
9
|
+
| 'pending'
|
|
10
|
+
/** Being delivered right now. */
|
|
11
|
+
| 'processing'
|
|
12
|
+
/** A delivery failed; it will be retried. */
|
|
13
|
+
| 'error'
|
|
14
|
+
/** Delivered. */
|
|
15
|
+
| 'fired'
|
|
16
|
+
/** Gave up after the last retry, or the endpoint answered 410 Gone. */
|
|
17
|
+
| 'failed';
|
|
18
|
+
|
|
19
|
+
/** The last delivery attempt of an alarm. */
|
|
20
|
+
export type AlarmAttempt = {
|
|
21
|
+
/** When the attempt was made (ISO 8601). */
|
|
22
|
+
at: string | null;
|
|
23
|
+
/** True if the endpoint answered with a 2xx status. */
|
|
24
|
+
success: boolean;
|
|
25
|
+
/** HTTP status of the answer, if there was one. */
|
|
26
|
+
statusCode: number | null;
|
|
27
|
+
/** Kind of failure when there was no answer, e.g. `timeout` or `dns`. */
|
|
28
|
+
errorType: string | null;
|
|
29
|
+
errorMessage: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** An alarm as the server stores it. */
|
|
33
|
+
export type AlarmInfo = {
|
|
34
|
+
/** Changes every time the key is set again; also the `Idempotency-Key` of its deliveries. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Your name for the alarm, unique per app. */
|
|
37
|
+
key: string;
|
|
38
|
+
/** When it fires (ISO 8601). */
|
|
39
|
+
fireAt: string;
|
|
40
|
+
/** The https url that is called. */
|
|
41
|
+
url: string;
|
|
42
|
+
/** Sent back to you, unchanged, when the alarm fires. */
|
|
43
|
+
payload: Record<string, unknown> | null;
|
|
44
|
+
status: AlarmStatus;
|
|
45
|
+
/** How many deliveries were attempted. */
|
|
46
|
+
attempts: number;
|
|
47
|
+
lastAttempt: AlarmAttempt | null;
|
|
48
|
+
/** When the next retry happens, while `status` is `error`. */
|
|
49
|
+
nextAttemptAfter: string | null;
|
|
50
|
+
firedAt: string | null;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The body POSTed to the alarm's url when it fires. */
|
|
56
|
+
export type AlarmEvent = {
|
|
57
|
+
type: 'alarm';
|
|
58
|
+
id: string;
|
|
59
|
+
appId: string;
|
|
60
|
+
key: string;
|
|
61
|
+
/** The time the alarm was set for (ISO 8601). */
|
|
62
|
+
fireAt: string;
|
|
63
|
+
/** 1 for the first delivery, 2 for the first retry, and so on. */
|
|
64
|
+
attempt: number;
|
|
65
|
+
payload: Record<string, unknown> | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type SetAlarmParams = {
|
|
69
|
+
/** Your name for the alarm. Setting the same key again replaces the alarm. */
|
|
70
|
+
key: string;
|
|
71
|
+
/** When to fire: a Date, milliseconds since the epoch, or an ISO 8601 string. A time in the past fires at once. */
|
|
72
|
+
fireAt: Date | number | string;
|
|
73
|
+
/** A public https url. It receives a POST signed with `Instant-Signature`. */
|
|
74
|
+
url: string;
|
|
75
|
+
/** A JSON object (64 KB at most) sent back to you when the alarm fires. */
|
|
76
|
+
payload?: Record<string, unknown> | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type ListAlarmsParams = {
|
|
80
|
+
/** Keep one status only. */
|
|
81
|
+
status?: AlarmStatus;
|
|
82
|
+
/** Page size, 100 by default, 1000 at most. */
|
|
83
|
+
limit?: number;
|
|
84
|
+
/** Continue after this `fireAt` (the last alarm of the previous page). */
|
|
85
|
+
after?: Date | number | string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type AlarmValidateOpts = {
|
|
89
|
+
receivedAt?: Date | null;
|
|
90
|
+
/** Oldest signature accepted, in seconds. Default 300. */
|
|
91
|
+
tolerance?: number | null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type AlarmsDeps = {
|
|
95
|
+
apiURI: string;
|
|
96
|
+
appId: string;
|
|
97
|
+
/** Builds the authorized request headers; throws without an admin token. */
|
|
98
|
+
headers: () => Record<string, string>;
|
|
99
|
+
jsonFetch: (input: string, init?: RequestInit) => Promise<unknown>;
|
|
100
|
+
/** Checks an `Instant-Signature` header and returns the verified body text. */
|
|
101
|
+
verifySignature: (
|
|
102
|
+
signatureHeader: string,
|
|
103
|
+
body: string | (() => Promise<string>),
|
|
104
|
+
opts?: AlarmValidateOpts | null,
|
|
105
|
+
) => Promise<string>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// #endregion
|
|
109
|
+
|
|
110
|
+
// #region Parsing
|
|
111
|
+
|
|
112
|
+
const ALARM_STATUSES: readonly AlarmStatus[] = [
|
|
113
|
+
'pending',
|
|
114
|
+
'processing',
|
|
115
|
+
'error',
|
|
116
|
+
'fired',
|
|
117
|
+
'failed',
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
121
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isAlarmStatus(x: unknown): x is AlarmStatus {
|
|
125
|
+
return ALARM_STATUSES.some((status) => status === x);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stringOrNull(x: unknown): string | null {
|
|
129
|
+
return typeof x === 'string' ? x : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function numberOrNull(x: unknown): number | null {
|
|
133
|
+
return typeof x === 'number' ? x : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requiredString(source: Record<string, unknown>, field: string) {
|
|
137
|
+
const value = source[field];
|
|
138
|
+
if (typeof value !== 'string') {
|
|
139
|
+
throw new Error(`Invalid alarm: expected a string in \`${field}\`.`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseAttempt(x: unknown): AlarmAttempt | null {
|
|
145
|
+
if (!isRecord(x)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
at: stringOrNull(x.at),
|
|
150
|
+
success: x.success === true,
|
|
151
|
+
statusCode: numberOrNull(x.statusCode),
|
|
152
|
+
errorType: stringOrNull(x.errorType),
|
|
153
|
+
errorMessage: stringOrNull(x.errorMessage),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseAlarm(x: unknown): AlarmInfo {
|
|
158
|
+
if (!isRecord(x)) {
|
|
159
|
+
throw new Error('Invalid alarm: expected an object.');
|
|
160
|
+
}
|
|
161
|
+
if (!isAlarmStatus(x.status)) {
|
|
162
|
+
throw new Error('Invalid alarm: unknown status.');
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
id: requiredString(x, 'id'),
|
|
166
|
+
key: requiredString(x, 'key'),
|
|
167
|
+
fireAt: requiredString(x, 'fireAt'),
|
|
168
|
+
url: requiredString(x, 'url'),
|
|
169
|
+
payload: isRecord(x.payload) ? x.payload : null,
|
|
170
|
+
status: x.status,
|
|
171
|
+
attempts: numberOrNull(x.attempts) ?? 0,
|
|
172
|
+
lastAttempt: parseAttempt(x.lastAttempt),
|
|
173
|
+
nextAttemptAfter: stringOrNull(x.nextAttemptAfter),
|
|
174
|
+
firedAt: stringOrNull(x.firedAt),
|
|
175
|
+
createdAt: requiredString(x, 'createdAt'),
|
|
176
|
+
updatedAt: requiredString(x, 'updatedAt'),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Reads a verified alarm body. Throws if it is not an alarm. */
|
|
181
|
+
export function parseAlarmEvent(bodyText: string): AlarmEvent {
|
|
182
|
+
const body: unknown = JSON.parse(bodyText);
|
|
183
|
+
if (!isRecord(body) || body.type !== 'alarm') {
|
|
184
|
+
throw new Error(
|
|
185
|
+
'Invalid alarm body, expected an object with type "alarm".',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
type: 'alarm',
|
|
190
|
+
id: requiredString(body, 'id'),
|
|
191
|
+
appId: requiredString(body, 'appId'),
|
|
192
|
+
key: requiredString(body, 'key'),
|
|
193
|
+
fireAt: requiredString(body, 'fireAt'),
|
|
194
|
+
attempt: numberOrNull(body.attempt) ?? 1,
|
|
195
|
+
payload: isRecord(body.payload) ? body.payload : null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Turns a Date / epoch milliseconds / ISO string into what the server takes. */
|
|
200
|
+
export function toAlarmTime(time: Date | number | string): number | string {
|
|
201
|
+
if (time instanceof Date) {
|
|
202
|
+
const ms = time.getTime();
|
|
203
|
+
if (Number.isNaN(ms)) {
|
|
204
|
+
throw new Error('Invalid alarm time: the Date is not valid.');
|
|
205
|
+
}
|
|
206
|
+
return ms;
|
|
207
|
+
}
|
|
208
|
+
return time;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// #endregion
|
|
212
|
+
|
|
213
|
+
// #region Alarms
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Alarms: ask the server to call your url at a given time.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* // When work starts, ask to be called if it is not done in one hour.
|
|
220
|
+
* await db.alarms.set({
|
|
221
|
+
* key: `effort:${taskId}`,
|
|
222
|
+
* fireAt: Date.now() + 60 * 60 * 1000,
|
|
223
|
+
* url: 'https://example.com/api/alarms',
|
|
224
|
+
* payload: { taskId },
|
|
225
|
+
* });
|
|
226
|
+
*
|
|
227
|
+
* // In the route behind that url.
|
|
228
|
+
* export async function POST(req: Request) {
|
|
229
|
+
* const alarm = await db.alarms.validateRequest(req);
|
|
230
|
+
* await pauseTask(alarm.payload);
|
|
231
|
+
* return new Response('ok');
|
|
232
|
+
* }
|
|
233
|
+
*/
|
|
234
|
+
export class Alarms {
|
|
235
|
+
#deps: AlarmsDeps;
|
|
236
|
+
|
|
237
|
+
constructor(deps: AlarmsDeps) {
|
|
238
|
+
this.#deps = deps;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#url(query?: Record<string, string>): string {
|
|
242
|
+
const qs = new URLSearchParams({ app_id: this.#deps.appId, ...query });
|
|
243
|
+
return `${this.#deps.apiURI}/admin/alarms?${qs.toString()}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Creates the alarm for `key`, or replaces the one that exists. A replaced
|
|
248
|
+
* alarm starts over with a new id, even if it had already fired.
|
|
249
|
+
*/
|
|
250
|
+
set = async (params: SetAlarmParams): Promise<AlarmInfo> => {
|
|
251
|
+
const res = await this.#deps.jsonFetch(this.#url(), {
|
|
252
|
+
method: 'PUT',
|
|
253
|
+
headers: this.#deps.headers(),
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
key: params.key,
|
|
256
|
+
fireAt: toAlarmTime(params.fireAt),
|
|
257
|
+
url: params.url,
|
|
258
|
+
payload: params.payload ?? null,
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
return parseAlarm(isRecord(res) ? res.alarm : null);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/** Returns the alarm for `key`, or null if there is none. */
|
|
265
|
+
get = async (key: string): Promise<AlarmInfo | null> => {
|
|
266
|
+
const res = await this.#deps.jsonFetch(this.#url({ key }), {
|
|
267
|
+
method: 'GET',
|
|
268
|
+
headers: this.#deps.headers(),
|
|
269
|
+
});
|
|
270
|
+
const alarm = isRecord(res) ? res.alarm : null;
|
|
271
|
+
return alarm == null ? null : parseAlarm(alarm);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** Lists the app's alarms, soonest first. */
|
|
275
|
+
list = async (params: ListAlarmsParams = {}): Promise<AlarmInfo[]> => {
|
|
276
|
+
const query: Record<string, string> = {};
|
|
277
|
+
if (params.status) {
|
|
278
|
+
query.status = params.status;
|
|
279
|
+
}
|
|
280
|
+
if (params.limit !== undefined) {
|
|
281
|
+
query.limit = String(params.limit);
|
|
282
|
+
}
|
|
283
|
+
if (params.after !== undefined) {
|
|
284
|
+
const after = toAlarmTime(params.after);
|
|
285
|
+
query.after =
|
|
286
|
+
typeof after === 'number' ? new Date(after).toISOString() : after;
|
|
287
|
+
}
|
|
288
|
+
const res = await this.#deps.jsonFetch(this.#url(query), {
|
|
289
|
+
method: 'GET',
|
|
290
|
+
headers: this.#deps.headers(),
|
|
291
|
+
});
|
|
292
|
+
const alarms = isRecord(res) ? res.alarms : null;
|
|
293
|
+
return Array.isArray(alarms) ? alarms.map(parseAlarm) : [];
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/** Deletes the alarm for `key`. Returns false if there was none. */
|
|
297
|
+
delete = async (key: string): Promise<boolean> => {
|
|
298
|
+
const res = await this.#deps.jsonFetch(this.#url({ key }), {
|
|
299
|
+
method: 'DELETE',
|
|
300
|
+
headers: this.#deps.headers(),
|
|
301
|
+
});
|
|
302
|
+
return isRecord(res) && res.deleted === true;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Verifies the `Instant-Signature` header of an alarm delivery against its
|
|
307
|
+
* raw body and returns the alarm. Throws if the signature is wrong or too
|
|
308
|
+
* old, if the body is not an alarm, or if the alarm belongs to another app.
|
|
309
|
+
*/
|
|
310
|
+
validate = async (
|
|
311
|
+
signatureHeader: string,
|
|
312
|
+
body: string | (() => Promise<string>),
|
|
313
|
+
opts?: AlarmValidateOpts | null,
|
|
314
|
+
): Promise<AlarmEvent> => {
|
|
315
|
+
const bodyText = await this.#deps.verifySignature(
|
|
316
|
+
signatureHeader,
|
|
317
|
+
body,
|
|
318
|
+
opts,
|
|
319
|
+
);
|
|
320
|
+
const event = parseAlarmEvent(bodyText);
|
|
321
|
+
// The signing key is the server's, shared by every app on it: without
|
|
322
|
+
// this check another app could aim its own alarm at this endpoint.
|
|
323
|
+
if (event.appId !== this.#deps.appId) {
|
|
324
|
+
throw new Error('Alarm belongs to another app');
|
|
325
|
+
}
|
|
326
|
+
return event;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/** Same as {@link validate}, reading the header and body from a `Request`. */
|
|
330
|
+
validateRequest = async (
|
|
331
|
+
req: Request,
|
|
332
|
+
opts?: AlarmValidateOpts | null,
|
|
333
|
+
): Promise<AlarmEvent> => {
|
|
334
|
+
const signatureHeader = req.headers.get('instant-signature');
|
|
335
|
+
if (!signatureHeader) {
|
|
336
|
+
throw new Error('Request is missing Instant-Signature header');
|
|
337
|
+
}
|
|
338
|
+
return this.validate(signatureHeader, () => req.text(), opts);
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// #endregion
|
package/src/index.ts
CHANGED
|
@@ -99,6 +99,16 @@ import {
|
|
|
99
99
|
import { parseCookie } from 'cookie';
|
|
100
100
|
import { EventSource } from '@instantdb/eventsource';
|
|
101
101
|
import { MessageEventPolyfill } from './polyfill.ts';
|
|
102
|
+
import {
|
|
103
|
+
Alarms,
|
|
104
|
+
type AlarmStatus,
|
|
105
|
+
type AlarmAttempt,
|
|
106
|
+
type AlarmInfo,
|
|
107
|
+
type AlarmEvent,
|
|
108
|
+
type SetAlarmParams,
|
|
109
|
+
type ListAlarmsParams,
|
|
110
|
+
type AlarmValidateOpts,
|
|
111
|
+
} from './alarms.ts';
|
|
102
112
|
import {
|
|
103
113
|
Webhooks,
|
|
104
114
|
WebhooksManager,
|
|
@@ -1142,6 +1152,8 @@ class InstantAdminDatabase<
|
|
|
1142
1152
|
rooms: Rooms<Schema>;
|
|
1143
1153
|
impersonationOpts?: ImpersonationOpts;
|
|
1144
1154
|
webhooks: Webhooks<Schema>;
|
|
1155
|
+
/** Roko DB: ask the server to call your url at a given time. */
|
|
1156
|
+
alarms: Alarms;
|
|
1145
1157
|
|
|
1146
1158
|
#sseConnection: SSEConnection | null = null;
|
|
1147
1159
|
#sseBackoff = 0;
|
|
@@ -1157,6 +1169,14 @@ class InstantAdminDatabase<
|
|
|
1157
1169
|
this.streams = new Streams(this.#ensureInstantStream.bind(this));
|
|
1158
1170
|
this.rooms = new Rooms<Schema>(this.config);
|
|
1159
1171
|
this.webhooks = new Webhooks<Schema>(this.config, jsonFetch);
|
|
1172
|
+
this.alarms = new Alarms({
|
|
1173
|
+
apiURI: this.config.apiURI,
|
|
1174
|
+
appId: this.config.appId,
|
|
1175
|
+
headers: () => authorizedHeaders(this.config),
|
|
1176
|
+
jsonFetch,
|
|
1177
|
+
verifySignature: (signatureHeader, body, opts) =>
|
|
1178
|
+
this.webhooks.verifySignature(signatureHeader, body, opts),
|
|
1179
|
+
});
|
|
1160
1180
|
this.#log = createLogger(!!this.config.verbose, this.config.logger);
|
|
1161
1181
|
}
|
|
1162
1182
|
|
|
@@ -1627,6 +1647,14 @@ export {
|
|
|
1627
1647
|
type InstantRouteHandlerPayloadByType,
|
|
1628
1648
|
type InstantRouteHandlerType,
|
|
1629
1649
|
type InstantRouteHandlerBody,
|
|
1650
|
+
Alarms,
|
|
1651
|
+
type AlarmStatus,
|
|
1652
|
+
type AlarmAttempt,
|
|
1653
|
+
type AlarmInfo,
|
|
1654
|
+
type AlarmEvent,
|
|
1655
|
+
type SetAlarmParams,
|
|
1656
|
+
type ListAlarmsParams,
|
|
1657
|
+
type AlarmValidateOpts,
|
|
1630
1658
|
Webhooks,
|
|
1631
1659
|
WebhooksManager,
|
|
1632
1660
|
type WebhookAction,
|