@poolse/sdk 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.
package/dist/index.js ADDED
@@ -0,0 +1,985 @@
1
+ import { Socket } from 'phoenix';
2
+
3
+ // src/config.ts
4
+ var DEFAULT_MAX_RETRIES = 3;
5
+ var DEFAULT_BASE_BACKOFF_MS = 250;
6
+ var DEFAULT_MAX_BACKOFF_MS = 3e4;
7
+ function resolveConfig(config) {
8
+ if (!config.apiUrl) {
9
+ throw new Error("Poolse: `apiUrl` is required.");
10
+ }
11
+ if (typeof config.getToken !== "function") {
12
+ throw new Error("Poolse: `getToken` is required and must be a function.");
13
+ }
14
+ const rawFetch = config.fetch ?? globalThis.fetch;
15
+ if (typeof rawFetch !== "function") {
16
+ throw new Error(
17
+ "Poolse: no global `fetch` found. Provide one via `config.fetch` (Node <18 or a sandboxed runtime)."
18
+ );
19
+ }
20
+ const fetchFn = rawFetch.bind(globalThis);
21
+ return {
22
+ apiUrl: trimTrailingSlash(config.apiUrl),
23
+ getToken: config.getToken,
24
+ fetch: fetchFn,
25
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
26
+ baseBackoffMs: config.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS,
27
+ maxBackoffMs: config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS,
28
+ generateIdempotencyKey: config.generateIdempotencyKey ?? defaultIdempotencyKey,
29
+ wsUrl: config.wsUrl,
30
+ socketPath: config.socketPath ?? "/socket",
31
+ onSocketError: config.onSocketError
32
+ };
33
+ }
34
+ function trimTrailingSlash(s) {
35
+ return s.endsWith("/") ? s.slice(0, -1) : s;
36
+ }
37
+ function defaultIdempotencyKey() {
38
+ const c = globalThis.crypto;
39
+ if (c && typeof c.randomUUID === "function") {
40
+ return c.randomUUID();
41
+ }
42
+ throw new Error(
43
+ "Poolse: globalThis.crypto.randomUUID() is not available; supply `config.generateIdempotencyKey` instead."
44
+ );
45
+ }
46
+
47
+ // src/errors.ts
48
+ var PoolseError = class extends Error {
49
+ name = "PoolseError";
50
+ constructor(message) {
51
+ super(message);
52
+ Object.setPrototypeOf(this, new.target.prototype);
53
+ }
54
+ };
55
+ var NetworkError = class extends PoolseError {
56
+ name = "NetworkError";
57
+ cause;
58
+ constructor(message, cause) {
59
+ super(message);
60
+ this.cause = cause;
61
+ }
62
+ };
63
+ var ApiError = class extends PoolseError {
64
+ name = "ApiError";
65
+ status;
66
+ code;
67
+ docUrl;
68
+ details;
69
+ constructor(status, envelope) {
70
+ super(`[${status}] ${envelope.code}: ${envelope.message}`);
71
+ this.status = status;
72
+ this.code = envelope.code;
73
+ this.docUrl = envelope.doc_url;
74
+ this.details = envelope.details;
75
+ }
76
+ };
77
+ var RateLimitedError = class extends ApiError {
78
+ name = "RateLimitedError";
79
+ retryAfterMs;
80
+ constructor(envelope, retryAfterMs) {
81
+ super(429, envelope);
82
+ this.retryAfterMs = retryAfterMs;
83
+ }
84
+ };
85
+ var AuthError = class extends ApiError {
86
+ name = "AuthError";
87
+ constructor(envelope) {
88
+ super(401, envelope);
89
+ }
90
+ };
91
+
92
+ // src/realtime/realtime.ts
93
+ var PoolseRealtime = class {
94
+ config;
95
+ tokenCache;
96
+ socketPath;
97
+ wsUrl;
98
+ socket = null;
99
+ conversations = /* @__PURE__ */ new Map();
100
+ userChannel = null;
101
+ status = "idle";
102
+ statusListeners = /* @__PURE__ */ new Set();
103
+ constructor(config, tokenCache, opts = {}) {
104
+ this.config = config;
105
+ this.tokenCache = tokenCache;
106
+ this.socketPath = opts.socketPath ?? "/socket";
107
+ this.wsUrl = opts.wsUrl ?? deriveWsUrl(config.apiUrl);
108
+ }
109
+ /** Current connection status. */
110
+ getStatus() {
111
+ return this.status;
112
+ }
113
+ /** Subscribe to connection-status changes. */
114
+ onStatus(listener) {
115
+ this.statusListeners.add(listener);
116
+ return () => this.statusListeners.delete(listener);
117
+ }
118
+ /**
119
+ * Open the socket (idempotent). Synchronous construction so callers
120
+ * can join a channel on the next line; the underlying WebSocket
121
+ * connects on the next tick once the JWT pre-fetch resolves.
122
+ *
123
+ * Phoenix.js invokes the `params` callback SYNCHRONOUSLY on every
124
+ * (re)connect and does NOT await its return value — see
125
+ * `phoenix/priv/static/phoenix.mjs::endPointURL`. So `params` has
126
+ * to read a token that's already in hand. We:
127
+ *
128
+ * 1. Construct the Socket immediately (sets `this.socket` so
129
+ * concurrent `conversation()` / `user()` callers can attach
130
+ * channels — Phoenix buffers joins until the socket opens).
131
+ * 2. Pre-fetch the JWT through `TokenCache`, which fills its
132
+ * internal cache.
133
+ * 3. Call `socket.connect()` so phoenix.js's first handshake reads
134
+ * a primed `peekToken()`.
135
+ *
136
+ * On reconnect, Phoenix calls `params()` again; the cache is still
137
+ * warm (default JWT exp ~1h, refresh window 30s) so `peekToken()`
138
+ * returns the live token. When the token genuinely expires, our
139
+ * REST 401 path invalidates the cache; the next reconnect's
140
+ * `peekToken()` is `null` and the handshake intentionally fails so
141
+ * the cache can re-fill on the next iteration.
142
+ */
143
+ connect() {
144
+ if (this.socket) return;
145
+ this.setStatus("connecting");
146
+ const socket = new Socket(`${this.wsUrl}${this.socketPath}`, {
147
+ params: () => ({ token: this.tokenCache.peekToken() ?? "" })
148
+ // Phoenix's default reconnect strategy: 10ms, 50ms, 100ms, 150ms,
149
+ // 200ms, 250ms, 500ms, 1s, 2s, 5s — perfectly reasonable for chat.
150
+ });
151
+ socket.onOpen(() => this.setStatus("connected"));
152
+ socket.onClose(() => {
153
+ this.setStatus(this.status === "closed" ? "closed" : "reconnecting");
154
+ });
155
+ socket.onError((err) => {
156
+ this.setStatus("reconnecting");
157
+ this.config.onSocketError?.(new PoolseError(`socket error: ${String(err)}`));
158
+ });
159
+ this.socket = socket;
160
+ void this.tokenCache.getToken().catch((err) => {
161
+ this.config.onSocketError?.(
162
+ new PoolseError(`token fetch failed before socket open: ${String(err)}`)
163
+ );
164
+ }).finally(() => {
165
+ socket.connect();
166
+ });
167
+ }
168
+ /** Close the socket and tear down every joined channel. */
169
+ disconnect() {
170
+ this.setStatus("closed");
171
+ this.conversations.forEach((c) => c._destroy());
172
+ this.conversations.clear();
173
+ this.userChannel?._destroy();
174
+ this.userChannel = null;
175
+ if (this.socket) {
176
+ this.socket.disconnect();
177
+ this.socket = null;
178
+ }
179
+ }
180
+ /**
181
+ * Subscribe to a conversation. Returns a typed handle with
182
+ * `onMessage`, `onTyping`, etc. Reusing the same `id` returns the
183
+ * same handle — re-subscribing doesn't open a second channel.
184
+ */
185
+ conversation(conversationId) {
186
+ const existing = this.conversations.get(conversationId);
187
+ if (existing) return existing;
188
+ this.connect();
189
+ if (!this.socket) {
190
+ throw new PoolseError("socket not initialised \u2014 call connect() first");
191
+ }
192
+ const channel = this.socket.channel(`conversation:${conversationId}`, {});
193
+ const handle = new ConversationChannel(conversationId, channel);
194
+ this.conversations.set(conversationId, handle);
195
+ handle._join();
196
+ return handle;
197
+ }
198
+ /**
199
+ * Subscribe to the current user's `user:<id>` channel. Only the user
200
+ * matching the JWT can join — poolse's UserChannel enforces this.
201
+ */
202
+ user(userId) {
203
+ if (this.userChannel) return this.userChannel;
204
+ this.connect();
205
+ if (!this.socket) {
206
+ throw new PoolseError("socket not initialised \u2014 call connect() first");
207
+ }
208
+ const channel = this.socket.channel(`user:${userId}`, {});
209
+ const handle = new UserChannel(userId, channel);
210
+ this.userChannel = handle;
211
+ handle._join();
212
+ return handle;
213
+ }
214
+ /** Drop a conversation handle and leave the channel. */
215
+ leave(conversationId) {
216
+ const handle = this.conversations.get(conversationId);
217
+ if (!handle) return;
218
+ handle._destroy();
219
+ this.conversations.delete(conversationId);
220
+ }
221
+ setStatus(status) {
222
+ if (this.status === status) return;
223
+ this.status = status;
224
+ this.statusListeners.forEach((l) => l(status));
225
+ }
226
+ };
227
+ var ConversationChannel = class {
228
+ conversationId;
229
+ channel;
230
+ // Map from event-name → set of listeners. We bind one Phoenix `.on(...)`
231
+ // per event name (no matter how many JS listeners) and fan out
232
+ // ourselves — much cheaper than re-binding on every subscription.
233
+ listeners = /* @__PURE__ */ new Map();
234
+ constructor(conversationId, channel) {
235
+ this.conversationId = conversationId;
236
+ this.channel = channel;
237
+ }
238
+ /** New message pushed to the conversation. */
239
+ onMessage(fn) {
240
+ return this.subscribe("message:new", fn);
241
+ }
242
+ /** Existing message edited by its sender. */
243
+ onMessageUpdated(fn) {
244
+ return this.subscribe("message:updated", fn);
245
+ }
246
+ /** Tombstone for a soft-deleted message. */
247
+ onMessageDeleted(fn) {
248
+ return this.subscribe("message:deleted", fn);
249
+ }
250
+ onTypingStart(fn) {
251
+ return this.subscribe("typing:start", fn);
252
+ }
253
+ onTypingStop(fn) {
254
+ return this.subscribe("typing:stop", fn);
255
+ }
256
+ onReactionAdded(fn) {
257
+ return this.subscribe("reaction:added", fn);
258
+ }
259
+ onReactionRemoved(fn) {
260
+ return this.subscribe("reaction:removed", fn);
261
+ }
262
+ onPresenceState(fn) {
263
+ return this.subscribe("presence_state", fn);
264
+ }
265
+ onPresenceDiff(fn) {
266
+ return this.subscribe("presence_diff", fn);
267
+ }
268
+ /** Send a typing ping to the server. Debounced server-side. */
269
+ sendTyping() {
270
+ this.channel.push("typing", {});
271
+ }
272
+ /** @internal — called by `PoolseRealtime.conversation/1`. */
273
+ _join() {
274
+ this.channel.join();
275
+ }
276
+ /** @internal — called when the consumer leaves this conversation. */
277
+ _destroy() {
278
+ this.listeners.clear();
279
+ this.channel.leave();
280
+ }
281
+ subscribe(event, fn) {
282
+ let set = this.listeners.get(event);
283
+ if (!set) {
284
+ set = /* @__PURE__ */ new Set();
285
+ this.listeners.set(event, set);
286
+ this.channel.on(event, (payload) => {
287
+ const listeners = this.listeners.get(event);
288
+ if (listeners) listeners.forEach((l) => l(payload));
289
+ });
290
+ }
291
+ set.add(fn);
292
+ return () => {
293
+ set.delete(fn);
294
+ };
295
+ }
296
+ };
297
+ var UserChannel = class {
298
+ userId;
299
+ channel;
300
+ mentionListeners = /* @__PURE__ */ new Set();
301
+ conversationCreatedListeners = /* @__PURE__ */ new Set();
302
+ mentionBound = false;
303
+ conversationCreatedBound = false;
304
+ constructor(userId, channel) {
305
+ this.userId = userId;
306
+ this.channel = channel;
307
+ }
308
+ onMention(fn) {
309
+ if (!this.mentionBound) {
310
+ this.mentionBound = true;
311
+ this.channel.on("mention:new", (payload) => {
312
+ this.mentionListeners.forEach((l) => l(payload));
313
+ });
314
+ }
315
+ this.mentionListeners.add(fn);
316
+ return () => {
317
+ this.mentionListeners.delete(fn);
318
+ };
319
+ }
320
+ /**
321
+ * Subscribe to "you've been added to a conversation" notifications.
322
+ * Fires once per new membership — either because you created the
323
+ * conversation, or because someone added you to an existing one.
324
+ *
325
+ * Payload is the full {@link Conversation} row so consumers can
326
+ * prepend it to a local list without a refetch.
327
+ */
328
+ onConversationCreated(fn) {
329
+ if (!this.conversationCreatedBound) {
330
+ this.conversationCreatedBound = true;
331
+ this.channel.on("conversation:created", (payload) => {
332
+ this.conversationCreatedListeners.forEach((l) => l(payload));
333
+ });
334
+ }
335
+ this.conversationCreatedListeners.add(fn);
336
+ return () => {
337
+ this.conversationCreatedListeners.delete(fn);
338
+ };
339
+ }
340
+ /** @internal */
341
+ _join() {
342
+ this.channel.join();
343
+ }
344
+ /** @internal */
345
+ _destroy() {
346
+ this.mentionListeners.clear();
347
+ this.conversationCreatedListeners.clear();
348
+ this.channel.leave();
349
+ }
350
+ };
351
+ function deriveWsUrl(apiUrl) {
352
+ return apiUrl.replace(/^http/, "ws");
353
+ }
354
+
355
+ // src/resources/attachments.ts
356
+ var AttachmentsResource = class {
357
+ /**
358
+ * The PUT to the presigned URL bypasses the SDK's authenticated
359
+ * REST client (presigned URLs encode their own auth and MUST NOT
360
+ * receive an `Authorization` header). It still respects
361
+ * `config.fetch` if the customer provided one — required for tests
362
+ * with a mock fetch, and for runtimes where `globalThis.fetch` is
363
+ * not the right transport.
364
+ */
365
+ constructor(client, fetchFn) {
366
+ this.client = client;
367
+ this.fetchFn = fetchFn;
368
+ }
369
+ client;
370
+ fetchFn;
371
+ /**
372
+ * Step 1 of an upload — request a presigned PUT URL. Use this when
373
+ * you want to drive the PUT yourself (e.g. resumable uploads,
374
+ * React Native FileSystem). For the common case prefer
375
+ * {@link upload}, which does both steps for you.
376
+ */
377
+ requestUpload(attrs, opts = {}) {
378
+ return this.client.request({
379
+ method: "POST",
380
+ path: "/v1/attachments/upload-url",
381
+ body: attrs,
382
+ ...opts.signal ? { signal: opts.signal } : {}
383
+ });
384
+ }
385
+ /**
386
+ * One-call upload: request a presigned URL, PUT the bytes to it,
387
+ * return the attachment row. After this resolves the attachment is
388
+ * ready to be referenced from a message send.
389
+ *
390
+ * ```ts
391
+ * // Browser <input type="file">:
392
+ * const file = inputEl.files![0]!;
393
+ * const att = await chat.attachments.upload({
394
+ * body: file,
395
+ * contentType: file.type,
396
+ * byteSize: file.size,
397
+ * filename: file.name,
398
+ * });
399
+ * await chat.conversations.one(convId).messages.send({
400
+ * body: 'Look at this!',
401
+ * custom_data: { attachment_id: att.id },
402
+ * });
403
+ * ```
404
+ *
405
+ * Note: the PUT uses the runtime's bare `fetch` (NOT the SDK's
406
+ * authenticated REST client) — presigned URLs already encode their
407
+ * own auth and MUST NOT receive an `Authorization` header.
408
+ */
409
+ async upload(input, opts = {}) {
410
+ const req = {
411
+ content_type: input.contentType,
412
+ byte_size: input.byteSize,
413
+ ...input.filename !== void 0 ? { original_filename: input.filename } : {}
414
+ };
415
+ const { attachment, upload } = await this.requestUpload(req, opts);
416
+ const putInit = {
417
+ method: upload.method.toUpperCase(),
418
+ headers: upload.headers,
419
+ body: input.body,
420
+ ...opts.signal ? { signal: opts.signal } : {}
421
+ };
422
+ const res = await this.fetchFn(upload.url, putInit);
423
+ if (!res.ok) {
424
+ throw new Error(
425
+ `Poolse: presigned upload PUT failed (${res.status}) for attachment ${attachment.id}`
426
+ );
427
+ }
428
+ return attachment;
429
+ }
430
+ /** Returns a handle for further operations on a single attachment. */
431
+ one(id) {
432
+ return new AttachmentHandle(this.client, id);
433
+ }
434
+ };
435
+ var AttachmentHandle = class {
436
+ constructor(client, id) {
437
+ this.client = client;
438
+ this.id = id;
439
+ }
440
+ client;
441
+ id;
442
+ /**
443
+ * Request a presigned GET URL (~1h TTL). Conversation-member-gated
444
+ * server-side. Useful when rendering files in chat: cache the URL
445
+ * client-side until close to expiry, then re-fetch.
446
+ */
447
+ downloadUrl(opts = {}) {
448
+ return this.client.request({
449
+ method: "GET",
450
+ path: `/v1/attachments/${this.id}/download-url`,
451
+ ...opts.signal ? { signal: opts.signal } : {}
452
+ });
453
+ }
454
+ /**
455
+ * Delete the attachment row + best-effort bucket object delete.
456
+ * Authz: uploader (while still `:pending`) or message-sender / conv
457
+ * owner-admin (once linked).
458
+ */
459
+ delete(opts = {}) {
460
+ return this.client.request({
461
+ method: "DELETE",
462
+ path: `/v1/attachments/${this.id}`,
463
+ ...opts.signal ? { signal: opts.signal } : {}
464
+ });
465
+ }
466
+ };
467
+
468
+ // src/resources/messages.ts
469
+ var ConversationMessages = class {
470
+ constructor(client, conversationId) {
471
+ this.client = client;
472
+ this.conversationId = conversationId;
473
+ }
474
+ client;
475
+ conversationId;
476
+ list(opts = {}, signal) {
477
+ return this.client.request({
478
+ method: "GET",
479
+ path: `/v1/conversations/${this.conversationId}/messages`,
480
+ query: {
481
+ ...opts.limit !== void 0 ? { limit: opts.limit } : {},
482
+ ...opts.before !== void 0 ? { before: opts.before } : {}
483
+ },
484
+ ...signal ? { signal } : {}
485
+ });
486
+ }
487
+ /**
488
+ * Send a message to this conversation.
489
+ *
490
+ * If `attrs.id` is omitted the SDK generates a v4 UUID and uses it
491
+ * as both the wire-level idempotency key AND the literal message.id
492
+ * the server stores. Two side-effects that make a real-time UI
493
+ * trivial:
494
+ *
495
+ * * Resending the same `id` (e.g. a network-retry) returns the
496
+ * ORIGINAL message instead of inserting a duplicate.
497
+ * * The realtime `message:new` broadcast carries this same id,
498
+ * so an optimistic UI can pre-render the row under the final id
499
+ * and dedup by id alone — no client/server id swap needed.
500
+ *
501
+ * Pass an explicit `attrs.id` only when you generated it yourself
502
+ * upstream (e.g. you already render an optimistic row in your hook
503
+ * and want the server to confirm under the same key).
504
+ */
505
+ send(attrs, signal) {
506
+ const body = attrs.id !== void 0 ? attrs : { ...attrs, id: generateClientMessageId() };
507
+ return this.client.request({
508
+ method: "POST",
509
+ path: `/v1/conversations/${this.conversationId}/messages`,
510
+ body,
511
+ ...signal ? { signal } : {}
512
+ });
513
+ }
514
+ markRead(messageId, signal) {
515
+ return this.client.request({
516
+ method: "POST",
517
+ path: `/v1/conversations/${this.conversationId}/read`,
518
+ body: { message_id: messageId },
519
+ ...signal ? { signal } : {}
520
+ });
521
+ }
522
+ };
523
+ var MessageHandle = class {
524
+ constructor(client, id) {
525
+ this.client = client;
526
+ this.id = id;
527
+ }
528
+ client;
529
+ id;
530
+ update(attrs, signal) {
531
+ return this.client.request({
532
+ method: "PATCH",
533
+ path: `/v1/messages/${this.id}`,
534
+ body: attrs,
535
+ ...signal ? { signal } : {}
536
+ });
537
+ }
538
+ delete(signal) {
539
+ return this.client.request({
540
+ method: "DELETE",
541
+ path: `/v1/messages/${this.id}`,
542
+ ...signal ? { signal } : {}
543
+ });
544
+ }
545
+ replies(opts = {}, signal) {
546
+ return this.client.request({
547
+ method: "GET",
548
+ path: `/v1/messages/${this.id}/replies`,
549
+ query: {
550
+ ...opts.limit !== void 0 ? { limit: opts.limit } : {},
551
+ ...opts.after !== void 0 ? { after: opts.after } : {}
552
+ },
553
+ ...signal ? { signal } : {}
554
+ });
555
+ }
556
+ addReaction(emoji, signal) {
557
+ const body = { emoji };
558
+ return this.client.request({
559
+ method: "POST",
560
+ path: `/v1/messages/${this.id}/reactions`,
561
+ body,
562
+ ...signal ? { signal } : {}
563
+ });
564
+ }
565
+ removeReaction(emoji, signal) {
566
+ return this.client.request({
567
+ method: "DELETE",
568
+ path: `/v1/messages/${this.id}/reactions/${encodeURIComponent(emoji)}`,
569
+ ...signal ? { signal } : {}
570
+ });
571
+ }
572
+ };
573
+ var MessagesResource = class {
574
+ constructor(client) {
575
+ this.client = client;
576
+ }
577
+ client;
578
+ one(id) {
579
+ return new MessageHandle(this.client, id);
580
+ }
581
+ };
582
+ function generateClientMessageId() {
583
+ const c = globalThis.crypto;
584
+ if (c && typeof c.randomUUID === "function") {
585
+ return c.randomUUID();
586
+ }
587
+ throw new Error(
588
+ "Poolse: globalThis.crypto.randomUUID() unavailable \u2014 pass `id` explicitly in MessageCreateRequest (your env lacks a built-in UUID generator)."
589
+ );
590
+ }
591
+
592
+ // src/resources/conversations.ts
593
+ var ConversationHandle = class {
594
+ constructor(client, id) {
595
+ this.client = client;
596
+ this.id = id;
597
+ this.messages = new ConversationMessages(this.client, this.id);
598
+ }
599
+ client;
600
+ id;
601
+ /**
602
+ * Message ops scoped to this conversation: `list`, `send`, `markRead`.
603
+ * Lazy: constructed on first access so an idle handle stays cheap.
604
+ */
605
+ messages;
606
+ show(signal) {
607
+ return this.client.request({
608
+ method: "GET",
609
+ path: `/v1/conversations/${this.id}`,
610
+ ...signal ? { signal } : {}
611
+ });
612
+ }
613
+ update(attrs, signal) {
614
+ return this.client.request({
615
+ method: "PATCH",
616
+ path: `/v1/conversations/${this.id}`,
617
+ body: attrs,
618
+ ...signal ? { signal } : {}
619
+ });
620
+ }
621
+ // ── members ────────────────────────────────────────────────────────────
622
+ listMembers(signal) {
623
+ return this.client.request({
624
+ method: "GET",
625
+ path: `/v1/conversations/${this.id}/members`,
626
+ ...signal ? { signal } : {}
627
+ });
628
+ }
629
+ /**
630
+ * Add multiple users to this conversation in one round-trip.
631
+ *
632
+ * `externalIds` are the stable customer-side identifiers you passed
633
+ * to `POST /v1/users` when creating each user — the server resolves
634
+ * them to internal user_ids and creates one membership row per id.
635
+ *
636
+ * Requires `:manage_members` on this conversation (owner or admin).
637
+ *
638
+ * ```ts
639
+ * await chat.conversations.one(convId).addMembers(['alice', 'bob']);
640
+ * ```
641
+ */
642
+ addMembers(externalIds, opts = {}) {
643
+ return this.client.request({
644
+ method: "POST",
645
+ path: `/v1/conversations/${this.id}/members`,
646
+ body: {
647
+ external_ids: externalIds,
648
+ ...opts.role !== void 0 ? { role: opts.role } : {}
649
+ },
650
+ ...opts.signal ? { signal: opts.signal } : {}
651
+ });
652
+ }
653
+ /**
654
+ * Add a single user. Convenience wrapper around {@link addMembers}
655
+ * that unwraps the returned list to the single membership row.
656
+ *
657
+ * ```ts
658
+ * const m = await chat.conversations.one(convId).addMember('alice');
659
+ * ```
660
+ */
661
+ async addMember(externalId, opts = {}) {
662
+ const list = await this.addMembers([externalId], opts);
663
+ const row = list.data[0];
664
+ if (!row) {
665
+ throw new Error("Poolse: addMember succeeded but server returned no membership row.");
666
+ }
667
+ return row;
668
+ }
669
+ removeMember(userId, signal) {
670
+ return this.client.request({
671
+ method: "DELETE",
672
+ path: `/v1/conversations/${this.id}/members/${userId}`,
673
+ ...signal ? { signal } : {}
674
+ });
675
+ }
676
+ };
677
+ var ConversationsResource = class {
678
+ constructor(client) {
679
+ this.client = client;
680
+ }
681
+ client;
682
+ list(signal) {
683
+ return this.client.request({
684
+ method: "GET",
685
+ path: "/v1/conversations",
686
+ ...signal ? { signal } : {}
687
+ });
688
+ }
689
+ create(attrs, signal) {
690
+ return this.client.request({
691
+ method: "POST",
692
+ path: "/v1/conversations",
693
+ body: attrs,
694
+ ...signal ? { signal } : {}
695
+ });
696
+ }
697
+ /** Returns a handle for further operations on a single conversation. */
698
+ one(id) {
699
+ return new ConversationHandle(this.client, id);
700
+ }
701
+ };
702
+
703
+ // src/resources/me.ts
704
+ var MeResource = class {
705
+ constructor(client) {
706
+ this.client = client;
707
+ }
708
+ client;
709
+ /** GET /v1/me */
710
+ show(signal) {
711
+ return this.client.request({
712
+ method: "GET",
713
+ path: "/v1/me",
714
+ ...signal ? { signal } : {}
715
+ });
716
+ }
717
+ };
718
+
719
+ // src/rest-client.ts
720
+ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET"]);
721
+ var RestClient = class {
722
+ config;
723
+ tokenCache;
724
+ constructor(config, tokenCache) {
725
+ this.config = config;
726
+ this.tokenCache = tokenCache;
727
+ }
728
+ async request(opts) {
729
+ const url = this.buildUrl(opts.path, opts.query);
730
+ const maxRetries = opts.maxRetries ?? this.config.maxRetries;
731
+ const idempotencyKey = this.resolveIdempotencyKey(opts);
732
+ let attempt = 0;
733
+ let triedAuthRefresh = false;
734
+ for (; ; ) {
735
+ const body = opts.body === void 0 ? void 0 : JSON.stringify(opts.body);
736
+ const headers = await this.buildHeaders(opts.method, idempotencyKey, body !== void 0);
737
+ let response;
738
+ try {
739
+ const init = { method: opts.method, headers };
740
+ if (body !== void 0) init.body = body;
741
+ if (opts.signal) init.signal = opts.signal;
742
+ response = await this.config.fetch(url, init);
743
+ } catch (err) {
744
+ if (attempt < maxRetries && isRetryableNetworkError(err)) {
745
+ await sleep(this.backoffDelay(attempt));
746
+ attempt += 1;
747
+ continue;
748
+ }
749
+ throw new NetworkError("Network request failed", err);
750
+ }
751
+ if (response.status >= 200 && response.status < 300) {
752
+ return await parseJsonOrNull(response);
753
+ }
754
+ if (response.status === 401) {
755
+ if (!triedAuthRefresh) {
756
+ this.tokenCache.invalidate();
757
+ triedAuthRefresh = true;
758
+ continue;
759
+ }
760
+ throw new AuthError(await parseEnvelope(response));
761
+ }
762
+ if (response.status === 429) {
763
+ const envelope = await parseEnvelope(response);
764
+ const retryAfterMs = retryAfterHeaderMs(response);
765
+ if (attempt < maxRetries) {
766
+ await sleep(Math.max(retryAfterMs ?? 0, this.backoffDelay(attempt)));
767
+ attempt += 1;
768
+ continue;
769
+ }
770
+ throw new RateLimitedError(envelope, retryAfterMs ?? 0);
771
+ }
772
+ if (response.status >= 500 && attempt < maxRetries) {
773
+ await sleep(this.backoffDelay(attempt));
774
+ attempt += 1;
775
+ continue;
776
+ }
777
+ throw new ApiError(response.status, await parseEnvelope(response));
778
+ }
779
+ }
780
+ // ── helpers ────────────────────────────────────────────────────────────
781
+ buildUrl(path, query) {
782
+ const base = `${this.config.apiUrl}${path.startsWith("/") ? path : `/${path}`}`;
783
+ if (!query) return base;
784
+ const params = new URLSearchParams();
785
+ for (const [k, v] of Object.entries(query)) {
786
+ if (v !== void 0 && v !== null) {
787
+ params.append(k, String(v));
788
+ }
789
+ }
790
+ const qs = params.toString();
791
+ return qs ? `${base}?${qs}` : base;
792
+ }
793
+ async buildHeaders(method, idempotencyKey, hasBody) {
794
+ const headers = { Accept: "application/json" };
795
+ if (hasBody) headers["Content-Type"] = "application/json";
796
+ const token = await this.config.getToken();
797
+ if (token) headers["Authorization"] = `Bearer ${token}`;
798
+ if (!IDEMPOTENT_METHODS.has(method) && idempotencyKey) {
799
+ headers["Idempotency-Key"] = idempotencyKey;
800
+ }
801
+ return headers;
802
+ }
803
+ resolveIdempotencyKey(opts) {
804
+ if (opts.idempotencyKey === null) return null;
805
+ if (opts.idempotencyKey) return opts.idempotencyKey;
806
+ if (IDEMPOTENT_METHODS.has(opts.method)) return null;
807
+ return this.config.generateIdempotencyKey();
808
+ }
809
+ backoffDelay(attempt) {
810
+ const exp = this.config.baseBackoffMs * 2 ** attempt;
811
+ const capped = Math.min(this.config.maxBackoffMs, exp);
812
+ return Math.floor(Math.random() * capped);
813
+ }
814
+ };
815
+ async function parseEnvelope(response) {
816
+ try {
817
+ const json = await response.json();
818
+ if (json?.error?.code) return json.error;
819
+ } catch {
820
+ }
821
+ return {
822
+ code: "unknown_error",
823
+ message: `HTTP ${response.status}`,
824
+ doc_url: ""
825
+ };
826
+ }
827
+ async function parseJsonOrNull(response) {
828
+ if (response.status === 204) return null;
829
+ const text = await response.text();
830
+ if (!text) return null;
831
+ return JSON.parse(text);
832
+ }
833
+ function retryAfterHeaderMs(response) {
834
+ const raw = response.headers.get("retry-after");
835
+ if (!raw) return null;
836
+ const seconds = Number.parseInt(raw, 10);
837
+ return Number.isFinite(seconds) ? seconds * 1e3 : null;
838
+ }
839
+ function isRetryableNetworkError(err) {
840
+ if (err instanceof DOMException && err.name === "AbortError") return false;
841
+ return true;
842
+ }
843
+ function sleep(ms) {
844
+ return new Promise((resolve) => setTimeout(resolve, ms));
845
+ }
846
+
847
+ // src/token-cache.ts
848
+ var REFRESH_BUFFER_MS = 3e4;
849
+ var FALLBACK_TTL_MS = 6e4;
850
+ var TokenCache = class {
851
+ constructor(fetcher) {
852
+ this.fetcher = fetcher;
853
+ }
854
+ fetcher;
855
+ token = null;
856
+ expMs = null;
857
+ inFlight = null;
858
+ /**
859
+ * Synchronously return the cached token without triggering a fetch.
860
+ * Returns `null` if the cache is empty OR if the cached token is
861
+ * within the refresh window (treating near-expiry tokens as stale
862
+ * keeps the realtime layer from handshaking with an about-to-expire
863
+ * JWT when a refresh is already due).
864
+ *
865
+ * Exists for callers like Phoenix.js's `params` callback that the
866
+ * library invokes synchronously and does NOT await — see
867
+ * `phoenix/priv/static/phoenix.mjs::endPointURL()`.
868
+ */
869
+ peekToken() {
870
+ if (this.token === null || this.expMs === null) return this.token;
871
+ return Date.now() < this.expMs - REFRESH_BUFFER_MS ? this.token : null;
872
+ }
873
+ async getToken(opts = {}) {
874
+ if (opts.forceRefresh) this.invalidate();
875
+ const now = Date.now();
876
+ if (this.token && this.expMs !== null && now < this.expMs - REFRESH_BUFFER_MS) {
877
+ return this.token;
878
+ }
879
+ if (this.inFlight) return this.inFlight;
880
+ this.inFlight = this.fetchAndStore().finally(() => {
881
+ this.inFlight = null;
882
+ });
883
+ return this.inFlight;
884
+ }
885
+ invalidate() {
886
+ this.token = null;
887
+ this.expMs = null;
888
+ }
889
+ async fetchAndStore() {
890
+ const token = await this.fetcher();
891
+ if (!token) {
892
+ this.token = null;
893
+ this.expMs = null;
894
+ return null;
895
+ }
896
+ const expSec = parseJwtExp(token);
897
+ if (expSec !== null) {
898
+ const expMs = expSec * 1e3;
899
+ this.expMs = expMs <= Date.now() ? Date.now() + FALLBACK_TTL_MS : expMs;
900
+ } else {
901
+ this.expMs = Date.now() + FALLBACK_TTL_MS;
902
+ }
903
+ this.token = token;
904
+ return token;
905
+ }
906
+ };
907
+ function parseJwtExp(token) {
908
+ const parts = token.split(".");
909
+ if (parts.length !== 3) return null;
910
+ try {
911
+ const json = decodeBase64Url(parts[1]);
912
+ const payload = JSON.parse(json);
913
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp : null;
914
+ } catch {
915
+ return null;
916
+ }
917
+ }
918
+ function decodeBase64Url(s) {
919
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
920
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
921
+ if (typeof atob === "function") return atob(padded);
922
+ const g = globalThis;
923
+ if (g.Buffer) return g.Buffer.from(padded, "base64").toString("binary");
924
+ throw new Error("No base64 decoder available");
925
+ }
926
+
927
+ // src/poolse.ts
928
+ var Poolse = class {
929
+ /** `/v1/me` — current End User. */
930
+ me;
931
+ /** `/v1/conversations` collection + per-conversation handle factory. */
932
+ conversations;
933
+ /** `/v1/messages/:id/*` — accessed via `chat.messages.one(id)`. */
934
+ messages;
935
+ /** `/v1/attachments/*` — presigned-URL uploads/downloads. */
936
+ attachments;
937
+ /**
938
+ * Low-level REST client. Exposed for advanced use cases (custom endpoints,
939
+ * raw retry/headers control). Most callers should use the resources above.
940
+ */
941
+ rest;
942
+ /**
943
+ * WebSocket / Phoenix Channels client. Lazily connects on the first
944
+ * `poolse.realtime.conversation(id)` / `poolse.realtime.user(id)`
945
+ * call — passing `config.apiUrl` (with `http(s)://` swapped to
946
+ * `ws(s)://`) for the socket URL by default, overridable via
947
+ * `config.wsUrl`.
948
+ */
949
+ realtime;
950
+ resolved;
951
+ tokenCache;
952
+ constructor(config) {
953
+ this.resolved = resolveConfig(config);
954
+ this.tokenCache = new TokenCache(this.resolved.getToken);
955
+ const cachedConfig = {
956
+ ...this.resolved,
957
+ getToken: () => this.tokenCache.getToken()
958
+ };
959
+ this.rest = new RestClient(cachedConfig, this.tokenCache);
960
+ this.me = new MeResource(this.rest);
961
+ this.conversations = new ConversationsResource(this.rest);
962
+ this.messages = new MessagesResource(this.rest);
963
+ this.attachments = new AttachmentsResource(this.rest, cachedConfig.fetch);
964
+ this.realtime = new PoolseRealtime(cachedConfig, this.tokenCache, {
965
+ ...this.resolved.wsUrl !== void 0 ? { wsUrl: this.resolved.wsUrl } : {},
966
+ socketPath: this.resolved.socketPath
967
+ });
968
+ }
969
+ /**
970
+ * Tear down the SDK: close the WebSocket, drop all channels.
971
+ * No-op for REST — fetch() doesn't keep persistent state.
972
+ * Call this when the user signs out or the SDK instance is
973
+ * being replaced.
974
+ */
975
+ destroy() {
976
+ this.realtime.disconnect();
977
+ }
978
+ };
979
+
980
+ // src/version.ts
981
+ var version = "0.0.1";
982
+
983
+ export { ApiError, AttachmentHandle, AttachmentsResource, AuthError, ConversationChannel, ConversationHandle, ConversationMessages, ConversationsResource, MeResource, MessageHandle, MessagesResource, NetworkError, Poolse, PoolseError, PoolseRealtime, RateLimitedError, UserChannel, version };
984
+ //# sourceMappingURL=index.js.map
985
+ //# sourceMappingURL=index.js.map