whisp-js 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +739 -2
- package/dist/cjs/client.d.ts +213 -0
- package/dist/cjs/client.js +570 -0
- package/dist/cjs/constants.d.ts +43 -0
- package/dist/cjs/constants.js +60 -0
- package/dist/cjs/errors.d.ts +96 -0
- package/dist/cjs/errors.js +115 -0
- package/dist/cjs/index.d.ts +19 -0
- package/dist/cjs/index.js +39 -0
- package/dist/cjs/listener.d.ts +93 -0
- package/dist/cjs/listener.js +457 -0
- package/dist/cjs/media.d.ts +40 -0
- package/dist/cjs/media.js +181 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/refs.d.ts +4 -0
- package/dist/cjs/refs.js +38 -0
- package/dist/cjs/runtime.d.ts +24 -0
- package/dist/cjs/runtime.js +93 -0
- package/dist/cjs/sse.d.ts +32 -0
- package/dist/cjs/sse.js +105 -0
- package/dist/cjs/transport.d.ts +61 -0
- package/dist/cjs/transport.js +181 -0
- package/dist/cjs/types.d.ts +583 -0
- package/dist/cjs/types.js +14 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/webhook.d.ts +27 -0
- package/dist/cjs/webhook.js +128 -0
- package/dist/esm/client.d.ts +213 -0
- package/dist/esm/client.js +566 -0
- package/dist/esm/constants.d.ts +43 -0
- package/dist/esm/constants.js +57 -0
- package/dist/esm/errors.d.ts +96 -0
- package/dist/esm/errors.js +107 -0
- package/dist/esm/index.d.ts +19 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/listener.d.ts +93 -0
- package/dist/esm/listener.js +452 -0
- package/dist/esm/media.d.ts +40 -0
- package/dist/esm/media.js +175 -0
- package/dist/esm/refs.d.ts +4 -0
- package/dist/esm/refs.js +33 -0
- package/dist/esm/runtime.d.ts +24 -0
- package/dist/esm/runtime.js +84 -0
- package/dist/esm/sse.d.ts +32 -0
- package/dist/esm/sse.js +100 -0
- package/dist/esm/transport.d.ts +61 -0
- package/dist/esm/transport.js +172 -0
- package/dist/esm/types.d.ts +583 -0
- package/dist/esm/types.js +13 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/webhook.d.ts +27 -0
- package/dist/esm/webhook.js +123 -0
- package/package.json +37 -5
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `on(event, handler)` over `GET /events/stream` (phase2-design 6.5, 12.4).
|
|
3
|
+
*
|
|
4
|
+
* - One stream per `Whisp` instance, opened by its first event handler and
|
|
5
|
+
* closed with its last, filtered by the instance's numbers and never by
|
|
6
|
+
* type: handlers are matched here, so adding one never reconnects.
|
|
7
|
+
* - Every connection sends `Last-Event-ID` = the cursor: the SSE `id` of the
|
|
8
|
+
* last event whose handlers finished, or of a later `ping` (how far the
|
|
9
|
+
* stream has covered), or, before the first one, the `id` of `ready` (where
|
|
10
|
+
* the stream started). The cursor is a position, so a resume after a
|
|
11
|
+
* pruned `presence.updated` loses nothing; a resume the server can't
|
|
12
|
+
* honour comes back as `ready { resumed: false, reason }`, reported once as
|
|
13
|
+
* `'gap'`, and the cursor moves to that `ready`'s `id`.
|
|
14
|
+
* - Reconnects after 1, 2, 4, 8, 15 s, then every 15 s; the schedule starts
|
|
15
|
+
* over once a connection reaches `ready`. A watchdog drops a connection that
|
|
16
|
+
* sent no byte for 45 s (the server pings every 20 s). A 429 waits 30 s. A
|
|
17
|
+
* 4xx that a retry can't fix (401 TOKEN_INVALID or TOKEN_REVOKED, 403, 404
|
|
18
|
+
* NUMBER_NOT_IN_PROJECT, a 400) stops the listener for good with `'error'`,
|
|
19
|
+
* as does the server's own `error` frame with TOKEN_REVOKED.
|
|
20
|
+
* - Handlers run one at a time, in order. The body isn't read while they run,
|
|
21
|
+
* so a slow handler slows the stream down instead of piling events up here;
|
|
22
|
+
* past 1000 events behind, the server ends the stream and the reconnect
|
|
23
|
+
* catches up from Postgres with the cursor. A handler that throws emits
|
|
24
|
+
* `'error'` and the stream goes on. The cursor moves when an event's
|
|
25
|
+
* handlers have settled, and at once for an event nobody listens to.
|
|
26
|
+
*/
|
|
27
|
+
import { PUBLIC_EVENT_TYPES } from './constants.js';
|
|
28
|
+
import { localError, readApiError, WhispConnectionError, WhispError } from './errors.js';
|
|
29
|
+
import { sleep } from './runtime.js';
|
|
30
|
+
import { createSseParser } from './sse.js';
|
|
31
|
+
import { errorFromResponse, isRetryableStatus } from './transport.js';
|
|
32
|
+
/** Wait before reconnect attempt N (0-based); the last value repeats. */
|
|
33
|
+
export const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
|
|
34
|
+
/** No byte for this long and the connection is dropped (the server pings every 20 s). */
|
|
35
|
+
export const WATCHDOG_MS = 45_000;
|
|
36
|
+
/** The wait after a 429 (TOO_MANY_STREAMS or RATE_LIMITED), unless `Retry-After` asks for longer (up to a minute). */
|
|
37
|
+
export const RATE_LIMITED_WAIT_MS = 30_000;
|
|
38
|
+
const RATE_LIMITED_MAX_WAIT_MS = 60_000;
|
|
39
|
+
/** The types this version delivers; any other is skipped (its cursor still moves). */
|
|
40
|
+
const KNOWN_TYPES = new Set(PUBLIC_EVENT_TYPES);
|
|
41
|
+
const LIFECYCLE = new Set(['open', 'close', 'gap', 'error']);
|
|
42
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
43
|
+
const asError = (value) => (value instanceof Error ? value : new Error(String(value)));
|
|
44
|
+
/** The handler's argument: `data` plus the envelope (12.4). */
|
|
45
|
+
export const toListenerEvent = (envelope, cursor) => {
|
|
46
|
+
const data = isRecord(envelope.data) ? envelope.data : {};
|
|
47
|
+
return {
|
|
48
|
+
...data,
|
|
49
|
+
number: envelope.number.phone,
|
|
50
|
+
numberId: envelope.number.id,
|
|
51
|
+
event: { id: envelope.id, type: envelope.type, createdAt: envelope.createdAt, cursor },
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const isEnvelope = (value) => isRecord(value) &&
|
|
55
|
+
typeof value.id === 'string' &&
|
|
56
|
+
typeof value.type === 'string' &&
|
|
57
|
+
typeof value.createdAt === 'string' &&
|
|
58
|
+
isRecord(value.number) &&
|
|
59
|
+
typeof value.number.id === 'string' &&
|
|
60
|
+
typeof value.number.phone === 'string';
|
|
61
|
+
export class Listener {
|
|
62
|
+
#config;
|
|
63
|
+
#handlers = new Map();
|
|
64
|
+
#cursor;
|
|
65
|
+
/** Bumped by every start and stop, so a loop from before a stop never acts after it. */
|
|
66
|
+
#generation = 0;
|
|
67
|
+
#running = false;
|
|
68
|
+
/** Ends the current connection: aborts its fetch and cancels its body. */
|
|
69
|
+
#cancel = null;
|
|
70
|
+
#wait = null;
|
|
71
|
+
#attempt = 0;
|
|
72
|
+
constructor(config) {
|
|
73
|
+
this.#config = config;
|
|
74
|
+
this.#cursor = config.cursor;
|
|
75
|
+
}
|
|
76
|
+
get cursor() {
|
|
77
|
+
return this.#cursor;
|
|
78
|
+
}
|
|
79
|
+
get running() {
|
|
80
|
+
return this.#running;
|
|
81
|
+
}
|
|
82
|
+
add(name, handler) {
|
|
83
|
+
let set = this.#handlers.get(name);
|
|
84
|
+
if (!set) {
|
|
85
|
+
set = new Set();
|
|
86
|
+
this.#handlers.set(name, set);
|
|
87
|
+
}
|
|
88
|
+
set.add(handler);
|
|
89
|
+
if (!LIFECYCLE.has(name) && !this.#running)
|
|
90
|
+
this.#start();
|
|
91
|
+
}
|
|
92
|
+
remove(name, handler) {
|
|
93
|
+
const set = this.#handlers.get(name);
|
|
94
|
+
if (!set)
|
|
95
|
+
return;
|
|
96
|
+
set.delete(handler);
|
|
97
|
+
if (set.size === 0)
|
|
98
|
+
this.#handlers.delete(name);
|
|
99
|
+
// The last event handler gone: nothing would read the stream, so it closes.
|
|
100
|
+
if (this.#running && !this.#hasEventHandlers()) {
|
|
101
|
+
const closeHandlers = [...(this.#handlers.get('close') ?? [])];
|
|
102
|
+
this.#stop();
|
|
103
|
+
for (const closeHandler of closeHandlers)
|
|
104
|
+
this.#callQuietly(closeHandler, { reconnect: false });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Ends the stream and its reconnects, and forgets every handler; a later `on()` starts again from the cursor. */
|
|
108
|
+
close() {
|
|
109
|
+
const wasRunning = this.#running;
|
|
110
|
+
this.#stop();
|
|
111
|
+
const closeHandlers = [...(this.#handlers.get('close') ?? [])];
|
|
112
|
+
this.#handlers.clear();
|
|
113
|
+
if (wasRunning)
|
|
114
|
+
for (const handler of closeHandlers)
|
|
115
|
+
this.#callQuietly(handler, { reconnect: false });
|
|
116
|
+
}
|
|
117
|
+
#hasEventHandlers() {
|
|
118
|
+
for (const name of this.#handlers.keys())
|
|
119
|
+
if (!LIFECYCLE.has(name))
|
|
120
|
+
return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
#start() {
|
|
124
|
+
this.#running = true;
|
|
125
|
+
this.#attempt = 0;
|
|
126
|
+
const generation = ++this.#generation;
|
|
127
|
+
void this.#loop(generation).catch(error => this.#config.report(asError(error)));
|
|
128
|
+
}
|
|
129
|
+
#stop() {
|
|
130
|
+
this.#running = false;
|
|
131
|
+
this.#generation++;
|
|
132
|
+
this.#cancel?.();
|
|
133
|
+
this.#cancel = null;
|
|
134
|
+
this.#wait?.abort();
|
|
135
|
+
this.#wait = null;
|
|
136
|
+
}
|
|
137
|
+
#live(generation) {
|
|
138
|
+
return this.#running && this.#generation === generation;
|
|
139
|
+
}
|
|
140
|
+
async #loop(generation) {
|
|
141
|
+
while (this.#live(generation)) {
|
|
142
|
+
const outcome = await this.#connect(generation);
|
|
143
|
+
if (!this.#live(generation) || outcome.kind === 'closed')
|
|
144
|
+
return;
|
|
145
|
+
if (outcome.kind === 'stop') {
|
|
146
|
+
this.#stop();
|
|
147
|
+
await this.#emit('error', outcome.error);
|
|
148
|
+
for (const handler of [...(this.#handlers.get('close') ?? [])]) {
|
|
149
|
+
this.#callQuietly(handler, { reconnect: false, error: outcome.error });
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (outcome.reached)
|
|
154
|
+
this.#attempt = 0;
|
|
155
|
+
const delay = outcome.delayMs ?? RECONNECT_DELAYS_MS[Math.min(this.#attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
156
|
+
this.#attempt += 1;
|
|
157
|
+
for (const handler of [...(this.#handlers.get('close') ?? [])]) {
|
|
158
|
+
this.#callQuietly(handler, outcome.error ? { reconnect: true, error: outcome.error } : { reconnect: true });
|
|
159
|
+
}
|
|
160
|
+
const wait = new AbortController();
|
|
161
|
+
this.#wait = wait;
|
|
162
|
+
await sleep(this.#config.timers, delay, wait.signal);
|
|
163
|
+
if (this.#wait === wait)
|
|
164
|
+
this.#wait = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async #connect(generation) {
|
|
168
|
+
let target;
|
|
169
|
+
try {
|
|
170
|
+
target = this.#config.resolve();
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return { kind: 'stop', error: error instanceof WhispError ? error : localError('OPTIONS_INVALID', String(error)) };
|
|
174
|
+
}
|
|
175
|
+
const { timers } = this.#config;
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
let reader = null;
|
|
178
|
+
// Aborting the fetch ends its body in every runtime's fetch; cancelling the reader too covers a custom
|
|
179
|
+
// fetch that doesn't tie the body to the signal, so a stop or the watchdog never leaves a read hanging.
|
|
180
|
+
const cancel = () => {
|
|
181
|
+
controller.abort();
|
|
182
|
+
void reader?.cancel().catch(() => { });
|
|
183
|
+
};
|
|
184
|
+
this.#cancel = cancel;
|
|
185
|
+
let abortedBy = null;
|
|
186
|
+
let timer = timers.setTimeout(() => {
|
|
187
|
+
abortedBy = 'connect';
|
|
188
|
+
cancel();
|
|
189
|
+
}, this.#config.connectTimeoutMs);
|
|
190
|
+
const sentCursor = this.#cursor;
|
|
191
|
+
const headers = {
|
|
192
|
+
authorization: `Bearer ${target.token}`,
|
|
193
|
+
accept: 'text/event-stream',
|
|
194
|
+
'cache-control': 'no-store',
|
|
195
|
+
'user-agent': target.userAgent,
|
|
196
|
+
};
|
|
197
|
+
if (sentCursor !== null)
|
|
198
|
+
headers['last-event-id'] = sentCursor;
|
|
199
|
+
let url = `${target.baseUrl.replace(/\/+$/, '')}/events/stream`;
|
|
200
|
+
if (this.#config.numbers && this.#config.numbers.length > 0) {
|
|
201
|
+
url += `?${new URLSearchParams({ numbers: this.#config.numbers.join(',') }).toString()}`;
|
|
202
|
+
}
|
|
203
|
+
let reached = false;
|
|
204
|
+
try {
|
|
205
|
+
let response;
|
|
206
|
+
try {
|
|
207
|
+
response = await target.fetch(url, { method: 'GET', headers, redirect: 'manual', signal: controller.signal });
|
|
208
|
+
}
|
|
209
|
+
catch (cause) {
|
|
210
|
+
if (!this.#live(generation))
|
|
211
|
+
return { kind: 'closed' };
|
|
212
|
+
const error = abortedBy === 'connect'
|
|
213
|
+
? new WhispConnectionError('TIMEOUT', 'The event stream did not answer in time', cause)
|
|
214
|
+
: new WhispConnectionError('NETWORK_ERROR', 'The event stream could not connect', cause);
|
|
215
|
+
return { kind: 'retry', error, reached };
|
|
216
|
+
}
|
|
217
|
+
timers.clearTimeout(timer);
|
|
218
|
+
timer = undefined;
|
|
219
|
+
if (response.status < 200 || response.status >= 300) {
|
|
220
|
+
const text = await response.text().catch(() => '');
|
|
221
|
+
const error = errorFromResponse(response, text, timers.now());
|
|
222
|
+
if (response.status === 429) {
|
|
223
|
+
const asked = error.retryAfter !== null ? error.retryAfter * 1_000 : 0;
|
|
224
|
+
return {
|
|
225
|
+
kind: 'retry',
|
|
226
|
+
error,
|
|
227
|
+
reached,
|
|
228
|
+
delayMs: Math.min(RATE_LIMITED_MAX_WAIT_MS, Math.max(RATE_LIMITED_WAIT_MS, asked)),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// 401, 403, 404 and every other answer a retry can't change end the listener.
|
|
232
|
+
if (!isRetryableStatus(response.status))
|
|
233
|
+
return { kind: 'stop', error };
|
|
234
|
+
return { kind: 'retry', error, reached };
|
|
235
|
+
}
|
|
236
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
237
|
+
if (!contentType.toLowerCase().startsWith('text/event-stream') || !response.body) {
|
|
238
|
+
await response.body?.cancel().catch(() => { });
|
|
239
|
+
return {
|
|
240
|
+
kind: 'retry',
|
|
241
|
+
reached,
|
|
242
|
+
error: new WhispError({
|
|
243
|
+
code: 'INVALID_RESPONSE',
|
|
244
|
+
status: response.status,
|
|
245
|
+
message: `The event stream answered ${response.status} with ${contentType || 'no content type'}, not an event stream`,
|
|
246
|
+
requestId: response.headers.get('x-request-id'),
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
reader = response.body.getReader();
|
|
251
|
+
const parser = createSseParser();
|
|
252
|
+
const armWatchdog = () => {
|
|
253
|
+
timers.clearTimeout(timer);
|
|
254
|
+
timer = timers.setTimeout(() => {
|
|
255
|
+
abortedBy = 'watchdog';
|
|
256
|
+
cancel();
|
|
257
|
+
}, WATCHDOG_MS);
|
|
258
|
+
};
|
|
259
|
+
const silent = (cause) => new WhispConnectionError('TIMEOUT', `The event stream sent nothing for ${WATCHDOG_MS / 1_000} s`, cause);
|
|
260
|
+
for (;;) {
|
|
261
|
+
armWatchdog();
|
|
262
|
+
let chunk;
|
|
263
|
+
try {
|
|
264
|
+
chunk = await reader.read();
|
|
265
|
+
}
|
|
266
|
+
catch (cause) {
|
|
267
|
+
if (!this.#live(generation))
|
|
268
|
+
return { kind: 'closed' };
|
|
269
|
+
const error = abortedBy === 'watchdog'
|
|
270
|
+
? silent(cause)
|
|
271
|
+
: new WhispConnectionError('NETWORK_ERROR', 'The event stream was cut', cause);
|
|
272
|
+
return { kind: 'retry', error, reached };
|
|
273
|
+
}
|
|
274
|
+
// A cancelled reader ends with `done`, not an error.
|
|
275
|
+
if (!this.#live(generation))
|
|
276
|
+
return { kind: 'closed' };
|
|
277
|
+
if (abortedBy === 'watchdog')
|
|
278
|
+
return { kind: 'retry', error: silent(), reached };
|
|
279
|
+
// Handlers may take their time: the watchdog only watches the wait for bytes.
|
|
280
|
+
timers.clearTimeout(timer);
|
|
281
|
+
timer = undefined;
|
|
282
|
+
const events = chunk.done ? parser.end() : parser.push(chunk.value);
|
|
283
|
+
for (const event of events) {
|
|
284
|
+
if (!this.#live(generation))
|
|
285
|
+
return { kind: 'closed' };
|
|
286
|
+
const step = await this.#handle(event, sentCursor);
|
|
287
|
+
if (step === 'ready')
|
|
288
|
+
reached = true;
|
|
289
|
+
else if (step !== 'next')
|
|
290
|
+
return step;
|
|
291
|
+
}
|
|
292
|
+
if (chunk.done)
|
|
293
|
+
return { kind: 'retry', reached };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (cause) {
|
|
297
|
+
if (!this.#live(generation))
|
|
298
|
+
return { kind: 'closed' };
|
|
299
|
+
return { kind: 'retry', reached, error: asError(cause) };
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
timers.clearTimeout(timer);
|
|
303
|
+
if (this.#cancel === cancel)
|
|
304
|
+
this.#cancel = null;
|
|
305
|
+
// Whatever ended this connection, its socket is freed.
|
|
306
|
+
cancel();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** One parsed SSE event. `'next'` goes on reading; anything else ends this connection. */
|
|
310
|
+
async #handle(event, sentCursor) {
|
|
311
|
+
switch (event.event) {
|
|
312
|
+
case 'ready': {
|
|
313
|
+
let ready = { resumed: false };
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(event.data);
|
|
316
|
+
if (isRecord(parsed) && typeof parsed.resumed === 'boolean') {
|
|
317
|
+
ready =
|
|
318
|
+
parsed.reason === 'cursor_unknown' || parsed.reason === 'cursor_expired'
|
|
319
|
+
? { resumed: parsed.resumed, reason: parsed.reason }
|
|
320
|
+
: { resumed: parsed.resumed };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// A ready we can't read still opens the stream.
|
|
325
|
+
}
|
|
326
|
+
// sdk-reconnect-without-cursor-loses-events: ready's `id` is where this stream starts. With no
|
|
327
|
+
// cursor yet, or a cursor the server refused, that is the resume point from now on: a reconnect
|
|
328
|
+
// before the first event resumes there instead of at the new live end, `cursor` can be saved at
|
|
329
|
+
// once, and a refused cursor is one 'gap' rather than one per reconnect. The gap below still
|
|
330
|
+
// names the cursor that was sent.
|
|
331
|
+
const start = event.id;
|
|
332
|
+
if (start !== undefined && start !== '' && (sentCursor === null || !ready.resumed))
|
|
333
|
+
this.#cursor = start;
|
|
334
|
+
await this.#emit('open', ready);
|
|
335
|
+
if (sentCursor !== null && !ready.resumed) {
|
|
336
|
+
await this.#emit('gap', { reason: ready.reason ?? 'cursor_unknown', cursor: sentCursor });
|
|
337
|
+
}
|
|
338
|
+
return 'ready';
|
|
339
|
+
}
|
|
340
|
+
case 'ping':
|
|
341
|
+
// feed-cursor-quiet-project: a ping's `id` is how far the stream has covered. Handlers run one at a
|
|
342
|
+
// time and nothing is read meanwhile, so every event before it has settled: the cursor follows, and
|
|
343
|
+
// a listener whose numbers stay quiet for days still resumes inside retention. Without one (an older
|
|
344
|
+
// server), nothing moves.
|
|
345
|
+
if (event.id !== undefined && event.id !== '')
|
|
346
|
+
this.#cursor = event.id;
|
|
347
|
+
return 'next';
|
|
348
|
+
case 'error': {
|
|
349
|
+
let api = readApiError(null);
|
|
350
|
+
try {
|
|
351
|
+
api = readApiError(JSON.parse(event.data));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Not JSON: reported with a generic code below.
|
|
355
|
+
}
|
|
356
|
+
const error = new WhispError({
|
|
357
|
+
code: api?.code ?? 'STREAM_ERROR',
|
|
358
|
+
message: api?.message ?? 'The event stream sent an error',
|
|
359
|
+
params: api?.params ?? null,
|
|
360
|
+
});
|
|
361
|
+
// A revoked token won't work again: stop. SHUTDOWN (a redeploy) and the rest: reconnect.
|
|
362
|
+
if (error.code === 'TOKEN_REVOKED' || error.code === 'TOKEN_INVALID')
|
|
363
|
+
return { kind: 'stop', error };
|
|
364
|
+
if (error.code !== 'SHUTDOWN')
|
|
365
|
+
await this.#emit('error', error);
|
|
366
|
+
return { kind: 'retry', error, reached: true };
|
|
367
|
+
}
|
|
368
|
+
default:
|
|
369
|
+
return this.#deliver(event);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async #deliver(event) {
|
|
373
|
+
const cursor = event.id;
|
|
374
|
+
// Only events with an id are public events; anything else is not ours to hand out.
|
|
375
|
+
if (cursor === undefined || cursor === '')
|
|
376
|
+
return 'next';
|
|
377
|
+
if (!KNOWN_TYPES.has(event.event)) {
|
|
378
|
+
// A type this version doesn't know (the vocabulary only grows, 4.1): nobody can listen, so move on.
|
|
379
|
+
this.#cursor = cursor;
|
|
380
|
+
return 'next';
|
|
381
|
+
}
|
|
382
|
+
const handlers = [
|
|
383
|
+
...(this.#handlers.get(event.event) ?? []),
|
|
384
|
+
...(event.event === 'message.received' ? (this.#handlers.get('message') ?? []) : []),
|
|
385
|
+
...(this.#handlers.get('*') ?? []),
|
|
386
|
+
];
|
|
387
|
+
if (handlers.length > 0) {
|
|
388
|
+
let parsed;
|
|
389
|
+
try {
|
|
390
|
+
parsed = JSON.parse(event.data);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
parsed = undefined;
|
|
394
|
+
}
|
|
395
|
+
if (!isEnvelope(parsed) || parsed.type !== event.event) {
|
|
396
|
+
await this.#emit('error', new WhispError({
|
|
397
|
+
code: 'INVALID_RESPONSE',
|
|
398
|
+
message: `The event stream sent a ${event.event} event that is not in the documented shape`,
|
|
399
|
+
params: { cursor },
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
const payload = toListenerEvent(parsed, cursor);
|
|
404
|
+
for (const handler of handlers) {
|
|
405
|
+
try {
|
|
406
|
+
await handler(payload);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
await this.#emit('error', asError(error), payload);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.#cursor = cursor;
|
|
415
|
+
return 'next';
|
|
416
|
+
}
|
|
417
|
+
/** Runs a lifecycle event's handlers in order; a throwing `open` or `gap` handler is reported as `'error'`. */
|
|
418
|
+
async #emit(name, arg, event) {
|
|
419
|
+
const handlers = [...(this.#handlers.get(name) ?? [])];
|
|
420
|
+
if (name === 'error') {
|
|
421
|
+
if (handlers.length === 0)
|
|
422
|
+
return this.#config.report(arg);
|
|
423
|
+
for (const handler of handlers) {
|
|
424
|
+
try {
|
|
425
|
+
await handler(arg, event);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
this.#config.report(asError(error));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
for (const handler of handlers) {
|
|
434
|
+
try {
|
|
435
|
+
await handler(arg);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
await this.#emit('error', asError(error));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
#callQuietly(handler, arg) {
|
|
443
|
+
try {
|
|
444
|
+
const result = handler(arg);
|
|
445
|
+
if (result instanceof Promise)
|
|
446
|
+
result.catch(error => this.#config.report(asError(error)));
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
this.#config.report(asError(error));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Timers } from './runtime.js';
|
|
2
|
+
import { type FetchLike } from './transport.js';
|
|
3
|
+
import type { UploadKind } from './types.js';
|
|
4
|
+
/** What a file send takes: a public https URL the server downloads, or bytes the SDK uploads first. */
|
|
5
|
+
export type MediaSource = MediaUrl | MediaFile;
|
|
6
|
+
export interface MediaUrl {
|
|
7
|
+
/** https only; the server fetches it (private addresses and redirects are refused). */
|
|
8
|
+
url: string;
|
|
9
|
+
fileName?: string;
|
|
10
|
+
mime?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface MediaFile {
|
|
13
|
+
file: Blob | Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>;
|
|
14
|
+
/** Shown to the recipient for documents. Defaults to the File's name, else the kind. */
|
|
15
|
+
fileName?: string;
|
|
16
|
+
/** Defaults to the Blob's type, else a guess from fileName's extension. */
|
|
17
|
+
mime?: string;
|
|
18
|
+
/** A stream's length in bytes, checked against what it yields. Optional: the stream is read first anyway. */
|
|
19
|
+
size?: number;
|
|
20
|
+
}
|
|
21
|
+
/** A file ready to upload: its bytes in a form fetch sends with a Content-Length. */
|
|
22
|
+
export interface PreparedFile {
|
|
23
|
+
body: Blob | Uint8Array;
|
|
24
|
+
size: number;
|
|
25
|
+
mime: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
/** The type a file name's extension suggests, or undefined. A voice note's `.webm` is audio, not video. */
|
|
29
|
+
export declare const mimeFromName: (name: string | undefined, kind: UploadKind) => string | undefined;
|
|
30
|
+
/** The bytes, size, type and name of a file to upload; throws MEDIA_INVALID when one can't be known. */
|
|
31
|
+
export declare const prepareFile: (kind: UploadKind, source: MediaFile) => Promise<PreparedFile>;
|
|
32
|
+
export interface PutConfig {
|
|
33
|
+
fetch: FetchLike;
|
|
34
|
+
timers: Timers;
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
maxRetries: number;
|
|
37
|
+
retrySignal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
/** The presigned PUT, retried like an API call (network errors, timeouts, 408, 429, 5xx). */
|
|
40
|
+
export declare const putUpload: (config: PutConfig, url: string, headers: Record<string, string>, file: PreparedFile) => Promise<void>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files for sends and downloads (phase2-design 5.8, 12.3).
|
|
3
|
+
*
|
|
4
|
+
* Sending a file you hold: `POST /uploads` (kind, type, size, name), then a
|
|
5
|
+
* PUT of the bytes to the presigned URL with exactly the headers it came
|
|
6
|
+
* with, then the send with `{ uploadId }`. The presigned PUT signs the
|
|
7
|
+
* length, and fetch sends a stream body chunked (no length), so a
|
|
8
|
+
* `ReadableStream` is read into memory first, never past its kind's cap; a
|
|
9
|
+
* `Blob` (`Bun.file()`, Node's `fs.openAsBlob()`) is sent as is. The PUT goes
|
|
10
|
+
* to storage, never to the API, so it carries no token.
|
|
11
|
+
*
|
|
12
|
+
* Downloading: `GET /messages/:id/media?redirect=0` with the token, then the
|
|
13
|
+
* returned link in a new request with no `Authorization` and
|
|
14
|
+
* `redirect: 'error'`. The SDK never follows the API's 302 with the token
|
|
15
|
+
* attached (12.3).
|
|
16
|
+
*/
|
|
17
|
+
import { UPLOAD_MAX_BYTES } from './constants.js';
|
|
18
|
+
import { localError, WhispConnectionError, WhispError } from './errors.js';
|
|
19
|
+
import { sleep } from './runtime.js';
|
|
20
|
+
import { backoffDelay, isRetryableStatus } from './transport.js';
|
|
21
|
+
const EXTENSION_MIME = {
|
|
22
|
+
jpg: 'image/jpeg',
|
|
23
|
+
jpeg: 'image/jpeg',
|
|
24
|
+
png: 'image/png',
|
|
25
|
+
webp: 'image/webp',
|
|
26
|
+
gif: 'image/gif',
|
|
27
|
+
mp4: 'video/mp4',
|
|
28
|
+
ogg: 'audio/ogg',
|
|
29
|
+
oga: 'audio/ogg',
|
|
30
|
+
opus: 'audio/ogg',
|
|
31
|
+
mp3: 'audio/mpeg',
|
|
32
|
+
m4a: 'audio/mp4',
|
|
33
|
+
aac: 'audio/aac',
|
|
34
|
+
wav: 'audio/wav',
|
|
35
|
+
weba: 'audio/webm',
|
|
36
|
+
pdf: 'application/pdf',
|
|
37
|
+
txt: 'text/plain',
|
|
38
|
+
csv: 'text/csv',
|
|
39
|
+
json: 'application/json',
|
|
40
|
+
zip: 'application/zip',
|
|
41
|
+
doc: 'application/msword',
|
|
42
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
43
|
+
xls: 'application/vnd.ms-excel',
|
|
44
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
45
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
46
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
47
|
+
};
|
|
48
|
+
/** The type a file name's extension suggests, or undefined. A voice note's `.webm` is audio, not video. */
|
|
49
|
+
export const mimeFromName = (name, kind) => {
|
|
50
|
+
const extension = name?.split('.').pop()?.toLowerCase();
|
|
51
|
+
if (!extension || extension === name?.toLowerCase())
|
|
52
|
+
return undefined;
|
|
53
|
+
if (extension === 'webm')
|
|
54
|
+
return kind === 'voice' ? 'audio/webm' : 'video/webm';
|
|
55
|
+
return EXTENSION_MIME[extension];
|
|
56
|
+
};
|
|
57
|
+
const megabytes = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
58
|
+
const readStream = async (stream, max, kind) => {
|
|
59
|
+
const reader = stream.getReader();
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
try {
|
|
63
|
+
for (;;) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done)
|
|
66
|
+
break;
|
|
67
|
+
total += value.byteLength;
|
|
68
|
+
if (total > max) {
|
|
69
|
+
await reader.cancel().catch(() => { });
|
|
70
|
+
throw localError('MEDIA_INVALID', `The ${kind} is over its ${megabytes(max)} limit`);
|
|
71
|
+
}
|
|
72
|
+
chunks.push(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
const out = new Uint8Array(total);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
for (const chunk of chunks) {
|
|
81
|
+
out.set(chunk, offset);
|
|
82
|
+
offset += chunk.byteLength;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
};
|
|
86
|
+
/** The bytes, size, type and name of a file to upload; throws MEDIA_INVALID when one can't be known. */
|
|
87
|
+
export const prepareFile = async (kind, source) => {
|
|
88
|
+
const { file } = source;
|
|
89
|
+
const max = UPLOAD_MAX_BYTES[kind];
|
|
90
|
+
let body;
|
|
91
|
+
let blobType;
|
|
92
|
+
let blobName;
|
|
93
|
+
if (typeof Blob !== 'undefined' && file instanceof Blob) {
|
|
94
|
+
body = file;
|
|
95
|
+
blobType = file.type || undefined;
|
|
96
|
+
const named = file.name;
|
|
97
|
+
blobName = typeof named === 'string' && named !== '' ? named.split(/[\\/]/).pop() : undefined;
|
|
98
|
+
}
|
|
99
|
+
else if (file instanceof Uint8Array) {
|
|
100
|
+
body = file;
|
|
101
|
+
}
|
|
102
|
+
else if (file instanceof ArrayBuffer) {
|
|
103
|
+
body = new Uint8Array(file);
|
|
104
|
+
}
|
|
105
|
+
else if (typeof ReadableStream !== 'undefined' && file instanceof ReadableStream) {
|
|
106
|
+
body = await readStream(file, max, kind);
|
|
107
|
+
if (source.size !== undefined && source.size !== body.byteLength) {
|
|
108
|
+
throw localError('MEDIA_INVALID', `The stream gave ${body.byteLength} bytes, not the ${source.size} its size said`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
throw localError('MEDIA_INVALID', '`file` must be a Blob, a Uint8Array, an ArrayBuffer or a ReadableStream');
|
|
113
|
+
}
|
|
114
|
+
const size = body instanceof Uint8Array ? body.byteLength : body.size;
|
|
115
|
+
if (size === 0)
|
|
116
|
+
throw localError('MEDIA_INVALID', `The ${kind} is empty`);
|
|
117
|
+
if (size > max)
|
|
118
|
+
throw localError('MEDIA_INVALID', `The ${kind} is over its ${megabytes(max)} limit`);
|
|
119
|
+
const name = (source.fileName?.trim() || blobName || kind).slice(0, 255);
|
|
120
|
+
const mime = source.mime?.trim() ||
|
|
121
|
+
blobType ||
|
|
122
|
+
mimeFromName(source.fileName ?? blobName, kind) ||
|
|
123
|
+
(kind === 'document' ? 'application/octet-stream' : undefined);
|
|
124
|
+
if (!mime) {
|
|
125
|
+
throw localError('MEDIA_INVALID', `Pass the ${kind}'s mime type (for example ${kind === 'image' ? 'image/jpeg' : kind === 'video' ? 'video/mp4' : 'audio/ogg'}), or a fileName with its extension`);
|
|
126
|
+
}
|
|
127
|
+
return { body, size, mime, name };
|
|
128
|
+
};
|
|
129
|
+
/** The presigned PUT, retried like an API call (network errors, timeouts, 408, 429, 5xx). */
|
|
130
|
+
export const putUpload = async (config, url, headers, file) => {
|
|
131
|
+
// A slow link gets time for the bytes: at least 100 KB/s, never under the request timeout.
|
|
132
|
+
const timeoutMs = Math.max(config.timeoutMs, Math.ceil(file.size / 100));
|
|
133
|
+
let last = null;
|
|
134
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
135
|
+
if (attempt > 0) {
|
|
136
|
+
await sleep(config.timers, backoffDelay(attempt - 1, () => config.timers.random()), config.retrySignal);
|
|
137
|
+
if (config.retrySignal?.aborted)
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
let timedOut = false;
|
|
142
|
+
const timer = config.timers.setTimeout(() => {
|
|
143
|
+
timedOut = true;
|
|
144
|
+
controller.abort();
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
try {
|
|
147
|
+
const response = await config.fetch(url, {
|
|
148
|
+
method: 'PUT',
|
|
149
|
+
headers,
|
|
150
|
+
body: file.body,
|
|
151
|
+
redirect: 'error',
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
});
|
|
154
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
155
|
+
if (response.ok)
|
|
156
|
+
return;
|
|
157
|
+
last = new WhispError({
|
|
158
|
+
code: 'UPLOAD_FAILED',
|
|
159
|
+
status: response.status,
|
|
160
|
+
message: `The upload to storage answered ${response.status}`,
|
|
161
|
+
});
|
|
162
|
+
if (!isRetryableStatus(response.status))
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
catch (cause) {
|
|
166
|
+
last = timedOut
|
|
167
|
+
? new WhispConnectionError('TIMEOUT', `The upload did not finish within ${timeoutMs} ms`, cause)
|
|
168
|
+
: new WhispConnectionError('NETWORK_ERROR', 'The upload to storage failed', cause);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
config.timers.clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw last ?? new WhispError({ code: 'UPLOAD_FAILED', message: 'The upload to storage was stopped' });
|
|
175
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The canonical reference (`972501234567` or `num_7Hq2kD3x`), or null for anything else. */
|
|
2
|
+
export declare const normalizeNumberRef: (ref: unknown) => string | null;
|
|
3
|
+
/** Like normalizeNumberRef, but throws PHONE_INVALID naming the value. */
|
|
4
|
+
export declare const requireNumberRef: (ref: unknown, where: string) => string;
|