@xmtp/node-sdk 6.1.0 → 6.2.0-dev.4b7b477

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, 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,292 +387,237 @@ 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
+ // implements: PROC-028, PROC-031, PROC-032
436
+ async #read() {
437
+ if (this.#hasEnded())
438
+ return { done: true, value: undefined };
439
+ if (this.#reading)
440
+ throw new Error("A message read is already pending");
441
+ this.#reading = true;
442
+ try {
443
+ await this.#acknowledgePending();
444
+ while (!this.#hasEnded()) {
445
+ const item = await this.#reader.nextDelivery();
446
+ if (item === undefined || item === null) {
447
+ await this.return();
448
+ break;
449
+ }
450
+ if (this.#hasEnded()) {
451
+ await item.acknowledgement.reject();
452
+ break;
453
+ }
454
+ this.#pending = item.acknowledgement;
455
+ // A codec that throws must not advance the cursor. The message is
456
+ // retained and a later reader, or the same client with the codec
457
+ // registered, must still see it. Acknowledging here would skip a
458
+ // message nothing has read. This matches the undefined case below:
459
+ // both mean this client could not decode the message, so both hold
460
+ // the item rather than discarding it.
461
+ const value = await this.#convert(item.message, item.cursor);
462
+ if (this.#hasEnded())
463
+ break;
464
+ const checked = item.acknowledgement.checkOwner();
465
+ const valid = typeof checked === "boolean" ? checked : await checked;
466
+ if (this.#hasEnded())
467
+ break;
468
+ if (!valid) {
469
+ this.#pending = undefined;
470
+ await item.acknowledgement.reject();
471
+ continue;
472
+ }
473
+ if (value === undefined) {
474
+ // The converter could not produce a value. That includes a failed
475
+ // lookup, so it is not safe to acknowledge: skipping here would
476
+ // advance the durable cursor past a message nothing has read.
477
+ // Report it and stop, leaving the item replayable.
478
+ throw new Error("The retained message could not be decoded");
479
+ }
480
+ // Do not await between the final ownership check and the app handoff.
481
+ this.#cursor = item.cursor;
482
+ if (this.#onValue) {
483
+ await this.#onValue(value);
484
+ if (this.#hasEnded())
485
+ break;
486
+ await this.#acknowledgePending();
487
+ }
488
+ else {
489
+ return { done: false, value };
490
+ }
491
+ }
492
+ return { done: true, value: undefined };
455
493
  }
456
- else {
457
- this.#queue.push(value);
494
+ catch (error) {
495
+ if (!this.#hasEnded()) {
496
+ try {
497
+ // A handler that throws must not replace the real failure. The
498
+ // caller needs the original cause to know why the stream ended.
499
+ this.#options.onError?.(error);
500
+ }
501
+ catch {
502
+ // Reported through the rethrow below.
503
+ }
504
+ finally {
505
+ await this.return();
506
+ }
507
+ }
508
+ throw error;
458
509
  }
459
- };
460
- next = () => {
461
- if (this.isDone) {
462
- return Promise.resolve({ done: true, value: undefined });
510
+ finally {
511
+ this.#reading = false;
463
512
  }
464
- if (this.#queue.length > 0) {
465
- return Promise.resolve({
466
- done: false,
467
- value: this.#queue.shift(),
468
- });
513
+ }
514
+ async #close(pending) {
515
+ const invoke = (action) => {
516
+ try {
517
+ return Promise.resolve(action());
518
+ }
519
+ catch (error) {
520
+ return Promise.reject(error instanceof Error
521
+ ? error
522
+ : new Error("Message stream close failed", { cause: error }));
523
+ }
524
+ };
525
+ // Invoke close before any await so it fences pending reads and host handoffs.
526
+ const results = await Promise.allSettled([
527
+ invoke(() => this.#reader.close()),
528
+ invoke(() => pending?.reject()),
529
+ ]);
530
+ try {
531
+ for (const result of results) {
532
+ if (result.status === "rejected")
533
+ throw result.reason;
534
+ }
535
+ return { done: true, value: undefined };
469
536
  }
470
- return new Promise((resolve) => {
471
- this.#pendingResolves.push(resolve);
472
- });
473
- };
537
+ finally {
538
+ this.#options.onEnd?.();
539
+ }
540
+ }
474
541
  return = () => {
475
- this.onReturn?.();
476
- this.done();
477
- return Promise.resolve({
478
- done: true,
479
- value: undefined,
480
- });
542
+ if (this.#done) {
543
+ return this.#closing ?? Promise.resolve({ done: true, value: undefined });
544
+ }
545
+ this.#done = true;
546
+ const pending = this.#pending;
547
+ this.#pending = undefined;
548
+ this.#closing = this.#close(pending);
549
+ return this.#closing;
481
550
  };
482
- end = () => this.return();
551
+ end = this.return;
552
+ updateScope = (groupIds) => this.#reader.updateScope(groupIds);
553
+ updateFilter = (conversationType, consentStates) => this.#reader.updateFilter(conversationType, consentStates);
554
+ catchUpSnapshot = () => this.#reader.catchUpSnapshot();
555
+ catchUpChanged = () => this.#reader.catchUpChanged();
483
556
  [Symbol.asyncIterator]() {
484
557
  return this;
485
558
  }
486
559
  }
487
- const usableProperties = [
488
- "end",
489
- "isDone",
490
- "next",
491
- "return",
492
- Symbol.asyncIterator,
493
- ];
494
- const isUsableProperty = (prop) => {
495
- return usableProperties.includes(prop);
496
- };
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
- };
549
- }
550
- return undefined;
551
- },
552
- });
553
- }
554
560
 
