@secondlayer/sdk 9.0.2 → 10.1.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
+ });
222
256
  }
223
- async fetchResponse(method, path, body) {
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
+ }
281
+ }
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,47 +294,34 @@ 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`);
257
- }
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;
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 } : {});
277
323
  }
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
  }
@@ -290,8 +336,8 @@ import {
290
336
 
291
337
  // src/streams/errors.ts
292
338
  class StreamsServerError extends ApiError {
293
- constructor(message, status, body) {
294
- super(status, message, body, undefined, { retryable: true });
339
+ constructor(message, status, body, code) {
340
+ super(status, message, body, code, { retryable: true });
295
341
  this.name = "StreamsServerError";
296
342
  }
297
343
  }
@@ -302,13 +348,26 @@ class StreamsSignatureError extends SecondLayerError {
302
348
  this.name = "StreamsSignatureError";
303
349
  }
304
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
+ }
305
365
 
306
366
  // src/streams/cursor.ts
307
367
  var Cursor = {
308
368
  atHeight(height) {
309
- if (height <= 0) {
310
- return encodeStreamsCursor({ block_height: 0, event_index: 0 });
311
- }
369
+ if (height <= 0)
370
+ return null;
312
371
  return encodeStreamsCursor(blockEndCursor(height - 1));
313
372
  },
314
373
  parse(cursor) {
@@ -328,6 +387,39 @@ var Cursor = {
328
387
  function reorgKey(reorg) {
329
388
  return `${reorg.detected_at}|${reorg.fork_point_height}|${reorg.new_canonical_tip}`;
330
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
+ }
331
423
  function assertFinalizedCheckpoint(returned, checkpoint) {
332
424
  if (returned === null || returned === undefined)
333
425
  return;
@@ -407,13 +499,15 @@ async function defaultSleep(ms, signal) {
407
499
  if (signal?.aborted)
408
500
  return;
409
501
  await new Promise((resolve) => {
410
- const timeout = setTimeout(resolve, ms);
411
- if (!signal)
412
- return;
413
- signal.addEventListener("abort", () => {
502
+ const onAbort = () => {
414
503
  clearTimeout(timeout);
415
504
  resolve();
416
- }, { once: true });
505
+ };
506
+ const timeout = setTimeout(() => {
507
+ signal?.removeEventListener("abort", onAbort);
508
+ resolve();
509
+ }, ms);
510
+ signal?.addEventListener("abort", onAbort, { once: true });
417
511
  });
418
512
  }
419
513
  async function consumeStreamsEvents(opts) {
@@ -427,6 +521,7 @@ async function consumeStreamsEvents(opts) {
427
521
  const committedCursor = await opts.sink?.loadCursor() ?? null;
428
522
  let cursor = opts.fromCursor ?? committedCursor;
429
523
  const handledReorgs = new Set;
524
+ let reorgSince = null;
430
525
  let pages = 0;
431
526
  let emptyPolls = 0;
432
527
  let height = resumeHeight(cursor);
@@ -450,23 +545,57 @@ async function consumeStreamsEvents(opts) {
450
545
  signal: opts.signal
451
546
  });
452
547
  pages++;
453
- if (!finalizedOnly && (opts.onReorg || opts.sink)) {
454
- const fresh = envelope.reorgs.filter((reorg) => !handledReorgs.has(reorgKey(reorg))).sort((a, b) => a.fork_point_height - b.fork_point_height);
455
- if (fresh.length > 0) {
456
- const forkPoint = Math.min(...fresh.map((reorg) => reorg.fork_point_height));
457
- const rewind = Cursor.atHeight(forkPoint);
458
- for (const reorg of fresh) {
459
- await opts.sink?.rollback(reorg.fork_point_height, rewind);
460
- await opts.onReorg?.(reorg, { cursor: rewind });
461
- handledReorgs.add(reorgKey(reorg));
462
- }
463
- cursor = rewind;
464
- 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;
465
562
  scanned = height;
466
563
  emptyPolls = 0;
467
564
  continue;
468
565
  }
469
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
+ }
470
599
  const emitted = finalizedOnly ? envelope.events.filter((event) => event.finalized) : envelope.events;
471
600
  const checkpoint = finalizedOnly ? emitted.at(-1)?.cursor ?? cursor : envelope.next_cursor;
472
601
  height = emitted.at(-1)?.block_height ?? height;
@@ -744,15 +873,46 @@ function createStreamsDumps(opts) {
744
873
  return manifest;
745
874
  }
746
875
  async function download(file) {
876
+ return fetchPageWithRetry(() => downloadOnce(file), {
877
+ sleep: defaultSleep
878
+ });
879
+ }
880
+ async function downloadOnce(file) {
747
881
  const res = await opts.fetchImpl(fileUrl(file));
748
882
  if (!res.ok) {
749
883
  throw new StreamsServerError(`Could not download dump ${file.path} (${res.status}).`, res.status);
750
884
  }
751
- const bytes = new Uint8Array(await res.arrayBuffer());
752
- 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");
753
905
  if (digest !== file.sha256) {
754
906
  throw new StreamsSignatureError(`Dump ${file.path} sha256 mismatch (expected ${file.sha256}, got ${digest}).`);
755
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
+ }
756
916
  return bytes;
757
917
  }
758
918
  return { list, fileUrl, download };
@@ -760,6 +920,19 @@ function createStreamsDumps(opts) {
760
920
 
761
921
  // src/streams/subscribe.ts
762
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
+ }
763
936
  function subscribeStreamsEvents(opts) {
764
937
  const { params } = opts;
765
938
  const controller = new AbortController;
@@ -773,7 +946,9 @@ function subscribeStreamsEvents(opts) {
773
946
  });
774
947
  }
775
948
  let cursor = params.fromCursor ?? null;
776
- 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;
777
952
  const run = async () => {
778
953
  while (!controller.signal.aborted) {
779
954
  try {
@@ -787,55 +962,76 @@ function subscribeStreamsEvents(opts) {
787
962
  asset_identifier: params.assetIdentifier,
788
963
  filters: params.filters ? JSON.stringify(params.filters) : undefined
789
964
  })}`;
