@ttt-productions/chat-react 0.12.9 → 0.12.11

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.d.ts CHANGED
@@ -13,7 +13,7 @@ export type { RealtimeChatClient, RealtimeTransportConfig, ChannelClientState, C
13
13
  export type { ChatShellProps } from "./ui/ChatShell.js";
14
14
  export { ChatShell } from "./ui/ChatShell.js";
15
15
  export { MessageList } from "./ui/MessageList.js";
16
- export type { ComposerProps } from "./ui/Composer.js";
16
+ export type { ComposerProps, ComposerHandle } from "./ui/Composer.js";
17
17
  export { Composer } from "./ui/Composer.js";
18
18
  export type { MessageItemDefaultProps } from "./ui/MessageItemDefault.js";
19
19
  export { MessageItemDefault } from "./ui/MessageItemDefault.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,2BAA2B,EAC3B,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,yBAAyB,EACzB,qBAAqB,GACtB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,yBAAyB,EACzB,8BAA8B,GAC/B,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAM/C,YAAY,EACV,4BAA4B,EAC5B,gCAAgC,GACjC,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAGjD,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAE9C,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAKjE,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,6BAA6B,EAC7B,aAAa,EACb,eAAe,EACf,cAAc,EACd,cAAc,EACd,aAAa,EACb,cAAc,EACd,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAU7B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,YAAY,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,EACnB,KAAK,wBAAwB,GAC9B,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,EACtB,KAAK,0BAA0B,EAC/B,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,wCAAwC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,2BAA2B,EAC3B,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,yBAAyB,EACzB,qBAAqB,GACtB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,yBAAyB,EACzB,8BAA8B,GAC/B,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAM/C,YAAY,EACV,4BAA4B,EAC5B,gCAAgC,GACjC,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAGjD,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAE9C,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAKjE,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,6BAA6B,EAC7B,aAAa,EACb,eAAe,EACf,cAAc,EACd,cAAc,EACd,aAAa,EACb,cAAc,EACd,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAU7B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,YAAY,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,EACnB,KAAK,wBAAwB,GAC9B,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,EACtB,KAAK,0BAA0B,EAC/B,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,wCAAwC,CAAC"}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AA2Bb,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAU/C,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAIjD,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEjE,+EAA+E;AAC/E,kFAAkF;AAClF,oFAAoF;AACpF,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AA+B7B,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAyB,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,GAEpB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,GAKvB,MAAM,wCAAwC,CAAC","sourcesContent":["\"use client\";\n\n// @ttt-productions/chat-react — chat React UI, hooks, the Firebase-client\n// adapter config types, and React render types. Depends on the pure\n// @ttt-productions/chat-core for contracts, parser, and grouping.\n//\n// Apps import chat CSS via \"@ttt-productions/chat-react/styles\".\n\n// React-coupled config + render types (previously on chat-core root).\nexport type {\n ChatCoreConfig,\n ChatTransportMode,\n ChatRealtimeTransportConfig,\n ChatAttachmentConfig,\n ChatUploadAdapter,\n ChatMentionConfig,\n MessageRenderer,\n MessageRendererRegistry,\n RenderableMentionProvider,\n MentionResultRenderer,\n} from \"./types.js\";\n\n// Attachment-URL resolver context (render-time URL building from mediaAssetId).\nexport type {\n ChatAttachmentUrlResolver,\n ChatAttachmentUrlProviderProps,\n} from \"./context/ChatAttachmentUrlContext.js\";\nexport {\n ChatAttachmentUrlProvider,\n useChatAttachmentUrlResolver,\n} from \"./context/ChatAttachmentUrlContext.js\";\n\n// Attachment media-component injection (optional) — the app hands chat its own\n// display-path wrapper (recovery adapter, diagnostics, telemetry) so chat\n// media rides the same pipeline as every other surface; default stays the\n// package MediaViewer.\nexport type {\n ChatAttachmentMediaComponent,\n ChatAttachmentMediaProviderProps,\n} from \"./context/ChatAttachmentMediaContext.js\";\nexport {\n ChatAttachmentMediaProvider,\n useChatAttachmentMediaComponent,\n} from \"./context/ChatAttachmentMediaContext.js\";\n\n// Name-resolver context.\nexport type { ChatNameResolverProviderProps } from \"./context/ChatNameResolverContext.js\";\nexport {\n ChatNameResolverProvider,\n useChatNameResolver,\n useOptionalChatNameResolver,\n useResolvedSenderName,\n} from \"./context/ChatNameResolverContext.js\";\n\nexport type { UseChatMessagesResult } from \"./hooks/useChatMessages.js\";\nexport { useChatMessages } from \"./hooks/useChatMessages.js\";\nexport { canAccessThread } from \"./hooks/useChatThreadAccess.js\";\n\n// Realtime (Cloudflare Durable Object) transport — the client half of the chat\n// worker wire protocol. Opt-in via `ChatCoreConfig.transport === 'realtime'`; the\n// firestore transport stays the unchanged default. See docs/packages/chat-react.md.\nexport {\n createRealtimeChatClient,\n createInboxClient,\n ChannelClient,\n InboxClient,\n useRealtimeChatMessages,\n browserSocketFactory,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n CHAT_SUBPROTOCOL,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n} from \"./realtime/index.js\";\nexport type {\n RealtimeChatClient,\n RealtimeTransportConfig,\n ChannelClientState,\n ChannelClientConfig,\n InboxClientState,\n InboxClientConfig,\n UseRealtimeChatMessagesResult,\n GrantProvider,\n TransportTimers,\n RealtimeStatus,\n RealtimeSocket,\n SocketFactory,\n SocketHandlers,\n ChannelRefTuple,\n WireMessageRow,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n} from \"./realtime/index.js\";\n\n// TODO (re-export when a consumer needs them): the moderation-overlay helpers\n// `RevisionKind`, `ModerationOverlay`, `applyModerationOverlay`, `overlayFromRow`,\n// and `MODERATION_REDACTED_TEXT` live in `./realtime` but are intentionally NOT\n// re-exported from this package root yet — the consuming app's renderer reads moderation\n// state off `message.meta` (the realtime client applies the overlay internally), so\n// nothing imports them from here today. Add them to the block above when a consumer\n// genuinely needs to apply the overlay directly. (Comment-only; rides the next publish.)\n\nexport type { ChatShellProps } from \"./ui/ChatShell.js\";\nexport { ChatShell } from \"./ui/ChatShell.js\";\nexport { MessageList } from \"./ui/MessageList.js\";\nexport type { ComposerProps } from \"./ui/Composer.js\";\nexport { Composer } from \"./ui/Composer.js\";\nexport type { MessageItemDefaultProps } from \"./ui/MessageItemDefault.js\";\nexport { MessageItemDefault } from \"./ui/MessageItemDefault.js\";\nexport { MessageActions, ThreadActions } from \"./ui/menus.js\";\nexport { MessageText, type MessageTextProps } from \"./mentions/MessageText.js\";\nexport {\n MentionAutocomplete,\n type MentionAutocompleteProps,\n} from \"./mentions/MentionAutocomplete.js\";\nexport {\n useMentionAutocomplete,\n type UseMentionAutocompleteArgs,\n type UseMentionAutocompleteResult,\n type AutocompleteState,\n type AutocompleteResultGroup,\n} from \"./mentions/use-mention-autocomplete.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AA2Bb,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAU/C,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAIjD,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEjE,+EAA+E;AAC/E,kFAAkF;AAClF,oFAAoF;AACpF,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AA+B7B,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAyB,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,GAEpB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,GAKvB,MAAM,wCAAwC,CAAC","sourcesContent":["\"use client\";\n\n// @ttt-productions/chat-react — chat React UI, hooks, the Firebase-client\n// adapter config types, and React render types. Depends on the pure\n// @ttt-productions/chat-core for contracts, parser, and grouping.\n//\n// Apps import chat CSS via \"@ttt-productions/chat-react/styles\".\n\n// React-coupled config + render types (previously on chat-core root).\nexport type {\n ChatCoreConfig,\n ChatTransportMode,\n ChatRealtimeTransportConfig,\n ChatAttachmentConfig,\n ChatUploadAdapter,\n ChatMentionConfig,\n MessageRenderer,\n MessageRendererRegistry,\n RenderableMentionProvider,\n MentionResultRenderer,\n} from \"./types.js\";\n\n// Attachment-URL resolver context (render-time URL building from mediaAssetId).\nexport type {\n ChatAttachmentUrlResolver,\n ChatAttachmentUrlProviderProps,\n} from \"./context/ChatAttachmentUrlContext.js\";\nexport {\n ChatAttachmentUrlProvider,\n useChatAttachmentUrlResolver,\n} from \"./context/ChatAttachmentUrlContext.js\";\n\n// Attachment media-component injection (optional) — the app hands chat its own\n// display-path wrapper (recovery adapter, diagnostics, telemetry) so chat\n// media rides the same pipeline as every other surface; default stays the\n// package MediaViewer.\nexport type {\n ChatAttachmentMediaComponent,\n ChatAttachmentMediaProviderProps,\n} from \"./context/ChatAttachmentMediaContext.js\";\nexport {\n ChatAttachmentMediaProvider,\n useChatAttachmentMediaComponent,\n} from \"./context/ChatAttachmentMediaContext.js\";\n\n// Name-resolver context.\nexport type { ChatNameResolverProviderProps } from \"./context/ChatNameResolverContext.js\";\nexport {\n ChatNameResolverProvider,\n useChatNameResolver,\n useOptionalChatNameResolver,\n useResolvedSenderName,\n} from \"./context/ChatNameResolverContext.js\";\n\nexport type { UseChatMessagesResult } from \"./hooks/useChatMessages.js\";\nexport { useChatMessages } from \"./hooks/useChatMessages.js\";\nexport { canAccessThread } from \"./hooks/useChatThreadAccess.js\";\n\n// Realtime (Cloudflare Durable Object) transport — the client half of the chat\n// worker wire protocol. Opt-in via `ChatCoreConfig.transport === 'realtime'`; the\n// firestore transport stays the unchanged default. See docs/packages/chat-react.md.\nexport {\n createRealtimeChatClient,\n createInboxClient,\n ChannelClient,\n InboxClient,\n useRealtimeChatMessages,\n browserSocketFactory,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n CHAT_SUBPROTOCOL,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n} from \"./realtime/index.js\";\nexport type {\n RealtimeChatClient,\n RealtimeTransportConfig,\n ChannelClientState,\n ChannelClientConfig,\n InboxClientState,\n InboxClientConfig,\n UseRealtimeChatMessagesResult,\n GrantProvider,\n TransportTimers,\n RealtimeStatus,\n RealtimeSocket,\n SocketFactory,\n SocketHandlers,\n ChannelRefTuple,\n WireMessageRow,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n} from \"./realtime/index.js\";\n\n// TODO (re-export when a consumer needs them): the moderation-overlay helpers\n// `RevisionKind`, `ModerationOverlay`, `applyModerationOverlay`, `overlayFromRow`,\n// and `MODERATION_REDACTED_TEXT` live in `./realtime` but are intentionally NOT\n// re-exported from this package root yet — the consuming app's renderer reads moderation\n// state off `message.meta` (the realtime client applies the overlay internally), so\n// nothing imports them from here today. Add them to the block above when a consumer\n// genuinely needs to apply the overlay directly. (Comment-only; rides the next publish.)\n\nexport type { ChatShellProps } from \"./ui/ChatShell.js\";\nexport { ChatShell } from \"./ui/ChatShell.js\";\nexport { MessageList } from \"./ui/MessageList.js\";\nexport type { ComposerProps, ComposerHandle } from \"./ui/Composer.js\";\nexport { Composer } from \"./ui/Composer.js\";\nexport type { MessageItemDefaultProps } from \"./ui/MessageItemDefault.js\";\nexport { MessageItemDefault } from \"./ui/MessageItemDefault.js\";\nexport { MessageActions, ThreadActions } from \"./ui/menus.js\";\nexport { MessageText, type MessageTextProps } from \"./mentions/MessageText.js\";\nexport {\n MentionAutocomplete,\n type MentionAutocompleteProps,\n} from \"./mentions/MentionAutocomplete.js\";\nexport {\n useMentionAutocomplete,\n type UseMentionAutocompleteArgs,\n type UseMentionAutocompleteResult,\n type AutocompleteState,\n type AutocompleteResultGroup,\n} from \"./mentions/use-mention-autocomplete.js\";\n"]}
