@xmtp/node-sdk 6.2.0-dev.999b077 → 6.2.0-dev.e7edced

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 CHANGED
@@ -1,73 +1,45 @@
1
- import { BackendBuilder, generateInboxId as generateInboxId$1, getInboxIdByIdentity, contentTypeIntent, contentTypeActions, contentTypeWalletSendCalls, contentTypeLeaveRequest, contentTypeReadReceipt, contentTypeGroupUpdated, contentTypeTransactionReference, contentTypeMultiRemoteAttachment, contentTypeRemoteAttachment, contentTypeAttachment, contentTypeReaction, contentTypeReply, contentTypeMarkdown, contentTypeText, UnstableChangeCallbacks, createClientWithBackend, revokeInstallationsSignatureRequest, applySignatureRequest, fetchInboxStatesByInboxIds, verifySignedWithPublicKey, isAddressAuthorized, isInstallationAuthorized, Backend } from '@xmtp/node-bindings';
1
+ import { BackendBuilder, AuthCallback, generateInboxId as generateInboxId$1, getInboxIdByIdentity, contentTypeIntent, contentTypeActions, contentTypeWalletSendCalls, contentTypeLeaveRequest, contentTypeReadReceipt, contentTypeGroupUpdated, contentTypeTransactionReference, contentTypeMultiRemoteAttachment, contentTypeRemoteAttachment, contentTypeAttachment, contentTypeReaction, contentTypeReply, contentTypeMarkdown, contentTypeText, UnstableChangeCallbacks, createClientWithBackend, revokeInstallationsSignatureRequest, applySignatureRequest, fetchInboxStatesByInboxIds, verifySignedWithPublicKey, isAddressAuthorized, isInstallationAuthorized, fetchServerConfiguration, Backend } from '@xmtp/node-bindings';
2
2
  export { ActionStyle, BackupElementSelectionOption, ConsentEntityType, ConsentState, ContentType, ConversationType, DeliveryStatus, GroupMembershipState, GroupMessageKind, GroupPermissionsOptions, IdentifierKind, ListConversationsOrderBy, LogLevel, MessageSortBy, MetadataField, PermissionLevel, PermissionPolicy, PermissionUpdateType, ReactionAction, ReactionSchema, SortDirection, WorkerKind, contentTypeActions, contentTypeAttachment, contentTypeGroupUpdated, contentTypeIntent, contentTypeLeaveRequest, contentTypeMarkdown, contentTypeMultiRemoteAttachment, contentTypeReaction, contentTypeReadReceipt, contentTypeRemoteAttachment, contentTypeReply, contentTypeText, contentTypeTransactionReference, contentTypeWalletSendCalls, decryptAttachment, encodeActions, encodeAttachment, encodeIntent, encodeMarkdown, encodeMultiRemoteAttachment, encodeReaction, encodeReadReceipt, encodeRemoteAttachment, encodeText, encodeTransactionReference, encodeWalletSendCalls, encryptAttachment, flushTelemetry, initLogging } from '@xmtp/node-bindings';
3
- import { randomBytes } from 'node:crypto';
4
3
  import { contentTypeToString } from '@xmtp/content-type-primitives';
4
+ import { randomBytes } from 'node:crypto';
5
5
  import { isPromise } from 'node:util/types';
6
6
  import { join } from 'node:path';
7
7
  import process from 'node:process';
8
8
 