790
- const res = await opts.fetchImpl(url, {
791
- headers: {
792
- ...opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {},
793
- Accept: "text/event-stream"
794
- },
795
- signal: controller.signal
796
- });
797
- if (!res.ok) {
798
- throw new StreamsServerError(`Streams SSE returned ${res.status}.`, res.status);
799
- }
800
- if (!res.body) {
801
- throw new StreamsServerError("Streams SSE response has no body.", 0);
802
- }
803
- for await (const frame of parseSseFrames(res.body, controller.signal)) {
804
- if (frame.event === "ping" || !frame.data)
805
- continue;
806
- let parsed;
807
- try {
808
- parsed = JSON.parse(frame.data);
809
- } catch {
810
- continue;
811
- }
812
- if (!parsed.event)
813
- continue;
814
- if (opts.verify !== "off") {
815
- if (!parsed.sig) {
816
- if (opts.verify === "strict") {
817
- throw new StreamsSignatureError("Streams SSE frame signature is missing.");
818
- }
819
- } else {
820
- const key = await opts.loadKey();
821
- if (!ed25519.verifyEd25519(JSON.stringify(parsed.event), parsed.sig, key.publicKey)) {
822
- 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
+ }
823
994
  }
824
995
  }
996
+ await params.onEvent(parsed.event);
997
+ cursor = parsed.event.cursor ?? cursor;
825
998
  }
826
- cursor = parsed.event.cursor ?? cursor;
827
- await params.onEvent(parsed.event);
828
- }
999
+ });
1000
+ await sleep(reconnectDelay(attempt, baseDelayMs, undefined, opts.random), controller.signal);
1001
+ attempt += 1;
829
1002
  } catch (err) {
830
1003
  if (controller.signal.aborted)
831
1004
  return;
832
1005
  params.onError?.(err);
833
- 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;
834
1011
  }
835
1012
  }
836
1013
  };
837
- run();
838
- 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
+ }
839
1035
  }
