@ably/ai-transport 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +93 -111
  2. package/dist/ably-ai-transport.js +2401 -1387
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +116 -42
  7. package/dist/core/agent.d.ts +44 -0
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +24 -24
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +10 -12
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -2
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  20. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  21. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  22. package/dist/core/codec/output-descriptors.d.ts +237 -0
  23. package/dist/core/codec/types.d.ts +470 -119
  24. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  25. package/dist/core/transport/agent-session.d.ts +10 -0
  26. package/dist/core/transport/agent-view.d.ts +296 -0
  27. package/dist/core/transport/client-session.d.ts +13 -0
  28. package/dist/core/transport/decode-fold.d.ts +55 -0
  29. package/dist/core/transport/headers.d.ts +121 -14
  30. package/dist/core/transport/index.d.ts +5 -6
  31. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  32. package/dist/core/transport/invocation.d.ts +74 -0
  33. package/dist/core/transport/load-history-pages.d.ts +71 -0
  34. package/dist/core/transport/load-history.d.ts +44 -0
  35. package/dist/core/transport/pipe-stream.d.ts +9 -9
  36. package/dist/core/transport/run-manager.d.ts +76 -0
  37. package/dist/core/transport/session-support.d.ts +55 -0
  38. package/dist/core/transport/tree.d.ts +523 -109
  39. package/dist/core/transport/types/agent.d.ts +375 -0
  40. package/dist/core/transport/types/client.d.ts +201 -0
  41. package/dist/core/transport/types/shared.d.ts +24 -0
  42. package/dist/core/transport/types/tree.d.ts +357 -0
  43. package/dist/core/transport/types/view.d.ts +249 -0
  44. package/dist/core/transport/types.d.ts +13 -553
  45. package/dist/core/transport/view.d.ts +390 -84
  46. package/dist/core/transport/wire-log.d.ts +102 -0
  47. package/dist/errors.d.ts +27 -10
  48. package/dist/index.d.ts +8 -9
  49. package/dist/logger.d.ts +12 -0
  50. package/dist/react/ably-ai-transport-react.js +1365 -1010
  51. package/dist/react/ably-ai-transport-react.js.map +1 -1
  52. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  53. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  54. package/dist/react/contexts/client-session-context.d.ts +37 -0
  55. package/dist/react/contexts/client-session-provider.d.ts +56 -0
  56. package/dist/react/create-session-hooks.d.ts +116 -0
  57. package/dist/react/index.d.ts +13 -12
  58. package/dist/react/internal/skipped-session.d.ts +8 -0
  59. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  60. package/dist/react/use-ably-messages.d.ts +17 -14
  61. package/dist/react/use-client-session.d.ts +81 -0
  62. package/dist/react/use-create-view.d.ts +14 -13
  63. package/dist/react/use-tree.d.ts +30 -15
  64. package/dist/react/use-view.d.ts +81 -50
  65. package/dist/utils.d.ts +48 -71
  66. package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
  67. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  68. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  69. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  70. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  71. package/dist/vercel/codec/events.d.ts +50 -0
  72. package/dist/vercel/codec/fields.d.ts +44 -0
  73. package/dist/vercel/codec/fold-content.d.ts +16 -0
  74. package/dist/vercel/codec/fold-data.d.ts +16 -0
  75. package/dist/vercel/codec/fold-input.d.ts +67 -0
  76. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  77. package/dist/vercel/codec/fold-text.d.ts +16 -0
  78. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  79. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  80. package/dist/vercel/codec/index.d.ts +7 -20
  81. package/dist/vercel/codec/inputs.d.ts +11 -0
  82. package/dist/vercel/codec/outputs.d.ts +11 -0
  83. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  84. package/dist/vercel/codec/reducer.d.ts +62 -0
  85. package/dist/vercel/codec/tool-transitions.d.ts +2 -8
  86. package/dist/vercel/codec/wire-data.d.ts +34 -0
  87. package/dist/vercel/index.d.ts +5 -5
  88. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
  89. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  90. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
  91. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  92. package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
  93. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  94. package/dist/vercel/react/index.d.ts +1 -2
  95. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  96. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  97. package/dist/vercel/run-end-reason.d.ts +84 -0
  98. package/dist/vercel/tool-part.d.ts +21 -0
  99. package/dist/vercel/transport/chat-transport.d.ts +41 -24
  100. package/dist/vercel/transport/index.d.ts +24 -20
  101. package/dist/vercel/transport/run-output-stream.d.ts +54 -0
  102. package/dist/version.d.ts +2 -0
  103. package/package.json +31 -24
  104. package/src/constants.ts +124 -51
  105. package/src/core/agent.ts +92 -0
  106. package/src/core/channel-options.ts +89 -0
  107. package/src/core/codec/codec-event.ts +27 -0
  108. package/src/core/codec/decoder.ts +202 -105
  109. package/src/core/codec/define-codec.ts +432 -0
  110. package/src/core/codec/encoder.ts +114 -107
  111. package/src/core/codec/field-bag.ts +142 -0
  112. package/src/core/codec/fields.ts +193 -0
  113. package/src/core/codec/index.ts +56 -6
  114. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  115. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  116. package/src/core/codec/input-descriptors.ts +373 -0
  117. package/src/core/codec/lifecycle-tracker.ts +10 -9
  118. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  119. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  120. package/src/core/codec/output-descriptors.ts +307 -0
  121. package/src/core/codec/types.ts +505 -126
  122. package/src/core/codec/well-known-inputs.ts +96 -0
  123. package/src/core/transport/agent-session.ts +1085 -0
  124. package/src/core/transport/agent-view.ts +738 -0
  125. package/src/core/transport/client-session.ts +780 -0
  126. package/src/core/transport/decode-fold.ts +101 -0
  127. package/src/core/transport/headers.ts +234 -22
  128. package/src/core/transport/index.ts +27 -27
  129. package/src/core/transport/internal/bounded-map.ts +27 -0
  130. package/src/core/transport/invocation.ts +98 -0
  131. package/src/core/transport/load-history-pages.ts +220 -0
  132. package/src/core/transport/load-history.ts +271 -0
  133. package/src/core/transport/pipe-stream.ts +63 -39
  134. package/src/core/transport/run-manager.ts +243 -0
  135. package/src/core/transport/session-support.ts +96 -0
  136. package/src/core/transport/tree.ts +1293 -308
  137. package/src/core/transport/types/agent.ts +434 -0
  138. package/src/core/transport/types/client.ts +247 -0
  139. package/src/core/transport/types/shared.ts +27 -0
  140. package/src/core/transport/types/tree.ts +393 -0
  141. package/src/core/transport/types/view.ts +288 -0
  142. package/src/core/transport/types.ts +13 -706
  143. package/src/core/transport/view.ts +1229 -450
  144. package/src/core/transport/wire-log.ts +189 -0
  145. package/src/errors.ts +29 -9
  146. package/src/event-emitter.ts +3 -2
  147. package/src/index.ts +86 -42
  148. package/src/logger.ts +14 -1
  149. package/src/react/contexts/client-session-context.ts +41 -0
  150. package/src/react/contexts/client-session-provider.tsx +222 -0
  151. package/src/react/create-session-hooks.ts +141 -0
  152. package/src/react/index.ts +24 -13
  153. package/src/react/internal/skipped-session.ts +62 -0
  154. package/src/react/internal/use-resolved-session.ts +63 -0
  155. package/src/react/use-ably-messages.ts +32 -22
  156. package/src/react/use-client-session.ts +178 -0
  157. package/src/react/use-create-view.ts +33 -29
  158. package/src/react/use-tree.ts +61 -30
  159. package/src/react/use-view.ts +138 -96
  160. package/src/utils.ts +83 -131
  161. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  162. package/src/vercel/codec/events.ts +85 -0
  163. package/src/vercel/codec/fields.ts +58 -0
  164. package/src/vercel/codec/fold-content.ts +54 -0
  165. package/src/vercel/codec/fold-data.ts +46 -0
  166. package/src/vercel/codec/fold-input.ts +255 -0
  167. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  168. package/src/vercel/codec/fold-text.ts +55 -0
  169. package/src/vercel/codec/fold-tool-input.ts +86 -0
  170. package/src/vercel/codec/fold-tool-output.ts +79 -0
  171. package/src/vercel/codec/index.ts +28 -21
  172. package/src/vercel/codec/inputs.ts +116 -0
  173. package/src/vercel/codec/outputs.ts +207 -0
  174. package/src/vercel/codec/reducer-state.ts +169 -0
  175. package/src/vercel/codec/reducer.ts +191 -0
  176. package/src/vercel/codec/tool-transitions.ts +3 -14
  177. package/src/vercel/codec/wire-data.ts +64 -0
  178. package/src/vercel/index.ts +7 -19
  179. package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
  180. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  181. package/src/vercel/react/index.ts +3 -5
  182. package/src/vercel/react/use-chat-transport.ts +44 -66
  183. package/src/vercel/react/use-message-sync.ts +75 -39
  184. package/src/vercel/run-end-reason.ts +157 -0
  185. package/src/vercel/tool-part.ts +25 -0
  186. package/src/vercel/transport/chat-transport.ts +380 -98
  187. package/src/vercel/transport/index.ts +38 -37
  188. package/src/vercel/transport/run-output-stream.ts +169 -0
  189. package/src/version.ts +2 -0
  190. package/dist/core/transport/client-transport.d.ts +0 -10
  191. package/dist/core/transport/decode-history.d.ts +0 -43
  192. package/dist/core/transport/server-transport.d.ts +0 -7
  193. package/dist/core/transport/stream-router.d.ts +0 -29
  194. package/dist/core/transport/turn-manager.d.ts +0 -37
  195. package/dist/react/contexts/transport-context.d.ts +0 -31
  196. package/dist/react/contexts/transport-provider.d.ts +0 -49
  197. package/dist/react/create-transport-hooks.d.ts +0 -124
  198. package/dist/react/use-active-turns.d.ts +0 -12
  199. package/dist/react/use-client-transport.d.ts +0 -80
  200. package/dist/vercel/codec/accumulator.d.ts +0 -21
  201. package/dist/vercel/codec/decoder.d.ts +0 -22
  202. package/dist/vercel/codec/encoder.d.ts +0 -41
  203. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  204. package/dist/vercel/tool-approvals.d.ts +0 -124
  205. package/dist/vercel/tool-events.d.ts +0 -26
  206. package/src/core/transport/client-transport.ts +0 -977
  207. package/src/core/transport/decode-history.ts +0 -485
  208. package/src/core/transport/server-transport.ts +0 -612
  209. package/src/core/transport/stream-router.ts +0 -136
  210. package/src/core/transport/turn-manager.ts +0 -165
  211. package/src/react/contexts/transport-context.ts +0 -37
  212. package/src/react/contexts/transport-provider.tsx +0 -164
  213. package/src/react/create-transport-hooks.ts +0 -144
  214. package/src/react/use-active-turns.ts +0 -72
  215. package/src/react/use-client-transport.ts +0 -197
  216. package/src/vercel/codec/accumulator.ts +0 -588
  217. package/src/vercel/codec/decoder.ts +0 -618
  218. package/src/vercel/codec/encoder.ts +0 -410
  219. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  220. package/src/vercel/tool-approvals.ts +0 -380
  221. package/src/vercel/tool-events.ts +0 -53
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `toCodecEvents` — tag a decoded message's events with their wire direction.
3
+ *
4
+ * A decoded message is already split into inputs and outputs by the decoder
5
+ * (driven by the Ably message name — the authoritative direction signal). This
6
+ * helper folds that split into the ordered {@link CodecEvent} stream the reducer
7
+ * consumes, so the direction is carried explicitly rather than re-inferred from
8
+ * each event's shape. Inputs are tagged before outputs, preserving the wire
9
+ * order within a single message (a message is single-direction, so the relative
10
+ * order of the two groups is immaterial).
11
+ */
12
+
13
+ import type { CodecEvent, CodecInputEvent, CodecOutputEvent, DecodedMessage } from './types.js';
14
+
15
+ /**
16
+ * Tag a decoded message's events with their wire direction.
17
+ * @template TInput - The codec's input union.
18
+ * @template TOutput - The codec's output union.
19
+ * @param decoded - The decoder's input/output split for one inbound message.
20
+ * @returns The events as a direction-tagged {@link CodecEvent} list, inputs first.
21
+ */
22
+ export const toCodecEvents = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent>(
23
+ decoded: DecodedMessage<TInput, TOutput>,
24
+ ): CodecEvent<TInput, TOutput>[] => [
25
+ ...decoded.inputs.map((event): CodecEvent<TInput, TOutput> => ({ direction: 'input', event })),
26
+ ...decoded.outputs.map((event): CodecEvent<TInput, TOutput> => ({ direction: 'output', event })),
27
+ ];
@@ -3,27 +3,24 @@
3
3
  *