9
- /**
10
- * Pre-configured URLs for the XMTP network based on the environment
11
- *
12
- * @deprecated Use `createBackend()` instead.
13
- * @constant
14
- * @property {string} local - The local URL for the XMTP network
15
- * @property {string} dev - The development URL for the XMTP network
16
- * @property {string} production - The production URL for the XMTP network
17
- */
18
- const ApiUrls = {
19
- local: "http://localhost:5556",
20
- dev: "https://grpc.dev.xmtp.network:443",
21
- production: "https://grpc.production.xmtp.network:443",
22
- };
23
- /**
24
- * Pre-configured URLs for the XMTP history sync service based on the environment
25
- *
26
- * @constant
27
- * @property {string} local - The local URL for the XMTP history sync service
28
- * @property {string} dev - The development URL for the XMTP history sync service
29
- * @property {string} production - The production URL for the XMTP history sync service
30
- */
31
- const HistorySyncUrls = {
32
- local: "http://localhost:5558",
33
- dev: "https://message-history.dev.ephemera.network",
34
- production: "https://message-history.production.ephemera.network",
35
- "testnet-staging": "https://message-history.dev.ephemera.network",
36
- "testnet-dev": "https://message-history.dev.ephemera.network",
37
- testnet: "https://message-history.dev.ephemera.network",
38
- mainnet: "https://message-history.production.ephemera.network",
9
+ /** Keep callback failures and invalid credentials out of error messages. */
10
+ const readCredential = async (callback) => {
11
+ try {
12
+ const credential = await callback();
13
+ if (typeof credential.value !== "string" ||
14
+ (credential.name !== undefined && typeof credential.name !== "string") ||
15
+ !Number.isSafeInteger(credential.expiresAtSeconds)) {
16
+ throw new Error();
17
+ }
18
+ return {
19
+ name: credential.name,
20
+ value: credential.value,
21
+ expiresAtSeconds: credential.expiresAtSeconds,
22
+ };
23
+ }
24
+ catch {
25
+ throw new Error("auth callback failed");
26
+ }
39
27
  };
40
28
 
41
- const envMap = {
42
- local: "Local" /* BindingsEnv.Local */,
43
- dev: "Dev" /* BindingsEnv.Dev */,
44
- production: "Production" /* BindingsEnv.Production */,
45
- "testnet-staging": "TestnetStaging" /* BindingsEnv.TestnetStaging */,
46
- "testnet-dev": "TestnetDev" /* BindingsEnv.TestnetDev */,
47
- testnet: "Testnet" /* BindingsEnv.Testnet */,
48
- mainnet: "Mainnet" /* BindingsEnv.Mainnet */,
49
- };
50
- const reverseEnvMap = {
51
- ["Local" /* BindingsEnv.Local */]: "local",
52
- ["Dev" /* BindingsEnv.Dev */]: "dev",
53
- ["Production" /* BindingsEnv.Production */]: "production",
54
- ["TestnetStaging" /* BindingsEnv.TestnetStaging */]: "testnet-staging",
55
- ["TestnetDev" /* BindingsEnv.TestnetDev */]: "testnet-dev",
56
- ["Testnet" /* BindingsEnv.Testnet */]: "testnet",
57
- ["Mainnet" /* BindingsEnv.Mainnet */]: "mainnet",
58
- };
59
- const envToString = (env) => {
60
- return reverseEnvMap[env];
61
- };
62
29
  const createBackend = async (options) => {
63
- const env = options?.env ?? "dev";
64
- const builder = new BackendBuilder(envMap[env]);
65
- if (options?.apiUrl)
66
- builder.setApiUrl(options.apiUrl);
67
- if (options?.gatewayHost)
68
- builder.setGatewayHost(options.gatewayHost);
69
- if (options?.appVersion)
30
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Validate options from JavaScript callers.
31
+ if (!options?.backendUrl?.trim()) {
32
+ throw new Error("backendUrl is required");
33
+ }
34
+ const builder = new BackendBuilder(options.backendUrl);
35
+ if (options.env !== undefined)
36
+ builder.setEnv(options.env);
37
+ if (options.appVersion !== undefined)
70
38
  builder.setAppVersion(options.appVersion);
39
+ if (options.authCallback) {
40
+ const callback = options.authCallback;
41
+ builder.authCallback(new AuthCallback(() => readCredential(callback)));
42
+ }
71
43
  return builder.build();
72
44
  };
73
45
 
@@ -173,6 +145,13 @@ function nsToDate(ns) {
173
145
  return new Date(Number(ns / 1000000n));
174
146
  }
175
147
 
148
+ const decodeFailures = new WeakMap();
149
+ /** Reject failed registered codecs at delivery without changing legacy reads. */
150
+ const assertMessageDecodedForDelivery = (message) => {
151
+ const failure = decodeFailures.get(message);
152
+ if (failure)
153
+ throw failure.error;
154
+ };
176
155
  const getContentFromDecodedMessageContent = (content) => {
177
156
  switch (content.type) {
178
157
  case "Text" /* DecodedMessageContentType.Text */: {
@@ -303,6 +282,8 @@ const getContentTypeFromDecodedMessageContent = (content) => {
303
282
  * @property {bigint} sentAtNs - Timestamp when the message was sent (in nanoseconds)
304
283
  */
305
284
  class DecodedMessage {
285
+ /** Database-local position of this delivery. Absent for ordinary history reads. */
286
+ deliveryCursor;
306
287
  content;
307
288
  contentType;
308
289
  conversationId;
@@ -331,8 +312,15 @@ class DecodedMessage {
331
312
  this.fallback = message.fallback ?? undefined;
332
313
  this.kind = message.kind;
333
314
  this.deliveryStatus = message.deliveryStatus;
315
+ const decodeChild = (child) => {
316
+ const decoded = new DecodedMessage(codecRegistry, child);
317
+ const failure = decodeFailures.get(decoded);
318
+ if (failure && !decodeFailures.has(this))
319
+ decodeFailures.set(this, failure);
320
+ return decoded;
321
+ };
334
322
  this.numReplies = message.numReplies;
335
- this.reactions = message.reactions.map((reaction) => new DecodedMessage(codecRegistry, reaction));
323
+ this.reactions = message.reactions.map((reaction) => decodeChild(reaction));
336
324
  this.content =
337
325
  getContentFromDecodedMessageContent(message.content) ??
338
326
  undefined;
@@ -347,6 +335,8 @@ class DecodedMessage {
347
335
  replyContent = codec.decode(replyContent);
348
336
  }
349
337
  catch (error) {
338
+ if (!decodeFailures.has(this))
339
+ decodeFailures.set(this, { error });
350
340
  if (error instanceof Error) {
351
341
  console.warn(`Error decoding custom content: ${error.message}`);
352
342
  }
@@ -361,7 +351,7 @@ class DecodedMessage {
361
351
  content: replyContent,
362
352
  contentType: getContentTypeFromDecodedMessageContent(reply.content),
363
353
  inReplyTo: reply.inReplyTo
364
- ? new DecodedMessage(codecRegistry, reply.inReplyTo)
354
+ ? decodeChild(reply.inReplyTo)
365
355
  : null,
366
356
  };
367
357
  break;
@@ -375,6 +365,8 @@ class DecodedMessage {
375
365
  this.content = codec.decode(customContent);
376
366
  }
377
367
  catch (error) {
368
+ if (!decodeFailures.has(this))
369
+ decodeFailures.set(this, { error });
378
370
  if (error instanceof Error) {
379
371
  console.warn(`Error decoding custom content: ${error.message}`);
380
372
  }
@@ -395,430 +387,258 @@ class DecodedMessage {
395
387
  }
396
388
  }
397
389
 
398
- /**
399
- * AsyncStream provides an async iterable interface for streaming data.
400
- *
401
- * This class implements a producer-consumer pattern where:
402
- * - Producers can push values using the `push()` method
403
- * - Consumers can iterate over values asynchronously using `for await` loops or `next()`
404
- * - Values are queued internally when no consumers are waiting
405
- * - Consumers are resolved immediately when values are available
406
- * - The stream can be terminated using `done()`, `return()`, or `end()`
407
- *
408
- * @example
409
- * ```typescript
410
- * const stream = new AsyncStream<string>();
411
- *
412
- * stream.push("hello");
413
- * stream.push("world");
414
- *
415
- * for await (const value of stream) {
416
- * console.log(value); // "hello", "world"
417
- * }
418
- * ```
419
- */
420
- class AsyncStream {
421
- isDone = false;
422
- #pendingResolves = [];
423
- #queue;
424
- onDone;
425
- onReturn;
426
- constructor() {
427
- this.#queue = [];
428
- this.isDone = false;
429
- }
430
- flush() {
431
- while (this.#pendingResolves.length > 0) {
432
- const nextResolve = this.#pendingResolves.shift();
433
- if (nextResolve) {
434
- nextResolve({ done: true, value: undefined });
435
- }
390
+ /** One pending item. Construction selects callback mode or next-request acknowledgement. */
391
+ class MessageStream {
392
+ #reader;
393
+ #convert;
394
+ #options;
395
+ #onValue;
396
+ #pending;
397
+ #reading = false;
398
+ #done = false;
399
+ #closing;
400
+ #cursor;
401
+ constructor(reader, convert, options = {}) {
402
+ this.#reader = reader;
403
+ this.#convert = convert;
404
+ this.#options = options;
405
+ this.#onValue = options.onValue;
406
+ if (this.#onValue) {
407
+ // Callback mode has no caller waiting on next(). Errors reach onError before cleanup.
408
+ void this.#read().catch(() => undefined);
436
409
  }
437
410
  }
438
- done() {
439
- this.flush();
440
- this.#queue = [];
441
- this.#pendingResolves = [];
442
- this.isDone = true;
443
- this.onDone?.();
411
+ get isDone() {
412
+ return this.#done;
444
413
  }
445
- push = (value) => {
446
- if (this.isDone) {
447
- return;
414
+ get deliveredCursor() {
415
+ return this.#cursor;
416
+ }
417
+ #hasEnded() {
418
+ return this.#done;
419
+ }
420
+ next = () => {
421
+ if (this.#onValue) {
422
+ return Promise.reject(new Error("Cannot call next() on a message stream in callback mode"));
448
423
  }
449
- const nextResolve = this.#pendingResolves.shift();
450
- if (nextResolve) {
451
- nextResolve({
452
- done: false,
453
- value,
454
- });
424
+ return this.#read();
425
+ };
426
+ async #acknowledgePending() {
427
+ const pending = this.#pending;
428
+ if (pending) {
429
+ // Keep the token until success so close can reject a failed acknowledgement.
430
+ await pending.acknowledge();
431
+ if (this.#pending === pending)
432
+ this.#pending = undefined;
433
+ }
434
+ }
435
+ async #read() {
436
+ if (this.#hasEnded())
437
+ return { done: true, value: undefined };
438
+ if (this.#reading)
439
+ throw new Error("A message read is already pending");
440
+ this.#reading = true;
441
+ try {
442
+ await this.#acknowledgePending();
443
+ while (!this.#hasEnded()) {
444
+ const item = await this.#reader.nextDelivery();
445
+ if (item === undefined || item === null) {
446
+ await this.return();
447
+ break;
448
+ }
449
+ if (this.#hasEnded()) {
450
+ await item.acknowledgement.reject();
451
+ break;
452
+ }
453
+ this.#pending = item.acknowledgement;
454
+ // A codec that throws must not advance the cursor. The message is
455
+ // retained and a later reader, or the same client with the codec
456
+ // registered, must still see it. Acknowledging here would skip a
457
+ // message nothing has read. This matches the undefined case below:
458
+ // both mean this client could not decode the message, so both hold
459
+ // the item rather than discarding it.
460
+ const value = await this.#convert(item.message, item.cursor);
461
+ if (this.#hasEnded())
462
+ break;
463
+ const checked = item.acknowledgement.checkOwner();
464
+ const valid = typeof checked === "boolean" ? checked : await checked;
465
+ if (this.#hasEnded())
466
+ break;
467
+ if (!valid) {
468
+ this.#pending = undefined;
469
+ await item.acknowledgement.reject();
470
+ continue;
471
+ }
472
+ if (value === undefined) {
473
+ // The converter could not produce a value. That includes a failed
474
+ // lookup, so it is not safe to acknowledge: skipping here would
475
+ // advance the durable cursor past a message nothing has read.
476
+ // Report it and stop, leaving the item replayable.
477
+ throw new Error("The retained message could not be decoded");
478
+ }
479
+ // Do not await between the final ownership check and the app handoff.
480
+ this.#cursor = item.cursor;
481
+ if (this.#onValue) {
482
+ await this.#onValue(value);
483
+ if (this.#hasEnded())
484
+ break;
485
+ await this.#acknowledgePending();
486
+ }
487
+ else {
488
+ return { done: false, value };
489
+ }
490
+ }
491
+ return { done: true, value: undefined };
455
492
  }
456
- else {
457
- this.#queue.push(value);
493
+ catch (error) {
494
+ if (!this.#hasEnded()) {
495
+ try {
496
+ // A handler that throws must not replace the real failure. The
497
+ // caller needs the original cause to know why the stream ended.
498
+ this.#options.onError?.(error);
499
+ }
500
+ catch {
501
+ // Reported through the rethrow below.
502
+ }
503
+ finally {
504
+ await this.return();
505
+ }
506
+ }
507
+ throw error;
458
508
  }
459
- };
460
- next = () => {
461
- if (this.isDone) {
462
- return Promise.resolve({ done: true, value: undefined });
509
+ finally {
510
+ this.#reading = false;
463
511
  }
464
- if (this.#queue.length > 0) {
465
- return Promise.resolve({
466
- done: false,
467
- value: this.#queue.shift(),
468
- });
512
+ }
513
+ async #close(pending) {
514
+ const invoke = (action) => {
515
+ try {
516
+ return Promise.resolve(action());
517
+ }
518
+ catch (error) {
519
+ return Promise.reject(error instanceof Error
520
+ ? error
521
+ : new Error("Message stream close failed", { cause: error }));
522
+ }
523
+ };
524
+ // Invoke close before any await so it fences pending reads and host handoffs.
525
+ const results = await Promise.allSettled([
526
+ invoke(() => this.#reader.close()),
527
+ invoke(() => pending?.reject()),
528
+ ]);
529
+ try {
530
+ for (const result of results) {
531
+ if (result.status === "rejected")
532
+ throw result.reason;
533
+ }
534
+ return { done: true, value: undefined };
469
535
  }
470
- return new Promise((resolve) => {
471
- this.#pendingResolves.push(resolve);
472
- });
473
- };
536
+ finally {
537
+ this.#options.onEnd?.();
538
+ }
539
+ }
474
540
  return = () => {
475
- this.onReturn?.();
476
- this.done();
477
- return Promise.resolve({
478
- done: true,
479
- value: undefined,
480
- });
541
+ if (this.#done) {
542
+ return this.#closing ?? Promise.resolve({ done: true, value: undefined });
543
+ }
544
+ this.#done = true;
545
+ const pending = this.#pending;
546
+ this.#pending = undefined;
547
+ this.#closing = this.#close(pending);
548
+ return this.#closing;
481
549
  };
482
- end = () => this.return();
550
+ end = this.return;
551
+ updateScope = (groupIds) => this.#reader.updateScope(groupIds);
552
+ updateFilter = (conversationType, consentStates) => this.#reader.updateFilter(conversationType, consentStates);
553
+ catchUpSnapshot = () => this.#reader.catchUpSnapshot();
554
+ catchUpChanged = () => this.#reader.catchUpChanged();
483
555
  [Symbol.asyncIterator]() {
484
556
  return this;
485
557
  }
486
558
  }
487
- const usableProperties = [
488
- "end",
489
- "isDone",
490
- "next",
491
- "return",
492
- Symbol.asyncIterator,
493
- ];
494
- const isUsableProperty = (prop) => {
495
- return usableProperties.includes(prop);
559
+
560
+ /** A notification failure with a stable error code. */
561
+ class NotificationError extends Error {
562
+ code;
563
+ constructor(code, options) {
564
+ super(code, options);
565
+ this.code = code;
566
+ this.name = "NotificationError";
567
+ }
568
+ }
569
+ const toBindingNotificationConfig = (config) => {
570
+ const { channel, ...rules } = config;
571
+ return {
572
+ ...rules,
573
+ channel: channel.type,
574
+ ...(channel.type === "http"
575
+ ? { url: channel.url, signingKey: Array.from(channel.signingKey) }
576
+ : { token: channel.token }),
577
+ };
496
578
  };
497
- /**
498
- * Creates a read-only proxy for AsyncStream instances that restricts access to consumer-only methods.
499
- *
500
- * This proxy only exposes the following properties and methods:
501
- * - `next()`: Get the next value from the stream
502
- * - `end()`: Terminate the stream and stop iteration
503
- * - `return()`: Same as end(), terminates the stream
504
- * - `isDone`: Boolean indicating if the stream has been terminated
505
- * - `Symbol.asyncIterator`: Enables `for await` loop iteration
506
- *
507
- * Producer methods like `push()`, `done()`, and `flush()` are hidden to prevent
508
- * consumers from accidentally modifying the stream state.
509
- *
510
- * @param stream - The AsyncStream instance to create a proxy for
511
- * @returns A read-only proxy that implements AsyncStreamProxy<T>
512
- *
513
- * @example
514
- * ```typescript
515
- * const stream = new AsyncStream<string>();
516
- * const proxy = createAsyncStreamProxy(stream);
517
- *
518
- * stream.push("hello");
519
- * stream.push("world");
520
- *
521
- * for await (const value of proxy) {
522
- * console.log(value); // "hello", "world"
523
- * }
524
- * ```
525
- */
526
- function createAsyncStreamProxy(stream) {
527
- return new Proxy(stream, {
528
- get(target, prop, receiver) {
529
- if (isUsableProperty(prop)) {
530
- return Reflect.get(target, prop, receiver);
531
- }
532
- },
533
- set() {
534
- return true;
535
- },
536
- has(_target, prop) {
537
- return isUsableProperty(prop);
538
- },
539
- ownKeys() {
540
- return usableProperties;
541
- },
542
- getOwnPropertyDescriptor(target, prop) {
543
- if (isUsableProperty(prop)) {
544
- return {
545
- enumerable: true,
546
- configurable: true,
547
- value: Reflect.get(target, prop),
548
- };
579
+ const toNotificationState = (value) => {
580
+ switch (value.state) {
581
+ case 0 /* NotificationStateKind.Disabled */:
582
+ return { state: "disabled" };
583
+ case 1 /* NotificationStateKind.Enabled */:
584
+ return { state: "enabled" };
585
+ case 2 /* NotificationStateKind.Failed */: {
586
+ const codes = {
587
+ [0 /* NotificationFailure.PermissionDenied */]: "PermissionDenied",
588
+ [1 /* NotificationFailure.InvalidArgument */]: "InvalidArgument",
589
+ [2 /* NotificationFailure.OutOfRange */]: "OutOfRange",
590
+ [3 /* NotificationFailure.Unimplemented */]: "Unimplemented",
591
+ [4 /* NotificationFailure.ChannelNotConfigured */]: "ChannelNotConfigured",
592
+ };
593
+ if (value.failure == null) {
594
+ throw new Error("Notification failure is missing its error");
549
595
  }
550
- return undefined;
551
- },
552
- });
553
- }
596
+ return {
597
+ state: "failed",
598
+ error: new NotificationError(`NotificationError::${codes[value.failure]}`),
599
+ };
600
+ }
601
+ }
602
+ };
603
+ const toBindingNotificationOverride = (value) => {
604
+ switch (value) {
605
+ case "enabled":
606
+ return 0 /* BindingNotificationOverride.Enabled */;
607
+ case "disabled":
608
+ return 1 /* BindingNotificationOverride.Disabled */;
609
+ case "default":
610
+ return 2 /* BindingNotificationOverride.Default */;
611
+ }
612
+ };
613
+ const throwNotificationError = (error) => {
614
+ if (error instanceof Error) {
615
+ const code = /^\[(NotificationError::[^\]]+)\]/.exec(error.message)?.[1];
616
+ if (code)
617
+ throw new NotificationError(code, { cause: error });
618
+ }
619
+ throw error;
620
+ };
554
621
 
555
- const DEFAULT_RETRY_DELAY = 60_000; // milliseconds
556
- const DEFAULT_RETRY_ATTEMPTS = 10;
557
622
  /**
558
- * Creates a stream from a stream function
559
- *
560
- * If the stream fails, an attempt will be made to restart it.
561
- *
562
- * Ending the stream is terminal: no callbacks are invoked and no native
563
- * stream is created after the stream ends.
564
- *
565
- * This function is not intended to be used directly.
623
+ * Represents a conversation
566
624
  *
567
- * @param streamFunction - The stream function to create a stream from
568
- * @param streamValueMutator - An optional function to mutate the value emitted from the stream
569
- * @param options - The options for the stream
570
- * @param args - Additional arguments to pass to the stream function
571
- * @returns An async iterable stream proxy
572
- * @throws {StreamInvalidRetryAttemptsError} if the retryAttempts option is less than 0 and retryOnFail is true
573
- * @throws {StreamFailedError} if the stream fails and can't be restarted
625
+ * This class is not intended to be initialized directly.
574
626
  */
575
- const createStream = async (streamFunction, streamValueMutator, options) => {
576
- const { onEnd, onError, onFail, onRestart, onRetry, onValue, retryAttempts = DEFAULT_RETRY_ATTEMPTS, retryDelay = DEFAULT_RETRY_DELAY, retryOnFail = true, } = options ?? {};
577
- // retry attempts must be greater than 0
578
- if (retryOnFail && retryAttempts < 0) {
579
- throw new StreamInvalidRetryAttemptsError();
580
- }
581
- const asyncStream = new AsyncStream();
582
- // lifecycle state, owned by this wrapper
583
- let stopped = false;
584
- // reading the flag through a function call defeats TS control-flow
585
- // narrowing, which cannot see the closure mutation across awaits
586
- const isStopped = () => stopped;
587
- let currentCloser;
588
- let retryTimer;
589
- let retryInFlight = false;
590
- // set when a restart's native stream closes during its own creation, so the
591
- // completed attempt reschedules instead of installing an already-dead closer
592
- let closePendingDuringRestart = false;
593
- // read through a call so no-unnecessary-condition cannot narrow the flag to
594
- // a constant; handleNativeClose mutates it across an await
595
- const isClosePending = () => closePendingDuringRestart;
596
- // the retry budget is monotonic: it is never reset for the lifetime of
597
- // this wrapper, so restarts are bounded even across successful restarts
598
- let remainingRetries = retryAttempts;
599
- // terminal transition: cancel any pending retry, close the active native
600
- // stream, and notify onEnd exactly once
601
- const stop = () => {
602
- if (isStopped()) {
603
- return;
604
- }
605
- stopped = true;
606
- closePendingDuringRestart = false;
607
- if (retryTimer !== undefined) {
608
- clearTimeout(retryTimer);
609
- retryTimer = undefined;
610
- }
611
- currentCloser?.end();
612
- currentCloser = undefined;
613
- onEnd?.();
614
- };
615
- // registered before any async work so ending the stream is always terminal,
616
- // even while a retry is pending or a native stream is being created
617
- asyncStream.onDone = stop;
618
- const fail = (error) => {
619
- if (isStopped()) {
620
- return;
621
- }
622
- // the terminal transition must run even if onError throws, otherwise a
623
- // throwing consumer callback leaves the stream open and hangs next()
624
- try {
625
- onError?.(error);
626
- }
627
- finally {
628
- void asyncStream.end();
629
- }
630
- };
631
- const streamCallback = (error, value) => {
632
- // an ended stream must not invoke any callbacks
633
- if (isStopped()) {
634
- return;
635
- }
636
- // if a stream error occurs, call the onError callback
637
- if (error) {
638
- onError?.(error);
639
- return;
640
- }
641
- // ensure the value is not undefined
642
- if (value !== undefined) {
643
- try {
644
- // if a streamValueMutator is provided, mutate the value
645
- if (streamValueMutator) {
646
- const mutatedValue = streamValueMutator(value);
647
- if (isPromise(mutatedValue)) {
648
- void mutatedValue
649
- .then((mutatedValue) => {
650
- // the stream may have ended while the value was mutating
651
- if (!isStopped() && mutatedValue !== undefined) {
652
- asyncStream.push(mutatedValue);
653
- onValue?.(mutatedValue);
654
- }
655
- })
656
- .catch((error) => {
657
- if (!isStopped()) {
658
- onError?.(error);
659
- }
660
- });
661
- }
662
- else {
663
- // a synchronous mutator may have ended the stream; gate delivery
664
- // on the stopped flag to match the async branch above
665
- if (!isStopped() && mutatedValue !== undefined) {
666
- asyncStream.push(mutatedValue);
667
- onValue?.(mutatedValue);
668
- }
669
- }
670
- }
671
- else {
672
- asyncStream.push(value);
673
- onValue?.(value);
674
- }
675
- }
676
- catch (error) {
677
- onError?.(error);
678
- }
679
- }
680
- };
681
- const scheduleRetry = () => {
682
- // at most one retry may be in flight per wrapper
683
- if (isStopped() || retryInFlight) {
684
- return;
685
- }
686
- if (remainingRetries <= 0) {
687
- fail(new StreamFailedError(retryAttempts));
688
- return;
689
- }
690
- retryInFlight = true;
691
- retryTimer = setTimeout(() => {
692
- retryTimer = undefined;
693
- void attemptRestart();
694
- }, retryDelay);
695
- };
696
- const attemptRestart = async () => {
697
- if (isStopped()) {
698
- retryInFlight = false;
699
- return;
700
- }
701
- // scope the pending-close flag to this attempt: a close recorded while the
702
- // retry timer was merely pending belongs to the stream that scheduled it
703
- closePendingDuringRestart = false;
704
- remainingRetries -= 1;
705
- onRetry?.(retryAttempts - remainingRetries, retryAttempts);
706
- if (isStopped()) {
707
- // onRetry may have ended the stream; do not open a native stream after
708
- // termination
709
- retryInFlight = false;
710
- return;
711
- }
712
- try {
713
- // attempt to restart the stream
714
- const streamCloser = await streamFunction(streamCallback, handleNativeClose);
715
- if (isStopped()) {
716
- // the stream ended while the native stream was being created
717
- streamCloser.end();
718
- retryInFlight = false;
719
- return;
720
- }
721
- await streamCloser.waitForReady();
722
- if (isStopped()) {
723
- streamCloser.end();
724
- retryInFlight = false;
725
- return;
726
- }
727
- if (isClosePending()) {
728
- // the replacement stream closed during its own creation; discard it
729
- // and schedule a fresh attempt instead of installing a dead closer
730
- closePendingDuringRestart = false;
731
- streamCloser.end();
732
- retryInFlight = false;
733
- scheduleRetry();
734
- return;
735
- }
736
- currentCloser = streamCloser;
737
- retryInFlight = false;
738
- // stream restarted, call the onRestart callback
739
- onRestart?.();
740
- }
741
- catch (error) {
742
- retryInFlight = false;
743
- if (isStopped()) {
744
- return;
745
- }
746
- closePendingDuringRestart = false;
747
- onError?.(error);
748
- scheduleRetry();
749
- }
750
- };
751
- const handleNativeClose = () => {
752
- // ending the stream closes the native stream, which still triggers this
753
- // callback; only an unexpected close is a failure
754
- if (isStopped()) {
755
- return;
756
- }
757
- currentCloser = undefined;
758
- onFail?.();
759
- if (retryOnFail) {
760
- // a native close during an in-flight restart is dropped by the
761
- // single-flight guard; record it so the completed attempt reschedules
762
- // instead of installing a stream that already died
763
- if (retryInFlight) {
764
- closePendingDuringRestart = true;
765
- return;
766
- }
767
- scheduleRetry();
768
- }
769
- else {
770
- fail(new StreamFailedError(0));
771
- }
772
- };
773
- try {
774
- // create the stream
775
- const streamCloser = await streamFunction(streamCallback, handleNativeClose);
776
- if (isStopped()) {
777
- streamCloser.end();
778
- }
779
- else {
780
- await streamCloser.waitForReady();
781
- if (isStopped()) {
782
- streamCloser.end();
783
- }
784
- else {
785
- currentCloser = streamCloser;
786
- }
787
- }
788
- }
789
- catch (error) {
790
- onError?.(error);
791
- if (retryOnFail) {
792
- scheduleRetry();
793
- }
794
- else {
795
- fail(new StreamFailedError(0));
796
- }
797
- }
798
- // return a proxy for the async stream
799
- return createAsyncStreamProxy(asyncStream);
800
- };
801
-
802
- /**
803
- * Represents a conversation
804
- *
805
- * This class is not intended to be initialized directly.
806
- */
807
- class Conversation {
808
- #client;
809
- #codecRegistry;
810
- #conversation;
811
- /**
812
- * Creates a new conversation instance
813
- *
814
- * @param client - The client instance managing the conversation
815
- * @param codecRegistry - The codec registry instance
816
- * @param conversation - The underlying conversation instance
817
- */
818
- constructor(client, codecRegistry, conversation) {
819
- this.#client = client;
820
- this.#codecRegistry = codecRegistry;
821
- this.#conversation = conversation;
627
+ class Conversation {
628
+ #client;
629
+ #codecRegistry;
630
+ #conversation;
631
+ /**
632
+ * Creates a new conversation instance
633
+ *
634
+ * @param client - The client instance managing the conversation
635
+ * @param codecRegistry - The codec registry instance
636
+ * @param conversation - The underlying conversation instance
637
+ */
638
+ constructor(client, codecRegistry, conversation) {
639
+ this.#client = client;
640
+ this.#codecRegistry = codecRegistry;
641
+ this.#conversation = conversation;
822
642
  }
823
643
  /**
824
644
  * Gets the unique identifier for this conversation
@@ -832,6 +652,16 @@ class Conversation {
832
652
  get isActive() {
833
653
  return this.#conversation.isActive();
834
654
  }
655
+ /** Override notification rules, or reset the override with "default". */
656
+ setNotifications(value) {
657
+ return Promise.resolve().then(() => {
658
+ this.#conversation.setNotifications(toBindingNotificationOverride(value));
659
+ });
660
+ }
661
+ /** Read the effective notification value for this conversation. */
662
+ notificationsEnabled() {
663
+ return Promise.resolve().then(() => this.#conversation.notificationsEnabled());
664
+ }
835
665
  /**
836
666
  * Gets the inbox ID that added this client's inbox to the conversation
837
667
  */
@@ -851,7 +681,7 @@ class Conversation {
851
681
  return nsToDate(this.createdAtNs);
852
682
  }
853
683
  get topic() {
854
- return `/xmtp/mls/1/g-${this.id}/proto`;
684
+ return `00${this.id}`;
855
685
  }
856
686
  pausedForVersion() {
857
687
  return this.#conversation.pausedForVersion() ?? undefined;
@@ -893,26 +723,31 @@ class Conversation {
893
723
  return this.#conversation.sync();
894
724
  }
895
725
  /**
896
- * Creates a stream for new messages in this conversation
726
+ * Reads retained messages after the default acknowledgement position.
727
+ * Set `from` to replay after a cursor without changing default progress.
728
+ * Set `onValue` for callback mode, or request items with the iterator.
729
+ * Core owns network recovery. Legacy retry options and `disableSync` do not apply.
897
730
  *
898
- * @param options - Optional stream options
731
+ * @param options - Optional delivery callbacks and replay cursor
899
732
  * @returns Stream instance for new messages
900
733
  */
901
734
  async stream(options) {
902
- const stream = async (callback, onFail) => {
903
- if (!options?.disableSync) {
904
- await this.sync();
905
- }
906
- return this.#conversation.stream(callback, onFail);
907
- };
908
- const convertMessage = (value) => {
735
+ const reader = await this.#conversation.messageReader(options?.from);
736
+ const convertMessage = (value, cursor) => {
909
737
  const enrichedMessage = this.#client.conversations.getMessageById(value.id);
910
- if (enrichedMessage === undefined) {
911
- console.warn(`Streamed message with ID "${value.id}" not found`);
738
+ if (enrichedMessage !== undefined) {
739
+ assertMessageDecodedForDelivery(enrichedMessage);
740
+ enrichedMessage.deliveryCursor = cursor;
912
741
  }
913
742
  return enrichedMessage;
914
743
  };
915
- return createStream(stream, convertMessage, options);
744
+ return new MessageStream(reader, convertMessage, options);
745
+ }
746
+ messageHistorySnapshot(limit) {
747
+ return this.#conversation.messageHistorySnapshot(limit);
748
+ }
749
+ beginningDeliveryCursor() {
750
+ return this.#conversation.beginningDeliveryCursor();
916
751
  }
917
752
  /**
918
753
  * Decodes, decrypts, and persists a raw envelope from a group message stream.
@@ -1414,16 +1249,420 @@ class Group extends Conversation {
1414
1249
  async requestRemoval() {
1415
1250
  return this.#conversation.leaveGroup();
1416
1251
  }
1417
- /**
1418
- * Checks if the current user has requested to leave the group
1419
- *
1420
- * @returns Boolean
1421
- */
1422
- isPendingRemoval() {
1423
- return (this.#conversation.membershipState() ===
1424
- 4 /* GroupMembershipState.PendingRemove */);
1252
+ /**
1253
+ * Checks if the current user has requested to leave the group
1254
+ *
1255
+ * @returns Boolean
1256
+ */
1257
+ isPendingRemoval() {
1258
+ return (this.#conversation.membershipState() ===
1259
+ 4 /* GroupMembershipState.PendingRemove */);
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * AsyncStream provides an async iterable interface for streaming data.
1265
+ *
1266
+ * This class implements a producer-consumer pattern where:
1267
+ * - Producers can push values using the `push()` method
1268
+ * - Consumers can iterate over values asynchronously using `for await` loops or `next()`
1269
+ * - Values are queued internally when no consumers are waiting
1270
+ * - Consumers are resolved immediately when values are available
1271
+ * - The stream can be terminated using `done()`, `return()`, or `end()`
1272
+ *
1273
+ * @example
1274
+ * ```typescript
1275
+ * const stream = new AsyncStream<string>();
1276
+ *
1277
+ * stream.push("hello");
1278
+ * stream.push("world");
1279
+ *
1280
+ * for await (const value of stream) {
1281
+ * console.log(value); // "hello", "world"
1282
+ * }
1283
+ * ```
1284
+ */
1285
+ class AsyncStream {
1286
+ isDone = false;
1287
+ #pendingResolves = [];
1288
+ #queue;
1289
+ onDone;
1290
+ onReturn;
1291
+ constructor() {
1292
+ this.#queue = [];
1293
+ this.isDone = false;
1294
+ }
1295
+ flush() {
1296
+ while (this.#pendingResolves.length > 0) {
1297
+ const nextResolve = this.#pendingResolves.shift();
1298
+ if (nextResolve) {
1299
+ nextResolve({ done: true, value: undefined });
1300
+ }
1301
+ }
1302
+ }
1303
+ done() {
1304
+ this.flush();
1305
+ this.#queue = [];
1306
+ this.#pendingResolves = [];
1307
+ this.isDone = true;
1308
+ this.onDone?.();
1309
+ }
1310
+ push = (value) => {
1311
+ if (this.isDone) {
1312
+ return;
1313
+ }
1314
+ const nextResolve = this.#pendingResolves.shift();
1315
+ if (nextResolve) {
1316
+ nextResolve({
1317
+ done: false,
1318
+ value,
1319
+ });
1320
+ }
1321
+ else {
1322
+ this.#queue.push(value);
1323
+ }
1324
+ };
1325
+ next = () => {
1326
+ if (this.isDone) {
1327
+ return Promise.resolve({ done: true, value: undefined });
1328
+ }
1329
+ if (this.#queue.length > 0) {
1330
+ return Promise.resolve({
1331
+ done: false,
1332
+ value: this.#queue.shift(),
1333
+ });
1334
+ }
1335
+ return new Promise((resolve) => {
1336
+ this.#pendingResolves.push(resolve);
1337
+ });
1338
+ };
1339
+ return = () => {
1340
+ this.onReturn?.();
1341
+ this.done();
1342
+ return Promise.resolve({
1343
+ done: true,
1344
+ value: undefined,
1345
+ });
1346
+ };
1347
+ end = () => this.return();
1348
+ [Symbol.asyncIterator]() {
1349
+ return this;
1350
+ }
1351
+ }
1352
+ const usableProperties = [
1353
+ "end",
1354
+ "isDone",
1355
+ "next",
1356
+ "return",
1357
+ Symbol.asyncIterator,
1358
+ ];
1359
+ const isUsableProperty = (prop) => {
1360
+ return usableProperties.includes(prop);
1361
+ };
1362
+ /**
1363
+ * Creates a read-only proxy for AsyncStream instances that restricts access to consumer-only methods.
1364
+ *
1365
+ * This proxy only exposes the following properties and methods:
1366
+ * - `next()`: Get the next value from the stream
1367
+ * - `end()`: Terminate the stream and stop iteration
1368
+ * - `return()`: Same as end(), terminates the stream
1369
+ * - `isDone`: Boolean indicating if the stream has been terminated
1370
+ * - `Symbol.asyncIterator`: Enables `for await` loop iteration
1371
+ *
1372
+ * Producer methods like `push()`, `done()`, and `flush()` are hidden to prevent
1373
+ * consumers from accidentally modifying the stream state.
1374
+ *
1375
+ * @param stream - The AsyncStream instance to create a proxy for
1376
+ * @returns A read-only proxy that implements AsyncStreamProxy<T>
1377
+ *
1378
+ * @example
1379
+ * ```typescript
1380
+ * const stream = new AsyncStream<string>();
1381
+ * const proxy = createAsyncStreamProxy(stream);
1382
+ *
1383
+ * stream.push("hello");
1384
+ * stream.push("world");
1385
+ *
1386
+ * for await (const value of proxy) {
1387
+ * console.log(value); // "hello", "world"
1388
+ * }
1389
+ * ```
1390
+ */
1391
+ function createAsyncStreamProxy(stream) {
1392
+ return new Proxy(stream, {
1393
+ get(target, prop, receiver) {
1394
+ if (isUsableProperty(prop)) {
1395
+ return Reflect.get(target, prop, receiver);
1396
+ }
1397
+ },
1398
+ set() {
1399
+ return true;
1400
+ },
1401
+ has(_target, prop) {
1402
+ return isUsableProperty(prop);
1403
+ },
1404
+ ownKeys() {
1405
+ return usableProperties;
1406
+ },
1407
+ getOwnPropertyDescriptor(target, prop) {
1408
+ if (isUsableProperty(prop)) {
1409
+ return {
1410
+ enumerable: true,
1411
+ configurable: true,
1412
+ value: Reflect.get(target, prop),
1413
+ };
1414
+ }
1415
+ return undefined;
1416
+ },
1417
+ });
1418
+ }
1419
+
1420
+ const DEFAULT_RETRY_DELAY = 60_000; // milliseconds
1421
+ const DEFAULT_RETRY_ATTEMPTS = 10;
1422
+ /**
1423
+ * Creates a stream from a stream function
1424
+ *
1425
+ * If the stream fails, an attempt will be made to restart it.
1426
+ *
1427
+ * Ending the stream is terminal: no callbacks are invoked and no native
1428
+ * stream is created after the stream ends.
1429
+ *
1430
+ * This function is not intended to be used directly.
1431
+ *
1432
+ * @param streamFunction - The stream function to create a stream from
1433
+ * @param streamValueMutator - An optional function to mutate the value emitted from the stream
1434
+ * @param options - The options for the stream
1435
+ * @returns An async iterable stream proxy
1436
+ * @throws {StreamInvalidRetryAttemptsError} if the retryAttempts option is less than 0 and retryOnFail is true
1437
+ * @throws {StreamFailedError} if the stream fails and can't be restarted
1438
+ */
1439
+ const createStream = async (streamFunction, streamValueMutator, options) => {
1440
+ const { onEnd, onError, onFail, onRestart, onRetry, onValue, retryAttempts = DEFAULT_RETRY_ATTEMPTS, retryDelay = DEFAULT_RETRY_DELAY, retryOnFail = true, } = options ?? {};
1441
+ // retry attempts must be greater than 0
1442
+ if (retryOnFail && retryAttempts < 0) {
1443
+ throw new StreamInvalidRetryAttemptsError();
1444
+ }
1445
+ const asyncStream = new AsyncStream();
1446
+ // lifecycle state, owned by this wrapper
1447
+ let stopped = false;
1448
+ // reading the flag through a function call defeats TS control-flow
1449
+ // narrowing, which cannot see the closure mutation across awaits
1450
+ const isStopped = () => stopped;
1451
+ let currentCloser;
1452
+ let retryTimer;
1453
+ let retryInFlight = false;
1454
+ // set when a restart's native stream closes during its own creation, so the
1455
+ // completed attempt reschedules instead of installing an already-dead closer
1456
+ let closePendingDuringRestart = false;
1457
+ // read through a call so no-unnecessary-condition cannot narrow the flag to
1458
+ // a constant; handleNativeClose mutates it across an await
1459
+ const isClosePending = () => closePendingDuringRestart;
1460
+ // the retry budget is monotonic: it is never reset for the lifetime of
1461
+ // this wrapper, so restarts are bounded even across successful restarts
1462
+ let remainingRetries = retryAttempts;
1463
+ // terminal transition: cancel any pending retry, close the active native
1464
+ // stream, and notify onEnd exactly once
1465
+ const stop = () => {
1466
+ if (isStopped()) {
1467
+ return;
1468
+ }
1469
+ stopped = true;
1470
+ closePendingDuringRestart = false;
1471
+ if (retryTimer !== undefined) {
1472
+ clearTimeout(retryTimer);
1473
+ retryTimer = undefined;
1474
+ }
1475
+ currentCloser?.end();
1476
+ currentCloser = undefined;
1477
+ onEnd?.();
1478
+ };
1479
+ // registered before any async work so ending the stream is always terminal,
1480
+ // even while a retry is pending or a native stream is being created
1481
+ asyncStream.onDone = stop;
1482
+ const fail = (error) => {
1483
+ if (isStopped()) {
1484
+ return;
1485
+ }
1486
+ // the terminal transition must run even if onError throws, otherwise a
1487
+ // throwing consumer callback leaves the stream open and hangs next()
1488
+ try {
1489
+ onError?.(error);
1490
+ }
1491
+ finally {
1492
+ void asyncStream.end();
1493
+ }
1494
+ };
1495
+ const handleAsyncError = (error) => {
1496
+ if (!isStopped()) {
1497
+ onError?.(error);
1498
+ }
1499
+ };
1500
+ const streamCallback = (error, value) => {
1501
+ // an ended stream must not invoke any callbacks
1502
+ if (isStopped()) {
1503
+ return;
1504
+ }
1505
+ // if a stream error occurs, call the onError callback
1506
+ if (error) {
1507
+ onError?.(error);
1508
+ return;
1509
+ }
1510
+ // ensure the value is not undefined
1511
+ if (value !== undefined) {
1512
+ try {
1513
+ // if a streamValueMutator is provided, mutate the value
1514
+ if (streamValueMutator) {
1515
+ const mutatedValue = streamValueMutator(value);
1516
+ if (isPromise(mutatedValue)) {
1517
+ void mutatedValue
1518
+ .then((mutatedValue) => {
1519
+ // the stream may have ended while the value was mutating
1520
+ if (!isStopped() && mutatedValue !== undefined) {
1521
+ asyncStream.push(mutatedValue);
1522
+ return onValue?.(mutatedValue);
1523
+ }
1524
+ })
1525
+ .catch(handleAsyncError);
1526
+ }
1527
+ else {
1528
+ // a synchronous mutator may have ended the stream; gate delivery
1529
+ // on the stopped flag to match the async branch above
1530
+ if (!isStopped() && mutatedValue !== undefined) {
1531
+ asyncStream.push(mutatedValue);
1532
+ void Promise.resolve(onValue?.(mutatedValue)).catch(handleAsyncError);
1533
+ }
1534
+ }
1535
+ }
1536
+ else {
1537
+ asyncStream.push(value);
1538
+ void Promise.resolve(onValue?.(value)).catch(handleAsyncError);
1539
+ }
1540
+ }
1541
+ catch (error) {
1542
+ onError?.(error);
1543
+ }
1544
+ }
1545
+ };
1546
+ const scheduleRetry = () => {
1547
+ // at most one retry may be in flight per wrapper
1548
+ if (isStopped() || retryInFlight) {
1549
+ return;
1550
+ }
1551
+ if (remainingRetries <= 0) {
1552
+ fail(new StreamFailedError(retryAttempts));
1553
+ return;
1554
+ }
1555
+ retryInFlight = true;
1556
+ retryTimer = setTimeout(() => {
1557
+ retryTimer = undefined;
1558
+ void attemptRestart();
1559
+ }, retryDelay);
1560
+ };
1561
+ const attemptRestart = async () => {
1562
+ if (isStopped()) {
1563
+ retryInFlight = false;
1564
+ return;
1565
+ }
1566
+ // scope the pending-close flag to this attempt: a close recorded while the
1567
+ // retry timer was merely pending belongs to the stream that scheduled it
1568
+ closePendingDuringRestart = false;
1569
+ remainingRetries -= 1;
1570
+ onRetry?.(retryAttempts - remainingRetries, retryAttempts);
1571
+ if (isStopped()) {
1572
+ // onRetry may have ended the stream; do not open a native stream after
1573
+ // termination
1574
+ retryInFlight = false;
1575
+ return;
1576
+ }
1577
+ try {
1578
+ // attempt to restart the stream
1579
+ const streamCloser = await streamFunction(streamCallback, handleNativeClose);
1580
+ if (isStopped()) {
1581
+ // the stream ended while the native stream was being created
1582
+ streamCloser.end();
1583
+ retryInFlight = false;
1584
+ return;
1585
+ }
1586
+ await streamCloser.waitForReady();
1587
+ if (isStopped()) {
1588
+ streamCloser.end();
1589
+ retryInFlight = false;
1590
+ return;
1591
+ }
1592
+ if (isClosePending()) {
1593
+ // the replacement stream closed during its own creation; discard it
1594
+ // and schedule a fresh attempt instead of installing a dead closer
1595
+ closePendingDuringRestart = false;
1596
+ streamCloser.end();
1597
+ retryInFlight = false;
1598
+ scheduleRetry();
1599
+ return;
1600
+ }
1601
+ currentCloser = streamCloser;
1602
+ retryInFlight = false;
1603
+ // stream restarted, call the onRestart callback
1604
+ onRestart?.();
1605
+ }
1606
+ catch (error) {
1607
+ retryInFlight = false;
1608
+ if (isStopped()) {
1609
+ return;
1610
+ }
1611
+ closePendingDuringRestart = false;
1612
+ onError?.(error);
1613
+ scheduleRetry();
1614
+ }
1615
+ };
1616
+ const handleNativeClose = () => {
1617
+ // ending the stream closes the native stream, which still triggers this
1618
+ // callback; only an unexpected close is a failure
1619
+ if (isStopped()) {
1620
+ return;
1621
+ }
1622
+ currentCloser = undefined;
1623
+ onFail?.();
1624
+ if (retryOnFail) {
1625
+ // a native close during an in-flight restart is dropped by the
1626
+ // single-flight guard; record it so the completed attempt reschedules
1627
+ // instead of installing a stream that already died
1628
+ if (retryInFlight) {
1629
+ closePendingDuringRestart = true;
1630
+ return;
1631
+ }
1632
+ scheduleRetry();
1633
+ }
1634
+ else {
1635
+ fail(new StreamFailedError(0));
1636
+ }
1637
+ };
1638
+ try {
1639
+ // create the stream
1640
+ const streamCloser = await streamFunction(streamCallback, handleNativeClose);
1641
+ if (isStopped()) {
1642
+ streamCloser.end();
1643
+ }
1644
+ else {
1645
+ await streamCloser.waitForReady();
1646
+ if (isStopped()) {
1647
+ streamCloser.end();
1648
+ }
1649
+ else {
1650
+ currentCloser = streamCloser;
1651
+ }
1652
+ }
1653
+ }
1654
+ catch (error) {
1655
+ onError?.(error);
1656
+ if (retryOnFail) {
1657
+ scheduleRetry();
1658
+ }
1659
+ else {
1660
+ fail(new StreamFailedError(0));
1661
+ }
1425
1662
  }
1426
- }
1663
+ // return a proxy for the async stream
1664
+ return createAsyncStreamProxy(asyncStream);
1665
+ };
1427
1666
 
1428
1667
  /**
1429
1668
  * Manages conversations
@@ -1447,14 +1686,14 @@ class Conversations {
1447
1686
  this.#conversations = conversations;
1448
1687
  }
1449
1688
  get topic() {
1450
- return `/xmtp/mls/1/w-${this.#client.installationId}/proto`;
1689
+ return `01${this.#client.installationId}`;
1451
1690
  }
1452
1691
  /**
1453
1692
  * Retrieves a conversation by its ID
1454
1693
  *
1455
1694
  * @param id - The conversation ID to look up
1456
1695
  * @returns The conversation if found, undefined otherwise
1457
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1696
+ * @see https://docs.xmtp.org/sdk/conversations/
1458
1697
  */
1459
1698
  async getConversationById(id) {
1460
1699
  try {
@@ -1479,7 +1718,7 @@ class Conversations {
1479
1718
  *
1480
1719
  * @param inboxId - The inbox ID to look up
1481
1720
  * @returns The DM if found, undefined otherwise
1482
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1721
+ * @see https://docs.xmtp.org/sdk/conversations/
1483
1722
  */
1484
1723
  getDmByInboxId(inboxId) {
1485
1724
  try {
@@ -1496,7 +1735,7 @@ class Conversations {
1496
1735
  *
1497
1736
  * @param identifier - The identifier to look up
1498
1737
  * @returns Promise that resolves with the DM, if found
1499
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1738
+ * @see https://docs.xmtp.org/sdk/conversations/
1500
1739
  */
1501
1740
  async fetchDmByIdentifier(identifier) {
1502
1741
  const inboxId = await this.#client.fetchInboxIdByIdentifier(identifier);
@@ -1510,7 +1749,7 @@ class Conversations {
1510
1749
  *
1511
1750
  * @param id - The message ID to look up
1512
1751
  * @returns The decoded message if found, undefined otherwise
1513
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1752
+ * @see https://docs.xmtp.org/sdk/conversations/
1514
1753
  */
1515
1754
  getMessageById(id) {
1516
1755
  try {
@@ -1527,7 +1766,7 @@ class Conversations {
1527
1766
  *
1528
1767
  * @param options - Optional group creation options
1529
1768
  * @returns The new group
1530
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#optimistically-create-a-new-group-chat
1769
+ * @see https://docs.xmtp.org/sdk/conversations/#optimistically-create-a-group
1531
1770
  */
1532
1771
  createGroupOptimistic(options) {
1533
1772
  const group = this.#conversations.createGroupOptimistic(options);
@@ -1539,7 +1778,7 @@ class Conversations {
1539
1778
  * @param identifiers - Array of identifiers for group members
1540
1779
  * @param options - Optional group creation options
1541
1780
  * @returns The new group
1542
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#create-a-new-group-chat
1781
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-group
1543
1782
  */
1544
1783
  async createGroupWithIdentifiers(identifiers, options) {
1545
1784
  const group = await this.#conversations.createGroupByIdentity(identifiers, options);
@@ -1552,7 +1791,7 @@ class Conversations {
1552
1791
  * @param inboxIds - Array of inbox IDs for group members
1553
1792
  * @param options - Optional group creation options
1554
1793
  * @returns The new group
1555
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#create-a-new-group-chat
1794
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-group
1556
1795
  */
1557
1796
  async createGroup(inboxIds, options) {
1558
1797
  const group = await this.#conversations.createGroup(inboxIds, options);
@@ -1565,7 +1804,7 @@ class Conversations {
1565
1804
  * @param identifier - Identifier for the DM recipient
1566
1805
  * @param options - Optional DM creation options
1567
1806
  * @returns The new DM
1568
- * @see https://docs.xmtp.org/agents/build-agents/create-conversations#by-ethereum-address-1
1807
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-dm
1569
1808
  */
1570
1809
  async createDmWithIdentifier(identifier, options) {
1571
1810
  const group = await this.#conversations.createDmByIdentity(identifier, options);
@@ -1578,7 +1817,7 @@ class Conversations {
1578
1817
  * @param inboxId - Inbox ID for the DM recipient
1579
1818
  * @param options - Optional DM creation options
1580
1819
  * @returns The new DM
1581
- * @see https://docs.xmtp.org/agents/build-agents/create-conversations#by-inbox-id-1
1820
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-dm
1582
1821
  */
1583
1822
  async createDm(inboxId, options) {
1584
1823
  const group = await this.#conversations.createDm(inboxId, options);
@@ -1590,7 +1829,7 @@ class Conversations {
1590
1829
  *
1591
1830
  * @param options - Optional filtering and pagination options
1592
1831
  * @returns Array of conversations
1593
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/list
1832
+ * @see https://docs.xmtp.org/sdk/conversations/
1594
1833
  */
1595
1834
  async list(options) {
1596
1835
  const groups = this.#conversations.list(options);
@@ -1613,7 +1852,7 @@ class Conversations {
1613
1852
  *
1614
1853
  * @param options - Optional filtering and pagination options
1615
1854
  * @returns Array of groups
1616
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/list#list-existing-conversations
1855
+ * @see https://docs.xmtp.org/sdk/conversations/
1617
1856
  */
1618
1857
  listGroups(options) {
1619
1858
  const groups = this.#conversations.list({
@@ -1630,7 +1869,7 @@ class Conversations {
1630
1869
  *
1631
1870
  * @param options - Optional filtering and pagination options
1632
1871
  * @returns Array of DMs
1633
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/list#list-existing-conversations
1872
+ * @see https://docs.xmtp.org/sdk/conversations/
1634
1873
  */
1635
1874
  listDms(options) {
1636
1875
  const groups = this.#conversations.list({
@@ -1646,7 +1885,7 @@ class Conversations {
1646
1885
  * Synchronizes conversations for the current client from the network
1647
1886
  *
1648
1887
  * @returns Promise that resolves when sync is complete
1649
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/sync-and-syncall
1888
+ * @see https://docs.xmtp.org/sdk/sync/
1650
1889
  */
1651
1890
  async sync() {
1652
1891
  return this.#conversations.sync();
@@ -1657,7 +1896,7 @@ class Conversations {
1657
1896
  *
1658
1897
  * @param consentStates - Optional array of consent states to filter by
1659
1898
  * @returns Promise that resolves when sync is complete
1660
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/sync-and-syncall#sync-all-new-welcomes-conversations-messages-and-preferences
1899
+ * @see https://docs.xmtp.org/sdk/sync/
1661
1900
  */
1662
1901
  async syncAll(consentStates) {
1663
1902
  return this.#conversations.syncAll(consentStates);
@@ -1668,7 +1907,7 @@ class Conversations {
1668
1907
  * @param options - Optional stream options
1669
1908
  * @param options.conversationType - Optional conversation type to filter by
1670
1909
  * @returns Stream instance for new conversations
1671
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-conversations
1910
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1672
1911
  */
1673
1912
  async stream(options) {
1674
1913
  const stream = async (callback, onFail) => {
@@ -1697,7 +1936,7 @@ class Conversations {
1697
1936
  *
1698
1937
  * @param options - Optional stream options
1699
1938
  * @returns Stream instance for new group conversations
1700
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-conversations
1939
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1701
1940
  */
1702
1941
  async streamGroups(options) {
1703
1942
  const stream = async (callback, onFail) => {
@@ -1716,7 +1955,7 @@ class Conversations {
1716
1955
  *
1717
1956
  * @param options - Optional stream options
1718
1957
  * @returns Stream instance for new DM conversations
1719
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-conversations
1958
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1720
1959
  */
1721
1960
  async streamDms(options) {
1722
1961
  const stream = async (callback, onFail) => {
@@ -1731,37 +1970,42 @@ class Conversations {
1731
1970
  return createStream(stream, convertConversation, options);
1732
1971
  }
1733
1972
  /**
1734
- * Creates a stream for all new messages
1973
+ * Reads retained messages after each group's default acknowledgement position.
1974
+ * Set `from` to replay after a cursor without changing default progress.
1975
+ * Set `onValue` for callback mode, or request items with the iterator.
1976
+ * Core owns network recovery. Legacy retry options and `disableSync` do not apply.
1735
1977
  *
1736
1978
  * @param options - Optional stream options
1737
1979
  * @param options.conversationType - Optional conversation type to filter by
1738
1980
  * @param options.consentStates - Optional array of consent states to filter by
1739
1981
  * @returns Stream instance for new messages
1740
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-messages
1982
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1741
1983
  */
1742
1984
  async streamAllMessages(options) {
1743
- const streamAllMessages = async (callback, onFail) => {
1744
- if (!options?.disableSync) {
1745
- await this.syncAll(options?.consentStates);
1746
- }
1747
- return this.#conversations.streamAllMessages(callback, onFail, options?.conversationType, options?.consentStates);
1748
- };
1749
- const convertMessage = (value) => {
1985
+ const reader = await this.#conversations.messageReader(options?.groupIds, options?.conversationType, options?.consentStates, options?.from);
1986
+ const convertMessage = (value, cursor) => {
1750
1987
  const enrichedMessage = this.getMessageById(value.id);
1751
- if (enrichedMessage === undefined) {
1752
- console.warn(`Streamed message with ID "${value.id}" not found`);
1988
+ if (enrichedMessage !== undefined) {
1989
+ assertMessageDecodedForDelivery(enrichedMessage);
1990
+ enrichedMessage.deliveryCursor = cursor;
1753
1991
  }
1754
1992
  return enrichedMessage;
1755
1993
  };
1756
- return createStream(streamAllMessages, convertMessage, options);
1994
+ return new MessageStream(reader, convertMessage, options);
1995
+ }
1996
+ messageHistorySnapshot(limit, options) {
1997
+ return this.#conversations.messageHistorySnapshot(limit, options?.groupIds, options?.conversationType, options?.consentStates);
1998
+ }
1999
+ beginningDeliveryCursor() {
2000
+ return this.#conversations.beginningDeliveryCursor();
1757
2001
  }
1758
2002
  /**
1759
- * Creates a stream for all new group messages
2003
+ * Reads retained group messages with default progress or an explicit replay cursor.
1760
2004
  *
1761
2005
  * @param options - Optional stream options
1762
2006
  * @param options.consentStates - Optional array of consent states to filter by
1763
2007
  * @returns Stream instance for new group messages
1764
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-messages
2008
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1765
2009
  */
1766
2010
  async streamAllGroupMessages(options) {
1767
2011
  return this.streamAllMessages({
@@ -1771,12 +2015,12 @@ class Conversations {
1771
2015
  });
1772
2016
  }
1773
2017
  /**
1774
- * Creates a stream for all new DM messages
2018
+ * Reads retained DM messages with default progress or an explicit replay cursor.
1775
2019
  *
1776
2020
  * @param options - Optional stream options
1777
2021
  * @param options.consentStates - Optional array of consent states to filter by
1778
2022
  * @returns Stream instance for new DM messages
1779
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-messages
2023
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1780
2024
  */
1781
2025
  async streamAllDmMessages(options) {
1782
2026
  return this.streamAllMessages({
@@ -1825,7 +2069,7 @@ class Conversations {
1825
2069
  * Gets the HMAC keys for all conversations
1826
2070
  *
1827
2071
  * @returns The HMAC keys for all conversations
1828
- * @see https://docs.xmtp.org/chat-apps/push-notifs/push-notifs#get-hmac-keys-for-a-conversation
2072
+ * @see https://docs.xmtp.org/sdk/push-notifications/
1829
2073
  */
1830
2074
  hmacKeys() {
1831
2075
  return this.#conversations.hmacKeys();
@@ -1963,18 +2207,150 @@ class Preferences {
1963
2207
  }
1964
2208
  }
1965
2209
 
2210
+ /**
2211
+ * Typed failures for the deployment configuration a backend publishes
2212
+ * (spec 006 §7, CFG-083).
2213
+ *
2214
+ * The bindings report every one of these as an `Error` whose message begins
2215
+ * with `[ClientError::<Variant>]`. Matching on that string is what this module
2216
+ * removes: each of the six documented codes becomes its own class, so an app
2217
+ * writes `error instanceof BackendMismatchError` instead of a regular
2218
+ * expression over a message that may be reworded.
2219
+ *
2220
+ * Every class keeps the binding's message and the original error as `cause`,
2221
+ * so the identifiers, versions, scopes, or chain the core reported are still
2222
+ * readable for diagnostics.
2223
+ */
2224
+ /**
2225
+ * Base class of the six configuration failures. Catch this to handle any of
2226
+ * them; catch a subclass to handle one.
2227
+ */
2228
+ class ServerConfigurationError extends Error {
2229
+ code;
2230
+ constructor(
2231
+ /** The stable code the bindings reported, e.g. `ClientError::AuthRequired`. */
2232
+ code, message, options) {
2233
+ super(message, options);
2234
+ this.code = code;
2235
+ this.name = "ServerConfigurationError";
2236
+ }
2237
+ }
2238
+ /**
2239
+ * The configuration could not be read: the deployment did not answer, answered
2240
+ * `UNIMPLEMENTED` because it predates spec 006, or the copy could not be
2241
+ * stored.
2242
+ */
2243
+ class ConfigurationUnavailableError extends ServerConfigurationError {
2244
+ constructor(message, options) {
2245
+ super("ClientError::ConfigurationUnavailable", message, options);
2246
+ this.name = "ConfigurationUnavailableError";
2247
+ }
2248
+ }
2249
+ /**
2250
+ * The deployment answered with a configuration no client can use: an empty or
2251
+ * malformed identifier, an unparseable minimum version, or a chain that is not
2252
+ * a CAIP-2 identifier. Nothing is stored.
2253
+ */
2254
+ class ConfigurationInvalidError extends ServerConfigurationError {
2255
+ constructor(message, options) {
2256
+ super("ClientError::ConfigurationInvalid", message, options);
2257
+ this.name = "ConfigurationInvalidError";
2258
+ }
2259
+ }
2260
+ /**
2261
+ * This database is bound to a different deployment. The identifier the backend
2262
+ * published is not the one the database was created against. Recovery is a
2263
+ * database created for the deployment the app now uses.
2264
+ */
2265
+ class BackendMismatchError extends ServerConfigurationError {
2266
+ constructor(message, options) {
2267
+ super("ClientError::BackendMismatch", message, options);
2268
+ this.name = "BackendMismatchError";
2269
+ }
2270
+ }
2271
+ /** The deployment requires a newer libxmtp than this SDK is built on. */
2272
+ class ClientVersionTooOldError extends ServerConfigurationError {
2273
+ constructor(message, options) {
2274
+ super("ClientError::ClientVersionTooOld", message, options);
2275
+ this.name = "ClientVersionTooOldError";
2276
+ }
2277
+ }
2278
+ /**
2279
+ * The deployment requires authentication and no credential source was
2280
+ * configured. The message carries the scopes it requires.
2281
+ */
2282
+ class AuthRequiredError extends ServerConfigurationError {
2283
+ constructor(message, options) {
2284
+ super("ClientError::AuthRequired", message, options);
2285
+ this.name = "AuthRequiredError";
2286
+ }
2287
+ }
2288
+ /**
2289
+ * A smart contract wallet signature names a chain the deployment does not
2290
+ * verify on. Raised before any network call, and only for signatures the app
2291
+ * supplied.
2292
+ */
2293
+ class ChainNotAcceptedError extends ServerConfigurationError {
2294
+ constructor(message, options) {
2295
+ super("ClientError::ChainNotAccepted", message, options);
2296
+ this.name = "ChainNotAcceptedError";
2297
+ }
2298
+ }
2299
+ const configurationCode = /^\[(ClientError::[A-Za-z]+)\]\s*([\s\S]*)$/;
2300
+ const build = (code, message, options) => {
2301
+ switch (code) {
2302
+ case "ClientError::ConfigurationUnavailable":
2303
+ return new ConfigurationUnavailableError(message, options);
2304
+ case "ClientError::ConfigurationInvalid":
2305
+ return new ConfigurationInvalidError(message, options);
2306
+ case "ClientError::BackendMismatch":
2307
+ return new BackendMismatchError(message, options);
2308
+ case "ClientError::ClientVersionTooOld":
2309
+ return new ClientVersionTooOldError(message, options);
2310
+ case "ClientError::AuthRequired":
2311
+ return new AuthRequiredError(message, options);
2312
+ case "ClientError::ChainNotAccepted":
2313
+ return new ChainNotAcceptedError(message, options);
2314
+ default:
2315
+ return undefined;
2316
+ }
2317
+ };
2318
+ /**
2319
+ * Classifies an error from any SDK call as one of the six configuration
2320
+ * failures, or `undefined` when it is something else.
2321
+ *
2322
+ * Use this where the SDK hands back the raw error, for instance a
2323
+ * `ChainNotAcceptedError` raised deep inside a call that supplies a smart
2324
+ * contract wallet signature.
2325
+ */
2326
+ const toServerConfigurationError = (error) => {
2327
+ if (!(error instanceof Error))
2328
+ return undefined;
2329
+ const match = configurationCode.exec(error.message);
2330
+ if (!match)
2331
+ return undefined;
2332
+ return build(match[1], match[2], { cause: error });
2333
+ };
2334
+ /**
2335
+ * Rethrows an error as its typed configuration failure, or unchanged when it
2336
+ * is not one.
2337
+ */
2338
+ const throwServerConfigurationError = (error) => {
2339
+ throw toServerConfigurationError(error) ?? error;
2340
+ };
2341
+
1966
2342
  const networkOptionKeys = [
1967
2343
  "env",
1968
- "apiUrl",
1969
- "gatewayHost",
2344
+ "backendUrl",
1970
2345
  "appVersion",
2346
+ "authCallback",
1971
2347
  ];
1972
2348
  const hasBackend = (options) => {
1973
2349
  return "backend" in options;
1974
2350
  };
1975
2351
  const resolveBackend$1 = async (options) => {
1976
2352
  if (!options) {
1977
- return createBackend();
2353
+ throw new Error("backendUrl is required");
1978
2354
  }
1979
2355
  if (hasBackend(options)) {
1980
2356
  // Validate that no NetworkOptions fields are also set
@@ -1992,7 +2368,13 @@ const createClient = async (identifier, options) => {
1992
2368
  const backend = await resolveBackend$1(options);
1993
2369
  const inboxId = (await getInboxIdForIdentifier(backend, identifier)) ||
1994
2370
  generateInboxId(identifier, options?.nonce);
1995
- const env = envToString(backend.env);
2371
+ const env = backend.env ?? "default";
2372
+ // `env` only labels the database file, so it must stay one path component.
2373
+ // Without this a value such as "../other" would move the database outside
2374
+ // the working directory.
2375
+ if (env === "" || /[/\\]/.test(env) || env === "." || env === "..") {
2376
+ throw new Error("env must be a single path component");
2377
+ }
1996
2378
  let dbPath;
1997
2379
  if (options?.dbPath === undefined) {
1998
2380
  // Default: auto-generated path
@@ -2011,6 +2393,8 @@ const createClient = async (identifier, options) => {
2011
2393
  level: options?.loggingLevel ?? "Off" /* LogLevel.Off */,
2012
2394
  stdoutLevel: options?.stdoutLoggingLevel,
2013
2395
  otelEndpoint: options?.otelEndpoint,
2396
+ otelServiceName: options?.otelServiceName,
2397
+ otelSampleRatio: options?.otelSampleRatio,
2014
2398
  resourceAttributes: options?.resourceAttributes,
2015
2399
  };
2016
2400
  const deviceSyncWorkerMode = options?.disableDeviceSync
@@ -2035,19 +2419,21 @@ const createClient = async (identifier, options) => {
2035
2419
  return { client, env };
2036
2420
  };
2037
2421
 
2038
- /**
2039
- * Resolves a `Backend` instance from either a `Backend` or an `XmtpEnv` string.
2040
- *
2041
- * @param envOrBackend - A `Backend` instance, or an `XmtpEnv` string
2042
- * @param gatewayHost - Optional gateway host (only used when `envOrBackend` is an `XmtpEnv`)
2043
- * @returns A `Backend` instance
2044
- */
2045
- const resolveBackend = async (envOrBackend, gatewayHost) => {
2046
- if (envOrBackend instanceof Backend) {
2047
- return envOrBackend;
2422
+ /** Resolve a backend from explicit network options or an existing backend. */
2423
+ const resolveBackend = async (optionsOrBackend) => {
2424
+ if (optionsOrBackend instanceof Backend) {
2425
+ return optionsOrBackend;
2048
2426
  }
2049
- return createBackend({ env: envOrBackend, gatewayHost });
2427
+ return createBackend(optionsOrBackend);
2050
2428
  };
2429
+ /**
2430
+ * Read a backend URL and app version from a bare URL, network options, or an
2431
+ * existing backend. `fetchServerConfiguration` builds no backend of its own,
2432
+ * so it needs the two values rather than a `Backend`.
2433
+ */
2434
+ const resolveEndpoint = (target) => typeof target === "string"
2435
+ ? { backendUrl: target }
2436
+ : { backendUrl: target.backendUrl, appVersion: target.appVersion };
2051
2437
  const createEphemeralIdentifier = () => ({
2052
2438
  identifier: `0x${randomBytes(20).toString("hex")}`,
2053
2439
  identifierKind: 0 /* IdentifierKind.Ethereum */,
@@ -2096,7 +2482,10 @@ class Client {
2096
2482
  return;
2097
2483
  }
2098
2484
  this.#identifier = identifier;
2099
- const { client, env } = await createClient(identifier, this.#options);
2485
+ // A build resolves the deployment configuration before any identity work,
2486
+ // so its six failures (spec 006 CFG-041, CFG-044, CFG-052, CFG-060,
2487
+ // CFG-062) surface here and are raised as their own types (CFG-083).
2488
+ const { client, env } = await createClient(identifier, this.#options).catch(throwServerConfigurationError);
2100
2489
  this.#client = client;
2101
2490
  this.#env = env;
2102
2491
  const conversations = this.#client.conversations();
@@ -2116,7 +2505,7 @@ class Client {
2116
2505
  const client = new Client(options);
2117
2506
  client.#signer = signer;
2118
2507
  await client.init(identifier);
2119
- if (!options?.disableAutoRegister) {
2508
+ if (!options.disableAutoRegister) {
2120
2509
  await client.register();
2121
2510
  }
2122
2511
  return client;
@@ -2152,12 +2541,12 @@ class Client {
2152
2541
  return this.#client?.appVersion();
2153
2542
  }
2154
2543
  /**
2155
- * Gets the XMTP environment the client is connected to
2544
+ * Gets the label used for the default database file name
2156
2545
  *
2157
2546
  * @throws {ClientNotInitializedError} if the client is not initialized
2158
2547
  */
2159
2548
  get env() {
2160
- if (!this.#env) {
2549
+ if (this.#env === undefined) {
2161
2550
  throw new ClientNotInitializedError();
2162
2551
  }
2163
2552
  return this.#env;
@@ -2297,7 +2686,12 @@ class Client {
2297
2686
  const identifier = await finalSigner.getIdentifier();
2298
2687
  switch (finalSigner.type) {
2299
2688
  case "SCW":
2300
- await signatureRequest.addScwSignature(identifier, signature, finalSigner.getChainId(), finalSigner.getBlockNumber?.());
2689
+ // The deployment publishes the chains it verifies on, so a signature
2690
+ // for any other chain is rejected here, before any network call
2691
+ // (spec 006 CFG-069, CFG-070).
2692
+ await signatureRequest
2693
+ .addScwSignature(identifier, signature, finalSigner.getChainId(), finalSigner.getBlockNumber?.())
2694
+ .catch(throwServerConfigurationError);
2301
2695
  break;
2302
2696
  case "EOA":
2303
2697
  await signatureRequest.addEcdsaSignature(signature);
@@ -2537,15 +2931,21 @@ class Client {
2537
2931
  await this.unsafe_addSignature(signatureRequest);
2538
2932
  await this.unsafe_applySignatureRequest(signatureRequest);
2539
2933
  }
2540
- static async revokeInstallations(signer, inboxId, installationIds, envOrBackend, gatewayHost) {
2541
- const backend = await resolveBackend(envOrBackend, gatewayHost);
2934
+ /** Revoke installations with an explicit backend or network options. */
2935
+ static async revokeInstallations(signer, inboxId, installationIds, optionsOrBackend) {
2936
+ const backend = await resolveBackend(optionsOrBackend);
2542
2937
  const identifier = await signer.getIdentifier();
2543
2938
  const signatureRequest = await revokeInstallationsSignatureRequest(backend, identifier, inboxId, installationIds);
2544
2939
  const signatureText = await signatureRequest.signatureText();
2545
2940
  const signature = await signer.signMessage(signatureText);
2546
2941
  switch (signer.type) {
2547
2942
  case "SCW":
2548
- await signatureRequest.addScwSignature(identifier, signature, signer.getChainId(), signer.getBlockNumber?.());
2943
+ // Same chain restriction as the instance path: the deployment's
2944
+ // accepted chains reject any other one here, before the network call
2945
+ // (spec 006 CFG-069, CFG-070).
2946
+ await signatureRequest
2947
+ .addScwSignature(identifier, signature, signer.getChainId(), signer.getBlockNumber?.())
2948
+ .catch(throwServerConfigurationError);
2549
2949
  break;
2550
2950
  case "EOA":
2551
2951
  await signatureRequest.addEcdsaSignature(signature);
@@ -2668,12 +3068,14 @@ class Client {
2668
3068
  return false;
2669
3069
  }
2670
3070
  }
2671
- static async fetchInboxStates(inboxIds, envOrBackend, gatewayHost) {
2672
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3071
+ /** Fetch inbox states with an explicit backend or network options. */
3072
+ static async fetchInboxStates(inboxIds, optionsOrBackend) {
3073
+ const backend = await resolveBackend(optionsOrBackend);
2673
3074
  return fetchInboxStatesByInboxIds(backend, inboxIds);
2674
3075
  }
2675
- static async fetchLatestInboxUpdatesCount(inboxIds, envOrBackend, gatewayHost) {
2676
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3076
+ /** Fetch inbox update counts with an explicit backend or network options. */
3077
+ static async fetchLatestInboxUpdatesCount(inboxIds, optionsOrBackend) {
3078
+ const backend = await resolveBackend(optionsOrBackend);
2677
3079
  // The node-bindings Client is a napi-rs class with no explicit close/free
2678
3080
  // method. Release is non-deterministic: once this reference goes out of
2679
3081
  // scope, JS GC will eventually invoke the Rust Drop impl and reclaim the
@@ -2687,9 +3089,9 @@ class Client {
2687
3089
  const result = await client.fetchInboxUpdatesCount(inboxIds, true);
2688
3090
  return toInboxUpdatesCountMap(result);
2689
3091
  }
2690
- /* eslint-enable @typescript-eslint/unified-signatures */
2691
- static async canMessage(identifiers, envOrBackend) {
2692
- const backend = await resolveBackend(envOrBackend);
3092
+ /** Check identifiers with an explicit backend or network options. */
3093
+ static async canMessage(identifiers, optionsOrBackend) {
3094
+ const backend = await resolveBackend(optionsOrBackend);
2693
3095
  const canMessageMap = new Map();
2694
3096
  for (const identifier of identifiers) {
2695
3097
  const inboxId = await getInboxIdForIdentifier(backend, identifier);
@@ -2714,12 +3116,14 @@ class Client {
2714
3116
  return false;
2715
3117
  }
2716
3118
  }
2717
- static async isAddressAuthorized(inboxId, address, envOrBackend, gatewayHost) {
2718
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3119
+ /** Check address authorization with an explicit backend or network options. */
3120
+ static async isAddressAuthorized(inboxId, address, optionsOrBackend) {
3121
+ const backend = await resolveBackend(optionsOrBackend);
2719
3122
  return await isAddressAuthorized(backend, inboxId, address);
2720
3123
  }
2721
- static async isInstallationAuthorized(inboxId, installation, envOrBackend, gatewayHost) {
2722
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3124
+ /** Check installation authorization with an explicit backend or network options. */
3125
+ static async isInstallationAuthorized(inboxId, installation, optionsOrBackend) {
3126
+ const backend = await resolveBackend(optionsOrBackend);
2723
3127
  return await isInstallationAuthorized(backend, inboxId, installation);
2724
3128
  }
2725
3129
  /**
@@ -2734,75 +3138,6 @@ class Client {
2734
3138
  excludeDisappearingMessages: false,
2735
3139
  };
2736
3140
  }
2737
- /**
2738
- * Get the default server URL based on the environment
2739
- */
2740
- #getDefaultServerUrl() {
2741
- const env = this.env;
2742
- return HistorySyncUrls[env];
2743
- }
2744
- /**
2745
- * Send a sync request to other devices on the network
2746
- *
2747
- * @param options - Archive options specifying what to sync (defaults to consent and messages)
2748
- * @param serverUrl - The server URL for the sync request (defaults to environment-specific URL)
2749
- * @returns Promise that resolves when the sync request is sent
2750
- */
2751
- async sendSyncRequest(options, serverUrl) {
2752
- if (!this.#client) {
2753
- throw new ClientNotInitializedError();
2754
- }
2755
- const resolvedOptions = options ?? this.#getDefaultArchiveOptions();
2756
- const resolvedServerUrl = serverUrl ?? this.#getDefaultServerUrl();
2757
- return this.#client
2758
- .deviceSync()
2759
- .sendSyncRequest(resolvedOptions, resolvedServerUrl);
2760
- }
2761
- /**
2762
- * Send a sync archive to the sync group
2763
- *
2764
- * @param pin - The pin used for reference when importing
2765
- * @param options - Archive options specifying what to sync (defaults to consent and messages)
2766
- * @param serverUrl - The server URL for the sync archive (defaults to environment-specific URL)
2767
- * @returns Promise that resolves when the sync archive is sent
2768
- */
2769
- async sendSyncArchive(pin, options, serverUrl) {
2770
- if (!this.#client) {
2771
- throw new ClientNotInitializedError();
2772
- }
2773
- const resolvedOptions = options ?? this.#getDefaultArchiveOptions();
2774
- const resolvedServerUrl = serverUrl ?? this.#getDefaultServerUrl();
2775
- return this.#client
2776
- .deviceSync()
2777
- .sendSyncArchive(resolvedOptions, resolvedServerUrl, pin);
2778
- }
2779
- /**
2780
- * Process a sync archive that matches the pin given
2781
- *
2782
- * @param archivePin - Optional pin to match. If not provided, processes the last archive sent
2783
- * @returns Promise that resolves when the archive is processed
2784
- */
2785
- async processSyncArchive(archivePin) {
2786
- if (!this.#client) {
2787
- throw new ClientNotInitializedError();
2788
- }
2789
- return this.#client.deviceSync().processSyncArchive(archivePin);
2790
- }
2791
- /**
2792
- * List the archives available for import in the sync group
2793
- *
2794
- * You may need to manually sync the sync group before calling
2795
- * this function to see recently uploaded archives.
2796
- *
2797
- * @param daysCutoff - Number of days to look back for archives
2798
- * @returns Array of available archive information
2799
- */
2800
- listAvailableArchives(daysCutoff) {
2801
- if (!this.#client) {
2802
- throw new ClientNotInitializedError();
2803
- }
2804
- return this.#client.deviceSync().listAvailableArchives(daysCutoff);
2805
- }
2806
3141
  /**
2807
3142
  * Archive application elements to file for later restoration
2808
3143
  *
@@ -2857,7 +3192,230 @@ class Client {
2857
3192
  }
2858
3193
  return this.#client.deviceSync().syncAllDeviceSyncGroups();
2859
3194
  }
3195
+ /** Enable notifications and register the delivery channel. */
3196
+ async enableNotifications(config) {
3197
+ if (!this.#client)
3198
+ throw new ClientNotInitializedError();
3199
+ try {
3200
+ return toNotificationState(await this.#client.enableNotifications(toBindingNotificationConfig(config)));
3201
+ }
3202
+ catch (error) {
3203
+ return throwNotificationError(error);
3204
+ }
3205
+ }
3206
+ /** Disable notifications. Per-conversation overrides remain stored. */
3207
+ async disableNotifications() {
3208
+ if (!this.#client)
3209
+ throw new ClientNotInitializedError();
3210
+ try {
3211
+ await this.#client.disableNotifications();
3212
+ }
3213
+ catch (error) {
3214
+ throwNotificationError(error);
3215
+ }
3216
+ }
3217
+ /** Read the local state without a backend request. */
3218
+ notificationState() {
3219
+ return Promise.resolve().then(() => {
3220
+ if (!this.#client)
3221
+ throw new ClientNotInitializedError();
3222
+ return toNotificationState(this.#client.notificationState());
3223
+ });
3224
+ }
3225
+ /**
3226
+ * What the deployment published about itself, as this client resolved it
3227
+ * when it was built (spec 006 CFG-030, CFG-080).
3228
+ *
3229
+ * Reads memory and makes no backend request. The value never changes for the
3230
+ * life of the client: a background refresh rewrites the stored copy, and a
3231
+ * new value takes effect at the next build.
3232
+ *
3233
+ * @throws {ClientNotInitializedError} if the client is not initialized
3234
+ */
3235
+ serverConfiguration() {
3236
+ if (!this.#client) {
3237
+ throw new ClientNotInitializedError();
3238
+ }
3239
+ return this.#client.serverConfiguration();
3240
+ }
3241
+ /**
3242
+ * Fetch the deployment configuration now, rewrite the stored copy, and
3243
+ * return what was fetched (spec 006 CFG-082).
3244
+ *
3245
+ * The snapshot this client holds is unchanged; use it to observe a change an
3246
+ * operator has made without restarting.
3247
+ *
3248
+ * @throws {ClientNotInitializedError} if the client is not initialized
3249
+ * @throws {ConfigurationUnavailableError} if the deployment did not answer
3250
+ * @throws {ConfigurationInvalidError} if it published an unusable configuration
3251
+ * @throws {BackendMismatchError} if it published a different identifier
3252
+ * @throws {ClientVersionTooOldError} if it now requires a newer libxmtp
3253
+ */
3254
+ async refreshServerConfiguration() {
3255
+ if (!this.#client) {
3256
+ throw new ClientNotInitializedError();
3257
+ }
3258
+ return this.#client
3259
+ .refreshServerConfiguration()
3260
+ .catch(throwServerConfigurationError);
3261
+ }
3262
+ /**
3263
+ * Read what a deployment publishes about itself with no database, no client,
3264
+ * and no credential (spec 006 CFG-081, CFG-045).
3265
+ *
3266
+ * Lets an app learn `auth.enabled`, `auth.requiredScopes`, and the accepted
3267
+ * smart contract wallet chains before it decides how to build a client.
3268
+ * Nothing is stored and no identifier binding is applied.
3269
+ *
3270
+ * @param target - A backend URL, network options, or an existing backend
3271
+ * @throws {ConfigurationUnavailableError} if the deployment did not answer
3272
+ * @throws {ConfigurationInvalidError} if it published an unusable configuration
3273
+ */
3274
+ static async fetchServerConfiguration(target) {
3275
+ const { backendUrl, appVersion } = resolveEndpoint(target);
3276
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Validate options from JavaScript callers.
3277
+ if (!backendUrl?.trim()) {
3278
+ throw new Error("backendUrl is required");
3279
+ }
3280
+ return fetchServerConfiguration(backendUrl, appVersion).catch(throwServerConfigurationError);
3281
+ }
2860
3282
  }
2861
3283
 
2862
- export { AccountAlreadyAssociatedError, ApiUrls, Client, ClientNotInitializedError, Conversation, Conversations, DecodedMessage, Dm, Group, HistorySyncUrls, InboxReassignError, MissingContentTypeError, SignerUnavailableError, StreamFailedError, StreamInvalidRetryAttemptsError, createBackend, envToString, generateInboxId, getInboxIdForIdentifier, isActions, isAttachment, isGroupUpdated, isHexString, isIntent, isLeaveRequest, isMarkdown, isMultiRemoteAttachment, isReaction, isReadReceipt, isRemoteAttachment, isReply, isText, isTextReply, isTransactionReference, isWalletSendCalls, validHex };
3284
+ const streamFailureMarker = "\n[XMTP_STREAM_FAILURE_V1]";
3285
+ const maxCursor = (1n << 64n) - 1n;
3286
+ /**
3287
+ * Reads structured progress from a stream, sync, or publication error.
3288
+ * All sequence values are exact bigints. Invalid or absent details return undefined.
3289
+ * A published-but-unconfirmed error does not mean that publication failed.
3290
+ */
3291
+ const getStreamFailureDetails = (error) => {
3292
+ try {
3293
+ const message = typeof error === "string" ? error : readObject(error).message;
3294
+ if (typeof message !== "string")
3295
+ return undefined;
3296
+ const marker = message.lastIndexOf(streamFailureMarker);
3297
+ if (marker === -1)
3298
+ return undefined;
3299
+ const details = readObject(JSON.parse(message.slice(marker + streamFailureMarker.length)));
3300
+ return {
3301
+ kind: readChoice(details.kind, [
3302
+ "barrier",
3303
+ "published_but_unconfirmed",
3304
+ "catch_up",
3305
+ ]),
3306
+ code: readString(details.code),
3307
+ message: readString(details.message),
3308
+ retryable: readBoolean(details.retryable),
3309
+ intentId: readNullable(details.intentId, readIntentId),
3310
+ publishedIntentIds: readArray(details.publishedIntentIds, readIntentId),
3311
+ summary: readNullable(details.summary, (value) => {
3312
+ const summary = readObject(value);
3313
+ return {
3314
+ messages: readCursor(summary.messages),
3315
+ conversations: readCursor(summary.conversations),
3316
+ failed: readCursor(summary.failed),
3317
+ completed: readBoolean(summary.completed),
3318
+ };
3319
+ }),
3320
+ barriers: readArray(details.barriers, readBarrier),
3321
+ };
3322
+ }
3323
+ catch {
3324
+ return undefined;
3325
+ }
3326
+ };
3327
+ const readBarrier = (value) => {
3328
+ const barrier = readObject(value);
3329
+ return {
3330
+ reason: readChoice(barrier.reason, ["blocked", "deadline", "cancelled"]),
3331
+ unfinished: readArray(barrier.unfinished, (value) => {
3332
+ const topic = readObject(value);
3333
+ return {
3334
+ topic: readTopic(topic.topic),
3335
+ target: readNullable(topic.target, readCursor),
3336
+ received: readCursor(topic.received),
3337
+ processed: readCursor(topic.processed),
3338
+ unresolvedWelcomes: readArray(topic.unresolvedWelcomes, readCursor),
3339
+ inactive: readBoolean(topic.inactive),
3340
+ cause: readNullable(topic.cause, (value) => {
3341
+ const cause = readObject(value);
3342
+ return {
3343
+ kind: readChoice(cause.kind, [
3344
+ "target_pending",
3345
+ "receipt_pending",
3346
+ "processing_pending",
3347
+ "blocked",
3348
+ "storage",
3349
+ "receiver",
3350
+ "invalid_topic",
3351
+ ]),
3352
+ code: readNullable(cause.code, readString),
3353
+ message: readString(cause.message),
3354
+ retryable: readBoolean(cause.retryable),
3355
+ };
3356
+ }),
3357
+ };
3358
+ }),
3359
+ };
3360
+ };
3361
+ const readObject = (value) => {
3362
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
3363
+ throw new Error("Invalid stream failure object");
3364
+ }
3365
+ return value;
3366
+ };
3367
+ const readString = (value) => {
3368
+ if (typeof value !== "string")
3369
+ throw new Error("Invalid stream failure text");
3370
+ return value;
3371
+ };
3372
+ const readBoolean = (value) => {
3373
+ if (typeof value !== "boolean")
3374
+ throw new Error("Invalid stream failure flag");
3375
+ return value;
3376
+ };
3377
+ const readChoice = (value, choices) => {
3378
+ const choice = choices.find((choice) => choice === value);
3379
+ if (choice === undefined)
3380
+ throw new Error("Invalid stream failure kind");
3381
+ return choice;
3382
+ };
3383
+ const readNullable = (value, read) => (value === null ? null : read(value));
3384
+ const readArray = (value, read) => {
3385
+ if (!Array.isArray(value))
3386
+ throw new Error("Invalid stream failure list");
3387
+ return value.map(read);
3388
+ };
3389
+ const readCursor = (value) => {
3390
+ const cursor = readString(value);
3391
+ if (!/^(0|[1-9][0-9]*)$/.test(cursor)) {
3392
+ throw new Error("Invalid stream failure sequence");
3393
+ }
3394
+ const number = BigInt(cursor);
3395
+ if (number > maxCursor)
3396
+ throw new Error("Stream failure sequence is too large");
3397
+ return number;
3398
+ };
3399
+ const readIntentId = (value) => {
3400
+ if (typeof value !== "number" ||
3401
+ !Number.isInteger(value) ||
3402
+ value < -2147483648 ||
3403
+ value > 2147483647) {
3404
+ throw new Error("Invalid stream failure intent ID");
3405
+ }
3406
+ return value;
3407
+ };
3408
+ const readTopic = (value) => {
3409
+ const hex = readString(value);
3410
+ if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex)) {
3411
+ throw new Error("Invalid stream failure topic");
3412
+ }
3413
+ const bytes = new Uint8Array(hex.length / 2);
3414
+ for (let index = 0; index < bytes.length; index++) {
3415
+ bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
3416
+ }
3417
+ return bytes;
3418
+ };
3419
+
3420
+ export { AccountAlreadyAssociatedError, AuthRequiredError, BackendMismatchError, ChainNotAcceptedError, Client, ClientNotInitializedError, ClientVersionTooOldError, CodecRegistry, ConfigurationInvalidError, ConfigurationUnavailableError, Conversation, Conversations, DebugInformation, DecodedMessage, Dm, Group, InboxReassignError, MessageStream, MissingContentTypeError, NotificationError, Preferences, ServerConfigurationError, SignerUnavailableError, StreamFailedError, StreamInvalidRetryAttemptsError, createBackend, generateInboxId, getInboxIdForIdentifier, getStreamFailureDetails, isActions, isAttachment, isGroupUpdated, isHexString, isIntent, isLeaveRequest, isMarkdown, isMultiRemoteAttachment, isReaction, isReadReceipt, isRemoteAttachment, isReply, isText, isTextReply, isTransactionReference, isWalletSendCalls, throwServerConfigurationError, toServerConfigurationError, validHex };
2863
3421
  //# sourceMappingURL=index.js.map