840
1036
  function sleep(ms, signal) {
841
1037
  return new Promise((resolve) => {
@@ -852,13 +1048,27 @@ function sleep(ms, signal) {
852
1048
  signal.addEventListener("abort", onAbort, { once: true });
853
1049
  });
854
1050
  }
855
- 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) {
856
1066
  const reader = body.getReader();
857
1067
  const decoder = new TextDecoder;
858
1068
  let buffer = "";
859
1069
  try {
860
1070
  while (!signal.aborted) {
861
- const { value, done } = await reader.read();
1071
+ const { value, done } = await readWithStaleTimer(reader, staleAfterMs);
862
1072
  if (done)
863
1073
  break;
864
1074
  buffer += decoder.decode(value, { stream: true });
@@ -920,51 +1130,33 @@ function streamsFilters(params) {
920
1130
  function normalizeBaseUrl(baseUrl) {
921
1131
  return baseUrl.replace(/\/+$/, "");
922
1132
  }
923
- async function responseBody(response) {
924
- const text = await response.text();
925
- if (text.length === 0)
926
- return;
1133
+ function isLoopbackUrl(baseUrl) {
927
1134
  try {
928
- 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.");
929
1137
  } catch {
930
- return text;
931
- }
932
- }
933
- function errorMessage(body, fallback) {
934
- if (body && typeof body === "object") {
935
- const record = body;
936
- const message = record.error ?? record.message;
937
- if (typeof message === "string" && message.length > 0)
938
- return message;
939
- }
940
- if (typeof body === "string" && body.length > 0)
941
- return body;
942
- return fallback;
943
- }
944
- async function mapStreamsError(response) {
945
- const body = await responseBody(response);
946
- if (response.status === 401) {
947
- throw new AuthError(errorMessage(body, "API key invalid or expired."));
948
- }
949
- if (response.status === 429) {
950
- const retryAfter = response.headers.get("Retry-After") ?? undefined;
951
- throw new RateLimitError(errorMessage(body, "Rate limited. Try again later."), retryAfter);
1138
+ return false;
952
1139
  }
953
- if (response.status >= 500) {
954
- throw new StreamsServerError(errorMessage(body, `Streams server returned ${response.status}.`), response.status, body);
955
- }
956
- throw new ValidationError(errorMessage(body, `Streams request returned ${response.status}.`), response.status, body);
957
1140
  }
958
1141
  function createStreamsClient(options) {
959
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
+ });
960
1149
  const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
961
1150
  const verify = options.verify;
962
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
+ }
963
1155
  let keyPromise = null;
964
1156
  function loadKey() {
965
1157
  if (keyPromise)
966
1158
  return keyPromise;
967
- keyPromise = (async () => {
1159
+ const pending = (async () => {
968
1160
  if (typeof verify === "object") {
969
1161
  return {
970
1162
  keyId: ed255192.ed25519KeyId(verify.publicKey),
@@ -972,8 +1164,16 @@ function createStreamsClient(options) {
972
1164
  publicKey: ed255192.loadEd25519PublicKey(verify.publicKey)
973
1165
  };
974
1166
  }
975
- 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
+ }
976
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
+ }
977
1177
  throw new StreamsSignatureError(`Could not fetch signing key (${res.status}).`);
978
1178
  }
979
1179
  const body = await res.json();
@@ -986,7 +1186,26 @@ function createStreamsClient(options) {
986
1186
  publicKey: ed255192.loadEd25519PublicKey(body.public_key_pem)
987
1187
  };
988
1188
  })();
989
- 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;
990
1209
  }
991
1210
  const dumps = createStreamsDumps({
992
1211
  baseUrl: options.dumpsBaseUrl,
@@ -996,7 +1215,7 @@ function createStreamsClient(options) {
996
1215
  });
997
1216
  async function request(path) {
998
1217
  const response = await fetchImpl(`${baseUrl}${path}`, {
999
- headers: options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}
1218
+ headers: authHeaders()
1000
1219
  });
1001
1220
  if (!response.ok)
1002
1221
  await mapStreamsError(response);
@@ -1008,18 +1227,7 @@ function createStreamsClient(options) {
1008
1227
  throw new StreamsSignatureError("Response is missing X-Signature.");
1009
1228
  }
1010
1229
  } else {
1011
- const responseKeyId = response.headers.get("X-Signature-KeyId");
1012
- let key = await loadKey();
1013
- if (responseKeyId && responseKeyId !== key.keyId) {
1014
- if (typeof verify === "object") {
1015
- throw new StreamsSignatureError(`Response signed with key '${responseKeyId}', expected pinned key '${key.keyId}'.`);
1016
- }
1017
- keyPromise = null;
1018
- key = await loadKey();
1019
- if (responseKeyId !== key.keyId) {
1020
- throw new StreamsSignatureError(`Response signed with key '${responseKeyId}' not served by the signing-key endpoint.`);
1021
- }
1022
- }
1230
+ const key = await loadKeyFor(response.headers.get("X-Signature-KeyId"));
1023
1231
  if (!ed255192.verifyEd25519(text, signature, key.publicKey)) {
1024
1232
  throw new StreamsSignatureError;
1025
1233
  }
@@ -1028,6 +1236,13 @@ function createStreamsClient(options) {
1028
1236
  return JSON.parse(text);
1029
1237
  }
1030
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);
1031
1246
  function encodeFilters(filters) {
1032
1247
  return filters ? JSON.stringify(filters) : undefined;
1033
1248
  }
@@ -1075,8 +1290,10 @@ function createStreamsClient(options) {
1075
1290
  ...streamsFilters(params),
1076
1291
  batchSize: params.batchSize ?? 100,
1077
1292
  fetchEvents,
1293
+ fetchReorgs,
1078
1294
  onBatch,
1079
1295
  onReorg: params.onReorg,
1296
+ maxRollbackDepth: params.maxRollbackDepth,
1080
1297
  emptyBackoffMs: params.emptyBackoffMs,
1081
1298
  maxPages: params.maxPages,
1082
1299
  maxEmptyPolls: params.maxEmptyPolls,
@@ -1118,10 +1335,12 @@ function createStreamsClient(options) {
1118
1335
  subscribe(params) {
1119
1336
  return subscribeStreamsEvents({
1120
1337
  baseUrl,
1121
- apiKey: options.apiKey,
1338
+ headers: authHeaders(),
1122
1339
  fetchImpl,
1123
1340
  verify: verifyMode,
1124
- loadKey,
1341
+ loadKey: loadKeyFor,
1342
+ reconnectDelayMs: params.reconnectDelayMs,
1343
+ staleAfterMs: params.staleAfterMs,
1125
1344
  params
1126
1345
  });
1127
1346
  },
@@ -1129,11 +1348,11 @@ function createStreamsClient(options) {
1129
1348
  const fromCursor = params.from === "genesis" ? null : params.from ?? null;
1130
1349
  const fromBlock = fromCursor ? cursorTuple(fromCursor)[0] : 0;
1131
1350
  const manifest = await dumps.list();
1132
- 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);
1133
1352
  for (const file of files) {
1134
1353
  if (params.signal?.aborted)
1135
1354
  break;
1136
- await params.onDumpFile(file);
1355
+ await params.onDumpFile(file, { from: fromCursor });
1137
1356
  }
1138
1357
  const seam = maxCursor(fromCursor, manifest.latest_finalized_cursor);
1139
1358
  return consumeStreamsEvents({
@@ -1158,14 +1377,7 @@ function createStreamsClient(options) {
1158
1377
  return request(`/v1/streams/blocks/${encodeURIComponent(String(heightOrHash))}/events`);
1159
1378
  }
1160
1379
  },
1161
- reorgs: {
1162
- list(params) {
1163
- return request(`/v1/streams/reorgs${buildQuery({
1164
- since: params.since,
1165
- limit: params.limit
1166
- })}`);
1167
- }
1168
- },
1380
+ reorgs: { list: listReorgs },
1169
1381
  dumps,
1170
1382
  canonical(height) {
1171
1383
  return request(`/v1/streams/canonical/${height}`);
@@ -1240,5 +1452,5 @@ export {
1240
1452
  AuthError
1241
1453
  };
1242
1454
 
1243
- //# debugId=F75F046797AE6F7A64756E2164756E21
1455
+ //# debugId=6013F1D9BD2F6AD764756E2164756E21
1244
1456
  //# sourceMappingURL=index.js.map