@agent-cards/commerce-sdk 0.5.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@ export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatur
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
5
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, } from './client.js';
5
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
7
7
  export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
8
- export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
9
- export { ackFor, type AckForOptions } from './ack.js';
10
- export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
8
+ export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolStore, } from './buy-tool.js';
9
+ export { instantAck, ackFor, type AckForOptions } from './ack.js';
10
+ export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, type BuyToolModelStore, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
12
+ export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
13
+ export { AGENTCARD_EVENT_TYPES, type AgentcardEventType, } from './events.js';
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_U
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
7
7
  export { toMessages, messageStream, } from './messages.js';
8
8
  export { buyTool, } from './buy-tool.js';
9
- export { ackFor } from './ack.js';
10
- export { forModel, stripLinks, } from './model-view.js';
9
+ export { instantAck, ackFor } from './ack.js';
10
+ export { forModel, forModelError, stripLinks, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
12
+ export { listenToAgentcard, } from './listen.js';
13
+ export { AGENTCARD_EVENT_TYPES, } from './events.js';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Hear every Agentcard event without a webhook endpoint. Your server opens one connection to
3
+ * Agentcard with your company credentials, and Agentcard streams your company's events down it:
4
+ * no public URL, no webhook secret, no signature to check. Each event is the envelope a webhook
5
+ * endpoint would receive, with the same id.
6
+ *
7
+ * The listener reconnects on its own, resumes after the last event it handled, skips an event it
8
+ * already handled, and falls back to asking every few seconds when the stream is not available.
9
+ * Shopper messages go through the same reader as `agentcardWebhook` (`readShopperMessage`), so
10
+ * `onMessage` gets the same ready-to-send text.
11
+ */
12
+ import { type AgentcardEvent } from './verify.js';
13
+ import { type ShopperMessage } from './shopper-message.js';
14
+ import { type AgentcardEventType } from './events.js';
15
+ import { type AgentcardClient } from './client.js';
16
+ /** Where the listener keeps the cursor of the last event it handled, so a restarted server resumes there. */
17
+ export interface ListenCursorStore {
18
+ /** The cursor saved last, or null to start from now. */
19
+ load(): Promise<string | null> | string | null;
20
+ /** Called after each handled event, and at most once a minute while no event arrives. */
21
+ save(cursor: string): Promise<void> | void;
22
+ }
23
+ /** The fetch the listener streams with: the global fetch of Node 18 and later. */
24
+ export type StreamFetch = (url: string, init: {
25
+ method: 'GET';
26
+ headers: Record<string, string>;
27
+ signal: AbortSignal;
28
+ }) => Promise<{
29
+ status: number;
30
+ ok: boolean;
31
+ headers: {
32
+ get(name: string): string | null;
33
+ };
34
+ body: {
35
+ getReader(): {
36
+ read(): Promise<{
37
+ done: boolean;
38
+ value?: Uint8Array;
39
+ }>;
40
+ cancel(reason?: unknown): Promise<void>;
41
+ };
42
+ } | null;
43
+ text(): Promise<string>;
44
+ }>;
45
+ export interface ListenOptions {
46
+ /**
47
+ * Receives each event for one of your shoppers, under your own id for them, with the message
48
+ * ready to post into that shopper's chat: the same as `agentcardWebhook`'s `onMessage`. A message
49
+ * whose link or window closed before it arrived reaches no shopper: its event goes to `onEvent`.
50
+ */
51
+ onMessage?: (externalUserId: string, message: ShopperMessage, event: AgentcardEvent<AgentcardEventType>) => void | Promise<void>;
52
+ /** Receives every event `onMessage` does not take: every event, when you pass no `onMessage`. */
53
+ onEvent?: (event: AgentcardEvent<AgentcardEventType | (string & {})>) => void | Promise<void>;
54
+ /**
55
+ * Receives what went wrong: a callback that threw five times, after which the listener moves on,
56
+ * a lost connection it is about to retry, a cursor Agentcard no longer keeps. A reporter that
57
+ * throws is ignored. Default: `console.error`.
58
+ */
59
+ onError?: (error: unknown, event?: AgentcardEvent) => void | Promise<void>;
60
+ /** Only these events: event types, families such as `order.*`, or `*`. Default: every event. */
61
+ events?: string[];
62
+ /** Keeps the cursor across restarts. Default: in memory, so a new process starts from now. */
63
+ cursor?: ListenCursorStore;
64
+ /** `auto` streams and falls back to polling when the stream is not available. Default `auto`. */
65
+ transport?: 'auto' | 'stream' | 'poll';
66
+ /** How long polling waits between reads. Default 2000. */
67
+ pollIntervalMs?: number;
68
+ /** How long the stream may stay silent before the listener reconnects. Agentcard sends a heartbeat every 15 seconds. Default 45000. */
69
+ heartbeatTimeoutMs?: number;
70
+ /** How long one polling read may take before the listener gives up on it and reads again. Default 30000. */
71
+ pollTimeoutMs?: number;
72
+ /** The fetch to stream with. Default: the global fetch. */
73
+ fetch?: StreamFetch;
74
+ }
75
+ /** A running listener. */
76
+ export interface AgentcardListener {
77
+ /** Resolves once the listener first reaches Agentcard, or once it closes. */
78
+ readonly ready: Promise<void>;
79
+ /** How the listener hears Agentcard right now. */
80
+ readonly transport: 'stream' | 'poll';
81
+ /** The cursor of the last event handled, or null before the first connection. */
82
+ readonly cursor: string | null;
83
+ /** Stops listening. Resolves once the callback that is running, if any, has returned. */
84
+ close(): Promise<void>;
85
+ }
86
+ /**
87
+ * Listen to every event Agentcard sends your company, over one connection your server opens.
88
+ * Build `agentcard` with your company's client credentials. Returns at once; `ready` resolves once
89
+ * the listener reaches Agentcard. Each event runs its callback once, in order, and the next event
90
+ * waits for it: a callback that throws runs again, up to five times, before the listener reports
91
+ * it and moves on. Run one listener per company and mode: two listeners each hear every event.
92
+ */
93
+ export declare function listenToAgentcard(agentcard: AgentcardClient, opts: ListenOptions): AgentcardListener;
package/dist/listen.js ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Hear every Agentcard event without a webhook endpoint. Your server opens one connection to
3
+ * Agentcard with your company credentials, and Agentcard streams your company's events down it:
4
+ * no public URL, no webhook secret, no signature to check. Each event is the envelope a webhook
5
+ * endpoint would receive, with the same id.
6
+ *
7
+ * The listener reconnects on its own, resumes after the last event it handled, skips an event it
8
+ * already handled, and falls back to asking every few seconds when the stream is not available.
9
+ * Shopper messages go through the same reader as `agentcardWebhook` (`readShopperMessage`), so
10
+ * `onMessage` gets the same ready-to-send text.
11
+ */
12
+ import { isRecord } from './verify.js';
13
+ import { isStale, readShopperMessage } from './shopper-message.js';
14
+ import { isEventPattern, matchesEventPattern } from './events.js';
15
+ import { AgentcardApiError } from './client.js';
16
+ /** How many handled event ids the listener remembers, the oldest forgotten first. */
17
+ const HANDLED_EVENTS_KEPT = 10_000;
18
+ /** Tries for one event's callback before the listener reports it and moves on. */
19
+ const CALLBACK_ATTEMPTS = 5;
20
+ const IDLE_SAVE_MS = 60_000;
21
+ const MAX_BACKOFF_MS = 30_000;
22
+ /**
23
+ * How far behind its cursor Agentcard hands events over again after a resume, in case one
24
+ * committed late, unless the stream says otherwise. The saved cursor carries the id of every event
25
+ * handled within this window and a margin, so a new process skips each of them.
26
+ */
27
+ const DEFAULT_REPLAY_MS = 10_000;
28
+ const REPLAY_MARGIN_MS = 5_000;
29
+ /** The time in a cursor, `<ms>_<row>`, or null for a cursor of another shape. */
30
+ const cursorTime = (cursor) => {
31
+ const m = cursor ? /^(\d{1,15})_/.exec(cursor) : null;
32
+ return m ? Number(m[1]) : null;
33
+ };
34
+ /** Streams in a row that answered but never said ready before `auto` polls instead: a proxy that buffers the stream, or closes it. */
35
+ const STREAM_ATTEMPTS_BEFORE_POLLING = 3;
36
+ /** Raised when the stream is not served here, so `auto` polls instead. */
37
+ class StreamUnavailable extends Error {
38
+ }
39
+ /** Raised when the stream answered but closed, or went silent, before Agentcard said it was ready. */
40
+ class StreamNotEstablished extends Error {
41
+ }
42
+ const sleep = (ms, signal) => new Promise((resolve) => {
43
+ if (signal.aborted)
44
+ return resolve();
45
+ const timer = setTimeout(done, ms);
46
+ function done() {
47
+ clearTimeout(timer);
48
+ signal.removeEventListener('abort', done);
49
+ resolve();
50
+ }
51
+ signal.addEventListener('abort', done);
52
+ });
53
+ function parseJson(text) {
54
+ try {
55
+ return JSON.parse(text);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /** An event envelope as Agentcard sends it, or null for anything else. */
62
+ function eventOf(value) {
63
+ if (!isRecord(value) || typeof value.id !== 'string' || typeof value.type !== 'string' || !isRecord(value.data))
64
+ return null;
65
+ return {
66
+ id: value.id,
67
+ type: value.type,
68
+ created: typeof value.created === 'number' ? value.created : 0,
69
+ livemode: value.livemode === true,
70
+ data: value.data,
71
+ };
72
+ }
73
+ /** The Agentcard error a refused read answered with. */
74
+ async function refusal(res) {
75
+ const body = parseJson(await res.text().catch(() => ''));
76
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
77
+ const code = typeof error.code === 'string' ? error.code : `http_${res.status}`;
78
+ const message = typeof error.message === 'string' ? error.message : `Agentcard answered ${res.status}.`;
79
+ return new AgentcardApiError(res.status, code, message, isRecord(body) ? body : undefined);
80
+ }
81
+ /**
82
+ * Listen to every event Agentcard sends your company, over one connection your server opens.
83
+ * Build `agentcard` with your company's client credentials. Returns at once; `ready` resolves once
84
+ * the listener reaches Agentcard. Each event runs its callback once, in order, and the next event
85
+ * waits for it: a callback that throws runs again, up to five times, before the listener reports
86
+ * it and moves on. Run one listener per company and mode: two listeners each hear every event.
87
+ */
88
+ export function listenToAgentcard(agentcard, opts) {
89
+ if (!agentcard || typeof agentcard.bearer !== 'function')
90
+ throw new TypeError('Pass the AgentcardClient built with your company credentials.');
91
+ if (!agentcard.company)
92
+ throw new TypeError('Listening takes your company credentials: build the client with clientId and clientSecret.');
93
+ if (opts === null || typeof opts !== 'object')
94
+ throw new TypeError('Pass { onMessage } or { onEvent }.');
95
+ if (opts.onMessage === undefined && opts.onEvent === undefined)
96
+ throw new TypeError('Pass onMessage, onEvent, or both.');
97
+ for (const name of ['onMessage', 'onEvent', 'onError']) {
98
+ if (opts[name] !== undefined && typeof opts[name] !== 'function')
99
+ throw new TypeError(`${name} must be a function.`);
100
+ }
101
+ const filter = opts.events === undefined ? null : [...opts.events];
102
+ if (filter !== null && (filter.length === 0 || filter.some((p) => typeof p !== 'string' || !isEventPattern(p)))) {
103
+ throw new TypeError('events lists event types Agentcard sends, families such as order.*, or *. Leave it out for every event.');
104
+ }
105
+ const transportOpt = opts.transport ?? 'auto';
106
+ if (!['auto', 'stream', 'poll'].includes(transportOpt))
107
+ throw new TypeError("transport is 'auto', 'stream' or 'poll'.");
108
+ const fetchFn = (opts.fetch ?? globalThis.fetch);
109
+ if (typeof fetchFn !== 'function')
110
+ throw new TypeError('Pass fetch: this runtime has no global fetch. Node 18 and later have one.');
111
+ const pollIntervalMs = opts.pollIntervalMs ?? 2_000;
112
+ const heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? 45_000;
113
+ const pollTimeoutMs = opts.pollTimeoutMs ?? 30_000;
114
+ const stop = new AbortController();
115
+ let transport = transportOpt === 'poll' ? 'poll' : 'stream';
116
+ let cursor = null;
117
+ let savedCursor = null;
118
+ let lastSave = 0;
119
+ let resolveReady;
120
+ const ready = new Promise((resolve) => {
121
+ resolveReady = resolve;
122
+ });
123
+ const report = (error, event) => {
124
+ try {
125
+ const reported = opts.onError ? opts.onError(error, event) : console.error('Agentcard listener:', error);
126
+ if (reported && typeof reported.then === 'function')
127
+ reported.then(undefined, () => { });
128
+ }
129
+ catch {
130
+ // A failing reporter never stops the listener.
131
+ }
132
+ };
133
+ const finished = new Set();
134
+ /** The ids handled last, saved with the cursor, so a new process skips what Agentcard hands it again. */
135
+ /** Each handled id with the cursor time it came with, which is never earlier than the event's own place. */
136
+ const recent = [];
137
+ let replayMs = DEFAULT_REPLAY_MS;
138
+ const finishAt = (id, at) => {
139
+ finished.add(id);
140
+ if (finished.size > HANDLED_EVENTS_KEPT)
141
+ finished.delete(finished.values().next().value);
142
+ recent.push({ id, at: at ?? Date.now() });
143
+ if (recent.length > HANDLED_EVENTS_KEPT)
144
+ recent.shift();
145
+ };
146
+ /**
147
+ * What the store keeps: Agentcard's cursor, then the id of every event a resume from it could hand
148
+ * over again, as `<cursor>.<id>,<id>`. An id older than the replay window behind the cursor is dropped.
149
+ */
150
+ const stored = (next) => {
151
+ const at = cursorTime(next);
152
+ if (at !== null) {
153
+ const keepFrom = at - replayMs - REPLAY_MARGIN_MS;
154
+ while (recent.length > 0 && recent[0].at < keepFrom)
155
+ recent.shift();
156
+ }
157
+ return recent.length > 0 ? `${next}.${recent.map((r) => r.id).join(',')}` : next;
158
+ };
159
+ const save = async (next, force) => {
160
+ cursor = next;
161
+ const value = stored(next);
162
+ if (!opts.cursor || value === savedCursor)
163
+ return;
164
+ if (!force && Date.now() - lastSave < IDLE_SAVE_MS)
165
+ return;
166
+ try {
167
+ await opts.cursor.save(value);
168
+ savedCursor = value;
169
+ lastSave = Date.now();
170
+ }
171
+ catch (error) {
172
+ report(error);
173
+ }
174
+ };
175
+ /**
176
+ * Runs one event's callback, and tries again when it throws, up to five times in all. Answers
177
+ * false when the listener closed with the callback still failing: the event stays unfinished and
178
+ * nothing saves past it, so the next process runs it again.
179
+ */
180
+ const handle = async (event, at) => {
181
+ if (finished.has(event.id))
182
+ return true;
183
+ const finish = (id) => {
184
+ finishAt(id, at);
185
+ return true;
186
+ };
187
+ if (filter !== null && !filter.some((p) => matchesEventPattern(event.type, p)))
188
+ return finish(event.id);
189
+ let message = null;
190
+ try {
191
+ message = readShopperMessage(event);
192
+ }
193
+ catch (error) {
194
+ // A message the package cannot read reads the same way every time, so it is reported once.
195
+ report(error, event);
196
+ }
197
+ // A message whose link or window closed before it arrived reaches no shopper, and the event still reaches onEvent.
198
+ const forShopper = message && message.externalUserId !== null && !isStale(message) ? message : null;
199
+ for (let attempt = 1;; attempt++) {
200
+ try {
201
+ if (opts.onMessage && forShopper?.externalUserId)
202
+ await opts.onMessage(forShopper.externalUserId, forShopper, event);
203
+ else if (opts.onEvent)
204
+ await opts.onEvent(event);
205
+ return finish(event.id);
206
+ }
207
+ catch (error) {
208
+ if (attempt >= CALLBACK_ATTEMPTS) {
209
+ report(error, event);
210
+ return finish(event.id);
211
+ }
212
+ // Closing mid-failure leaves the event for the next process, which has the tries left.
213
+ if (stop.signal.aborted) {
214
+ report(error, event);
215
+ return false;
216
+ }
217
+ await sleep(1_000 * 2 ** (attempt - 1), stop.signal);
218
+ }
219
+ }
220
+ };
221
+ const query = (after, extra = {}) => {
222
+ const params = new URLSearchParams(extra);
223
+ if (after)
224
+ params.set('after', after);
225
+ if (filter)
226
+ params.set('types', filter.join(','));
227
+ const text = params.toString();
228
+ return text ? `?${text}` : '';
229
+ };
230
+ /** One authorized GET: a 401 renews the token once. */
231
+ const get = async (path, accept, signal) => {
232
+ let token = await agentcard.bearer();
233
+ for (let attempt = 0;; attempt++) {
234
+ const res = await fetchFn(`${agentcard.baseUrl}${path}`, { method: 'GET', headers: { authorization: `Bearer ${token}`, accept }, signal });
235
+ if (res.status !== 401 || attempt > 0)
236
+ return res;
237
+ await res.text().catch(() => '');
238
+ token = await agentcard.bearer(token);
239
+ }
240
+ };
241
+ /** A refused cursor: Agentcard no longer keeps it, so the listener reports it and starts from now. */
242
+ const dropCursor = async (error) => {
243
+ report(error);
244
+ cursor = null;
245
+ };
246
+ /** One stream connection, read to its end. Resolves when the server asks to reconnect or the connection drops. */
247
+ const streamOnce = async () => {
248
+ const conn = new AbortController();
249
+ const abort = () => conn.abort();
250
+ stop.signal.addEventListener('abort', abort);
251
+ let silence;
252
+ let wentSilent = false;
253
+ const arm = () => {
254
+ if (silence)
255
+ clearTimeout(silence);
256
+ silence = setTimeout(() => {
257
+ wentSilent = true;
258
+ abort();
259
+ }, heartbeatTimeoutMs);
260
+ };
261
+ let opened = false;
262
+ try {
263
+ arm();
264
+ let res;
265
+ try {
266
+ res = await get(`/api/v2/events/stream${query(cursor)}`, 'text/event-stream', conn.signal);
267
+ }
268
+ catch (error) {
269
+ // No answer within the heartbeat timeout is a stream held back on its way, not a lost network.
270
+ if (wentSilent && !stop.signal.aborted)
271
+ throw new StreamNotEstablished('The event stream sent nothing before Agentcard said it was ready.');
272
+ throw error;
273
+ }
274
+ if (res.status === 400 || res.status === 410) {
275
+ const error = await refusal(res);
276
+ if (error.code === 'invalid_cursor' || error.code === 'cursor_expired') {
277
+ await dropCursor(error);
278
+ return 'reconnect';
279
+ }
280
+ throw error;
281
+ }
282
+ const type = res.headers.get('content-type') ?? '';
283
+ if (res.status === 404 || res.status === 405 || (res.ok && !type.includes('text/event-stream'))) {
284
+ await res.text().catch(() => '');
285
+ throw new StreamUnavailable(`Agentcard answered the stream with ${res.status} ${type || 'and no content type'}.`);
286
+ }
287
+ if (!res.ok || !res.body)
288
+ throw await refusal(res);
289
+ const reader = res.body.getReader();
290
+ const decoder = new TextDecoder();
291
+ let buffer = '';
292
+ for (;;) {
293
+ let chunk;
294
+ try {
295
+ chunk = await reader.read();
296
+ }
297
+ catch (error) {
298
+ if (!opened && !stop.signal.aborted)
299
+ throw new StreamNotEstablished('The event stream went silent before Agentcard said it was ready.');
300
+ throw error;
301
+ }
302
+ const { done, value } = chunk;
303
+ // A connection that ends before Agentcard said it is ready counts as a failure, so it backs off.
304
+ if (done) {
305
+ if (!opened)
306
+ throw new StreamNotEstablished('The event stream closed before Agentcard said it was ready.');
307
+ return 'dropped';
308
+ }
309
+ arm();
310
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n');
311
+ let cut;
312
+ while ((cut = buffer.indexOf('\n\n')) >= 0) {
313
+ const block = buffer.slice(0, cut);
314
+ buffer = buffer.slice(cut + 2);
315
+ let id;
316
+ let name = 'message';
317
+ const data = [];
318
+ for (const line of block.split('\n')) {
319
+ if (line === '' || line.startsWith(':'))
320
+ continue;
321
+ const colon = line.indexOf(':');
322
+ const field = colon < 0 ? line : line.slice(0, colon);
323
+ const val = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
324
+ if (field === 'id')
325
+ id = val;
326
+ else if (field === 'event')
327
+ name = val;
328
+ else if (field === 'data')
329
+ data.push(val);
330
+ }
331
+ if (name === 'ready') {
332
+ opened = true;
333
+ resolveReady();
334
+ const announced = parseJson(data.join('\n'));
335
+ if (isRecord(announced) && typeof announced.replay_seconds === 'number' && announced.replay_seconds > 0) {
336
+ replayMs = Math.max(DEFAULT_REPLAY_MS, announced.replay_seconds * 1000);
337
+ }
338
+ if (id)
339
+ await save(id, false);
340
+ }
341
+ else if (name === 'heartbeat') {
342
+ if (id)
343
+ await save(id, false);
344
+ }
345
+ else if (name === 'reconnect') {
346
+ if (id)
347
+ await save(id, false);
348
+ await reader.cancel().catch(() => { });
349
+ return 'reconnect';
350
+ }
351
+ else if (name === 'message' || name === 'replay') {
352
+ // A replay is an event Agentcard hands over again after a resume, in case it committed late; an id already handled runs nothing.
353
+ const event = eventOf(parseJson(data.join('\n')));
354
+ if (event) {
355
+ // A callback that runs long is not silence on the stream.
356
+ if (silence)
357
+ clearTimeout(silence);
358
+ const handled = await handle(event, cursorTime(id));
359
+ arm();
360
+ // An event the shutdown left unfinished saves no cursor past it.
361
+ if (!handled)
362
+ return 'dropped';
363
+ }
364
+ if (id)
365
+ await save(id, true);
366
+ }
367
+ if (stop.signal.aborted)
368
+ return 'dropped';
369
+ }
370
+ }
371
+ }
372
+ finally {
373
+ if (silence)
374
+ clearTimeout(silence);
375
+ stop.signal.removeEventListener('abort', abort);
376
+ conn.abort();
377
+ }
378
+ };
379
+ /** One page of a polling read, given up after `pollTimeoutMs` so a stalled read never stops the listener. */
380
+ const readPage = async () => {
381
+ const conn = new AbortController();
382
+ const abort = () => conn.abort();
383
+ stop.signal.addEventListener('abort', abort);
384
+ const deadline = setTimeout(abort, pollTimeoutMs);
385
+ try {
386
+ const res = await get(`/api/v2/events${query(cursor, { limit: '100' })}`, 'application/json', conn.signal);
387
+ return { status: res.status, ok: res.ok, text: await res.text() };
388
+ }
389
+ finally {
390
+ clearTimeout(deadline);
391
+ stop.signal.removeEventListener('abort', abort);
392
+ }
393
+ };
394
+ /** One polling read, every page of it. */
395
+ const pollOnce = async () => {
396
+ for (;;) {
397
+ const res = await readPage();
398
+ if (!res.ok) {
399
+ const error = await refusal({ status: res.status, text: async () => res.text });
400
+ if (error.code === 'invalid_cursor' || error.code === 'cursor_expired')
401
+ return dropCursor(error);
402
+ throw error;
403
+ }
404
+ const body = parseJson(res.text);
405
+ if (!isRecord(body) || !Array.isArray(body.data))
406
+ throw new AgentcardApiError(res.status, 'invalid_answer', 'Agentcard answered the event read with a body the listener cannot read.');
407
+ resolveReady();
408
+ for (const item of body.data) {
409
+ if (stop.signal.aborted)
410
+ return;
411
+ const event = isRecord(item) ? eventOf(item.event) : null;
412
+ const itemCursor = isRecord(item) && typeof item.cursor === 'string' ? item.cursor : null;
413
+ // An event the shutdown left unfinished saves no cursor past it.
414
+ if (event && !(await handle(event, cursorTime(itemCursor))))
415
+ return;
416
+ if (itemCursor)
417
+ await save(itemCursor, true);
418
+ }
419
+ if (typeof body.cursor === 'string')
420
+ await save(body.cursor, body.data.length > 0);
421
+ if (body.has_more !== true)
422
+ return;
423
+ }
424
+ };
425
+ const loop = (async () => {
426
+ if (opts.cursor) {
427
+ try {
428
+ const loaded = await opts.cursor.load();
429
+ if (typeof loaded === 'string' && loaded !== '') {
430
+ savedCursor = loaded;
431
+ const dot = loaded.indexOf('.');
432
+ cursor = dot < 0 ? loaded : loaded.slice(0, dot);
433
+ for (const id of dot < 0 ? [] : loaded.slice(dot + 1).split(','))
434
+ if (/^[A-Za-z0-9_]{1,64}$/.test(id))
435
+ finishAt(id, cursorTime(cursor));
436
+ }
437
+ }
438
+ catch (error) {
439
+ report(error);
440
+ }
441
+ }
442
+ let failures = 0;
443
+ let unestablished = 0;
444
+ while (!stop.signal.aborted) {
445
+ try {
446
+ if (transport === 'stream') {
447
+ const ended = await streamOnce();
448
+ failures = 0;
449
+ unestablished = 0;
450
+ if (ended === 'reconnect')
451
+ continue;
452
+ }
453
+ else {
454
+ await pollOnce();
455
+ failures = 0;
456
+ await sleep(pollIntervalMs, stop.signal);
457
+ continue;
458
+ }
459
+ }
460
+ catch (error) {
461
+ if (stop.signal.aborted)
462
+ break;
463
+ if (error instanceof StreamUnavailable && transportOpt === 'auto') {
464
+ transport = 'poll';
465
+ continue;
466
+ }
467
+ failures++;
468
+ report(error);
469
+ if (error instanceof StreamNotEstablished && transportOpt === 'auto' && ++unestablished >= STREAM_ATTEMPTS_BEFORE_POLLING) {
470
+ // Something between here and Agentcard holds the stream back; the same events come by polling, from the same cursor.
471
+ transport = 'poll';
472
+ continue;
473
+ }
474
+ }
475
+ if (stop.signal.aborted)
476
+ break;
477
+ // A dropped connection or a failed read waits a little longer each time, up to 30 seconds.
478
+ const backoff = Math.min(MAX_BACKOFF_MS, 1_000 * 2 ** Math.max(0, failures - 1));
479
+ await sleep(failures === 0 ? 250 : backoff * (0.5 + Math.random() / 2), stop.signal);
480
+ }
481
+ resolveReady();
482
+ })();
483
+ return {
484
+ ready,
485
+ get transport() {
486
+ return transport;
487
+ },
488
+ get cursor() {
489
+ return cursor;
490
+ },
491
+ async close() {
492
+ stop.abort();
493
+ await loop;
494
+ },
495
+ };
496
+ }