4
4
  * Handles the Ably message action patterns (create, append, update, delete)
5
5
  * and delegates to domain-specific hooks for event building and discrete
6
- * event decoding.
6
+ * event decoding. Stream trackers are version-guarded: a delivery whose
7
+ * `Message.version.serial` the tracker has already incorporated decodes to
8
+ * nothing, so the same decoder instance can serve both the live
9
+ * subscription and history hydration without double-decoding.
7
10
  *
8
11
  * Domain decoders call `createDecoderCore(hooks, options)` and provide hooks
9
- * for stream classification, event building, and discrete decoding.
12
+ * for stream classification, event building, and discrete decoding. Hooks
13
+ * return a flat `TEvent[]` — no event-vs-message union. Per-message routing
14
+ * concerns (`codec-message-id`) are handled by the SDK via `ReducerMeta`, not
15
+ * here.
10
16
  */
11
17
 
12
18
  import type * as Ably from 'ably';
13
19
 
14
- import { HEADER_MSG_ID, HEADER_STATUS, HEADER_STREAM, HEADER_STREAM_ID } from '../../constants.js';
20
+ import { HEADER_STATUS, HEADER_STREAM, HEADER_STREAM_ID } from '../../constants.js';
15
21
  import type { Logger } from '../../logger.js';
