@secondlayer/sdk 9.0.1 → 10.0.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.
@@ -88,8 +88,8 @@ class ApiError extends SecondLayerError {
88
88
 
89
89
  class AuthError extends ApiError {
90
90
  status = 401;
91
- constructor(message = "API key invalid or expired.") {
92
- super(401, message);
91
+ constructor(message = "API key invalid or expired.", body, code) {
92
+ super(401, message, body, code);
93
93
  this.name = "AuthError";
94
94
  }
95
95
  }
@@ -97,8 +97,8 @@ class AuthError extends ApiError {
97
97
  class RateLimitError extends ApiError {
98
98
  retryAfter;
99
99
  status = 429;
100
- constructor(message = "Rate limited. Try again later.", retryAfter) {
101
- super(429, message, undefined, undefined, {
100
+ constructor(message = "Rate limited. Try again later.", retryAfter, body, code) {
101
+ super(429, message, body, code, {
102
102
  retryable: true,
103
103
  retryAfterSeconds: parseRetryAfter(retryAfter)
104
104
  });
@@ -108,8 +108,8 @@ class RateLimitError extends ApiError {
108
108
  }
109
109
 
110
110
  class ValidationError extends ApiError {
111
- constructor(message, status, body) {
112
- super(status, message, body, undefined, { retryable: false });
111
+ constructor(message, status, body, code) {
112
+ super(status, message, body, code, { retryable: false });
113
113
  this.name = "ValidationError";
114
114
  }
115
115
  }
@@ -125,18 +125,22 @@ function parseRetryAfter(value) {
125
125
  return;
126
126
  }
127
127
 
128
- class VersionConflictError extends ApiError {
129
- currentVersion;
130
- expectedVersion;
131
- constructor(currentVersion, expectedVersion, message = `Version conflict: expected ${expectedVersion}, current ${currentVersion}`) {
132
- super(409, message, { currentVersion, expectedVersion });
133
- this.currentVersion = currentVersion;
134
- this.expectedVersion = expectedVersion;
135
- this.name = "VersionConflictError";
136
- }
137
- }
138
-
139
128
  // src/base.ts
129
+ var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
130
+ function abortError() {
131
+ return new DOMException("This operation was aborted", "AbortError");
132
+ }
133
+ function raceAbort(promise, signal) {
134
+ if (signal.aborted)
135
+ return Promise.reject(signal.reason ?? abortError());
136
+ return new Promise((resolve, reject) => {
137
+ const onAbort = () => reject(signal.reason ?? abortError());
138
+ signal.addEventListener("abort", onAbort, { once: true });
139
+ promise.then(resolve, reject).finally(() => {
140
+ signal.removeEventListener("abort", onAbort);
141
+ });
142
+ });
143
+ }
140
144
  var LOCAL_API_URL = "http://127.0.0.1:3800";
141
145
  function resolveBaseUrl(explicit) {
142
146
  if (explicit && explicit.length > 0)
@@ -166,6 +170,9 @@ function resolveApiKey(apiKey) {
166
170
  }
167
171
  return instanceToken ?? legacyKey;
168
172
  }
173
+ function seg(value) {
174
+ return encodeURIComponent(String(value));
175
+ }
169
176
  function buildQuery(params) {
170
177
  const search = new URLSearchParams;
171
178
  for (const [name, value] of Object.entries(params)) {
@@ -179,16 +186,39 @@ function buildQuery(params) {
179
186
  const query = search.toString();
180
187
  return query ? `?${query}` : "";
181
188
  }
189
+ function parseErrorEnvelope(text) {
190
+ if (text.length === 0)
191
+ return { body: undefined };
192
+ try {
193
+ const json = JSON.parse(text);
194
+ let message;
195
+ let code;
196
+ if (json && typeof json === "object") {
197
+ const err = json.error ?? json.message;
198
+ if (typeof err === "string" && err.length > 0)
199
+ message = err;
200
+ else if (err && typeof err === "object")
201
+ message = JSON.stringify(err);
202
+ if (typeof json.code === "string")
203
+ code = json.code;
204
+ }
205
+ return { message, code, body: json };
206
+ } catch {
207
+ return { message: text, body: text };
208
+ }
209
+ }
182
210
 
183
211
  class BaseClient {
184
212
  baseUrl;
185
213
  apiKey;
186
214
  origin;
187
215
  fetchImpl;
216
+ requestTimeoutMs;
188
217
  constructor(options = {}) {
189
218
  this.baseUrl = resolveBaseUrl(options.baseUrl);
190
219
  this.apiKey = resolveApiKey(options.apiKey);
191
220
  this.origin = options.origin ?? "cli";
221
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
192
222
  this.fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args));
193
223
  }
194
224
  static authHeaders(apiKey) {
@@ -200,27 +230,56 @@ class BaseClient {
200
230
  }
201
231
  return headers;
202
232
  }
203
- async request(method, path, body) {
204
- const response = await this.fetchResponse(method, path, body);
205
- if (response.status === 204) {
206
- return;
207
- }
208
- return response.json();
233
+ async request(method, path, body, opts = {}) {
234
+ return this.withRequestBudget(opts.signal, async (signal) => {
235
+ const response = await this.fetchResponse(method, path, body, signal);
236
+ if (response.status === 204) {
237
+ return;
238
+ }
239
+ return await raceAbort(response.json(), signal);
240
+ });
209
241
  }
210
- async requestOrNull(method, path, body) {
242
+ async requestOrNull(method, path, body, opts = {}) {
211
243
  try {
212
- return await this.request(method, path, body);
244
+ return await this.request(method, path, body, opts);
213
245
  } catch (err) {
214
246
  if (err instanceof ApiError && err.status === 404)
215
247
  return null;
216
248
  throw err;
217
249
  }
218
250
  }
219
- async requestText(method, path, body) {
220
- const response = await this.fetchResponse(method, path, body);
221
- return response.text();
251
+ async requestText(method, path, body, opts = {}) {
252
+ return this.withRequestBudget(opts.signal, async (signal) => {
253
+ const response = await this.fetchResponse(method, path, body, signal);
254
+ return raceAbort(response.text(), signal);
255
+ });
256
+ }
257
+ async withRequestBudget(callerSignal, run) {
258
+ const controller = new AbortController;
259
+ let timedOut = false;
260
+ const forward = () => controller.abort(callerSignal?.reason ?? abortError());
261
+ if (callerSignal?.aborted)
262
+ forward();
263
+ else
264
+ callerSignal?.addEventListener("abort", forward, { once: true });
265
+ const timer = this.requestTimeoutMs > 0 ? setTimeout(() => {
266
+ timedOut = true;
267
+ controller.abort(abortError());
268
+ }, this.requestTimeoutMs) : undefined;
269
+ try {
270
+ return await run(controller.signal);
271
+ } catch (err) {
272
+ if (timedOut && !callerSignal?.aborted) {
273
+ throw new ApiError(0, `No response from ${this.baseUrl} within ${this.requestTimeoutMs}ms. Retryable. Raise requestTimeoutMs for slow filtered reads.`, undefined, "REQUEST_TIMEOUT", { retryable: true, cause: err instanceof Error ? err : undefined });
274
+ }
275
+ throw err;
276
+ } finally {
277
+ if (timer !== undefined)
278
+ clearTimeout(timer);
279
+ callerSignal?.removeEventListener("abort", forward);
280
+ }
222
281
  }
223
- async fetchResponse(method, path, body) {
282
+ async fetchResponse(method, path, body, signal) {
224
283
  const url = `${this.baseUrl}${path}`;
225
284
  const headers = BaseClient.authHeaders(this.apiKey);
226
285
  headers["x-sl-origin"] = this.origin;
@@ -235,56 +294,50 @@ class BaseClient {
235
294
  }
236
295
  let response;
237
296
  try {
238
- response = await this.fetchImpl(url, {
297
+ const pending = this.fetchImpl(url, {
239
298
  method,
240
299
  headers,
241
- body: serializedBody
300
+ body: serializedBody,
301
+ signal
242
302
  });
303
+ response = signal ? await raceAbort(pending, signal) : await pending;
243
304
  } catch (err) {
305
+ if (signal?.aborted)
306
+ throw err;
244
307
  throw new ApiError(0, `Cannot reach API at ${this.baseUrl}. Check your connection or try again.`, undefined, undefined, { retryable: true, cause: err instanceof Error ? err : undefined });
245
308
  }
246
309
  if (!response.ok) {
310
+ const errorBody = signal ? await raceAbort(response.text(), signal) : await response.text();
311
+ const envelope = parseErrorEnvelope(errorBody);
312
+ const retryAfter = response.headers.get("Retry-After") ?? undefined;
247
313
  if (response.status === 401) {
248
- throw new AuthError;
314
+ throw new AuthError(envelope.message ?? "API key invalid or expired.", envelope.body, envelope.code);
249
315
  }
250
316
  if (response.status === 429) {
251
- const retryAfter = response.headers.get("Retry-After");
252
- const msg = retryAfter ? `Rate limited. Wait ${retryAfter} seconds.` : "Rate limited. Try again later.";
253
- throw new RateLimitError(msg, retryAfter ?? undefined);
317
+ const fallback = retryAfter ? `Rate limited. Wait ${retryAfter} seconds.` : "Rate limited. Try again later.";
318
+ throw new RateLimitError(envelope.message ?? fallback, retryAfter, envelope.body, envelope.code);
254
319
  }
255
320
  if (response.status >= 500) {
256
- throw new ApiError(response.status, `Server error. Try again or check status at ${this.baseUrl}/health`);
321
+ const retryAfterSeconds = parseRetryAfter(retryAfter);
322
+ throw new ApiError(response.status, envelope.message ?? `Server error. Try again or check status at ${this.baseUrl}/health`, envelope.body, envelope.code, retryAfterSeconds !== undefined ? { retryAfterSeconds } : {});
257
323
  }
258
- const errorBody = await response.text();
259
- let message = `HTTP ${response.status}`;
260
- let parsedBody = errorBody;
261
- let code;
262
- try {
263
- const json = JSON.parse(errorBody);
264
- parsedBody = json;
265
- const err = json.error ?? json.message;
266
- if (typeof err === "string") {
267
- message = err;
268
- } else if (err && typeof err === "object") {
269
- message = JSON.stringify(err);
270
- }
271
- if (typeof json.code === "string") {
272
- code = json.code;
273
- }
274
- } catch {
275
- if (errorBody)
276
- message = errorBody;
277
- }
278
- throw new ApiError(response.status, message, parsedBody, code);
324
+ throw new ApiError(response.status, envelope.message ?? `HTTP ${response.status}`, envelope.body, envelope.code);
279
325
  }
280
326
  return response;
281
327
  }
282
328
  }
283
329
 
330
+ // src/streams/cursor.ts
331
+ import {
332
+ blockEndCursor,
333
+ decodeStreamsCursor,
334
+ encodeStreamsCursor
335
+ } from "@secondlayer/shared";
336
+
284
337
  // src/streams/errors.ts
285
338
  class StreamsServerError extends ApiError {
286
- constructor(message, status, body) {
287
- super(status, message, body, undefined, { retryable: true });
339
+ constructor(message, status, body, code) {
340
+ super(status, message, body, code, { retryable: true });
288
341
  this.name = "StreamsServerError";
289
342
  }
290
343
  }
@@ -295,23 +348,38 @@ class StreamsSignatureError extends SecondLayerError {
295
348
  this.name = "StreamsSignatureError";
296
349
  }
297
350
  }
351
+ async function mapStreamsError(response) {
352
+ const { message, code, body } = parseErrorEnvelope(await response.text());
353
+ if (response.status === 401) {
354
+ throw new AuthError(message ?? "API key invalid or expired.", body, code);
355
+ }
356
+ if (response.status === 429) {
357
+ const retryAfter = response.headers.get("Retry-After") ?? undefined;
358
+ throw new RateLimitError(message ?? "Rate limited. Try again later.", retryAfter, body, code);
359
+ }
360
+ if (response.status >= 500) {
361
+ throw new StreamsServerError(message ?? `Streams server returned ${response.status}.`, response.status, body, code);
362
+ }
363
+ throw new ValidationError(message ?? `Streams request returned ${response.status}.`, response.status, body, code);
364
+ }
298
365
 
299
366
  // src/streams/cursor.ts
300
- var REWIND_FOOT_INDEX_SENTINEL = 2147483647;
301
367
  var Cursor = {
302
368
  atHeight(height) {
303
369
  if (height <= 0)
304
- return "0:0";
305
- return `${height - 1}:${REWIND_FOOT_INDEX_SENTINEL}`;
370
+ return null;
371
+ return encodeStreamsCursor(blockEndCursor(height - 1));
306
372
  },
307
373
  parse(cursor) {
308
- const parts = cursor.split(":");
309
- const blockHeight = Number(parts[0]);
310
- const eventIndex = Number(parts[1]);
311
- if (parts.length !== 2 || !Number.isInteger(blockHeight) || !Number.isInteger(eventIndex)) {
374
+ try {
375
+ const decoded = decodeStreamsCursor(cursor);
376
+ return {
377
+ blockHeight: decoded.block_height,
378
+ eventIndex: decoded.event_index
379
+ };
380
+ } catch {
312
381
  throw new ValidationError(`Invalid stream cursor "${cursor}"; expected "<block>:<index>" (e.g. "951475:3").`, 400);
313
382
  }
314
- return { blockHeight, eventIndex };
315
383
  }
316
384
  };
317
385
 
@@ -319,6 +387,39 @@ var Cursor = {
319
387
  function reorgKey(reorg) {
320
388
  return `${reorg.detected_at}|${reorg.fork_point_height}|${reorg.new_canonical_tip}`;
321
389
  }
390
+ var DEFAULT_MAX_ROLLBACK_DEPTH = 1000;
391
+ async function applyReorgs(opts) {
392
+ const fresh = opts.reorgs.filter((reorg) => !opts.handled.has(opts.keyOf(reorg)));
393
+ if (fresh.length === 0)
394
+ return null;
395
+ for (const reorg of fresh) {
396
+ const fork = reorg.fork_point_height;
397
+ if (!Number.isSafeInteger(fork) || fork < 0) {
398
+ throw new ValidationError(`Reorg fork_point_height ${String(fork)} is not a non-negative integer. Rollback deletes every row at or above the fork from every declared table, so a malformed fork point is refused before the sink runs.`, 400);
399
+ }
400
+ }
401
+ const position = resumeHeight(opts.cursor);
402
+ const applicable = fresh.filter((reorg) => position !== null && reorg.fork_point_height <= position).sort((a, b) => a.fork_point_height - b.fork_point_height);
403
+ for (const reorg of fresh) {
404
+ if (!applicable.includes(reorg))
405
+ opts.handled.add(opts.keyOf(reorg));
406
+ }
407
+ if (applicable.length === 0 || position === null)
408
+ return null;
409
+ const forkPoint = applicable[0]?.fork_point_height ?? position;
410
+ const depth = position - forkPoint;
411
+ const maxDepth = opts.maxRollbackDepth ?? DEFAULT_MAX_ROLLBACK_DEPTH;
412
+ if (depth > maxDepth) {
413
+ throw new ValidationError(`Reorg at fork ${forkPoint} rewinds ${depth} blocks below the checkpoint at height ${position}, past maxRollbackDepth (${maxDepth}). A rollback this deep deletes every row at or above the fork from every declared table. Confirm the source is trusted, then pass maxRollbackDepth: ${depth} or higher to allow it.`, 400);
414
+ }
415
+ const rewind = Cursor.atHeight(forkPoint);
416
+ for (const reorg of applicable) {
417
+ await opts.sink?.rollback(reorg.fork_point_height, rewind);
418
+ await opts.onReorg?.(reorg, { cursor: rewind });
419
+ opts.handled.add(opts.keyOf(reorg));
420
+ }
421
+ return { forkPoint, cursor: rewind };
422
+ }
322
423
  function assertFinalizedCheckpoint(returned, checkpoint) {
323
424
  if (returned === null || returned === undefined)
324
425
  return;
@@ -398,13 +499,15 @@ async function defaultSleep(ms, signal) {
398
499
  if (signal?.aborted)
399
500
  return;
400
501
  await new Promise((resolve) => {
401
- const timeout = setTimeout(resolve, ms);
402
- if (!signal)
403
- return;
404
- signal.addEventListener("abort", () => {
502
+ const onAbort = () => {
405
503
  clearTimeout(timeout);
406
504
  resolve();
407
- }, { once: true });
505
+ };
506
+ const timeout = setTimeout(() => {
507
+ signal?.removeEventListener("abort", onAbort);
508
+ resolve();
509
+ }, ms);
510
+ signal?.addEventListener("abort", onAbort, { once: true });
408
511
  });
409
512
  }
410
513
  async function consumeStreamsEvents(opts) {
@@ -418,6 +521,7 @@ async function consumeStreamsEvents(opts) {
418
521
  const committedCursor = await opts.sink?.loadCursor() ?? null;
419
522
  let cursor = opts.fromCursor ?? committedCursor;
420
523
  const handledReorgs = new Set;
524
+ let reorgSince = null;
421
525
  let pages = 0;
422
526
  let emptyPolls = 0;
423
527
  let height = resumeHeight(cursor);
@@ -441,23 +545,57 @@ async function consumeStreamsEvents(opts) {
441
545
  signal: opts.signal
442
546
  });
443
547
  pages++;
444
- if (!finalizedOnly && (opts.onReorg || opts.sink)) {
445
- const fresh = envelope.reorgs.filter((reorg) => !handledReorgs.has(reorgKey(reorg))).sort((a, b) => a.fork_point_height - b.fork_point_height);
446
- if (fresh.length > 0) {
447
- const forkPoint = Math.min(...fresh.map((reorg) => reorg.fork_point_height));
448
- const rewind = Cursor.atHeight(forkPoint);
449
- for (const reorg of fresh) {
450
- await opts.sink?.rollback(reorg.fork_point_height, rewind);
451
- await opts.onReorg?.(reorg, { cursor: rewind });
452
- handledReorgs.add(reorgKey(reorg));
453
- }
454
- cursor = rewind;
455
- height = forkPoint > 0 ? forkPoint - 1 : null;
548
+ const reorgsOn = !finalizedOnly && Boolean(opts.onReorg || opts.sink);
549
+ if (reorgsOn) {
550
+ const rewound = await applyReorgs({
551
+ reorgs: envelope.reorgs,
552
+ cursor,
553
+ handled: handledReorgs,
554
+ keyOf: reorgKey,
555
+ sink: opts.sink,
556
+ onReorg: opts.onReorg,
557
+ maxRollbackDepth: opts.maxRollbackDepth
558
+ });
559
+ if (rewound) {
560
+ cursor = rewound.cursor;
561
+ height = rewound.forkPoint > 0 ? rewound.forkPoint - 1 : null;
456
562
  scanned = height;
457
563
  emptyPolls = 0;
458
564
  continue;
459
565
  }
460
566
  }
567
+ if (reorgsOn && opts.fetchReorgs && envelope.events.length === 0) {
568
+ reorgSince ??= cursor;
569
+ if (reorgSince !== null) {
570
+ const since = reorgSince;
571
+ const fetchReorgs = opts.fetchReorgs;
572
+ const listed = await fetchPageWithRetry(() => fetchReorgs({ since }), {
573
+ retryCount: opts.retryCount,
574
+ retryDelay: opts.retryDelay,
575
+ onError: opts.onError,
576
+ sleep,
577
+ signal: opts.signal
578
+ });
579
+ if (listed.next_since !== null)
580
+ reorgSince = listed.next_since;
581
+ const rewound = await applyReorgs({
582
+ reorgs: listed.reorgs,
583
+ cursor,
584
+ handled: handledReorgs,
585
+ keyOf: reorgKey,
586
+ sink: opts.sink,
587
+ onReorg: opts.onReorg,
588
+ maxRollbackDepth: opts.maxRollbackDepth
589
+ });
590
+ if (rewound) {
591
+ cursor = rewound.cursor;
592
+ height = rewound.forkPoint > 0 ? rewound.forkPoint - 1 : null;
593
+ scanned = height;
594
+ emptyPolls = 0;
595
+ continue;
596
+ }
597
+ }
598
+ }
461
599
  const emitted = finalizedOnly ? envelope.events.filter((event) => event.finalized) : envelope.events;
462
600
  const checkpoint = finalizedOnly ? emitted.at(-1)?.cursor ?? cursor : envelope.next_cursor;
463
601
  height = emitted.at(-1)?.block_height ?? height;
@@ -735,15 +873,46 @@ function createStreamsDumps(opts) {
735
873
  return manifest;
736
874
  }
737
875
  async function download(file) {
876
+ return fetchPageWithRetry(() => downloadOnce(file), {
877
+ sleep: defaultSleep
878
+ });
879
+ }
880
+ async function downloadOnce(file) {
738
881
  const res = await opts.fetchImpl(fileUrl(file));
739
882
  if (!res.ok) {
740
883
  throw new StreamsServerError(`Could not download dump ${file.path} (${res.status}).`, res.status);
741
884
  }
742
- const bytes = new Uint8Array(await res.arrayBuffer());
743
- const digest = createHash("sha256").update(bytes).digest("hex");
885
+ if (!res.body) {
886
+ throw new StreamsServerError(`Dump ${file.path} response has no body.`, 0);
887
+ }
888
+ const hash = createHash("sha256");
889
+ const chunks = [];
890
+ let total = 0;
891
+ const reader = res.body.getReader();
892
+ try {
893
+ while (true) {
894
+ const { value, done } = await reader.read();
895
+ if (done)
896
+ break;
897
+ hash.update(value);
898
+ chunks.push(value);
899
+ total += value.byteLength;
900
+ }
901
+ } catch {
902
+ throw new StreamsServerError(`Download of dump ${file.path} was interrupted after ${total} bytes.`, 0);
903
+ }
904
+ const digest = hash.digest("hex");
744
905
  if (digest !== file.sha256) {
745
906
  throw new StreamsSignatureError(`Dump ${file.path} sha256 mismatch (expected ${file.sha256}, got ${digest}).`);
746
907
  }
908
+ if (chunks.length === 1 && chunks[0])
909
+ return chunks[0];
910
+ const bytes = new Uint8Array(total);
911
+ let offset = 0;
912
+ for (const chunk of chunks) {
913
+ bytes.set(chunk, offset);
914
+ offset += chunk.byteLength;
915
+ }
747
916
  return bytes;
748
917
  }
749
918
  return { list, fileUrl, download };
@@ -751,6 +920,19 @@ function createStreamsDumps(opts) {
751
920
 
752
921
  // src/streams/subscribe.ts
753
922
  import { ed25519 } from "@secondlayer/shared";
923
+ var SUBSCRIBE_MAX_RECONNECT_DELAY_MS = 30000;
924
+ var SUBSCRIBE_DEFAULT_STALE_AFTER_MS = 60000;
925
+ function reconnectDelay(attempt, baseMs, retryAfterSeconds, random = Math.random) {
926
+ const capped = Math.min(baseMs * 2 ** Math.max(0, attempt), SUBSCRIBE_MAX_RECONNECT_DELAY_MS);
927
+ const jittered = capped * (0.5 + random() * 0.5);
928
+ const floor = (retryAfterSeconds ?? 0) * 1000;
929
+ return Math.max(jittered, floor);
930
+ }
931
+ function isTerminal(err) {
932
+ if (err instanceof StreamsSignatureError)
933
+ return true;
934
+ return err?.retryable === false;
935
+ }
754
936
  function subscribeStreamsEvents(opts) {
755
937
  const { params } = opts;
756
938
  const controller = new AbortController;
@@ -764,7 +946,9 @@ function subscribeStreamsEvents(opts) {
764
946
  });
765
947
  }
766
948
  let cursor = params.fromCursor ?? null;
767
- const reconnectDelayMs = opts.reconnectDelayMs ?? 1000;
949
+ const baseDelayMs = opts.reconnectDelayMs ?? params.reconnectDelayMs ?? 1000;
950
+ const staleAfterMs = opts.staleAfterMs ?? params.staleAfterMs ?? SUBSCRIBE_DEFAULT_STALE_AFTER_MS;
951
+ let attempt = 0;
768
952
  const run = async () => {
769
953
  while (!controller.signal.aborted) {
770
954
  try {
@@ -778,55 +962,76 @@ function subscribeStreamsEvents(opts) {
778
962
  asset_identifier: params.assetIdentifier,
779
963
  filters: params.filters ? JSON.stringify(params.filters) : undefined
780
964
  })}`;
781
- const res = await opts.fetchImpl(url, {
782
- headers: {
783
- ...opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {},
784
- Accept: "text/event-stream"
785
- },
786
- signal: controller.signal
787
- });
788
- if (!res.ok) {
789
- throw new StreamsServerError(`Streams SSE returned ${res.status}.`, res.status);
790
- }
791
- if (!res.body) {
792
- throw new StreamsServerError("Streams SSE response has no body.", 0);
793
- }
794
- for await (const frame of parseSseFrames(res.body, controller.signal)) {
795
- if (frame.event === "ping" || !frame.data)
796
- continue;
797
- let parsed;
798
- try {
799
- parsed = JSON.parse(frame.data);
800
- } catch {
801
- continue;
802
- }
803
- if (!parsed.event)
804
- continue;
805
- if (opts.verify !== "off") {
806
- if (!parsed.sig) {
807
- if (opts.verify === "strict") {
808
- throw new StreamsSignatureError("Streams SSE frame signature is missing.");
809
- }
810
- } else {
811
- const key = await opts.loadKey();
812
- if (!ed25519.verifyEd25519(JSON.stringify(parsed.event), parsed.sig, key.publicKey)) {
813
- throw new StreamsSignatureError("Streams SSE frame signature is invalid.");
965
+ await readSse({
966
+ url,
967
+ headers: opts.headers ?? {},
968
+ signal: controller.signal,
969
+ fetchImpl: opts.fetchImpl,
970
+ staleAfterMs,
971
+ mapError: mapStreamsError,
972
+ onFrame: async (frame) => {
973
+ attempt = 0;
974
+ if (frame.event === "ping" || !frame.data)
975
+ return;
976
+ let parsed;
977
+ try {
978
+ parsed = JSON.parse(frame.data);
979
+ } catch {
980
+ return;
981
+ }
982
+ if (!parsed.event)
983
+ return;
984
+ if (opts.verify !== "off") {
985
+ if (!parsed.sig) {
986
+ if (opts.verify === "strict") {
987
+ throw new StreamsSignatureError("Streams SSE frame signature is missing.");
988
+ }
989
+ } else {
990
+ const key = await opts.loadKey(parsed.key_id);
991
+ if (!ed25519.verifyEd25519(JSON.stringify(parsed.event), parsed.sig, key.publicKey)) {
992
+ throw new StreamsSignatureError("Streams SSE frame signature is invalid.");
993
+ }
814
994
  }
815
995
  }
996
+ await params.onEvent(parsed.event);
997
+ cursor = parsed.event.cursor ?? cursor;
816
998
  }
817
- cursor = parsed.event.cursor ?? cursor;
818
- await params.onEvent(parsed.event);
819
- }
999
+ });
1000
+ await sleep(reconnectDelay(attempt, baseDelayMs, undefined, opts.random), controller.signal);
1001
+ attempt += 1;
820
1002
  } catch (err) {
821
1003
  if (controller.signal.aborted)
822
1004
  return;
823
1005
  params.onError?.(err);
824
- await sleep(reconnectDelayMs, controller.signal);
1006
+ if (isTerminal(err))
1007
+ throw err;
1008
+ const retryAfterSeconds = err.retryAfterSeconds;
1009
+ await sleep(reconnectDelay(attempt, baseDelayMs, retryAfterSeconds, opts.random), controller.signal);
1010
+ attempt += 1;
825
1011
  }
826
1012
  }
827
1013
  };
828
- run();
829
- return () => controller.abort();
1014
+ const done = run();
1015
+ done.catch(() => {});
1016
+ const unsubscribe = () => controller.abort();
1017
+ return Object.assign(unsubscribe, { done });
1018
+ }
1019
+ async function readSse(opts) {
1020
+ const res = await opts.fetchImpl(opts.url, {
1021
+ headers: { ...opts.headers ?? {}, Accept: "text/event-stream" },
1022
+ signal: opts.signal
1023
+ });
1024
+ if (!res.ok) {
1025
+ if (opts.mapError)
1026
+ await opts.mapError(res);
1027
+ throw new StreamsServerError(`Streams SSE returned ${res.status}.`, res.status);
1028
+ }
1029
+ if (!res.body) {
1030
+ throw new StreamsServerError("Streams SSE response has no body.", 0);
1031
+ }
1032
+ for await (const frame of parseSseFrames(res.body, opts.signal, opts.staleAfterMs)) {
1033
+ await opts.onFrame(frame);
1034
+ }
830
1035
  }
831
1036
  function sleep(ms, signal) {
832
1037
  return new Promise((resolve) => {
@@ -843,13 +1048,27 @@ function sleep(ms, signal) {
843
1048
  signal.addEventListener("abort", onAbort, { once: true });
844
1049
  });
845
1050
  }
846
- async function* parseSseFrames(body, signal) {
1051
+ async function readWithStaleTimer(reader, staleAfterMs) {
1052
+ if (staleAfterMs === undefined || !Number.isFinite(staleAfterMs)) {
1053
+ return reader.read();
1054
+ }
1055
+ let timer;
1056
+ const stale = new Promise((_, reject) => {
1057
+ timer = setTimeout(() => reject(new StreamsServerError(`Streams SSE went quiet for ${staleAfterMs} ms; reconnecting.`, 0, undefined, "STREAM_STALE")), staleAfterMs);
1058
+ });
1059
+ try {
1060
+ return await Promise.race([reader.read(), stale]);
1061
+ } finally {
1062
+ clearTimeout(timer);
1063
+ }
1064
+ }
1065
+ async function* parseSseFrames(body, signal, staleAfterMs) {
847
1066
  const reader = body.getReader();
848
1067
  const decoder = new TextDecoder;
849
1068
  let buffer = "";
850
1069
  try {
851
1070
  while (!signal.aborted) {
852
- const { value, done } = await reader.read();
1071
+ const { value, done } = await readWithStaleTimer(reader, staleAfterMs);
853
1072
  if (done)
854
1073
  break;
855
1074
  buffer += decoder.decode(value, { stream: true });
@@ -889,12 +1108,8 @@ function parseFrame(raw) {
889
1108
  function cursorTuple(cursor) {
890
1109
  if (!cursor)
891
1110
  return [-1, -1];
892
- const parts = cursor.split(":");
893
- const [block, index] = parts.map(Number);
894
- if (parts.length !== 2 || !Number.isInteger(block) || !Number.isInteger(index)) {
895
- throw new ValidationError(`Invalid stream cursor "${cursor}"; expected "<block>:<index>" (e.g. "951475:3").`, 400);
896
- }
897
- return [block, index];
1111
+ const { blockHeight, eventIndex } = Cursor.parse(cursor);
1112
+ return [blockHeight, eventIndex];
898
1113
  }
899
1114
  function maxCursor(a, b) {
900
1115
  const [ah, ai] = cursorTuple(a);
@@ -915,51 +1130,33 @@ function streamsFilters(params) {
915
1130
  function normalizeBaseUrl(baseUrl) {
916
1131
  return baseUrl.replace(/\/+$/, "");
917
1132
  }
918
- async function responseBody(response) {
919
- const text = await response.text();
920
- if (text.length === 0)
921
- return;
1133
+ function isLoopbackUrl(baseUrl) {
922
1134
  try {
923
- return JSON.parse(text);
1135
+ const { hostname } = new URL(baseUrl);
1136
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname.startsWith("127.");
924
1137
  } catch {
925
- return text;
926
- }
927
- }
928
- function errorMessage(body, fallback) {
929
- if (body && typeof body === "object") {
930
- const record = body;
931
- const message = record.error ?? record.message;
932
- if (typeof message === "string" && message.length > 0)
933
- return message;
934
- }
935
- if (typeof body === "string" && body.length > 0)
936
- return body;
937
- return fallback;
938
- }
939
- async function mapStreamsError(response) {
940
- const body = await responseBody(response);
941
- if (response.status === 401) {
942
- throw new AuthError(errorMessage(body, "API key invalid or expired."));
943
- }
944
- if (response.status === 429) {
945
- const retryAfter = response.headers.get("Retry-After") ?? undefined;
946
- throw new RateLimitError(errorMessage(body, "Rate limited. Try again later."), retryAfter);
947
- }
948
- if (response.status >= 500) {
949
- throw new StreamsServerError(errorMessage(body, `Streams server returned ${response.status}.`), response.status, body);
1138
+ return false;
950
1139
  }
951
- throw new ValidationError(errorMessage(body, `Streams request returned ${response.status}.`), response.status, body);
952
1140
  }
953
1141
  function createStreamsClient(options) {
954
1142
  const baseUrl = normalizeBaseUrl(resolveBaseUrl(options.baseUrl));
1143
+ const apiKey = resolveApiKey(options.apiKey);
1144
+ const origin = options.origin ?? "cli";
1145
+ const authHeaders = () => ({
1146
+ "x-sl-origin": origin,
1147
+ ...apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
1148
+ });
955
1149
  const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
956
1150
  const verify = options.verify;
957
1151
  const verifyMode = verify === false ? "off" : verify === undefined ? "lenient" : "strict";
1152
+ if (verify === true && baseUrl.startsWith("http:") && !isLoopbackUrl(baseUrl)) {
1153
+ throw new ValidationError(`verify: true over plain http to ${baseUrl} fetches the signing key from the same unprotected path it verifies. Pin the key with verify: { publicKey } (see GET /public/streams/signing-key), or use https.`, 400);
1154
+ }
958
1155
  let keyPromise = null;
959
1156
  function loadKey() {
960
1157
  if (keyPromise)
961
1158
  return keyPromise;
962
- keyPromise = (async () => {
1159
+ const pending = (async () => {
963
1160
  if (typeof verify === "object") {
964
1161
  return {
965
1162
  keyId: ed255192.ed25519KeyId(verify.publicKey),
@@ -967,8 +1164,16 @@ function createStreamsClient(options) {
967
1164
  publicKey: ed255192.loadEd25519PublicKey(verify.publicKey)
968
1165
  };
969
1166
  }
970
- const res = await fetchImpl(`${baseUrl}/public/streams/signing-key`);
1167
+ let res;
1168
+ try {
1169
+ res = await fetchImpl(`${baseUrl}/public/streams/signing-key`);
1170
+ } catch {
1171
+ throw new StreamsServerError(`Could not reach the signing key at ${baseUrl}/public/streams/signing-key. Retryable.`, 0, undefined, "SIGNING_KEY_UNAVAILABLE");
1172
+ }
971
1173
  if (!res.ok) {
1174
+ if (res.status >= 500 || res.status === 429) {
1175
+ throw new StreamsServerError(`Could not fetch signing key (${res.status}). Retryable.`, res.status, undefined, "SIGNING_KEY_UNAVAILABLE");
1176
+ }
972
1177
  throw new StreamsSignatureError(`Could not fetch signing key (${res.status}).`);
973
1178
  }
974
1179
  const body = await res.json();
@@ -981,7 +1186,26 @@ function createStreamsClient(options) {
981
1186
  publicKey: ed255192.loadEd25519PublicKey(body.public_key_pem)
982
1187
  };
983
1188
  })();
984
- return keyPromise;
1189
+ keyPromise = pending;
1190
+ pending.catch(() => {
1191
+ if (keyPromise === pending)
1192
+ keyPromise = null;
1193
+ });
1194
+ return pending;
1195
+ }
1196
+ async function loadKeyFor(responseKeyId) {
1197
+ let key = await loadKey();
1198
+ if (!responseKeyId || responseKeyId === key.keyId)
1199
+ return key;
1200
+ if (typeof verify === "object") {
1201
+ throw new StreamsSignatureError(`Response signed with key '${responseKeyId}', expected pinned key '${key.keyId}'.`);
1202
+ }
1203
+ keyPromise = null;
1204
+ key = await loadKey();
1205
+ if (responseKeyId !== key.keyId) {
1206
+ throw new StreamsSignatureError(`Response signed with key '${responseKeyId}' not served by the signing-key endpoint.`);
1207
+ }
1208
+ return key;
985
1209
  }
986
1210
  const dumps = createStreamsDumps({
987
1211
  baseUrl: options.dumpsBaseUrl,
@@ -991,7 +1215,7 @@ function createStreamsClient(options) {
991
1215
  });
992
1216
  async function request(path) {
993
1217
  const response = await fetchImpl(`${baseUrl}${path}`, {
994
- headers: options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}
1218
+ headers: authHeaders()
995
1219
  });
996
1220
  if (!response.ok)
997
1221
  await mapStreamsError(response);
@@ -1003,18 +1227,7 @@ function createStreamsClient(options) {
1003
1227
  throw new StreamsSignatureError("Response is missing X-Signature.");
1004
1228
  }
1005
1229
  } else {
1006
- const responseKeyId = response.headers.get("X-Signature-KeyId");
1007
- let key = await loadKey();
1008
- if (responseKeyId && responseKeyId !== key.keyId) {
1009
- if (typeof verify === "object") {
1010
- throw new StreamsSignatureError(`Response signed with key '${responseKeyId}', expected pinned key '${key.keyId}'.`);
1011
- }
1012
- keyPromise = null;
1013
- key = await loadKey();
1014
- if (responseKeyId !== key.keyId) {
1015
- throw new StreamsSignatureError(`Response signed with key '${responseKeyId}' not served by the signing-key endpoint.`);
1016
- }
1017
- }
1230
+ const key = await loadKeyFor(response.headers.get("X-Signature-KeyId"));
1018
1231
  if (!ed255192.verifyEd25519(text, signature, key.publicKey)) {
1019
1232
  throw new StreamsSignatureError;
1020
1233
  }
@@ -1023,6 +1236,13 @@ function createStreamsClient(options) {
1023
1236
  return JSON.parse(text);
1024
1237
  }
1025
1238
  const fetchEvents = listEvents;
1239
+ function listReorgs(params) {
1240
+ return request(`/v1/streams/reorgs${buildQuery({
1241
+ since: params.since,
1242
+ limit: params.limit
1243
+ })}`);
1244
+ }
1245
+ const fetchReorgs = (params) => listReorgs(params);
1026
1246
  function encodeFilters(filters) {
1027
1247
  return filters ? JSON.stringify(filters) : undefined;
1028
1248
  }
@@ -1070,8 +1290,10 @@ function createStreamsClient(options) {
1070
1290
  ...streamsFilters(params),
1071
1291
  batchSize: params.batchSize ?? 100,
1072
1292
  fetchEvents,
1293
+ fetchReorgs,
1073
1294
  onBatch,
1074
1295
  onReorg: params.onReorg,
1296
+ maxRollbackDepth: params.maxRollbackDepth,
1075
1297
  emptyBackoffMs: params.emptyBackoffMs,
1076
1298
  maxPages: params.maxPages,
1077
1299
  maxEmptyPolls: params.maxEmptyPolls,
@@ -1113,10 +1335,12 @@ function createStreamsClient(options) {
1113
1335
  subscribe(params) {
1114
1336
  return subscribeStreamsEvents({
1115
1337
  baseUrl,
1116
- apiKey: options.apiKey,
1338
+ headers: authHeaders(),
1117
1339
  fetchImpl,
1118
1340
  verify: verifyMode,
1119
- loadKey,
1341
+ loadKey: loadKeyFor,
1342
+ reconnectDelayMs: params.reconnectDelayMs,
1343
+ staleAfterMs: params.staleAfterMs,
1120
1344
  params
1121
1345
  });
1122
1346
  },
@@ -1124,11 +1348,11 @@ function createStreamsClient(options) {
1124
1348
  const fromCursor = params.from === "genesis" ? null : params.from ?? null;
1125
1349
  const fromBlock = fromCursor ? cursorTuple(fromCursor)[0] : 0;
1126
1350
  const manifest = await dumps.list();
1127
- const files = manifest.files.filter((file) => file.to_block >= fromBlock).sort((a, b) => a.from_block - b.from_block || a.to_block - b.to_block);
1351
+ const files = manifest.files.filter((file) => file.to_block >= fromBlock).filter((file) => fromCursor === null || file.max_cursor === null || maxCursor(fromCursor, file.max_cursor) !== fromCursor).sort((a, b) => a.from_block - b.from_block || a.to_block - b.to_block);
1128
1352
  for (const file of files) {
1129
1353
  if (params.signal?.aborted)
1130
1354
  break;
1131
- await params.onDumpFile(file);
1355
+ await params.onDumpFile(file, { from: fromCursor });
1132
1356
  }
1133
1357
  const seam = maxCursor(fromCursor, manifest.latest_finalized_cursor);
1134
1358
  return consumeStreamsEvents({
@@ -1153,14 +1377,7 @@ function createStreamsClient(options) {
1153
1377
  return request(`/v1/streams/blocks/${encodeURIComponent(String(heightOrHash))}/events`);
1154
1378
  }
1155
1379
  },
1156
- reorgs: {
1157
- list(params) {
1158
- return request(`/v1/streams/reorgs${buildQuery({
1159
- since: params.since,
1160
- limit: params.limit
1161
- })}`);
1162
- }
1163
- },
1380
+ reorgs: { list: listReorgs },
1164
1381
  dumps,
1165
1382
  canonical(height) {
1166
1383
  return request(`/v1/streams/canonical/${height}`);
@@ -1235,5 +1452,5 @@ export {
1235
1452
  AuthError
1236
1453
  };
1237
1454
 
1238
- //# debugId=3058A2F9A6830C7464756E2164756E21
1455
+ //# debugId=6013F1D9BD2F6AD764756E2164756E21
1239
1456
  //# sourceMappingURL=index.js.map