@@ -107,13 +107,51 @@ export declare class ChannelClient {
107
107
  /**
108
108
  * Flip an un-acked optimistic row to a visible failed state. The pending entry is
109
109
  * RETAINED (marked `failed`) so retrySend() can re-queue it with the SAME
110
- * clientMessageId; the row is marked `meta.sendFailed` so the default renderer
111
- * shows a failure with a retry affordance rather than an indistinguishable
112
- * "sent" bubble. A late ack/echo still reconciles + clears it.
110
+ * clientMessageId; the row is marked `meta.sendFailed` (plus the correlated
111
+ * `sendFailureCode`/`sendRetryable` when a `send-rejected` frame supplied them) so
112
+ * the default renderer shows the right reason + retry affordance rather than an
113
+ * indistinguishable "sent" bubble. A late ack/echo still reconciles + clears it.
114
+ *
115
+ * `code`/`retryable` are present for a correlated rejection (terminal) or a
116
+ * budget-exhausted server-retryable send; absent for a blind reconnect give-up
117
+ * (which keeps the neutral `send-failed` code).
113
118
  */
114
119
  private failPendingSend;
120
+ /** Clear a pending send's per-send retry timer (idempotent). */
121
+ private clearSendRetryTimer;
122
+ /**
123
+ * Stop tracking a send (acked / reconciled by an echo / history row): cancel its
124
+ * per-send retry timer first so a late reconcile never leaves a stray timer that
125
+ * would resend after the send already landed, then drop it from the map.
126
+ */
127
+ private clearPendingSend;
115
128
  /** One timer for the membership-pending re-send window; a newer frame resets it. */
116
129
  private scheduleMembershipPendingRetry;
130
+ /**
131
+ * Handle a CORRELATED `send-rejected` frame (the target protocol): parse the
132
+ * untrusted payload with the shared schema, correlate to EXACTLY ONE pending send
133
+ * by clientMessageId, and either fail just that bubble (terminal) or schedule a
134
+ * single per-send resend (retryable).
135
+ *
136
+ * A frame that fails validation is NOT simply dropped when it still names one of
137
+ * OUR pending sends: unlike an unknown frame type, an id-correlated rejection is
138
+ * the server explicitly telling us this send did not land — swallowing it would
139
+ * strand the bubble on "Sending…" forever (no further frame, no reconnect, no
140
+ * budget check ever runs). A future/unknown rejection code (version skew during a
141
+ * rollout) therefore fails that send CLOSED with the neutral copy + Retry. A
142
+ * malformed frame with no recognizable pending id is dropped (forward-compat
143
+ * noise), never surfaced as an error.
144
+ */
145
+ private applySendRejected;
146
+ /**
147
+ * Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
148
+ * stacks — a newer rejection clears the prior timer first). When it fires it
149
+ * re-sends the SAME clientMessageId (the DO dedups) unless the wall-clock budget
150
+ * has elapsed, in which case the send fails with its recorded reason.
151
+ */
152
+ private scheduleSendRetry;
153
+ /** Clamp a server `retryAfterMs` hint into a sane resend delay (defaulting when absent). */
154
+ private clampRetryDelay;
117
155
  /**
118
156
  * Flip EVERY un-acked pending send to the visible failed state. Called on the
119
157
  * terminal close paths (4403 REVOKED, reconnect give-up) — without this, a send
@@ -1 +1 @@
1
- {"version":3,"file":"channel-client.d.ts","sourceRoot":"","sources":["../../src/realtime/channel-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel-client.d.ts","sourceRoot":"","sources":["../../src/realtime/channel-client.ts"],"names":[],"mappings":"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"}
@@ -15,9 +15,9 @@
15
15
  //
16
16
  // Timers are injected (setTimeout/clearTimeout/now) so tests run deterministically.
17
17
  import { createReconnectController } from '@ttt-productions/realtime-core';
18
- import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
18
+ import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, ChatSendRejectedPayloadSchema, } from './wire.js';
19
19
  import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
20
- import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, } from './shared.js';
20
+ import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
21
21
  const INITIAL = {
22
22
  status: 'idle',
23
23
  messages: [],
@@ -182,11 +182,29 @@ export class ChannelClient {
182
182
  // re-queues it; the automatic reconnect resend leaves it alone.
183
183
  if (pending.failed)
184
184
  continue;
185
- const tooOld = now - pending.sentAt >= PENDING_SEND_MAX_AGE_MS;
186
- const tooManyAttempts = pending.attempts >= MAX_PENDING_SEND_ATTEMPTS;
187
- if (tooOld || tooManyAttempts) {
188
- this.failPendingSend(pending.clientMessageId);
189
- continue;
185
+ if (pending.serverRetryable) {
186
+ // The Worker declared this send retryable — give-up is the wall-clock budget
187
+ // ONLY (the 5-attempt cap must not fail a server-declared transient after
188
+ // ~10 s while the backend projection window is up to 60+ s).
189
+ if (now - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
190
+ this.failPendingSend(pending.clientMessageId, pending.lastRejectionCode, true);
191
+ continue;
192
+ }
193
+ // This reconnect resend supersedes any per-send timer scheduled before the
194
+ // socket dropped — clear it so the send doesn't go out twice (the DO would
195
+ // dedup, but the duplicate frame is pure waste). A fresh rejection frame
196
+ // re-schedules as usual.
197
+ this.clearSendRetryTimer(pending);
198
+ }
199
+ else {
200
+ // A blind reconnect resend of a send the server has said nothing about: the
201
+ // generic attempt/age caps still protect against a permanent ghost.
202
+ const tooOld = now - pending.sentAt >= PENDING_SEND_MAX_AGE_MS;
203
+ const tooManyAttempts = pending.attempts >= MAX_PENDING_SEND_ATTEMPTS;
204
+ if (tooOld || tooManyAttempts) {
205
+ this.failPendingSend(pending.clientMessageId);
206
+ continue;
207
+ }
190
208
  }
191
209
  const sent = this.sendFrame(CLIENT_FRAME.SEND, {
192
210
  clientMessageId: pending.clientMessageId,
@@ -200,19 +218,52 @@ export class ChannelClient {
200
218
  /**
201
219
  * Flip an un-acked optimistic row to a visible failed state. The pending entry is
202
220
  * RETAINED (marked `failed`) so retrySend() can re-queue it with the SAME
203
- * clientMessageId; the row is marked `meta.sendFailed` so the default renderer
204
- * shows a failure with a retry affordance rather than an indistinguishable
205
- * "sent" bubble. A late ack/echo still reconciles + clears it.
221
+ * clientMessageId; the row is marked `meta.sendFailed` (plus the correlated
222
+ * `sendFailureCode`/`sendRetryable` when a `send-rejected` frame supplied them) so
223
+ * the default renderer shows the right reason + retry affordance rather than an
224
+ * indistinguishable "sent" bubble. A late ack/echo still reconciles + clears it.
225
+ *
226
+ * `code`/`retryable` are present for a correlated rejection (terminal) or a
227
+ * budget-exhausted server-retryable send; absent for a blind reconnect give-up
228
+ * (which keeps the neutral `send-failed` code).
206
229
  */
207
- failPendingSend(clientMessageId) {
230
+ failPendingSend(clientMessageId, code, retryable) {
208
231
  const pending = this.pendingSends.get(clientMessageId);
209
232
  if (!pending || pending.failed)
210
233
  return;
211
234
  pending.failed = true;
235
+ // A terminal/exhausted send never retries on a timer — cancel any scheduled one.
236
+ this.clearSendRetryTimer(pending);
212
237
  const next = this.state.messages.map((m) => m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId
213
- ? { ...m, meta: { ...m.meta, sendFailed: true } }
238
+ ? {
239
+ ...m,
240
+ meta: {
241
+ ...m.meta,
242
+ sendFailed: true,
243
+ ...(code ? { sendFailureCode: code } : {}),
244
+ ...(retryable != null ? { sendRetryable: retryable } : {}),
245
+ },
246
+ }
214
247
  : m);
215
- this.setState({ messages: next, lastErrorCode: 'send-failed' });
248
+ this.setState({ messages: next, lastErrorCode: code ?? 'send-failed' });
249
+ }
250
+ /** Clear a pending send's per-send retry timer (idempotent). */
251
+ clearSendRetryTimer(pending) {
252
+ if (pending.retryTimer != null) {
253
+ this.timers.clearTimeout(pending.retryTimer);
254
+ pending.retryTimer = null;
255
+ }
256
+ }
257
+ /**
258
+ * Stop tracking a send (acked / reconciled by an echo / history row): cancel its
259
+ * per-send retry timer first so a late reconcile never leaves a stray timer that
260
+ * would resend after the send already landed, then drop it from the map.
261
+ */
262
+ clearPendingSend(clientMessageId) {
263
+ const pending = this.pendingSends.get(clientMessageId);
264
+ if (pending)
265
+ this.clearSendRetryTimer(pending);
266
+ this.pendingSends.delete(clientMessageId);
216
267
  }
217
268
  /** One timer for the membership-pending re-send window; a newer frame resets it. */
218
269
  scheduleMembershipPendingRetry(delayMs) {
@@ -225,6 +276,116 @@ export class ChannelClient {
225
276
  this.resendPendingSends();
226
277
  }, delayMs);
227
278
  }
279
+ /**
280
+ * Handle a CORRELATED `send-rejected` frame (the target protocol): parse the
281
+ * untrusted payload with the shared schema, correlate to EXACTLY ONE pending send
282
+ * by clientMessageId, and either fail just that bubble (terminal) or schedule a
283
+ * single per-send resend (retryable).
284
+ *
285
+ * A frame that fails validation is NOT simply dropped when it still names one of
286
+ * OUR pending sends: unlike an unknown frame type, an id-correlated rejection is
287
+ * the server explicitly telling us this send did not land — swallowing it would
288
+ * strand the bubble on "Sending…" forever (no further frame, no reconnect, no
289
+ * budget check ever runs). A future/unknown rejection code (version skew during a
290
+ * rollout) therefore fails that send CLOSED with the neutral copy + Retry. A
291
+ * malformed frame with no recognizable pending id is dropped (forward-compat
292
+ * noise), never surfaced as an error.
293
+ */
294
+ applySendRejected(payload) {
295
+ const parsed = ChatSendRejectedPayloadSchema.safeParse(payload);
296
+ if (!parsed.success) {
297
+ // Defensive fail-closed: if the unparseable rejection still carries a string
298
+ // id matching one of our un-failed pending sends, that send was rejected by a
299
+ // server speaking a newer/buggy dialect — fail it visibly (neutral code-less
300
+ // "Couldn't send" + Retry) instead of stranding it.
301
+ const rawId = payload.clientMessageId;
302
+ if (typeof rawId === 'string' && rawId.length > 0 && rawId.length <= 200) {
303
+ const orphaned = this.pendingSends.get(rawId);
304
+ if (orphaned && !orphaned.failed)
305
+ this.failPendingSend(rawId);
306
+ }
307
+ return;
308
+ }
309
+ const { clientMessageId, code, retryable, retryAfterMs } = parsed.data;
310
+ const pending = this.pendingSends.get(clientMessageId);
311
+ // No matching pending send — already acked/reconciled, or never ours. A late
312
+ // ack/echo (which may have already landed) always wins; nothing to correlate.
313
+ if (!pending || pending.failed)
314
+ return;
315
+ if (!retryable) {
316
+ // Terminal: an unchanged resend cannot succeed (blocked-word / archived /
317
+ // deleted). Fail ONLY this bubble, recording the reason + terminal class.
318
+ this.failPendingSend(clientMessageId, code, false);
319
+ return;
320
+ }
321
+ // Retryable: server-declared, so switch this send onto the wall-clock budget
322
+ // (the 5-attempt cap no longer applies) and record the reason for a possible
323
+ // budget-exhausted failure.
324
+ pending.serverRetryable = true;
325
+ pending.lastRejectionCode = code;
326
+ // If the budget already elapsed (a long stream of retryable rejections), fail
327
+ // now rather than scheduling yet another doomed resend.
328
+ if (this.timers.now() - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
329
+ this.failPendingSend(clientMessageId, code, true);
330
+ return;
331
+ }
332
+ this.scheduleSendRetry(clientMessageId, this.clampRetryDelay(retryAfterMs));
333
+ }
334
+ /**
335
+ * Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
336
+ * stacks — a newer rejection clears the prior timer first). When it fires it
337
+ * re-sends the SAME clientMessageId (the DO dedups) unless the wall-clock budget
338
+ * has elapsed, in which case the send fails with its recorded reason.
339
+ */
340
+ scheduleSendRetry(clientMessageId, delayMs) {
341
+ const pending = this.pendingSends.get(clientMessageId);
342
+ if (!pending)
343
+ return;
344
+ this.clearSendRetryTimer(pending);
345
+ pending.retryTimer = this.timers.setTimeout(() => {
346
+ pending.retryTimer = null;
347
+ if (this.closedByUs)
348
+ return;
349
+ const current = this.pendingSends.get(clientMessageId);
350
+ if (!current || current.failed)
351
+ return;
352
+ if (this.timers.now() - current.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
353
+ this.failPendingSend(clientMessageId, current.lastRejectionCode, true);
354
+ return;
355
+ }
356
+ // A server-retryable resend does NOT count toward the attempt cap (that cap is
357
+ // for blind reconnect resends only) — leave `attempts` untouched.
358
+ this.sendFrame(CLIENT_FRAME.SEND, {
359
+ clientMessageId: current.clientMessageId,
360
+ text: current.text,
361
+ replyTo: current.replyTo,
362
+ });
363
+ // Budget backstop: the contract says every send gets an ack or a rejection,
364
+ // and either one replaces/clears this timer. If the server goes silent (a
365
+ // contract violation), NOTHING else would ever re-check the budget on a
366
+ // stable socket — so arm a deadline check that fails the send at budget end.
367
+ // This makes SERVER_RETRYABLE_MAX_AGE_MS a hard ceiling, not a best effort.
368
+ const remainingMs = SERVER_RETRYABLE_MAX_AGE_MS - (this.timers.now() - current.sentAt) + SEND_RETRY_MIN_DELAY_MS;
369
+ current.retryTimer = this.timers.setTimeout(() => {
370
+ current.retryTimer = null;
371
+ if (this.closedByUs)
372
+ return;
373
+ const atDeadline = this.pendingSends.get(clientMessageId);
374
+ if (!atDeadline || atDeadline.failed)
375
+ return;
376
+ if (this.timers.now() - atDeadline.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
377
+ this.failPendingSend(clientMessageId, atDeadline.lastRejectionCode, true);
378
+ }
379
+ }, remainingMs);
380
+ }, delayMs);
381
+ }
382
+ /** Clamp a server `retryAfterMs` hint into a sane resend delay (defaulting when absent). */
383
+ clampRetryDelay(retryAfterMs) {
384
+ const raw = typeof retryAfterMs === 'number' && retryAfterMs > 0
385
+ ? retryAfterMs
386
+ : MEMBERSHIP_PENDING_DEFAULT_RETRY_MS;
387
+ return Math.min(Math.max(raw, SEND_RETRY_MIN_DELAY_MS), SEND_RETRY_MAX_DELAY_MS);
388
+ }
228
389
  /**
229
390
  * Flip EVERY un-acked pending send to the visible failed state. Called on the
230
391
  * terminal close paths (4403 REVOKED, reconnect give-up) — without this, a send
@@ -248,7 +409,12 @@ export class ChannelClient {
248
409
  const pending = this.pendingSends.get(clientMessageId);
249
410
  if (!pending)
250
411
  return false;
412
+ // A manual retry supersedes any scheduled server-retryable resend and starts a
413
+ // fresh budget from now; the server re-declares retryability if it rejects again.
414
+ this.clearSendRetryTimer(pending);
251
415
  pending.failed = false;
416
+ pending.serverRetryable = false;
417
+ pending.lastRejectionCode = undefined;
252
418
  pending.sentAt = this.timers.now();
253
419
  const sent = this.sendFrame(CLIENT_FRAME.SEND, {
254
420
  clientMessageId: pending.clientMessageId,
@@ -256,9 +422,14 @@ export class ChannelClient {
256
422
  replyTo: pending.replyTo,
257
423
  });
258
424
  pending.attempts = sent ? 1 : 0;
259
- const next = this.state.messages.map((m) => m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId && m.meta?.sendFailed
260
- ? { ...m, meta: { ...m.meta, sendFailed: false } }
261
- : m);
425
+ const next = this.state.messages.map((m) => {
426
+ if (!(m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId && m.meta?.sendFailed)) {
427
+ return m;
428
+ }
429
+ // Back to the normal pending look — drop every failure marker.
430
+ const { sendFailed: _sendFailed, sendFailureCode: _sendFailureCode, sendRetryable: _sendRetryable, ...restMeta } = m.meta;
431
+ return { ...m, meta: { ...restMeta } };
432
+ });
262
433
  this.setState({ messages: next, lastErrorCode: null });
263
434
  return true;
264
435
  }
@@ -343,16 +514,26 @@ export class ChannelClient {
343
514
  return this.applyTyping(payload.uid);
344
515
  case 'snapshot':
345
516
  return this.applyChannelSnapshot(payload);
517
+ case 'send-rejected':
518
+ return this.applySendRejected(payload);
346
519
  case 'error': {
347
520
  const code = String(payload.code ?? 'error');
348
- // Retryable: membership row not yet synced to the DO. Keep the send pending
349
- // (no visible error — the optimistic bubble stays "Sending…") and re-send
350
- // after the server-suggested delay via the normal resend machinery, whose
351
- // attempt/age caps flip it to the visible failed state if the window never
352
- // closes. Everything else surfaces as before.
521
+ // ROLLOUT COMPATIBILITY ONLY (not the target protocol): a Worker that predates
522
+ // the correlated `send-rejected` frame emits an UNCORRELATED
523
+ // error{code:'membership-pending'} with no clientMessageId, so we cannot target
524
+ // one send. Keep the send(s) pending (no visible error — the bubble stays
525
+ // "Sending…"), mark every in-flight send server-retryable so the wall-clock
526
+ // budget (NOT the 5-attempt cap) governs give-up, and re-send all after the
527
+ // server-suggested delay. The correlated path below is the target protocol.
353
528
  if (code === MEMBERSHIP_PENDING_CODE) {
354
529
  const raw = payload.retryAfterMs;
355
- const delay = typeof raw === 'number' && raw > 0 ? raw : MEMBERSHIP_PENDING_DEFAULT_RETRY_MS;
530
+ const delay = this.clampRetryDelay(typeof raw === 'number' ? raw : undefined);
531
+ for (const pending of this.pendingSends.values()) {
532
+ if (!pending.failed) {
533
+ pending.serverRetryable = true;
534
+ pending.lastRejectionCode = MEMBERSHIP_PENDING_CODE;
535
+ }
536
+ }
356
537
  return this.scheduleMembershipPendingRetry(delay);
357
538
  }
358
539
  return this.setState({ lastErrorCode: code });
@@ -482,9 +663,9 @@ export class ChannelClient {
482
663
  mergeMessage(mapped, row) {
483
664
  // If this canonical row carries a clientMessageId we still have pending (the
484
665
  // sender's own broadcast, or a resume delta that included our send), the send
485
- // landed — stop tracking it for reconnect resend.
666
+ // landed — stop tracking it for reconnect resend (and cancel any retry timer).
486
667
  if (row.clientMessageId)
487
- this.pendingSends.delete(row.clientMessageId);
668
+ this.clearPendingSend(row.clientMessageId);
488
669
  const next = this.state.messages.filter((m) => m.messageId !== mapped.messageId &&
489
670
  // drop the optimistic placeholder this server row reconciles
490
671
  (m.meta?.clientMessageId !== row.clientMessageId || !m.meta?.optimistic));
@@ -501,14 +682,16 @@ export class ChannelClient {
501
682
  // ack guarantees the seq even if the broadcast is dropped/reordered.
502
683
  const seq = payload.seq;
503
684
  const cmid = payload.clientMessageId;
504
- // The send is acked — stop tracking it for reconnect resend.
505
- this.pendingSends.delete(cmid);
685
+ // The send is acked — stop tracking it for reconnect resend (and cancel any
686
+ // per-send retry timer so a scheduled resend never fires after it landed).
687
+ this.clearPendingSend(cmid);
506
688
  const next = this.state.messages.map((m) => {
507
689
  if (!m.meta?.optimistic || m.meta?.clientMessageId !== cmid)
508
690
  return m;
509
- // Strip any failed marker — a late ack (e.g. a retry raced the cap) means the
510
- // send actually landed, so the row must render as delivered, not failed.
511
- const { sendFailed: _sendFailed, ...restMeta } = m.meta;
691
+ // Strip any failed markers — a late ack (e.g. a retry raced the cap, or a
692
+ // correlated rejection was superseded) means the send actually landed, so the
693
+ // row must render as delivered, not failed.
694
+ const { sendFailed: _sendFailed, sendFailureCode: _sendFailureCode, sendRetryable: _sendRetryable, ...restMeta } = m.meta;
512
695
  return { ...m, messageId: seqToMessageId(seq), meta: { ...restMeta, optimistic: false, seq } };
513
696
  });
514
697
  next.sort(sortBySeq);
@@ -527,7 +710,7 @@ export class ChannelClient {
527
710
  for (const r of rows) {
528
711
  if (r.clientMessageId) {
529
712
  incomingCmids.add(r.clientMessageId);
530
- this.pendingSends.delete(r.clientMessageId);
713
+ this.clearPendingSend(r.clientMessageId);
531
714
  }
532
715
  }
533
716
  const bySeq = new Map();
@@ -601,6 +784,8 @@ export class ChannelClient {
601
784
  sentAt: now,
602
785
  attempts: 1,
603
786
  failed: false,
787
+ serverRetryable: false,
788
+ retryTimer: null,
604
789
  });
605
790
  const echo = optimisticMessage({
606
791
  clientMessageId: args.clientMessageId,
@@ -681,6 +866,9 @@ export class ChannelClient {
681
866
  this.typingTimers.clear();
682
867
  this.overlays.clear();
683
868
  this.originalRows.clear();
869
+ // Cancel every per-send retry timer before dropping the tracking map.
870
+ for (const pending of this.pendingSends.values())
871
+ this.clearSendRetryTimer(pending);
684
872
  this.pendingSends.clear();
685
873
  if (this.reconnectTimer != null) {
686
874
  this.timers.clearTimeout(this.reconnectTimer);