16
- import { getHeaders } from '../../utils.js';
17
- import type { DecoderOutput, MessagePayload, StreamTrackerState } from './types.js';
18
-
19
- /**
20
- * Wrap a domain event as a single-element decoder output array.
21
- * @param event - The domain event to wrap.
22
- * @returns A single-element array containing the event as a decoder output.
23
- */
24
- export const eventOutput = <TEvent, TMessage>(event: TEvent): DecoderOutput<TEvent, TMessage>[] => [
25
- { kind: 'event', event },
26
- ];
22
+ import { getCodecHeaders, getTransportHeaders } from '../../utils.js';
23
+ import type { MessagePayload, StreamTrackerState } from './types.js';
27
24
 
28
25
  // ---------------------------------------------------------------------------
29
26
  // Options
@@ -44,32 +41,29 @@ export interface DecoderCoreOptions {
44
41
  // ---------------------------------------------------------------------------
45
42
 
46
43
  /** Hooks that a domain codec provides to the decoder core for stream classification and event building. */
47
- export interface DecoderCoreHooks<TEvent, TMessage> {
44
+ export interface DecoderCoreHooks<TEvent> {
48
45
  /**
49
46
  * Build domain events emitted when a new stream starts. May return multiple
50
47
  * events (e.g. a start event and a start-step event).
51
48
  */
52
- buildStartEvents(tracker: StreamTrackerState): DecoderOutput<TEvent, TMessage>[];
49
+ buildStartEvents(tracker: StreamTrackerState): TEvent[];
53
50
 
54
51
  /** Build domain events for a text delta received on a stream. */
55
- buildDeltaEvents(tracker: StreamTrackerState, delta: string): DecoderOutput<TEvent, TMessage>[];
52
+ buildDeltaEvents(tracker: StreamTrackerState, delta: string): TEvent[];
56
53
 
57
54
  /**
58
- * Build domain events emitted when a stream finishes (x-ably-status:finished).
59
- * Not called for aborted streams. The closing headers may differ from
60
- * tracker.headers if the closing append carried updated headers.
55
+ * Build domain events emitted when a stream completes (status:complete).
56
+ * Not called for cancelled streams. The closing codec headers may differ
57
+ * from tracker.codecHeaders if the closing append carried updated headers.
61
58
  */
62
- buildEndEvents(
63
- tracker: StreamTrackerState,
64
- closingHeaders: Record<string, string>,
65
- ): DecoderOutput<TEvent, TMessage>[];
59
+ buildEndEvents(tracker: StreamTrackerState, closingCodecHeaders: Record<string, string>): TEvent[];
66
60
 
67
61
  /**
68
- * Decode a discrete message (message.create where x-ably-stream is "false",
69
- * or a non-streamable first-contact update). Handles user messages, lifecycle
70
- * events, tool lifecycle, data-*, etc.
62
+ * Decode a discrete message (a `message.create` whose stream header is not
63
+ * "true", or a non-streamable first-contact update). Handles user messages,
64
+ * tool lifecycle, data-*, etc.
71
65
  */
72
- decodeDiscrete(input: MessagePayload): DecoderOutput<TEvent, TMessage>[];
66
+ decodeDiscrete(input: MessagePayload): TEvent[];
73
67
  }
74
68
 
75
69
  // ---------------------------------------------------------------------------
@@ -77,9 +71,9 @@ export interface DecoderCoreHooks<TEvent, TMessage> {
77
71
  // ---------------------------------------------------------------------------
78
72
 
79
73
  /** The decoder core returned by {@link createDecoderCore}. */
80
- export interface DecoderCore<TEvent, TMessage> {
81
- /** Decode a single Ably message into zero or more domain outputs. */
82
- decode(message: Ably.InboundMessage): DecoderOutput<TEvent, TMessage>[];
74
+ export interface DecoderCore<TEvent> {
75
+ /** Decode a single Ably message into zero or more domain TEvents. */
76
+ decode(message: Ably.InboundMessage): TEvent[];
83
77
  }
84
78
 
85
79
  // ---------------------------------------------------------------------------
@@ -87,70 +81,50 @@ export interface DecoderCore<TEvent, TMessage> {
87
81
  // ---------------------------------------------------------------------------
88
82
 
89
83
  // Spec: AIT-CD7
90
- class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessage> {
91
- private readonly _hooks: DecoderCoreHooks<TEvent, TMessage>;
84
+ class DefaultDecoderCore<TEvent> implements DecoderCore<TEvent> {
85
+ private readonly _hooks: DecoderCoreHooks<TEvent>;
92
86
  private readonly _logger: Logger | undefined;
93
87
  private readonly _onStreamUpdate: ((tracker: StreamTrackerState) => void) | undefined;
94
88
  private readonly _onStreamDelete: ((serial: string, tracker: StreamTrackerState | undefined) => void) | undefined;
95
89
  private readonly _serialState = new Map<string, StreamTrackerState>();
96
90
 
97
- constructor(hooks: DecoderCoreHooks<TEvent, TMessage>, options: DecoderCoreOptions = {}) {
91
+ constructor(hooks: DecoderCoreHooks<TEvent>, options: DecoderCoreOptions = {}) {
98
92
  this._hooks = hooks;
99
93
  this._onStreamUpdate = options.onStreamUpdate;
100
94
  this._onStreamDelete = options.onStreamDelete;
101
95
  this._logger = options.logger?.withContext({ component: 'DecoderCore' });
102
96
  }
103
97
 
104
- decode(message: Ably.InboundMessage): DecoderOutput<TEvent, TMessage>[] {
98
+ decode(message: Ably.InboundMessage): TEvent[] {
105
99
  const action = message.action;
106
100
 
107
101
  this._logger?.trace('DefaultDecoderCore.decode();', { action, serial: message.serial, name: message.name });
108
102
 
109
- let outputs: DecoderOutput<TEvent, TMessage>[];
110
-
111
103
  switch (action) {
112
104
  // Spec: AIT-CD7a
113
105
  case 'message.create': {
114
106
  const payload = this._toPayload(message);
115
-
116
- outputs =
117
- payload.headers?.[HEADER_STREAM] === 'true'
118
- ? this._decodeStreamedCreate(payload, message.serial)
119
- : this._hooks.decodeDiscrete(payload);
120
- break;
107
+ return payload.transportHeaders?.[HEADER_STREAM] === 'true'
108
+ ? this._decodeStreamedCreate(payload, message.serial, message.version.serial)
109
+ : this._hooks.decodeDiscrete(payload);
121
110
  }
122
111
 
123
112
  case 'message.append': {
124
- outputs = this._decodeAppend(message);
125
- break;
113
+ return this._decodeAppend(message);
126
114
  }
127
115
 
128
116
  case 'message.update': {
129
- outputs = this._decodeUpdate(message);
130
- break;
117
+ return this._decodeUpdate(message);
131
118
  }
132
119
 
133
120
  case 'message.delete': {
134
- outputs = this._decodeDelete(message);
135
- break;
121
+ return this._decodeDelete(message);
136
122
  }
137
123
 
138
124
  default: {
139
125
  return [];
140
126
  }
141
127
  }
142
-
143
- // Tag all event outputs with the message ID from x-ably-msg-id for accumulator correlation.
144
- const messageId = getHeaders(message)[HEADER_MSG_ID];
145
- if (messageId) {
146
- for (const output of outputs) {
147
- if (output.kind === 'event') {
148
- output.messageId = messageId;
149
- }
150
- }
151
- }
152
-
153
- return outputs;
154
128
  }
155
129
 
156
130
  // -------------------------------------------------------------------------
@@ -162,7 +136,8 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
162
136
  name: message.name ?? '',
163
137
  // CAST: Ably SDK types `data` as `any`; cast to unknown is the safe boundary type.
164
138
  data: message.data as unknown,
165
- headers: getHeaders(message),
139
+ transportHeaders: getTransportHeaders(message),
140
+ codecHeaders: getCodecHeaders(message),
166
141
  };
167
142
  }
168
143
 
@@ -197,6 +172,99 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
197
172
  }
198
173
  }
199
174
 
175
+ // -------------------------------------------------------------------------
176
+ // Private: version guard
177
+ // -------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Whether a delivery is already incorporated into (or out of contract for)
181
+ * an existing tracker, and so must decode to nothing. Covers two cases:
182
+ *
183
+ * - The delivery carries a `version.serial` at or below the tracker's —
184
+ * the mutation it describes is already incorporated (a history aggregate
185
+ * covered by live deltas, a resume retransmission, a whole-wire replay).
186
+ * - The tracker is closed — the stream has ended and its accumulated text
187
+ * has been dropped, so nothing further can fold into it. In-contract
188
+ * replays are already covered by the version check; this catches
189
+ * out-of-contract version-less deliveries for an ended stream.
190
+ *
191
+ * A version-bearing delivery that passes advances the tracker's version.
192
+ * @param method - Calling method name, for log messages.
193
+ * @param serial - The message serial (the tracker's key).
194
+ * @param tracker - The existing tracker for the serial.
195
+ * @param version - The delivery's `Message.version.serial`, if present.
196
+ * @returns True when the delivery must decode to nothing.
197
+ */
198
+ private _alreadyIncorporated(
199
+ method: string,
200
+ serial: string,
201
+ tracker: StreamTrackerState,
202
+ version: string | undefined,
203
+ ): boolean {
204
+ if (version !== undefined && version <= tracker.version) {
205
+ this._logger?.debug(`DefaultDecoderCore.${method}(); delivery already incorporated`, {
206
+ serial,
207
+ version,
208
+ trackerVersion: tracker.version,
209
+ });
210
+ return true;
211
+ }
212
+ if (tracker.closed) {
213
+ this._logger?.debug(`DefaultDecoderCore.${method}(); stream closed, dropping delivery`, { serial, version });
214
+ return true;
215
+ }
216
+ if (version !== undefined) tracker.version = version;
217
+ return false;
218
+ }
219
+
220
+ /**
221
+ * Close a tracker, dropping its accumulated text. What remains is a
222
+ * `{version, closed}` tombstone: enough to recognise covered replays and
223
+ * out-of-contract post-close deliveries, without retaining the stream's
224
+ * full content for the decoder's lifetime.
225
+ * @param tracker - The tracker to close.
226
+ */
227
+ private _closeTracker(tracker: StreamTrackerState): void {
228
+ tracker.closed = true;
229
+ tracker.accumulated = '';
230
+ }
231
+
232
+ // -------------------------------------------------------------------------
233
+ // Private: terminal-status transition
234
+ // -------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Apply a stream's terminal status (complete / cancelled) to a tracker. On
238
+ * `complete` it emits end events (read before the tracker is closed) and
239
+ * then closes the tracker; on `cancelled` it closes silently. Both the
240
+ * append and prefix-match update paths funnel through here so they can't
241
+ * diverge. Covered replays and post-close deliveries are filtered upstream
242
+ * by `_alreadyIncorporated`, so no closed-once guard is needed here.
243
+ * Returns whether a terminal transition fired (so callers can log it).
244
+ * @param tracker - The stream tracker to close.
245
+ * @param status - The status header value from the message (may be undefined).
246
+ * @param closingCodecHeaders - Codec headers from the closing message, passed to buildEndEvents.
247
+ * @param outputs - The output array end events are pushed into.
248
+ * @returns True when this call closed the tracker; false otherwise.
249
+ */
250
+ private _applyTerminalStatus(
251
+ tracker: StreamTrackerState,
252
+ status: string | undefined,
253
+ closingCodecHeaders: Record<string, string>,
254
+ outputs: TEvent[],
255
+ ): boolean {
256
+ if (status === 'complete') {
257
+ outputs.push(...this._hooks.buildEndEvents(tracker, closingCodecHeaders));
258
+ this._closeTracker(tracker);
259
+ return true;
260
+ }
261
+ if (status === 'cancelled') {
262
+ this._closeTracker(tracker);
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
200
268
  // -------------------------------------------------------------------------
201
269
  // Private: streamed message create
202
270
  // -------------------------------------------------------------------------
@@ -204,17 +272,29 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
204
272
  private _decodeStreamedCreate(
205
273
  payload: MessagePayload,
206
274
  serial: string | undefined,
207
- ): DecoderOutput<TEvent, TMessage>[] {
275
+ version: string | undefined,
276
+ ): TEvent[] {
208
277
  if (!serial) return [];
209
278
 
210
- const streamId = payload.headers?.[HEADER_STREAM_ID] ?? '';
211
- const h = payload.headers ?? {};
279
+ const existing = this._serialState.get(serial);
280
+ if (existing) {
281
+ // A create is the message's first version, so a tracker for this serial
282
+ // has already incorporated it (resume retransmission, whole-wire replay).
283
+ this._logger?.debug('DefaultDecoderCore._decodeStreamedCreate(); duplicate create for tracked stream', {
284
+ serial,
285
+ });
286
+ return [];
287
+ }
288
+
289
+ const streamId = payload.transportHeaders?.[HEADER_STREAM_ID] ?? '';
212
290
 
213
291
  const tracker: StreamTrackerState = {
214
292
  name: payload.name,
215
293
  streamId,
216
294
  accumulated: '',
217
- headers: { ...h },
295
+ codecHeaders: { ...payload.codecHeaders },
296
+ transportHeaders: { ...payload.transportHeaders },
297
+ version: version ?? serial,
218
298
  closed: false,
219
299
  };
220
300
  this._serialState.set(serial, tracker);
@@ -233,33 +313,42 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
233
313
  // -------------------------------------------------------------------------
234
314
 
235
315
  // Spec: AIT-CD8
236
- private _decodeAppend(message: Ably.InboundMessage): DecoderOutput<TEvent, TMessage>[] {
316
+ private _decodeAppend(message: Ably.InboundMessage): TEvent[] {
237
317
  const serial = message.serial;
238
318
  if (!serial) return [];
239
319
 
240
320
  const tracker = this._serialState.get(serial);
241
321
  if (!tracker) {
242
- // Unknown serial on append treat as first-contact update
322
+ // Out of contract: the platform converts the first post-attach append
323
+ // of an in-flight message into a full-contents update, so an append
324
+ // should never be a stream's first contact. Keep the first-contact
325
+ // heuristic as a defensive fallback.
326
+ this._logger?.warn('DefaultDecoderCore._decodeAppend(); append with no tracker, treating as first contact', {
327
+ serial,
328
+ });
243
329
  return this._decodeUpdate(message);
244
330
  }
245
331
 
246
- const h = getHeaders(message);
332
+ if (this._alreadyIncorporated('_decodeAppend', serial, tracker, message.version.serial)) return [];
333
+
334
+ const transport = getTransportHeaders(message);
335
+ const closingCodec = getCodecHeaders(message);
247
336
  const delta = typeof message.data === 'string' ? message.data : '';
248
- const status = h[HEADER_STATUS];
249
- const outputs: DecoderOutput<TEvent, TMessage>[] = [];
337
+ const status = transport[HEADER_STATUS];
338
+ const outputs: TEvent[] = [];
250
339
 
251
340
  if (delta.length > 0) {
252
341
  tracker.accumulated += delta;
253
342
  outputs.push(...this._hooks.buildDeltaEvents(tracker, delta));
254
343
  }
255
344
 
256
- if (status === 'finished' && !tracker.closed) {
257
- tracker.closed = true;
258
- outputs.push(...this._hooks.buildEndEvents(tracker, h));
259
- this._logger?.debug('DefaultDecoderCore._decodeAppend(); stream finished', { streamId: tracker.streamId });
260
- } else if (status === 'aborted' && !tracker.closed) {
261
- tracker.closed = true;
262
- this._logger?.debug('DefaultDecoderCore._decodeAppend(); stream aborted', { streamId: tracker.streamId });
345
+ if (this._applyTerminalStatus(tracker, status, closingCodec, outputs)) {
346
+ this._logger?.debug(
347
+ `DefaultDecoderCore._decodeAppend(); stream ${status === 'complete' ? 'complete' : 'cancelled'}`,
348
+ {
349
+ streamId: tracker.streamId,
350
+ },
351
+ );
263
352
  }
264
353
 
265
354
  return outputs;
@@ -270,47 +359,46 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
270
359
  // -------------------------------------------------------------------------
271
360
 
272
361
  // Spec: AIT-CD9
273
- private _decodeUpdate(message: Ably.InboundMessage): DecoderOutput<TEvent, TMessage>[] {
362
+ private _decodeUpdate(message: Ably.InboundMessage): TEvent[] {
274
363
  const serial = message.serial;
275
364
  if (!serial) return [];
276
365
 
277
366
  const payload = this._toPayload(message);
278
- const h = payload.headers ?? {};
279
- const isStreamed = h[HEADER_STREAM] === 'true';
280
- const status = h[HEADER_STATUS];
367
+ const transport = payload.transportHeaders ?? {};
368
+ const codec = payload.codecHeaders ?? {};
369
+ const isStreamed = transport[HEADER_STREAM] === 'true';
370
+ const status = transport[HEADER_STATUS];
281
371
 
282
372
  const tracker = this._serialState.get(serial);
283
373
 
284
374
  if (!tracker) {
285
- return this._decodeFirstContact(payload, isStreamed, status, serial);
375
+ return this._decodeFirstContact(payload, isStreamed, status, serial, message.version.serial);
286
376
  }
287
377
 
378
+ if (this._alreadyIncorporated('_decodeUpdate', serial, tracker, message.version.serial)) return [];
379
+
288
380
  // Updates to tracked streams use string data for prefix-match accumulation
289
381
  const data = this._stringData(message);
290
382
 
291
383
  // --- Tracker exists: prefix-match or replacement ---
292
384
  if (data.startsWith(tracker.accumulated)) {
293
385
  const delta = data.slice(tracker.accumulated.length);
294
- const outputs: DecoderOutput<TEvent, TMessage>[] = [];
386
+ const outputs: TEvent[] = [];
295
387
 
296
388
  if (delta.length > 0) {
297
389
  tracker.accumulated = data;
298
390
  outputs.push(...this._hooks.buildDeltaEvents(tracker, delta));
299
391
  }
300
392
 
301
- if (status === 'finished' && !tracker.closed) {
302
- tracker.closed = true;
303
- outputs.push(...this._hooks.buildEndEvents(tracker, h));
304
- } else if (status === 'aborted' && !tracker.closed) {
305
- tracker.closed = true;
306
- }
393
+ this._applyTerminalStatus(tracker, status, codec, outputs);
307
394
 
308
395
  return outputs;
309
396
  }
310
397
 
311
398
  // --- Replacement (NOT a prefix match) ---
312
399
  tracker.accumulated = data;
313
- tracker.headers = { ...h };
400
+ tracker.codecHeaders = { ...codec };
401
+ tracker.transportHeaders = { ...transport };
314
402
 
315
403
  this._invokeOnStreamUpdate(tracker);
316
404
 
@@ -322,14 +410,15 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
322
410
  isStreamed: boolean,
323
411
  status: string | undefined,
324
412
  serial: string,
325
- ): DecoderOutput<TEvent, TMessage>[] {
413
+ version: string | undefined,
414
+ ): TEvent[] {
326
415
  // Non-streamed messages are discrete
327
416
  if (!isStreamed) {
328
417
  return this._hooks.decodeDiscrete(payload);
329
418
  }
330
419
 
331
- const streamId = payload.headers?.[HEADER_STREAM_ID] ?? '';
332
- const h = payload.headers ?? {};
420
+ const streamId = payload.transportHeaders?.[HEADER_STREAM_ID] ?? '';
421
+ const codec = payload.codecHeaders ?? {};
333
422
  const data = typeof payload.data === 'string' ? payload.data : '';
334
423
 
335
424
  this._logger?.debug('DefaultDecoderCore._decodeFirstContact(); first-contact stream', {
@@ -343,20 +432,26 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
343
432
  name: payload.name,
344
433
  streamId,
345
434
  accumulated: data,
346
- headers: { ...h },
347
- closed: status === 'finished' || status === 'aborted',
435
+ codecHeaders: { ...codec },
436
+ transportHeaders: { ...payload.transportHeaders },
437
+ version: version ?? serial,
438
+ closed: false,
348
439
  };
349
440
  this._serialState.set(serial, newTracker);
350
441
 
351
- // Emit start + delta (if any) + end (if finished)
442
+ // Emit start + delta (if any) + end (if complete)
352
443
  const outputs = this._hooks.buildStartEvents(newTracker);
353
444
 
354
445
  if (data.length > 0) {
355
446
  outputs.push(...this._hooks.buildDeltaEvents(newTracker, data));
356
447
  }
357
448
 
358
- if (status === 'finished') {
359
- outputs.push(...this._hooks.buildEndEvents(newTracker, h));
449
+ if (status === 'complete') {
450
+ outputs.push(...this._hooks.buildEndEvents(newTracker, codec));
451
+ }
452
+
453
+ if (status === 'complete' || status === 'cancelled') {
454
+ this._closeTracker(newTracker);
360
455
  }
361
456
 
362
457
  return outputs;
@@ -367,7 +462,7 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
367
462
  // -------------------------------------------------------------------------
368
463
 
369
464
  // Spec: AIT-CD10
370
- private _decodeDelete(message: Ably.InboundMessage): DecoderOutput<TEvent, TMessage>[] {
465
+ private _decodeDelete(message: Ably.InboundMessage): TEvent[] {
371
466
  const serial = message.serial;
372
467
  if (!serial) return [];
373
468
 
@@ -376,8 +471,10 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
376
471
  this._invokeOnStreamDelete(serial, tracker);
377
472
 
378
473
  if (tracker) {
379
- tracker.accumulated = '';
380
- tracker.closed = true;
474
+ // No need to advance the tracker's version here: `_closeTracker` leaves a
475
+ // closed tombstone, and `_alreadyIncorporated`'s closed check drops every
476
+ // later delivery regardless of version.
477
+ this._closeTracker(tracker);
381
478
  }
382
479
 
383
480
  this._logger?.debug('DefaultDecoderCore._decodeDelete();', { serial });
@@ -396,7 +493,7 @@ class DefaultDecoderCore<TEvent, TMessage> implements DecoderCore<TEvent, TMessa
396
493
  * @param options - Decoder configuration (callbacks, logger).
397
494
  * @returns A new {@link DecoderCore} instance.
398
495
  */
399
- export const createDecoderCore = <TEvent, TMessage>(
400
- hooks: DecoderCoreHooks<TEvent, TMessage>,
496
+ export const createDecoderCore = <TEvent>(
497
+ hooks: DecoderCoreHooks<TEvent>,
401
498
  options: DecoderCoreOptions = {},
402
- ): DecoderCore<TEvent, TMessage> => new DefaultDecoderCore(hooks, options);
499
+ ): DecoderCore<TEvent> => new DefaultDecoderCore(hooks, options);