555
- const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
556
- const DEFAULT_RETRY_DELAY = 60_000; // milliseconds
557
- const DEFAULT_RETRY_ATTEMPTS = 10;
558
- /**
559
- * Creates a stream from a stream function
560
- *
561
- * If the stream fails, an attempt will be made to restart it.
562
- *
563
- * This function is not intended to be used directly.
564
- *
565
- * @param streamFunction - The stream function to create a stream from
566
- * @param streamValueMutator - An optional function to mutate the value emitted from the stream
567
- * @param options - The options for the stream
568
- * @param args - Additional arguments to pass to the stream function
569
- * @returns An async iterable stream proxy
570
- * @throws {StreamInvalidRetryAttemptsError} if the retryAttempts option is less than 0 and retryOnFail is true
571
- * @throws {StreamFailedError} if the stream fails and can't be restarted
572
- */
573
- const createStream = async (streamFunction, streamValueMutator, options) => {
574
- const { onError, onFail, onRestart, onRetry, onValue, retryAttempts = DEFAULT_RETRY_ATTEMPTS, retryDelay = DEFAULT_RETRY_DELAY, retryOnFail = true, } = options ?? {};
575
- // retry attempts must be greater than 0
576
- if (retryOnFail && retryAttempts < 0) {
577
- throw new StreamInvalidRetryAttemptsError();
561
+ /** A notification failure with a stable error code. */
562
+ class NotificationError extends Error {
563
+ code;
564
+ constructor(code, options) {
565
+ super(code, options);
566
+ this.code = code;
567
+ this.name = "NotificationError";
578
568
  }
579
- const asyncStream = new AsyncStream();
580
- const streamCallback = (error, value) => {
581
- // if a stream error occurs, call the onError callback
582
- if (error) {
583
- onError?.(error);
584
- return;
585
- }
586
- // ensure the value is not undefined
587
- if (value !== undefined) {
588
- try {
589
- // if a streamValueMutator is provided, mutate the value
590
- if (streamValueMutator) {
591
- const mutatedValue = streamValueMutator(value);
592
- if (isPromise(mutatedValue)) {
593
- void mutatedValue
594
- .then((mutatedValue) => {
595
- if (mutatedValue !== undefined) {
596
- asyncStream.push(mutatedValue);
597
- onValue?.(mutatedValue);
598
- }
599
- })
600
- .catch((error) => {
601
- onError?.(error);
602
- });
603
- }
604
- else {
605
- if (mutatedValue !== undefined) {
606
- asyncStream.push(mutatedValue);
607
- onValue?.(mutatedValue);
608
- }
609
- }
610
- }
611
- else {
612
- asyncStream.push(value);
613
- onValue?.(value);
614
- }
615
- }
616
- catch (error) {
617
- onError?.(error);
618
- }
619
- }
569
+ }
570
+ const toBindingNotificationConfig = (config) => {
571
+ const { channel, ...rules } = config;
572
+ return {
573
+ ...rules,
574
+ channel: channel.type,
575
+ ...(channel.type === "http"
576
+ ? { url: channel.url, signingKey: Array.from(channel.signingKey) }
577
+ : { token: channel.token }),
620
578
  };
621
- const retry = async (retries = retryAttempts) => {
622
- // if the stream has been retried the maximum number of times without
623
- // success, call onError
624
- if (retries === 0) {
625
- void asyncStream.end();
626
- onError?.(new StreamFailedError(retryAttempts));
627
- return;
628
- }
629
- // wait for the retry delay before attempting to restart the stream
630
- await wait(retryDelay);
631
- // call the onRetry callback
632
- onRetry?.(retryAttempts - retries + 1, retryAttempts);
633
- try {
634
- // attempt to restart the stream
635
- const streamCloser = await streamFunction(streamCallback, () => {
636
- // call the onFail callback
637
- onFail?.();
638
- void retry();
639
- });
640
- await streamCloser.waitForReady();
641
- // when the async stream is done, end the stream
642
- asyncStream.onDone = () => {
643
- streamCloser.end();
579
+ };
580
+ const toNotificationState = (value) => {
581
+ switch (value.state) {
582
+ case 0 /* NotificationStateKind.Disabled */:
583
+ return { state: "disabled" };
584
+ case 1 /* NotificationStateKind.Enabled */:
585
+ return { state: "enabled" };
586
+ case 2 /* NotificationStateKind.Failed */: {
587
+ const codes = {
588
+ [0 /* NotificationFailure.PermissionDenied */]: "PermissionDenied",
589
+ [1 /* NotificationFailure.InvalidArgument */]: "InvalidArgument",
590
+ [2 /* NotificationFailure.OutOfRange */]: "OutOfRange",
591
+ [3 /* NotificationFailure.Unimplemented */]: "Unimplemented",
592
+ [4 /* NotificationFailure.ChannelNotConfigured */]: "ChannelNotConfigured",
593
+ };
594
+ if (value.failure == null) {
595
+ throw new Error("Notification failure is missing its error");
596
+ }
597
+ return {
598
+ state: "failed",
599
+ error: new NotificationError(`NotificationError::${codes[value.failure]}`),
644
600
  };
645
- // stream restarted, call the onRestart callback
646
- onRestart?.();
647
- }
648
- catch (error) {
649
- onError?.(error);
650
- // retry
651
- void retry(retries - 1);
652
- }
653
- };
654
- const startRetry = () => {
655
- // if the stream should be retried, start the process
656
- if (retryOnFail) {
657
- void retry();
658
- }
659
- else {
660
- void asyncStream.end();
661
- // stream failed and should not be retried, throw an error
662
- onError?.(new StreamFailedError(0));
663
601
  }
664
- };
665
- try {
666
- // create the stream
667
- const streamCloser = await streamFunction(streamCallback, () => {
668
- // call the onFail callback
669
- onFail?.();
670
- startRetry();
671
- });
672
- await streamCloser.waitForReady();
673
- // when the async stream is done, end the stream
674
- asyncStream.onDone = () => {
675
- streamCloser.end();
676
- };
677
602
  }
678
- catch (error) {
679
- onError?.(error);
680
- startRetry();
603
+ };
604
+ const toBindingNotificationOverride = (value) => {
605
+ switch (value) {
606
+ case "enabled":
607
+ return 0 /* BindingNotificationOverride.Enabled */;
608
+ case "disabled":
609
+ return 1 /* BindingNotificationOverride.Disabled */;
610
+ case "default":
611
+ return 2 /* BindingNotificationOverride.Default */;
681
612
  }
682
- // return a proxy for the async stream
683
- return createAsyncStreamProxy(asyncStream);
613
+ };
614
+ const throwNotificationError = (error) => {
615
+ if (error instanceof Error) {
616
+ const code = /^\[(NotificationError::[^\]]+)\]/.exec(error.message)?.[1];
617
+ if (code)
618
+ throw new NotificationError(code, { cause: error });
619
+ }
620
+ throw error;
684
621
  };
685
622
 
686
623
  /**
@@ -716,6 +653,16 @@ class Conversation {
716
653
  get isActive() {
717
654
  return this.#conversation.isActive();
718
655
  }
656
+ /** Override notification rules, or reset the override with "default". */
657
+ setNotifications(value) {
658
+ return Promise.resolve().then(() => {
659
+ this.#conversation.setNotifications(toBindingNotificationOverride(value));
660
+ });
661
+ }
662
+ /** Read the effective notification value for this conversation. */
663
+ notificationsEnabled() {
664
+ return Promise.resolve().then(() => this.#conversation.notificationsEnabled());
665
+ }
719
666
  /**
720
667
  * Gets the inbox ID that added this client's inbox to the conversation
721
668
  */
@@ -735,7 +682,7 @@ class Conversation {
735
682
  return nsToDate(this.createdAtNs);
736
683
  }
737
684
  get topic() {
738
- return `/xmtp/mls/1/g-${this.id}/proto`;
685
+ return `00${this.id}`;
739
686
  }
740
687
  pausedForVersion() {
741
688
  return this.#conversation.pausedForVersion() ?? undefined;
@@ -777,26 +724,31 @@ class Conversation {
777
724
  return this.#conversation.sync();
778
725
  }
779
726
  /**
780
- * Creates a stream for new messages in this conversation
727
+ * Reads retained messages after the default acknowledgement position.
728
+ * Set `from` to replay after a cursor without changing default progress.
729
+ * Set `onValue` for callback mode, or request items with the iterator.
730
+ * Core owns network recovery. Legacy retry options and `disableSync` do not apply.
781
731
  *
782
- * @param options - Optional stream options
732
+ * @param options - Optional delivery callbacks and replay cursor
783
733
  * @returns Stream instance for new messages
784
734
  */
785
735
  async stream(options) {
786
- const stream = async (callback, onFail) => {
787
- if (!options?.disableSync) {
788
- await this.sync();
789
- }
790
- return this.#conversation.stream(callback, onFail);
791
- };
792
- const convertMessage = (value) => {
736
+ const reader = await this.#conversation.messageReader(options?.from);
737
+ const convertMessage = (value, cursor) => {
793
738
  const enrichedMessage = this.#client.conversations.getMessageById(value.id);
794
- if (enrichedMessage === undefined) {
795
- console.warn(`Streamed message with ID "${value.id}" not found`);
739
+ if (enrichedMessage !== undefined) {
740
+ assertMessageDecodedForDelivery(enrichedMessage);
741
+ enrichedMessage.deliveryCursor = cursor;
796
742
  }
797
743
  return enrichedMessage;
798
744
  };
799
- return createStream(stream, convertMessage, options);
745
+ return new MessageStream(reader, convertMessage, options);
746
+ }
747
+ messageHistorySnapshot(limit) {
748
+ return this.#conversation.messageHistorySnapshot(limit);
749
+ }
750
+ beginningDeliveryCursor() {
751
+ return this.#conversation.beginningDeliveryCursor();
800
752
  }
801
753
  /**
802
754
  * Decodes, decrypts, and persists a raw envelope from a group message stream.
@@ -1164,9 +1116,19 @@ class Group extends Conversation {
1164
1116
  * Updates the group's app data (max 8192 bytes)
1165
1117
  *
1166
1118
  * @param appData The new app data for the group
1167
- */
1168
- async updateAppData(appData) {
1169
- return this.#conversation.updateAppData({ value: appData });
1119
+ * @param expectedAppData Optional compare-and-swap guard. When provided, the
1120
+ * update is abandoned — rather than overwriting — if the committed value is
1121
+ * no longer this, including when another member's commit wins the race after
1122
+ * this update was published. Callers reconciling structured state should
1123
+ * pass the value they merged against, so a concurrent write surfaces as an
1124
+ * error instead of silently discarding someone else's change. Omit for
1125
+ * last-writer-wins.
1126
+ */
1127
+ async updateAppData(appData, expectedAppData) {
1128
+ return this.#conversation.updateAppData({
1129
+ value: appData,
1130
+ expectedValue: expectedAppData,
1131
+ });
1170
1132
  }
1171
1133
  /**
1172
1134
  * The permissions of the group
@@ -1288,16 +1250,420 @@ class Group extends Conversation {
1288
1250
  async requestRemoval() {
1289
1251
  return this.#conversation.leaveGroup();
1290
1252
  }
1291
- /**
1292
- * Checks if the current user has requested to leave the group
1293
- *
1294
- * @returns Boolean
1295
- */
1296
- isPendingRemoval() {
1297
- return (this.#conversation.membershipState() ===
1298
- 4 /* GroupMembershipState.PendingRemove */);
1253
+ /**
1254
+ * Checks if the current user has requested to leave the group
1255
+ *
1256
+ * @returns Boolean
1257
+ */
1258
+ isPendingRemoval() {
1259
+ return (this.#conversation.membershipState() ===
1260
+ 4 /* GroupMembershipState.PendingRemove */);
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * AsyncStream provides an async iterable interface for streaming data.
1266
+ *
1267
+ * This class implements a producer-consumer pattern where:
1268
+ * - Producers can push values using the `push()` method
1269
+ * - Consumers can iterate over values asynchronously using `for await` loops or `next()`
1270
+ * - Values are queued internally when no consumers are waiting
1271
+ * - Consumers are resolved immediately when values are available
1272
+ * - The stream can be terminated using `done()`, `return()`, or `end()`
1273
+ *
1274
+ * @example
1275
+ * ```typescript
1276
+ * const stream = new AsyncStream<string>();
1277
+ *
1278
+ * stream.push("hello");
1279
+ * stream.push("world");
1280
+ *
1281
+ * for await (const value of stream) {
1282
+ * console.log(value); // "hello", "world"
1283
+ * }
1284
+ * ```
1285
+ */
1286
+ class AsyncStream {
1287
+ isDone = false;
1288
+ #pendingResolves = [];
1289
+ #queue;
1290
+ onDone;
1291
+ onReturn;
1292
+ constructor() {
1293
+ this.#queue = [];
1294
+ this.isDone = false;
1295
+ }
1296
+ flush() {
1297
+ while (this.#pendingResolves.length > 0) {
1298
+ const nextResolve = this.#pendingResolves.shift();
1299
+ if (nextResolve) {
1300
+ nextResolve({ done: true, value: undefined });
1301
+ }
1302
+ }
1303
+ }
1304
+ done() {
1305
+ this.flush();
1306
+ this.#queue = [];
1307
+ this.#pendingResolves = [];
1308
+ this.isDone = true;
1309
+ this.onDone?.();
1310
+ }
1311
+ push = (value) => {
1312
+ if (this.isDone) {
1313
+ return;
1314
+ }
1315
+ const nextResolve = this.#pendingResolves.shift();
1316
+ if (nextResolve) {
1317
+ nextResolve({
1318
+ done: false,
1319
+ value,
1320
+ });
1321
+ }
1322
+ else {
1323
+ this.#queue.push(value);
1324
+ }
1325
+ };
1326
+ next = () => {
1327
+ if (this.isDone) {
1328
+ return Promise.resolve({ done: true, value: undefined });
1329
+ }
1330
+ if (this.#queue.length > 0) {
1331
+ return Promise.resolve({
1332
+ done: false,
1333
+ value: this.#queue.shift(),
1334
+ });
1335
+ }
1336
+ return new Promise((resolve) => {
1337
+ this.#pendingResolves.push(resolve);
1338
+ });
1339
+ };
1340
+ return = () => {
1341
+ this.onReturn?.();
1342
+ this.done();
1343
+ return Promise.resolve({
1344
+ done: true,
1345
+ value: undefined,
1346
+ });
1347
+ };
1348
+ end = () => this.return();
1349
+ [Symbol.asyncIterator]() {
1350
+ return this;
1351
+ }
1352
+ }
1353
+ const usableProperties = [
1354
+ "end",
1355
+ "isDone",
1356
+ "next",
1357
+ "return",
1358
+ Symbol.asyncIterator,
1359
+ ];
1360
+ const isUsableProperty = (prop) => {
1361
+ return usableProperties.includes(prop);
1362
+ };
1363
+ /**
1364
+ * Creates a read-only proxy for AsyncStream instances that restricts access to consumer-only methods.
1365
+ *
1366
+ * This proxy only exposes the following properties and methods:
1367
+ * - `next()`: Get the next value from the stream
1368
+ * - `end()`: Terminate the stream and stop iteration
1369
+ * - `return()`: Same as end(), terminates the stream
1370
+ * - `isDone`: Boolean indicating if the stream has been terminated
1371
+ * - `Symbol.asyncIterator`: Enables `for await` loop iteration
1372
+ *
1373
+ * Producer methods like `push()`, `done()`, and `flush()` are hidden to prevent
1374
+ * consumers from accidentally modifying the stream state.
1375
+ *
1376
+ * @param stream - The AsyncStream instance to create a proxy for
1377
+ * @returns A read-only proxy that implements AsyncStreamProxy<T>
1378
+ *
1379
+ * @example
1380
+ * ```typescript
1381
+ * const stream = new AsyncStream<string>();
1382
+ * const proxy = createAsyncStreamProxy(stream);
1383
+ *
1384
+ * stream.push("hello");
1385
+ * stream.push("world");
1386
+ *
1387
+ * for await (const value of proxy) {
1388
+ * console.log(value); // "hello", "world"
1389
+ * }
1390
+ * ```
1391
+ */
1392
+ function createAsyncStreamProxy(stream) {
1393
+ return new Proxy(stream, {
1394
+ get(target, prop, receiver) {
1395
+ if (isUsableProperty(prop)) {
1396
+ return Reflect.get(target, prop, receiver);
1397
+ }
1398
+ },
1399
+ set() {
1400
+ return true;
1401
+ },
1402
+ has(_target, prop) {
1403
+ return isUsableProperty(prop);
1404
+ },
1405
+ ownKeys() {
1406
+ return usableProperties;
1407
+ },
1408
+ getOwnPropertyDescriptor(target, prop) {
1409
+ if (isUsableProperty(prop)) {
1410
+ return {
1411
+ enumerable: true,
1412
+ configurable: true,
1413
+ value: Reflect.get(target, prop),
1414
+ };
1415
+ }
1416
+ return undefined;
1417
+ },
1418
+ });
1419
+ }
1420
+
1421
+ const DEFAULT_RETRY_DELAY = 60_000; // milliseconds
1422
+ const DEFAULT_RETRY_ATTEMPTS = 10;
1423
+ /**
1424
+ * Creates a stream from a stream function
1425
+ *
1426
+ * If the stream fails, an attempt will be made to restart it.
1427
+ *
1428
+ * Ending the stream is terminal: no callbacks are invoked and no native
1429
+ * stream is created after the stream ends.
1430
+ *
1431
+ * This function is not intended to be used directly.
1432
+ *
1433
+ * @param streamFunction - The stream function to create a stream from
1434
+ * @param streamValueMutator - An optional function to mutate the value emitted from the stream
1435
+ * @param options - The options for the stream
1436
+ * @returns An async iterable stream proxy
1437
+ * @throws {StreamInvalidRetryAttemptsError} if the retryAttempts option is less than 0 and retryOnFail is true
1438
+ * @throws {StreamFailedError} if the stream fails and can't be restarted
1439
+ */
1440
+ const createStream = async (streamFunction, streamValueMutator, options) => {
1441
+ const { onEnd, onError, onFail, onRestart, onRetry, onValue, retryAttempts = DEFAULT_RETRY_ATTEMPTS, retryDelay = DEFAULT_RETRY_DELAY, retryOnFail = true, } = options ?? {};
1442
+ // retry attempts must be greater than 0
1443
+ if (retryOnFail && retryAttempts < 0) {
1444
+ throw new StreamInvalidRetryAttemptsError();
1445
+ }
1446
+ const asyncStream = new AsyncStream();
1447
+ // lifecycle state, owned by this wrapper
1448
+ let stopped = false;
1449
+ // reading the flag through a function call defeats TS control-flow
1450
+ // narrowing, which cannot see the closure mutation across awaits
1451
+ const isStopped = () => stopped;
1452
+ let currentCloser;
1453
+ let retryTimer;
1454
+ let retryInFlight = false;
1455
+ // set when a restart's native stream closes during its own creation, so the
1456
+ // completed attempt reschedules instead of installing an already-dead closer
1457
+ let closePendingDuringRestart = false;
1458
+ // read through a call so no-unnecessary-condition cannot narrow the flag to
1459
+ // a constant; handleNativeClose mutates it across an await
1460
+ const isClosePending = () => closePendingDuringRestart;
1461
+ // the retry budget is monotonic: it is never reset for the lifetime of
1462
+ // this wrapper, so restarts are bounded even across successful restarts
1463
+ let remainingRetries = retryAttempts;
1464
+ // terminal transition: cancel any pending retry, close the active native
1465
+ // stream, and notify onEnd exactly once
1466
+ const stop = () => {
1467
+ if (isStopped()) {
1468
+ return;
1469
+ }
1470
+ stopped = true;
1471
+ closePendingDuringRestart = false;
1472
+ if (retryTimer !== undefined) {
1473
+ clearTimeout(retryTimer);
1474
+ retryTimer = undefined;
1475
+ }
1476
+ currentCloser?.end();
1477
+ currentCloser = undefined;
1478
+ onEnd?.();
1479
+ };
1480
+ // registered before any async work so ending the stream is always terminal,
1481
+ // even while a retry is pending or a native stream is being created
1482
+ asyncStream.onDone = stop;
1483
+ const fail = (error) => {
1484
+ if (isStopped()) {
1485
+ return;
1486
+ }
1487
+ // the terminal transition must run even if onError throws, otherwise a
1488
+ // throwing consumer callback leaves the stream open and hangs next()
1489
+ try {
1490
+ onError?.(error);
1491
+ }
1492
+ finally {
1493
+ void asyncStream.end();
1494
+ }
1495
+ };
1496
+ const handleAsyncError = (error) => {
1497
+ if (!isStopped()) {
1498
+ onError?.(error);
1499
+ }
1500
+ };
1501
+ const streamCallback = (error, value) => {
1502
+ // an ended stream must not invoke any callbacks
1503
+ if (isStopped()) {
1504
+ return;
1505
+ }
1506
+ // if a stream error occurs, call the onError callback
1507
+ if (error) {
1508
+ onError?.(error);
1509
+ return;
1510
+ }
1511
+ // ensure the value is not undefined
1512
+ if (value !== undefined) {
1513
+ try {
1514
+ // if a streamValueMutator is provided, mutate the value
1515
+ if (streamValueMutator) {
1516
+ const mutatedValue = streamValueMutator(value);
1517
+ if (isPromise(mutatedValue)) {
1518
+ void mutatedValue
1519
+ .then((mutatedValue) => {
1520
+ // the stream may have ended while the value was mutating
1521
+ if (!isStopped() && mutatedValue !== undefined) {
1522
+ asyncStream.push(mutatedValue);
1523
+ return onValue?.(mutatedValue);
1524
+ }
1525
+ })
1526
+ .catch(handleAsyncError);
1527
+ }
1528
+ else {
1529
+ // a synchronous mutator may have ended the stream; gate delivery
1530
+ // on the stopped flag to match the async branch above
1531
+ if (!isStopped() && mutatedValue !== undefined) {
1532
+ asyncStream.push(mutatedValue);
1533
+ void Promise.resolve(onValue?.(mutatedValue)).catch(handleAsyncError);
1534
+ }
1535
+ }
1536
+ }
1537
+ else {
1538
+ asyncStream.push(value);
1539
+ void Promise.resolve(onValue?.(value)).catch(handleAsyncError);
1540
+ }
1541
+ }
1542
+ catch (error) {
1543
+ onError?.(error);
1544
+ }
1545
+ }
1546
+ };
1547
+ const scheduleRetry = () => {
1548
+ // at most one retry may be in flight per wrapper
1549
+ if (isStopped() || retryInFlight) {
1550
+ return;
1551
+ }
1552
+ if (remainingRetries <= 0) {
1553
+ fail(new StreamFailedError(retryAttempts));
1554
+ return;
1555
+ }
1556
+ retryInFlight = true;
1557
+ retryTimer = setTimeout(() => {
1558
+ retryTimer = undefined;
1559
+ void attemptRestart();
1560
+ }, retryDelay);
1561
+ };
1562
+ const attemptRestart = async () => {
1563
+ if (isStopped()) {
1564
+ retryInFlight = false;
1565
+ return;
1566
+ }
1567
+ // scope the pending-close flag to this attempt: a close recorded while the
1568
+ // retry timer was merely pending belongs to the stream that scheduled it
1569
+ closePendingDuringRestart = false;
1570
+ remainingRetries -= 1;
1571
+ onRetry?.(retryAttempts - remainingRetries, retryAttempts);
1572
+ if (isStopped()) {
1573
+ // onRetry may have ended the stream; do not open a native stream after
1574
+ // termination
1575
+ retryInFlight = false;
1576
+ return;
1577
+ }
1578
+ try {
1579
+ // attempt to restart the stream
1580
+ const streamCloser = await streamFunction(streamCallback, handleNativeClose);
1581
+ if (isStopped()) {
1582
+ // the stream ended while the native stream was being created
1583
+ streamCloser.end();
1584
+ retryInFlight = false;
1585
+ return;
1586
+ }
1587
+ await streamCloser.waitForReady();
1588
+ if (isStopped()) {
1589
+ streamCloser.end();
1590
+ retryInFlight = false;
1591
+ return;
1592
+ }
1593
+ if (isClosePending()) {
1594
+ // the replacement stream closed during its own creation; discard it
1595
+ // and schedule a fresh attempt instead of installing a dead closer
1596
+ closePendingDuringRestart = false;
1597
+ streamCloser.end();
1598
+ retryInFlight = false;
1599
+ scheduleRetry();
1600
+ return;
1601
+ }
1602
+ currentCloser = streamCloser;
1603
+ retryInFlight = false;
1604
+ // stream restarted, call the onRestart callback
1605
+ onRestart?.();
1606
+ }
1607
+ catch (error) {
1608
+ retryInFlight = false;
1609
+ if (isStopped()) {
1610
+ return;
1611
+ }
1612
+ closePendingDuringRestart = false;
1613
+ onError?.(error);
1614
+ scheduleRetry();
1615
+ }
1616
+ };
1617
+ const handleNativeClose = () => {
1618
+ // ending the stream closes the native stream, which still triggers this
1619
+ // callback; only an unexpected close is a failure
1620
+ if (isStopped()) {
1621
+ return;
1622
+ }
1623
+ currentCloser = undefined;
1624
+ onFail?.();
1625
+ if (retryOnFail) {
1626
+ // a native close during an in-flight restart is dropped by the
1627
+ // single-flight guard; record it so the completed attempt reschedules
1628
+ // instead of installing a stream that already died
1629
+ if (retryInFlight) {
1630
+ closePendingDuringRestart = true;
1631
+ return;
1632
+ }
1633
+ scheduleRetry();
1634
+ }
1635
+ else {
1636
+ fail(new StreamFailedError(0));
1637
+ }
1638
+ };
1639
+ try {
1640
+ // create the stream
1641
+ const streamCloser = await streamFunction(streamCallback, handleNativeClose);
1642
+ if (isStopped()) {
1643
+ streamCloser.end();
1644
+ }
1645
+ else {
1646
+ await streamCloser.waitForReady();
1647
+ if (isStopped()) {
1648
+ streamCloser.end();
1649
+ }
1650
+ else {
1651
+ currentCloser = streamCloser;
1652
+ }
1653
+ }
1654
+ }
1655
+ catch (error) {
1656
+ onError?.(error);
1657
+ if (retryOnFail) {
1658
+ scheduleRetry();
1659
+ }
1660
+ else {
1661
+ fail(new StreamFailedError(0));
1662
+ }
1299
1663
  }
1300
- }
1664
+ // return a proxy for the async stream
1665
+ return createAsyncStreamProxy(asyncStream);
1666
+ };
1301
1667
 
1302
1668
  /**
1303
1669
  * Manages conversations
@@ -1321,14 +1687,14 @@ class Conversations {
1321
1687
  this.#conversations = conversations;
1322
1688
  }
1323
1689
  get topic() {
1324
- return `/xmtp/mls/1/w-${this.#client.installationId}/proto`;
1690
+ return `01${this.#client.installationId}`;
1325
1691
  }
1326
1692
  /**
1327
1693
  * Retrieves a conversation by its ID
1328
1694
  *
1329
1695
  * @param id - The conversation ID to look up
1330
1696
  * @returns The conversation if found, undefined otherwise
1331
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1697
+ * @see https://docs.xmtp.org/sdk/conversations/
1332
1698
  */
1333
1699
  async getConversationById(id) {
1334
1700
  try {
@@ -1353,7 +1719,7 @@ class Conversations {
1353
1719
  *
1354
1720
  * @param inboxId - The inbox ID to look up
1355
1721
  * @returns The DM if found, undefined otherwise
1356
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1722
+ * @see https://docs.xmtp.org/sdk/conversations/
1357
1723
  */
1358
1724
  getDmByInboxId(inboxId) {
1359
1725
  try {
@@ -1370,7 +1736,7 @@ class Conversations {
1370
1736
  *
1371
1737
  * @param identifier - The identifier to look up
1372
1738
  * @returns Promise that resolves with the DM, if found
1373
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1739
+ * @see https://docs.xmtp.org/sdk/conversations/
1374
1740
  */
1375
1741
  async fetchDmByIdentifier(identifier) {
1376
1742
  const inboxId = await this.#client.fetchInboxIdByIdentifier(identifier);
@@ -1384,7 +1750,7 @@ class Conversations {
1384
1750
  *
1385
1751
  * @param id - The message ID to look up
1386
1752
  * @returns The decoded message if found, undefined otherwise
1387
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#conversation-helper-methods
1753
+ * @see https://docs.xmtp.org/sdk/conversations/
1388
1754
  */
1389
1755
  getMessageById(id) {
1390
1756
  try {
@@ -1401,7 +1767,7 @@ class Conversations {
1401
1767
  *
1402
1768
  * @param options - Optional group creation options
1403
1769
  * @returns The new group
1404
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#optimistically-create-a-new-group-chat
1770
+ * @see https://docs.xmtp.org/sdk/conversations/#optimistically-create-a-group
1405
1771
  */
1406
1772
  createGroupOptimistic(options) {
1407
1773
  const group = this.#conversations.createGroupOptimistic(options);
@@ -1413,7 +1779,7 @@ class Conversations {
1413
1779
  * @param identifiers - Array of identifiers for group members
1414
1780
  * @param options - Optional group creation options
1415
1781
  * @returns The new group
1416
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#create-a-new-group-chat
1782
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-group
1417
1783
  */
1418
1784
  async createGroupWithIdentifiers(identifiers, options) {
1419
1785
  const group = await this.#conversations.createGroupByIdentity(identifiers, options);
@@ -1426,7 +1792,7 @@ class Conversations {
1426
1792
  * @param inboxIds - Array of inbox IDs for group members
1427
1793
  * @param options - Optional group creation options
1428
1794
  * @returns The new group
1429
- * @see https://docs.xmtp.org/chat-apps/core-messaging/create-conversations#create-a-new-group-chat
1795
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-group
1430
1796
  */
1431
1797
  async createGroup(inboxIds, options) {
1432
1798
  const group = await this.#conversations.createGroup(inboxIds, options);
@@ -1439,7 +1805,7 @@ class Conversations {
1439
1805
  * @param identifier - Identifier for the DM recipient
1440
1806
  * @param options - Optional DM creation options
1441
1807
  * @returns The new DM
1442
- * @see https://docs.xmtp.org/agents/build-agents/create-conversations#by-ethereum-address-1
1808
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-dm
1443
1809
  */
1444
1810
  async createDmWithIdentifier(identifier, options) {
1445
1811
  const group = await this.#conversations.createDmByIdentity(identifier, options);
@@ -1452,7 +1818,7 @@ class Conversations {
1452
1818
  * @param inboxId - Inbox ID for the DM recipient
1453
1819
  * @param options - Optional DM creation options
1454
1820
  * @returns The new DM
1455
- * @see https://docs.xmtp.org/agents/build-agents/create-conversations#by-inbox-id-1
1821
+ * @see https://docs.xmtp.org/sdk/conversations/#create-a-dm
1456
1822
  */
1457
1823
  async createDm(inboxId, options) {
1458
1824
  const group = await this.#conversations.createDm(inboxId, options);
@@ -1464,7 +1830,7 @@ class Conversations {
1464
1830
  *
1465
1831
  * @param options - Optional filtering and pagination options
1466
1832
  * @returns Array of conversations
1467
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/list
1833
+ * @see https://docs.xmtp.org/sdk/conversations/
1468
1834
  */
1469
1835
  async list(options) {
1470
1836
  const groups = this.#conversations.list(options);
@@ -1487,7 +1853,7 @@ class Conversations {
1487
1853
  *
1488
1854
  * @param options - Optional filtering and pagination options
1489
1855
  * @returns Array of groups
1490
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/list#list-existing-conversations
1856
+ * @see https://docs.xmtp.org/sdk/conversations/
1491
1857
  */
1492
1858
  listGroups(options) {
1493
1859
  const groups = this.#conversations.list({
@@ -1504,7 +1870,7 @@ class Conversations {
1504
1870
  *
1505
1871
  * @param options - Optional filtering and pagination options
1506
1872
  * @returns Array of DMs
1507
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/list#list-existing-conversations
1873
+ * @see https://docs.xmtp.org/sdk/conversations/
1508
1874
  */
1509
1875
  listDms(options) {
1510
1876
  const groups = this.#conversations.list({
@@ -1520,7 +1886,7 @@ class Conversations {
1520
1886
  * Synchronizes conversations for the current client from the network
1521
1887
  *
1522
1888
  * @returns Promise that resolves when sync is complete
1523
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/sync-and-syncall
1889
+ * @see https://docs.xmtp.org/sdk/sync/
1524
1890
  */
1525
1891
  async sync() {
1526
1892
  return this.#conversations.sync();
@@ -1531,7 +1897,7 @@ class Conversations {
1531
1897
  *
1532
1898
  * @param consentStates - Optional array of consent states to filter by
1533
1899
  * @returns Promise that resolves when sync is complete
1534
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/sync-and-syncall#sync-all-new-welcomes-conversations-messages-and-preferences
1900
+ * @see https://docs.xmtp.org/sdk/sync/
1535
1901
  */
1536
1902
  async syncAll(consentStates) {
1537
1903
  return this.#conversations.syncAll(consentStates);
@@ -1542,7 +1908,7 @@ class Conversations {
1542
1908
  * @param options - Optional stream options
1543
1909
  * @param options.conversationType - Optional conversation type to filter by
1544
1910
  * @returns Stream instance for new conversations
1545
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-conversations
1911
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1546
1912
  */
1547
1913
  async stream(options) {
1548
1914
  const stream = async (callback, onFail) => {
@@ -1571,7 +1937,7 @@ class Conversations {
1571
1937
  *
1572
1938
  * @param options - Optional stream options
1573
1939
  * @returns Stream instance for new group conversations
1574
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-conversations
1940
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1575
1941
  */
1576
1942
  async streamGroups(options) {
1577
1943
  const stream = async (callback, onFail) => {
@@ -1590,7 +1956,7 @@ class Conversations {
1590
1956
  *
1591
1957
  * @param options - Optional stream options
1592
1958
  * @returns Stream instance for new DM conversations
1593
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-conversations
1959
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1594
1960
  */
1595
1961
  async streamDms(options) {
1596
1962
  const stream = async (callback, onFail) => {
@@ -1605,37 +1971,42 @@ class Conversations {
1605
1971
  return createStream(stream, convertConversation, options);
1606
1972
  }
1607
1973
  /**
1608
- * Creates a stream for all new messages
1974
+ * Reads retained messages after each group's default acknowledgement position.
1975
+ * Set `from` to replay after a cursor without changing default progress.
1976
+ * Set `onValue` for callback mode, or request items with the iterator.
1977
+ * Core owns network recovery. Legacy retry options and `disableSync` do not apply.
1609
1978
  *
1610
1979
  * @param options - Optional stream options
1611
1980
  * @param options.conversationType - Optional conversation type to filter by
1612
1981
  * @param options.consentStates - Optional array of consent states to filter by
1613
1982
  * @returns Stream instance for new messages
1614
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-messages
1983
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1615
1984
  */
1616
1985
  async streamAllMessages(options) {
1617
- const streamAllMessages = async (callback, onFail) => {
1618
- if (!options?.disableSync) {
1619
- await this.syncAll(options?.consentStates);
1620
- }
1621
- return this.#conversations.streamAllMessages(callback, onFail, options?.conversationType, options?.consentStates);
1622
- };
1623
- const convertMessage = (value) => {
1986
+ const reader = await this.#conversations.messageReader(options?.groupIds, options?.conversationType, options?.consentStates, options?.from);
1987
+ const convertMessage = (value, cursor) => {
1624
1988
  const enrichedMessage = this.getMessageById(value.id);
1625
- if (enrichedMessage === undefined) {
1626
- console.warn(`Streamed message with ID "${value.id}" not found`);
1989
+ if (enrichedMessage !== undefined) {
1990
+ assertMessageDecodedForDelivery(enrichedMessage);
1991
+ enrichedMessage.deliveryCursor = cursor;
1627
1992
  }
1628
1993
  return enrichedMessage;
1629
1994
  };
1630
- return createStream(streamAllMessages, convertMessage, options);
1995
+ return new MessageStream(reader, convertMessage, options);
1996
+ }
1997
+ messageHistorySnapshot(limit, options) {
1998
+ return this.#conversations.messageHistorySnapshot(limit, options?.groupIds, options?.conversationType, options?.consentStates);
1999
+ }
2000
+ beginningDeliveryCursor() {
2001
+ return this.#conversations.beginningDeliveryCursor();
1631
2002
  }
1632
2003
  /**
1633
- * Creates a stream for all new group messages
2004
+ * Reads retained group messages with default progress or an explicit replay cursor.
1634
2005
  *
1635
2006
  * @param options - Optional stream options
1636
2007
  * @param options.consentStates - Optional array of consent states to filter by
1637
2008
  * @returns Stream instance for new group messages
1638
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-messages
2009
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1639
2010
  */
1640
2011
  async streamAllGroupMessages(options) {
1641
2012
  return this.streamAllMessages({
@@ -1645,12 +2016,12 @@ class Conversations {
1645
2016
  });
1646
2017
  }
1647
2018
  /**
1648
- * Creates a stream for all new DM messages
2019
+ * Reads retained DM messages with default progress or an explicit replay cursor.
1649
2020
  *
1650
2021
  * @param options - Optional stream options
1651
2022
  * @param options.consentStates - Optional array of consent states to filter by
1652
2023
  * @returns Stream instance for new DM messages
1653
- * @see https://docs.xmtp.org/chat-apps/list-stream-sync/stream#stream-new-group-chat-and-dm-messages
2024
+ * @see https://docs.xmtp.org/sdk/stream/#stream-methods
1654
2025
  */
1655
2026
  async streamAllDmMessages(options) {
1656
2027
  return this.streamAllMessages({
@@ -1699,7 +2070,7 @@ class Conversations {
1699
2070
  * Gets the HMAC keys for all conversations
1700
2071
  *
1701
2072
  * @returns The HMAC keys for all conversations
1702
- * @see https://docs.xmtp.org/chat-apps/push-notifs/push-notifs#get-hmac-keys-for-a-conversation
2073
+ * @see https://docs.xmtp.org/sdk/push-notifications/
1703
2074
  */
1704
2075
  hmacKeys() {
1705
2076
  return this.#conversations.hmacKeys();
@@ -1837,18 +2208,151 @@ class Preferences {
1837
2208
  }
1838
2209
  }
1839
2210
 
2211
+ /**
2212
+ * Typed failures for the deployment configuration a backend publishes
2213
+ * (spec 006 §7).
2214
+ *
2215
+ * The bindings report every one of these as an `Error` whose message begins
2216
+ * with `[ClientError::<Variant>]`. Matching on that string is what this module
2217
+ * removes: each of the six documented codes becomes its own class, so an app
2218
+ * writes `error instanceof BackendMismatchError` instead of a regular
2219
+ * expression over a message that may be reworded.
2220
+ *
2221
+ * Every class keeps the binding's message and the original error as `cause`,
2222
+ * so the identifiers, versions, scopes, or chain the core reported are still
2223
+ * readable for diagnostics.
2224
+ */
2225
+ /**
2226
+ * Base class of the six configuration failures. Catch this to handle any of
2227
+ * them; catch a subclass to handle one.
2228
+ */
2229
+ class ServerConfigurationError extends Error {
2230
+ code;
2231
+ constructor(
2232
+ /** The stable code the bindings reported, e.g. `ClientError::AuthRequired`. */
2233
+ code, message, options) {
2234
+ super(message, options);
2235
+ this.code = code;
2236
+ this.name = "ServerConfigurationError";
2237
+ }
2238
+ }
2239
+ /**
2240
+ * The configuration could not be read: the deployment did not answer, answered
2241
+ * `UNIMPLEMENTED` because it predates spec 006, or the copy could not be
2242
+ * stored.
2243
+ */
2244
+ class ConfigurationUnavailableError extends ServerConfigurationError {
2245
+ constructor(message, options) {
2246
+ super("ClientError::ConfigurationUnavailable", message, options);
2247
+ this.name = "ConfigurationUnavailableError";
2248
+ }
2249
+ }
2250
+ /**
2251
+ * The deployment answered with a configuration no client can use: an empty or
2252
+ * malformed identifier, an unparseable minimum version, or a chain that is not
2253
+ * a CAIP-2 identifier. Nothing is stored.
2254
+ */
2255
+ class ConfigurationInvalidError extends ServerConfigurationError {
2256
+ constructor(message, options) {
2257
+ super("ClientError::ConfigurationInvalid", message, options);
2258
+ this.name = "ConfigurationInvalidError";
2259
+ }
2260
+ }
2261
+ /**
2262
+ * This database is bound to a different deployment. The identifier the backend
2263
+ * published is not the one the database was created against. Recovery is a
2264
+ * database created for the deployment the app now uses.
2265
+ */
2266
+ class BackendMismatchError extends ServerConfigurationError {
2267
+ constructor(message, options) {
2268
+ super("ClientError::BackendMismatch", message, options);
2269
+ this.name = "BackendMismatchError";
2270
+ }
2271
+ }
2272
+ /** The deployment requires a newer libxmtp than this SDK is built on. */
2273
+ class ClientVersionTooOldError extends ServerConfigurationError {
2274
+ constructor(message, options) {
2275
+ super("ClientError::ClientVersionTooOld", message, options);
2276
+ this.name = "ClientVersionTooOldError";
2277
+ }
2278
+ }
2279
+ /**
2280
+ * The deployment requires authentication and no credential source was
2281
+ * configured. The message carries the scopes it requires.
2282
+ */
2283
+ class AuthRequiredError extends ServerConfigurationError {
2284
+ constructor(message, options) {
2285
+ super("ClientError::AuthRequired", message, options);
2286
+ this.name = "AuthRequiredError";
2287
+ }
2288
+ }
2289
+ /**
2290
+ * A smart contract wallet signature names a chain the deployment does not
2291
+ * verify on. Raised before any network call, and only for signatures the app
2292
+ * supplied.
2293
+ */
2294
+ class ChainNotAcceptedError extends ServerConfigurationError {
2295
+ constructor(message, options) {
2296
+ super("ClientError::ChainNotAccepted", message, options);
2297
+ this.name = "ChainNotAcceptedError";
2298
+ }
2299
+ }
2300
+ const configurationCode = /^\[(ClientError::[A-Za-z]+)\]\s*([\s\S]*)$/;
2301
+ // implements: CONF-064
2302
+ const build = (code, message, options) => {
2303
+ switch (code) {
2304
+ case "ClientError::ConfigurationUnavailable":
2305
+ return new ConfigurationUnavailableError(message, options);
2306
+ case "ClientError::ConfigurationInvalid":
2307
+ return new ConfigurationInvalidError(message, options);
2308
+ case "ClientError::BackendMismatch":
2309
+ return new BackendMismatchError(message, options);
2310
+ case "ClientError::ClientVersionTooOld":
2311
+ return new ClientVersionTooOldError(message, options);
2312
+ case "ClientError::AuthRequired":
2313
+ return new AuthRequiredError(message, options);
2314
+ case "ClientError::ChainNotAccepted":
2315
+ return new ChainNotAcceptedError(message, options);
2316
+ default:
2317
+ return undefined;
2318
+ }
2319
+ };
2320
+ /**
2321
+ * Classifies an error from any SDK call as one of the six configuration
2322
+ * failures, or `undefined` when it is something else.
2323
+ *
2324
+ * Use this where the SDK hands back the raw error, for instance a
2325
+ * `ChainNotAcceptedError` raised deep inside a call that supplies a smart
2326
+ * contract wallet signature.
2327
+ */
2328
+ const toServerConfigurationError = (error) => {
2329
+ if (!(error instanceof Error))
2330
+ return undefined;
2331
+ const match = configurationCode.exec(error.message);
2332
+ if (!match)
2333
+ return undefined;
2334
+ return build(match[1], match[2], { cause: error });
2335
+ };
2336
+ /**
2337
+ * Rethrows an error as its typed configuration failure, or unchanged when it
2338
+ * is not one.
2339
+ */
2340
+ const throwServerConfigurationError = (error) => {
2341
+ throw toServerConfigurationError(error) ?? error;
2342
+ };
2343
+
1840
2344
  const networkOptionKeys = [
1841
2345
  "env",
1842
- "apiUrl",
1843
- "gatewayHost",
2346
+ "backendUrl",
1844
2347
  "appVersion",
2348
+ "authCallback",
1845
2349
  ];
1846
2350
  const hasBackend = (options) => {
1847
2351
  return "backend" in options;
1848
2352
  };
1849
2353
  const resolveBackend$1 = async (options) => {
1850
2354
  if (!options) {
1851
- return createBackend();
2355
+ throw new Error("backendUrl is required");
1852
2356
  }
1853
2357
  if (hasBackend(options)) {
1854
2358
  // Validate that no NetworkOptions fields are also set
@@ -1866,7 +2370,13 @@ const createClient = async (identifier, options) => {
1866
2370
  const backend = await resolveBackend$1(options);
1867
2371
  const inboxId = (await getInboxIdForIdentifier(backend, identifier)) ||
1868
2372
  generateInboxId(identifier, options?.nonce);
1869
- const env = envToString(backend.env);
2373
+ const env = backend.env ?? "default";
2374
+ // `env` only labels the database file, so it must stay one path component.
2375
+ // Without this a value such as "../other" would move the database outside
2376
+ // the working directory.
2377
+ if (env === "" || /[/\\]/.test(env) || env === "." || env === "..") {
2378
+ throw new Error("env must be a single path component");
2379
+ }
1870
2380
  let dbPath;
1871
2381
  if (options?.dbPath === undefined) {
1872
2382
  // Default: auto-generated path
@@ -1885,6 +2395,8 @@ const createClient = async (identifier, options) => {
1885
2395
  level: options?.loggingLevel ?? "Off" /* LogLevel.Off */,
1886
2396
  stdoutLevel: options?.stdoutLoggingLevel,
1887
2397
  otelEndpoint: options?.otelEndpoint,
2398
+ otelServiceName: options?.otelServiceName,
2399
+ otelSampleRatio: options?.otelSampleRatio,
1888
2400
  resourceAttributes: options?.resourceAttributes,
1889
2401
  };
1890
2402
  const deviceSyncWorkerMode = options?.disableDeviceSync
@@ -1893,6 +2405,11 @@ const createClient = async (identifier, options) => {
1893
2405
  const dbEncryptionKey = isHexString(options?.dbEncryptionKey)
1894
2406
  ? Buffer.from(options.dbEncryptionKey.replace(/^0x/, ""), "hex")
1895
2407
  : options?.dbEncryptionKey;
2408
+ // Only build the registry when something is actually registered — an empty
2409
+ // one would make the core snapshot app_data on every processed message.
2410
+ const changeCallbacks = options?.unstableChangeCallbacks?.appData
2411
+ ? new UnstableChangeCallbacks(options.unstableChangeCallbacks.appData)
2412
+ : undefined;
1896
2413
  const client = await createClientWithBackend(backend, {
1897
2414
  dbPath: dbPath ?? undefined,
1898
2415
  encryptionKey: dbEncryptionKey,
@@ -1900,23 +2417,25 @@ const createClient = async (identifier, options) => {
1900
2417
  minDbPoolSize: options?.minDbPoolSize,
1901
2418
  useSingleConnection: options?.useSingleConnection,
1902
2419
  }, inboxId, identifier, deviceSyncWorkerMode, options?.workerConfig, logOptions, undefined, // allowOffline
1903
- options?.nonce);
2420
+ options?.nonce, changeCallbacks);
1904
2421
  return { client, env };
1905
2422
  };
1906
2423
 
1907
- /**
1908
- * Resolves a `Backend` instance from either a `Backend` or an `XmtpEnv` string.
1909
- *
1910
- * @param envOrBackend - A `Backend` instance, or an `XmtpEnv` string
1911
- * @param gatewayHost - Optional gateway host (only used when `envOrBackend` is an `XmtpEnv`)
1912
- * @returns A `Backend` instance
1913
- */
1914
- const resolveBackend = async (envOrBackend, gatewayHost) => {
1915
- if (envOrBackend instanceof Backend) {
1916
- return envOrBackend;
2424
+ /** Resolve a backend from explicit network options or an existing backend. */
2425
+ const resolveBackend = async (optionsOrBackend) => {
2426
+ if (optionsOrBackend instanceof Backend) {
2427
+ return optionsOrBackend;
1917
2428
  }
1918
- return createBackend({ env: envOrBackend, gatewayHost });
2429
+ return createBackend(optionsOrBackend);
1919
2430
  };
2431
+ /**
2432
+ * Read a backend URL and app version from a bare URL, network options, or an
2433
+ * existing backend. `fetchServerConfiguration` builds no backend of its own,
2434
+ * so it needs the two values rather than a `Backend`.
2435
+ */
2436
+ const resolveEndpoint = (target) => typeof target === "string"
2437
+ ? { backendUrl: target }
2438
+ : { backendUrl: target.backendUrl, appVersion: target.appVersion };
1920
2439
  const createEphemeralIdentifier = () => ({
1921
2440
  identifier: `0x${randomBytes(20).toString("hex")}`,
1922
2441
  identifierKind: 0 /* IdentifierKind.Ethereum */,
@@ -1965,7 +2484,9 @@ class Client {
1965
2484
  return;
1966
2485
  }
1967
2486
  this.#identifier = identifier;
1968
- const { client, env } = await createClient(identifier, this.#options);
2487
+ // A build resolves the deployment configuration before any identity work,
2488
+ // so its six configuration failures surface here as distinct types.
2489
+ const { client, env } = await createClient(identifier, this.#options).catch(throwServerConfigurationError);
1969
2490
  this.#client = client;
1970
2491
  this.#env = env;
1971
2492
  const conversations = this.#client.conversations();
@@ -1985,7 +2506,7 @@ class Client {
1985
2506
  const client = new Client(options);
1986
2507
  client.#signer = signer;
1987
2508
  await client.init(identifier);
1988
- if (!options?.disableAutoRegister) {
2509
+ if (!options.disableAutoRegister) {
1989
2510
  await client.register();
1990
2511
  }
1991
2512
  return client;
@@ -2021,12 +2542,12 @@ class Client {
2021
2542
  return this.#client?.appVersion();
2022
2543
  }
2023
2544
  /**
2024
- * Gets the XMTP environment the client is connected to
2545
+ * Gets the label used for the default database file name
2025
2546
  *
2026
2547
  * @throws {ClientNotInitializedError} if the client is not initialized
2027
2548
  */
2028
2549
  get env() {
2029
- if (!this.#env) {
2550
+ if (this.#env === undefined) {
2030
2551
  throw new ClientNotInitializedError();
2031
2552
  }
2032
2553
  return this.#env;
@@ -2166,7 +2687,11 @@ class Client {
2166
2687
  const identifier = await finalSigner.getIdentifier();
2167
2688
  switch (finalSigner.type) {
2168
2689
  case "SCW":
2169
- await signatureRequest.addScwSignature(identifier, signature, finalSigner.getChainId(), finalSigner.getBlockNumber?.());
2690
+ // The deployment publishes the chains it verifies on, so a signature
2691
+ // for any other chain is rejected here, before any network call.
2692
+ await signatureRequest
2693
+ .addScwSignature(identifier, signature, finalSigner.getChainId(), finalSigner.getBlockNumber?.())
2694
+ .catch(throwServerConfigurationError);
2170
2695
  break;
2171
2696
  case "EOA":
2172
2697
  await signatureRequest.addEcdsaSignature(signature);
@@ -2406,15 +2931,20 @@ class Client {
2406
2931
  await this.unsafe_addSignature(signatureRequest);
2407
2932
  await this.unsafe_applySignatureRequest(signatureRequest);
2408
2933
  }
2409
- static async revokeInstallations(signer, inboxId, installationIds, envOrBackend, gatewayHost) {
2410
- 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);
2411
2937
  const identifier = await signer.getIdentifier();
2412
2938
  const signatureRequest = await revokeInstallationsSignatureRequest(backend, identifier, inboxId, installationIds);
2413
2939
  const signatureText = await signatureRequest.signatureText();
2414
2940
  const signature = await signer.signMessage(signatureText);
2415
2941
  switch (signer.type) {
2416
2942
  case "SCW":
2417
- 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
+ await signatureRequest
2946
+ .addScwSignature(identifier, signature, signer.getChainId(), signer.getBlockNumber?.())
2947
+ .catch(throwServerConfigurationError);
2418
2948
  break;
2419
2949
  case "EOA":
2420
2950
  await signatureRequest.addEcdsaSignature(signature);
@@ -2537,12 +3067,14 @@ class Client {
2537
3067
  return false;
2538
3068
  }
2539
3069
  }
2540
- static async fetchInboxStates(inboxIds, envOrBackend, gatewayHost) {
2541
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3070
+ /** Fetch inbox states with an explicit backend or network options. */
3071
+ static async fetchInboxStates(inboxIds, optionsOrBackend) {
3072
+ const backend = await resolveBackend(optionsOrBackend);
2542
3073
  return fetchInboxStatesByInboxIds(backend, inboxIds);
2543
3074
  }
2544
- static async fetchLatestInboxUpdatesCount(inboxIds, envOrBackend, gatewayHost) {
2545
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3075
+ /** Fetch inbox update counts with an explicit backend or network options. */
3076
+ static async fetchLatestInboxUpdatesCount(inboxIds, optionsOrBackend) {
3077
+ const backend = await resolveBackend(optionsOrBackend);
2546
3078
  // The node-bindings Client is a napi-rs class with no explicit close/free
2547
3079
  // method. Release is non-deterministic: once this reference goes out of
2548
3080
  // scope, JS GC will eventually invoke the Rust Drop impl and reclaim the
@@ -2556,9 +3088,9 @@ class Client {
2556
3088
  const result = await client.fetchInboxUpdatesCount(inboxIds, true);
2557
3089
  return toInboxUpdatesCountMap(result);
2558
3090
  }
2559
- /* eslint-enable @typescript-eslint/unified-signatures */
2560
- static async canMessage(identifiers, envOrBackend) {
2561
- const backend = await resolveBackend(envOrBackend);
3091
+ /** Check identifiers with an explicit backend or network options. */
3092
+ static async canMessage(identifiers, optionsOrBackend) {
3093
+ const backend = await resolveBackend(optionsOrBackend);
2562
3094
  const canMessageMap = new Map();
2563
3095
  for (const identifier of identifiers) {
2564
3096
  const inboxId = await getInboxIdForIdentifier(backend, identifier);
@@ -2583,12 +3115,14 @@ class Client {
2583
3115
  return false;
2584
3116
  }
2585
3117
  }
2586
- static async isAddressAuthorized(inboxId, address, envOrBackend, gatewayHost) {
2587
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3118
+ /** Check address authorization with an explicit backend or network options. */
3119
+ static async isAddressAuthorized(inboxId, address, optionsOrBackend) {
3120
+ const backend = await resolveBackend(optionsOrBackend);
2588
3121
  return await isAddressAuthorized(backend, inboxId, address);
2589
3122
  }
2590
- static async isInstallationAuthorized(inboxId, installation, envOrBackend, gatewayHost) {
2591
- const backend = await resolveBackend(envOrBackend, gatewayHost);
3123
+ /** Check installation authorization with an explicit backend or network options. */
3124
+ static async isInstallationAuthorized(inboxId, installation, optionsOrBackend) {
3125
+ const backend = await resolveBackend(optionsOrBackend);
2592
3126
  return await isInstallationAuthorized(backend, inboxId, installation);
2593
3127
  }
2594
3128
  /**
@@ -2603,75 +3137,6 @@ class Client {
2603
3137
  excludeDisappearingMessages: false,
2604
3138
  };
2605
3139
  }
2606
- /**
2607
- * Get the default server URL based on the environment
2608
- */
2609
- #getDefaultServerUrl() {
2610
- const env = this.env;
2611
- return HistorySyncUrls[env];
2612
- }
2613
- /**
2614
- * Send a sync request to other devices on the network
2615
- *
2616
- * @param options - Archive options specifying what to sync (defaults to consent and messages)
2617
- * @param serverUrl - The server URL for the sync request (defaults to environment-specific URL)
2618
- * @returns Promise that resolves when the sync request is sent
2619
- */
2620
- async sendSyncRequest(options, serverUrl) {
2621
- if (!this.#client) {
2622
- throw new ClientNotInitializedError();
2623
- }
2624
- const resolvedOptions = options ?? this.#getDefaultArchiveOptions();
2625
- const resolvedServerUrl = serverUrl ?? this.#getDefaultServerUrl();
2626
- return this.#client
2627
- .deviceSync()
2628
- .sendSyncRequest(resolvedOptions, resolvedServerUrl);
2629
- }
2630
- /**
2631
- * Send a sync archive to the sync group
2632
- *
2633
- * @param pin - The pin used for reference when importing
2634
- * @param options - Archive options specifying what to sync (defaults to consent and messages)
2635
- * @param serverUrl - The server URL for the sync archive (defaults to environment-specific URL)
2636
- * @returns Promise that resolves when the sync archive is sent
2637
- */
2638
- async sendSyncArchive(pin, options, serverUrl) {
2639
- if (!this.#client) {
2640
- throw new ClientNotInitializedError();
2641
- }
2642
- const resolvedOptions = options ?? this.#getDefaultArchiveOptions();
2643
- const resolvedServerUrl = serverUrl ?? this.#getDefaultServerUrl();
2644
- return this.#client
2645
- .deviceSync()
2646
- .sendSyncArchive(resolvedOptions, resolvedServerUrl, pin);
2647
- }
2648
- /**
2649
- * Process a sync archive that matches the pin given
2650
- *
2651
- * @param archivePin - Optional pin to match. If not provided, processes the last archive sent
2652
- * @returns Promise that resolves when the archive is processed
2653
- */
2654
- async processSyncArchive(archivePin) {
2655
- if (!this.#client) {
2656
- throw new ClientNotInitializedError();
2657
- }
2658
- return this.#client.deviceSync().processSyncArchive(archivePin);
2659
- }
2660
- /**
2661
- * List the archives available for import in the sync group
2662
- *
2663
- * You may need to manually sync the sync group before calling
2664
- * this function to see recently uploaded archives.
2665
- *
2666
- * @param daysCutoff - Number of days to look back for archives
2667
- * @returns Array of available archive information
2668
- */
2669
- listAvailableArchives(daysCutoff) {
2670
- if (!this.#client) {
2671
- throw new ClientNotInitializedError();
2672
- }
2673
- return this.#client.deviceSync().listAvailableArchives(daysCutoff);
2674
- }
2675
3140
  /**
2676
3141
  * Archive application elements to file for later restoration
2677
3142
  *
@@ -2726,7 +3191,230 @@ class Client {
2726
3191
  }
2727
3192
  return this.#client.deviceSync().syncAllDeviceSyncGroups();
2728
3193
  }
3194
+ /** Enable notifications and register the delivery channel. */
3195
+ async enableNotifications(config) {
3196
+ if (!this.#client)
3197
+ throw new ClientNotInitializedError();
3198
+ try {
3199
+ return toNotificationState(await this.#client.enableNotifications(toBindingNotificationConfig(config)));
3200
+ }
3201
+ catch (error) {
3202
+ return throwNotificationError(error);
3203
+ }
3204
+ }
3205
+ /** Disable notifications. Per-conversation overrides remain stored. */
3206
+ async disableNotifications() {
3207
+ if (!this.#client)
3208
+ throw new ClientNotInitializedError();
3209
+ try {
3210
+ await this.#client.disableNotifications();
3211
+ }
3212
+ catch (error) {
3213
+ throwNotificationError(error);
3214
+ }
3215
+ }
3216
+ /** Read the local state without a backend request. */
3217
+ notificationState() {
3218
+ return Promise.resolve().then(() => {
3219
+ if (!this.#client)
3220
+ throw new ClientNotInitializedError();
3221
+ return toNotificationState(this.#client.notificationState());
3222
+ });
3223
+ }
3224
+ /**
3225
+ * What the deployment published about itself, as this client resolved it
3226
+ * when it was built.
3227
+ *
3228
+ * Reads memory and makes no backend request. The value never changes for the
3229
+ * life of the client: a background refresh rewrites the stored copy, and a
3230
+ * new value takes effect at the next build.
3231
+ *
3232
+ * @throws {ClientNotInitializedError} if the client is not initialized
3233
+ */
3234
+ serverConfiguration() {
3235
+ if (!this.#client) {
3236
+ throw new ClientNotInitializedError();
3237
+ }
3238
+ return this.#client.serverConfiguration();
3239
+ }
3240
+ /**
3241
+ * Fetch the deployment configuration now, rewrite the stored copy, and
3242
+ * return what was fetched.
3243
+ *
3244
+ * The snapshot this client holds is unchanged; use it to observe a change an
3245
+ * operator has made without restarting.
3246
+ *
3247
+ * @throws {ClientNotInitializedError} if the client is not initialized
3248
+ * @throws {ConfigurationUnavailableError} if the deployment did not answer
3249
+ * @throws {ConfigurationInvalidError} if it published an unusable configuration
3250
+ * @throws {BackendMismatchError} if it published a different identifier
3251
+ * @throws {ClientVersionTooOldError} if it now requires a newer libxmtp
3252
+ */
3253
+ async refreshServerConfiguration() {
3254
+ if (!this.#client) {
3255
+ throw new ClientNotInitializedError();
3256
+ }
3257
+ return this.#client
3258
+ .refreshServerConfiguration()
3259
+ .catch(throwServerConfigurationError);
3260
+ }
3261
+ /**
3262
+ * Read what a deployment publishes about itself with no database, no client,
3263
+ * and no credential.
3264
+ *
3265
+ * Lets an app learn `auth.enabled`, `auth.requiredScopes`, and the accepted
3266
+ * smart contract wallet chains before it decides how to build a client.
3267
+ * Nothing is stored and no identifier binding is applied.
3268
+ *
3269
+ * @param target - A backend URL, network options, or an existing backend
3270
+ * @throws {ConfigurationUnavailableError} if the deployment did not answer
3271
+ * @throws {ConfigurationInvalidError} if it published an unusable configuration
3272
+ */
3273
+ static async fetchServerConfiguration(target) {
3274
+ const { backendUrl, appVersion } = resolveEndpoint(target);
3275
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Validate options from JavaScript callers.
3276
+ if (!backendUrl?.trim()) {
3277
+ throw new Error("backendUrl is required");
3278
+ }
3279
+ return fetchServerConfiguration(backendUrl, appVersion).catch(throwServerConfigurationError);
3280
+ }
2729
3281
  }
2730
3282
 
2731
- 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 };
3283
+ const streamFailureMarker = "\n[XMTP_STREAM_FAILURE_V1]";
3284
+ const maxCursor = (1n << 64n) - 1n;
3285
+ /**
3286
+ * Reads structured progress from a stream, sync, or publication error.
3287
+ * All sequence values are exact bigints. Invalid or absent details return undefined.
3288
+ * A published-but-unconfirmed error does not mean that publication failed.
3289
+ */
3290
+ const getStreamFailureDetails = (error) => {
3291
+ try {
3292
+ const message = typeof error === "string" ? error : readObject(error).message;
3293
+ if (typeof message !== "string")
3294
+ return undefined;
3295
+ const marker = message.lastIndexOf(streamFailureMarker);
3296
+ if (marker === -1)
3297
+ return undefined;
3298
+ const details = readObject(JSON.parse(message.slice(marker + streamFailureMarker.length)));
3299
+ return {
3300
+ kind: readChoice(details.kind, [
3301
+ "barrier",
3302
+ "published_but_unconfirmed",
3303
+ "catch_up",
3304
+ ]),
3305
+ code: readString(details.code),
3306
+ message: readString(details.message),
3307
+ retryable: readBoolean(details.retryable),
3308
+ intentId: readNullable(details.intentId, readIntentId),
3309
+ publishedIntentIds: readArray(details.publishedIntentIds, readIntentId),
3310
+ summary: readNullable(details.summary, (value) => {
3311
+ const summary = readObject(value);
3312
+ return {
3313
+ messages: readCursor(summary.messages),
3314
+ conversations: readCursor(summary.conversations),
3315
+ failed: readCursor(summary.failed),
3316
+ completed: readBoolean(summary.completed),
3317
+ };
3318
+ }),
3319
+ barriers: readArray(details.barriers, readBarrier),
3320
+ };
3321
+ }
3322
+ catch {
3323
+ return undefined;
3324
+ }
3325
+ };
3326
+ const readBarrier = (value) => {
3327
+ const barrier = readObject(value);
3328
+ return {
3329
+ reason: readChoice(barrier.reason, ["blocked", "deadline", "cancelled"]),
3330
+ unfinished: readArray(barrier.unfinished, (value) => {
3331
+ const topic = readObject(value);
3332
+ return {
3333
+ topic: readTopic(topic.topic),
3334
+ target: readNullable(topic.target, readCursor),
3335
+ received: readCursor(topic.received),
3336
+ processed: readCursor(topic.processed),
3337
+ unresolvedWelcomes: readArray(topic.unresolvedWelcomes, readCursor),
3338
+ inactive: readBoolean(topic.inactive),
3339
+ cause: readNullable(topic.cause, (value) => {
3340
+ const cause = readObject(value);
3341
+ return {
3342
+ kind: readChoice(cause.kind, [
3343
+ "target_pending",
3344
+ "receipt_pending",
3345
+ "processing_pending",
3346
+ "blocked",
3347
+ "storage",
3348
+ "receiver",
3349
+ "invalid_topic",
3350
+ ]),
3351
+ code: readNullable(cause.code, readString),
3352
+ message: readString(cause.message),
3353
+ retryable: readBoolean(cause.retryable),
3354
+ };
3355
+ }),
3356
+ };
3357
+ }),
3358
+ };
3359
+ };
3360
+ const readObject = (value) => {
3361
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
3362
+ throw new Error("Invalid stream failure object");
3363
+ }
3364
+ return value;
3365
+ };
3366
+ const readString = (value) => {
3367
+ if (typeof value !== "string")
3368
+ throw new Error("Invalid stream failure text");
3369
+ return value;
3370
+ };
3371
+ const readBoolean = (value) => {
3372
+ if (typeof value !== "boolean")
3373
+ throw new Error("Invalid stream failure flag");
3374
+ return value;
3375
+ };
3376
+ const readChoice = (value, choices) => {
3377
+ const choice = choices.find((choice) => choice === value);
3378
+ if (choice === undefined)
3379
+ throw new Error("Invalid stream failure kind");
3380
+ return choice;
3381
+ };
3382
+ const readNullable = (value, read) => (value === null ? null : read(value));
3383
+ const readArray = (value, read) => {
3384
+ if (!Array.isArray(value))
3385
+ throw new Error("Invalid stream failure list");
3386
+ return value.map(read);
3387
+ };
3388
+ const readCursor = (value) => {
3389
+ const cursor = readString(value);
3390
+ if (!/^(0|[1-9][0-9]*)$/.test(cursor)) {
3391
+ throw new Error("Invalid stream failure sequence");
3392
+ }
3393
+ const number = BigInt(cursor);
3394
+ if (number > maxCursor)
3395
+ throw new Error("Stream failure sequence is too large");
3396
+ return number;
3397
+ };
3398
+ const readIntentId = (value) => {
3399
+ if (typeof value !== "number" ||
3400
+ !Number.isInteger(value) ||
3401
+ value < -2147483648 ||
3402
+ value > 2147483647) {
3403
+ throw new Error("Invalid stream failure intent ID");
3404
+ }
3405
+ return value;
3406
+ };
3407
+ const readTopic = (value) => {
3408
+ const hex = readString(value);
3409
+ if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex)) {
3410
+ throw new Error("Invalid stream failure topic");
3411
+ }
3412
+ const bytes = new Uint8Array(hex.length / 2);
3413
+ for (let index = 0; index < bytes.length; index++) {
3414
+ bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
3415
+ }
3416
+ return bytes;
3417
+ };
3418
+
3419
+ 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 };
2732
3420
  //# sourceMappingURL=index.js.map