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