@johannc-dev/zyrk 0.2.0 → 0.2.1

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.
@@ -299,25 +299,25 @@
299
299
  const summary = (r) => freeze({ key: r.key, size: r.size, contentType: r.contentType, lastModified: r.lastModified, expiresAt: r.expiresAt });
300
300
  const storageArea = (area) => {
301
301
  const read = async (key, offset, length) => {
302
- const first = await call({ StorageGet: { area, key, offset, length: length > CHUNK ? CHUNK : length } });
303
- if (first === null) return null;
304
- const end = Math.min(first.size, offset + length);
305
- const total = Math.max(0, end - first.offset);
302
+ const first2 = await call({ StorageGet: { area, key, offset, length: length > CHUNK ? CHUNK : length } });
303
+ if (first2 === null) return null;
304
+ const end = Math.min(first2.size, offset + length);
305
+ const total = Math.max(0, end - first2.offset);
306
306
  const out = new U8(total);
307
307
  let at = 0;
308
- let r = first;
308
+ let r = first2;
309
309
  for (; ; ) {
310
310
  const n = N.b64decodeInto(r.data, out, at);
311
311
  if (n < 0) throw fail("unavailable", "Corrupt storage chunk");
312
312
  at += n;
313
313
  if (at >= total || n === 0) break;
314
314
  r = await call({
315
- StorageGet: { area, key, offset: first.offset + at, length: Math.min(CHUNK, total - at) }
315
+ StorageGet: { area, key, offset: first2.offset + at, length: Math.min(CHUNK, total - at) }
316
316
  });
317
- if (r === null || r.etag !== first.etag)
317
+ if (r === null || r.etag !== first2.etag)
318
318
  throw fail("value_changed", "The object changed while it was read: read it again");
319
319
  }
320
- return { first, body: at === total ? out : out.subarray(0, at) };
320
+ return { first: first2, body: at === total ? out : out.subarray(0, at) };
321
321
  };
322
322
  const get = async (key, range) => {
323
323
  keyOf(key);
@@ -449,7 +449,7 @@
449
449
  version: execution.version,
450
450
  attempt: execution.attempt
451
451
  }),
452
- data: freeze({
452
+ data: batching ? batchedData() : freeze({
453
453
  walletTrades: trades("wallet"),
454
454
  tokenTrades: trades("token"),
455
455
  tokenTotals: async (mint, o) => {
@@ -515,5 +515,167 @@
515
515
  return text === void 0 ? "null" : text;
516
516
  })();
517
517
  };
518
+ const batching = N.batchReads === true;
519
+ const BATCH = 64;
520
+ const MAX_REENTRY = 1e3;
521
+ const reflectApply = Reflect.apply;
522
+ const promiseThen = PromiseCtor.prototype.then;
523
+ const ready = PromiseCtor.resolve();
524
+ Object.defineProperty(ready, "constructor", { value: void 0 });
525
+ let open = false;
526
+ let direct = false;
527
+ let reentry = 0;
528
+ let first = null;
529
+ let last = null;
530
+ let epoch = 0;
531
+ const wait = (run, hint) => new PromiseCtor((resolve, reject) => {
532
+ const q = { run, hint, next: null, resolve, reject };
533
+ if (last === null) first = q;
534
+ else last.next = q;
535
+ last = q;
536
+ });
537
+ const checkpoint = () => {
538
+ let q = first;
539
+ first = null;
540
+ last = null;
541
+ direct = reentry >= MAX_REENTRY;
542
+ while (q !== null) {
543
+ epoch++;
544
+ let n = 0;
545
+ let hinted = 0;
546
+ for (let r = q; r !== null && n < BATCH; r = r.next) {
547
+ n++;
548
+ if (r.hint !== null && r.hint(epoch)) hinted++;
549
+ }
550
+ if (hinted > 0) N.hint();
551
+ for (; n > 0; n--) {
552
+ const c = q;
553
+ q = c.next;
554
+ let value;
555
+ try {
556
+ value = c.run();
557
+ } catch (e) {
558
+ c.reject(e);
559
+ continue;
560
+ }
561
+ c.resolve(value);
562
+ }
563
+ }
564
+ direct = false;
565
+ if (first === null) {
566
+ reentry = 0;
567
+ open = false;
568
+ } else {
569
+ reentry++;
570
+ reflectApply(promiseThen, ready, [checkpoint]);
571
+ }
572
+ };
573
+ const openBatch = () => {
574
+ open = true;
575
+ reflectApply(promiseThen, ready, [checkpoint]);
576
+ };
577
+ const waiting = () => open && !direct;
578
+ const tradesBatched = (kind) => (key, w) => {
579
+ if (typeof key !== "string") throw fail("invalid_argument", "Expected an address string");
580
+ const { since, until, side, allowPartial, withSignatures, withMarket, withEvents, withOrder, withTx } = checkWindow(w);
581
+ return freeze({
582
+ [asyncIterator]() {
583
+ let after = null;
584
+ let done = false;
585
+ let closed = false;
586
+ let mark = 0;
587
+ const end = () => ({ done: true, value: void 0 });
588
+ const read = () => {
589
+ if (done) return end();
590
+ const batch = N.read(kind, key, since, until, side, allowPartial, withSignatures, after, withMarket, withEvents, withOrder, withTx);
591
+ if (batch === null) {
592
+ done = true;
593
+ return end();
594
+ }
595
+ after = batch.hour;
596
+ return { done: false, value: batch };
597
+ };
598
+ const hint = (e) => {
599
+ if (done || mark === e) return false;
600
+ mark = e;
601
+ N.hint(kind, key, since, until, side, allowPartial, withSignatures, after, withMarket, withEvents, withOrder, withTx);
602
+ return true;
603
+ };
604
+ const nextNow = async () => {
605
+ if (done || closed) return end();
606
+ if (!direct) openBatch();
607
+ return read();
608
+ };
609
+ const returnNow = async (value) => {
610
+ closed = true;
611
+ return { done: true, value };
612
+ };
613
+ return {
614
+ next() {
615
+ if (!waiting()) return nextNow();
616
+ if (closed) return wait(end, null);
617
+ return wait(read, hint);
618
+ },
619
+ return(value) {
620
+ if (!waiting()) return returnNow(value);
621
+ closed = true;
622
+ return wait(() => ({ done: true, value }), null);
623
+ },
624
+ [asyncIterator]() {
625
+ return this;
626
+ }
627
+ };
628
+ }
629
+ });
630
+ };
631
+ const totalsNow = async (mint, o) => {
632
+ if (typeof mint !== "string") throw fail("invalid_argument", "Expected a mint address");
633
+ const rawQuote = o ? o.quote : void 0;
634
+ const quote = rawQuote !== void 0 ? StringCtor(rawQuote) : null;
635
+ return N.totals(mint, quote);
636
+ };
637
+ const infoNow = async (mint) => {
638
+ if (typeof mint !== "string") throw fail("invalid_argument", "Expected a mint address");
639
+ return N.info(mint);
640
+ };
641
+ const batchedData = () => freeze({
642
+ walletTrades: tradesBatched("wallet"),
643
+ tokenTrades: tradesBatched("token"),
644
+ tokenTotals: (mint, o) => {
645
+ if (!waiting()) return totalsNow(mint, o);
646
+ let quote;
647
+ try {
648
+ if (typeof mint !== "string") throw fail("invalid_argument", "Expected a mint address");
649
+ const rawQuote = o ? o.quote : void 0;
650
+ quote = rawQuote !== void 0 ? StringCtor(rawQuote) : null;
651
+ } catch (e) {
652
+ return PromiseCtor.reject(e);
653
+ }
654
+ return wait(() => N.totals(mint, quote), null);
655
+ },
656
+ tokenInfo: (mint) => {
657
+ if (!waiting()) return infoNow(mint);
658
+ if (typeof mint !== "string") return PromiseCtor.reject(fail("invalid_argument", "Expected a mint address"));
659
+ return wait(() => N.info(mint), null);
660
+ },
661
+ volumeSearch: async function* (params) {
662
+ let p = { ...params };
663
+ for (; ; ) {
664
+ let page;
665
+ if (waiting()) {
666
+ const text = flatParams(p);
667
+ page = await wait(() => N.volume(text), null);
668
+ } else page = N.volume(flatParams(p));
669
+ yield page;
670
+ const next = page === null || typeof page !== "object" ? null : page.nextCursor;
671
+ if (next === null || next === void 0) return;
672
+ if (typeof next !== "string" || next === p.cursor)
673
+ throw fail("history_unavailable", "Invalid next cursor");
674
+ const w = page.window;
675
+ if (w && typeof w === "object" && "since" in w) p = { ...p, since: w.since, until: w.until };
676
+ p = { ...p, cursor: next };
677
+ }
678
+ }
679
+ });
518
680
  return freeze({ start, describe });
519
681
  });
package/dist/sdk.d.ts CHANGED
@@ -278,7 +278,12 @@ export declare const MarketEvent: {
278
278
  export declare const MarketFlag: {
279
279
  /** `tokenReserve` and `quoteReserve` are set. */
280
280
  readonly reserves: 1;
281
- /** `virtualTokenReserve` and `virtualQuoteReserve` are set. */
281
+ /**
282
+ * `virtualTokenReserve` and `virtualQuoteReserve` are both set (always
283
+ * with {@link MarketFlag.virtualTokenReserve} and
284
+ * {@link MarketFlag.virtualQuoteReserve}). PumpSwap trades usually carry
285
+ * one side only: test the side you need instead.
286
+ */
282
287
  readonly virtualReserves: 2;
283
288
  /**
284
289
  * The provider row holds several operations on this pool (e.g. a buy and
@@ -315,6 +320,14 @@ export declare const MarketFlag: {
315
320
  * trader receives 0.30 % less than `tokenRaw`. `chainBreak` accounts for it.
316
321
  */
317
322
  readonly invertedPool: 512;
323
+ /** `virtualTokenReserve` is set. */
324
+ readonly virtualTokenReserve: 1024;
325
+ /**
326
+ * `virtualQuoteReserve` is set. On PumpSwap (pools created by pump.fun)
327
+ * it is added to the real reserve: the effective quote reserve is
328
+ * `quoteReserve + virtualQuoteReserve`.
329
+ */
330
+ readonly virtualQuoteReserve: 2048;
318
331
  };
319
332
  /**
320
333
  * Pool state and fees of each trade (`withMarket: true`), entry `i` for
@@ -328,9 +341,12 @@ export interface MarketColumns {
328
341
  readonly tokenReserve: BigUint64Array;
329
342
  readonly quoteReserve: BigUint64Array;
330
343
  /**
331
- * Pumpfun: the virtual reserves of the bonding curve, the ones its
332
- * constant product uses. Pumpswap: the provider's virtual reserves, when
333
- * it gives them.
344
+ * Pumpfun: the total virtual reserves of the bonding curve, the ones its
345
+ * constant product uses. Pumpswap: a virtual amount added to the real
346
+ * reserve (effective quote reserve = `quoteReserve + virtualQuoteReserve`),
347
+ * usually on one side only. Each side is set when its flag is
348
+ * ({@link MarketFlag.virtualTokenReserve},
349
+ * {@link MarketFlag.virtualQuoteReserve}), `0` otherwise.
334
350
  */
335
351
  readonly virtualTokenReserve: BigUint64Array;
336
352
  readonly virtualQuoteReserve: BigUint64Array;
@@ -352,11 +368,11 @@ export interface MarketColumns {
352
368
  readonly event: Uint8Array;
353
369
  }
354
370
  /**
355
- * Latest known token metadata (`/v1/tokens/{mint}/info`, also `metadata` of
371
+ * Latest known token metadata (`ctx.data.tokenInfo`, also `metadata` of
356
372
  * `tokenTotals`): provider values copied verbatim from the newest row of the
357
373
  * mint that is cheap to reach, `null` when the row lacks them. `name`,
358
- * `symbol` and `uri` of stored hours come from the hour's `tokens.t2` (the
359
- * newest hour that has them), cut at 128, 64 and 512 UTF-8 bytes.
374
+ * `symbol` and `uri` of sealed hours come from the token index of the newest
375
+ * sealed hour that has them, cut at 128, 64 and 512 UTF-8 bytes.
360
376
  */
361
377
  export type TokenMetadata = {
362
378
  readonly name: string | null;
@@ -393,8 +409,8 @@ export type TokenInfo = {
393
409
  readonly metadata: TokenMetadata | null;
394
410
  };
395
411
  /**
396
- * Parameters of `/v1/volume/search`, with the same names and units
397
- * (`since`/`until` in **seconds**, multiples of 60). Paging is handled by
412
+ * Parameters of `ctx.data.volumeSearch` (`since`/`until` in **seconds**,
413
+ * multiples of 60, `[since, until)`, at most 31 days). Paging is handled by
398
414
  * `ctx.data.volumeSearch`: there is no `cursor`.
399
415
  */
400
416
  export interface VolumeSearchParams {
@@ -501,9 +517,9 @@ export interface Subscription {
501
517
  readonly maxReplayEvents?: number;
502
518
  }
503
519
  /**
504
- * A trade as published by the live feed, in the JSON shape of the public API
505
- * (`/v1/tokens/{mint}/operations`). Only the fields most programs read are
506
- * typed; the others are kept as they come.
520
+ * A trade as published by the live feed (the `operation` of a `trade` or
521
+ * `volume` event), with `id` the trade id `"hour:line:op"`. Only the fields
522
+ * most programs read are typed; the others are kept as they come.
507
523
  */
508
524
  export interface MarketOperation {
509
525
  readonly id: string;
@@ -795,7 +811,7 @@ export interface ExecutionInfo {
795
811
  /** 1 for the first attempt. */
796
812
  readonly attempt: number;
797
813
  }
798
- /** Lakebed's `StoragePutOptions` (every field optional here). */
814
+ /** Options of `put` (every field optional). */
799
815
  export interface StoragePutOptions {
800
816
  /** `type/subtype`; parameters are dropped. Default: `text/plain` for a string, else `application/octet-stream`. */
801
817
  readonly contentType?: string;
@@ -809,7 +825,7 @@ export interface StoragePutOptions {
809
825
  */
810
826
  readonly ifMatch?: string;
811
827
  }
812
- /** Lakebed's `StorageObjectHead`, plus where the object stands in time. */
828
+ /** The head of a stored object, with where it stands in time. */
813
829
  export interface StorageObjectHead {
814
830
  readonly key: string;
815
831
  readonly size: number;
@@ -824,11 +840,11 @@ export interface StorageObjectHead {
824
840
  /** Changes at every write (HTTP `ETag`). */
825
841
  readonly etag: string;
826
842
  }
827
- /** Lakebed's `StorageReadResult`. With a range, `body` is that range and `size` the whole object's. */
843
+ /** What `get` answers. With a range, `body` is that range and `size` the whole object's. */
828
844
  export interface StorageReadResult extends StorageObjectHead {
829
845
  readonly body: Uint8Array;
830
846
  }
831
- /** Lakebed's `StorageObjectSummary` (what `list` answers), plus expiry. */
847
+ /** One object of a `list` answer, with its expiry. */
832
848
  export interface StorageObjectSummary {
833
849
  readonly key: string;
834
850
  readonly size: number;
@@ -837,14 +853,14 @@ export interface StorageObjectSummary {
837
853
  readonly expiresAt: number | null;
838
854
  }
839
855
  /**
840
- * What `list` answers: Lakebed's array of summaries, plus `cursor` (pass it
856
+ * What `list` answers: an array of summaries, plus `cursor` (pass it
841
857
  * as `after` for the next page, `null` at the end) and `truncated`.
842
858
  */
843
859
  export type StorageListPage = StorageObjectSummary[] & {
844
860
  readonly cursor: string | null;
845
861
  readonly truncated: boolean;
846
862
  };
847
- /** Lakebed's `StorageLedgerUsage` for one area of the account, with its limits. */
863
+ /** Usage of one storage area of the account, with its limits. */
848
864
  export interface StorageUsage {
849
865
  readonly area: "short" | "long" | "durable";
850
866
  readonly initialized: boolean;
@@ -886,9 +902,9 @@ export interface StorageWriter {
886
902
  * execution fails afterwards.
887
903
  */
888
904
  export interface StorageArea {
889
- /** Lakebed's `put`. Answers the new head. */
905
+ /** Writes an object. Answers the new head. */
890
906
  put(key: string, body: StorageBody, options?: StoragePutOptions): Promise<StorageObjectHead>;
891
- /** Lakebed's `get`; `range` reads part of a large object. `null` when absent or expired. */
907
+ /** Reads an object; `range` reads part of a large object. `null` when absent or expired. */
892
908
  get(key: string, range?: {
893
909
  offset?: number;
894
910
  length?: number;
@@ -899,7 +915,7 @@ export interface StorageArea {
899
915
  ifMatch?: string;
900
916
  }): Promise<void>;
901
917
  /**
902
- * Lakebed's `list`: keys starting with `prefix`, in byte order, at most
918
+ * Lists keys starting with `prefix`, in byte order, at most
903
919
  * `maxObjects` (1000). For the next page, pass the last key as `after`.
904
920
  */
905
921
  list(prefix?: string, options?: {
@@ -924,16 +940,23 @@ export interface Context {
924
940
  walletTrades(wallet: Address, query: TradeQuery): AsyncIterable<HourBatch>;
925
941
  /** Trades of a token, one hour per batch, oldest first. */
926
942
  tokenTrades(mint: Address, query: TradeQuery): AsyncIterable<HourBatch>;
927
- /** Same JSON as `/v1/tokens/{mint}/totals` (with `metadata`). */
943
+ /**
944
+ * Volume of a token since its creation (`cumulativeRaw`, `buyRaw`,
945
+ * `sellRaw`, `buys`, `sells`, `sinceCreation.complete`, `coverage`), its
946
+ * `creation` and `metadata`, as JSON.
947
+ */
928
948
  tokenTotals(mint: Address, options?: {
929
949
  quote?: Address;
930
950
  }): Promise<Json>;
931
951
  /**
932
- * Same JSON as `/v1/tokens/{mint}/info`: latest known metadata and the
933
- * creation, at most two blocks read (no volumes).
952
+ * Latest known metadata and the creation, at most two blocks read (no
953
+ * volumes).
934
954
  */
935
955
  tokenInfo(mint: Address): Promise<TokenInfo>;
936
- /** Pages of `/v1/volume/search`, in its JSON shape. */
956
+ /**
957
+ * Tokens ranked by volume over a window, page by page (`items` of
958
+ * `{mint, volume, buyVolume, sellVolume, buys, sells, volumeRaw}`).
959
+ */
937
960
  volumeSearch(params: VolumeSearchParams): AsyncIterable<Json>;
938
961
  };
939
962
  /** Private JSON state of the program, kept between executions. */
@@ -1047,7 +1070,7 @@ export declare function parseTradeId(id: string): TradeKey;
1047
1070
  export declare function compareTradeIds(a: string, b: string): number;
1048
1071
  /**
1049
1072
  * Id of a live-feed operation, in the {@link tradeId} format (`operation.id`
1050
- * as delivered by funrun, else `order.ord`); `null` without order.
1073
+ * as delivered to subscriptions, else `order.ord`); `null` without order.
1051
1074
  */
1052
1075
  export declare function operationTradeId(operation: MarketOperation): string | null;
1053
1076
  /**
@@ -1115,9 +1138,9 @@ export declare function tradeAt(batch: HourBatch, i: number): Trade;
1115
1138
  * `error.code`; `message` is for humans and may change.
1116
1139
  */
1117
1140
  export type SdkErrorCode = "invalid_window" | "partial_coverage" | "history_preparing" | "storage_busy" | "history_unavailable" | "read_limit" | "read_time_limit" | "invalid_argument" | "unknown_function" | "execution_not_found" | "state_value_too_large" | "state_limit" | "children_limit" | "subscription_limit" | "price_subscription_limit" | "output_limit" | StorageErrorCode | (string & {});
1118
- /** Lakebed's `StorageErrorCode`, plus `already_exists`, `value_changed`, `io_limit`, `upload_limit`, `precondition_failed`. */
1141
+ /** Error codes of user storage (`ctx.storage` and the HTTP storage routes). */
1119
1142
  export type StorageErrorCode = "quota_exceeded" | "too_large" | "not_found" | "invalid_request" | "forbidden" | "unavailable" | "blocked_type" | "already_exists" | "value_changed" | "io_limit" | "upload_limit" | "precondition_failed";
1120
- /** HTTP status of each storage code (Lakebed's for its seven). */
1143
+ /** HTTP status of each storage code. */
1121
1144
  export declare const STORAGE_STATUS_BY_CODE: Readonly<Record<StorageErrorCode, number>>;
1122
1145
  /** Error thrown by a `ctx` call. `retryAfterMs` comes with `storage_busy`/`history_preparing`. */
1123
1146
  export interface SdkError extends Error {
package/dist/sdk.js CHANGED
@@ -47,6 +47,8 @@ export const MarketFlag = {
47
47
  mayhem: 128,
48
48
  feeWithoutToken: 256,
49
49
  invertedPool: 512,
50
+ virtualTokenReserve: 1024,
51
+ virtualQuoteReserve: 2048,
50
52
  };
51
53
  export function isFinal(result) {
52
54
  return result.status !== "queued" && result.status !== "running";
package/dist/stream.d.ts CHANGED
@@ -15,18 +15,66 @@ export interface StreamOptions {
15
15
  readonly apiKey: string;
16
16
  /** Resume after this sequence number. */
17
17
  readonly afterSeq?: number;
18
+ /**
19
+ * How long the server must keep refusing the key, with no successful join
20
+ * in between, before the stream ends with `invalid_key` (or `unauthorized`).
21
+ * Default 360 000 ms (6 min). See {@link executionEvents}: below the
22
+ * server's longest key sync delay, a key created just before the stream
23
+ * started could end it.
24
+ */
25
+ readonly keyRefusalWindowMs?: number;
18
26
  }
19
27
  /**
20
- * Live execution events of the account. Gaps are emitted as values; the
21
- * stream fails on undecodable payloads or when the server refuses the join.
22
- * The socket is closed when the stream is interrupted or ends.
28
+ * Live execution events of the account. Gaps are emitted as values.
29
+ *
30
+ * The stream reconnects by itself after a dropped or closed connection (no
31
+ * network, a server restart or deploy, a connection the server closed) and
32
+ * resumes after the last event. It fails, once, with:
33
+ * - a {@link DecodeError} on an undecodable payload;
34
+ * - a {@link NetworkError} when the server refuses the join, with the
35
+ * server's `reason` (`functions_disabled`, `functions_unavailable`);
36
+ * - a {@link NetworkError} whose `reason` is `functions_disabled` (Functions
37
+ * access removed) or `channel_closed` (the server closed the channel:
38
+ * subscribe again with `afterSeq`);
39
+ * - a {@link NetworkError} whose `reason` is `invalid_key` (the key was
40
+ * revoked, replaced or is unknown, or its account is not active) or
41
+ * `unauthorized` (the same, seen at the join): the server kept refusing
42
+ * the key for {@link StreamOptions.keyRefusalWindowMs} (default 6 min)
43
+ * with no successful join in between. A single refusal never ends the
44
+ * stream: a key created or changed less than a key sync ago, or a server
45
+ * whose key data is briefly behind, is refused too; the stream retries
46
+ * (every 5 s at most) and goes on as soon as a join succeeds. A refusal
47
+ * that the server does not name (a server starting, a network failure,
48
+ * the connection limit) is retried with no time limit;
49
+ * - a {@link NetworkError} whose `reason` is `account_changed`: a
50
+ * reconnection joined another account than the first join (the key was
51
+ * moved to another account). No event of the other account is delivered.
52
+ * This check needs a server that names the account in its join reply: a
53
+ * join answered without `account` (an older server, e.g. during a rollout
54
+ * or rollback) is accepted unchecked.
55
+ *
56
+ * A transient `status` `{available: false}` does not end it. An older
57
+ * server that does not say why it refused a connection gives no
58
+ * `invalid_key`: the stream then keeps reconnecting, as before. The socket
59
+ * is closed and no reconnection happens once the stream is interrupted or
60
+ * has failed.
23
61
  */
24
62
  export declare const executionEvents: (options: StreamOptions) => Stream.Stream<FunctionsMessage, StreamError>;
25
- /** Callback API kept for existing users; errors are tagged `StreamError`s or raw socket errors. */
63
+ /**
64
+ * Callback API kept for existing users; errors are tagged `StreamError`s or
65
+ * raw socket errors (transient: it reconnects by itself). While the server
66
+ * refuses the key (see {@link executionEvents}) nothing is reported: it
67
+ * retries silently, and reports once if the refusal lasts the whole window.
68
+ * After a `NetworkError` (`functions_disabled`, `functions_unavailable`,
69
+ * `channel_closed`, `invalid_key`, `unauthorized`, `account_changed`) the
70
+ * socket is closed and nothing more arrives.
71
+ */
26
72
  export declare function subscribeFunctions(options: {
27
73
  url: string;
28
74
  apiKey: string;
29
75
  afterSeq?: number;
76
+ /** See {@link StreamOptions.keyRefusalWindowMs}. */
77
+ keyRefusalWindowMs?: number;
30
78
  onEvent: (event: ExecutionEvent) => void;
31
79
  onGap?: (gap: Gap) => void;
32
80
  onError?: (error: unknown) => void;
package/dist/stream.js CHANGED
@@ -6,19 +6,33 @@ export { ExecutionEvent, Gap } from "./contracts.js";
6
6
  const decoder = (schema, what) => decodeWith(schema, undefined, (message) => new DecodeError({ what, message }));
7
7
  const decodeEvent = decoder(ExecutionEvent, "execution event");
8
8
  const decodeGap = decoder(Gap, "stream gap");
9
+ const refusedKey = 4401;
10
+ const defaultKeyRefusalWindowMs = 360_000;
11
+ const reconnectDelay = (closes) => Math.min(5_000, 500 * 2 ** closes) * (0.5 + Math.random() / 2);
9
12
  function connect(options, handlers) {
10
13
  let cursor = options.afterSeq;
14
+ const window = options.keyRefusalWindowMs ?? defaultKeyRefusalWindowMs;
11
15
  const endpoint = new URL("/v1/stream", options.url);
12
16
  endpoint.protocol =
13
17
  endpoint.protocol === "https:" || endpoint.protocol === "wss:"
14
18
  ? "wss:"
15
19
  : "ws:";
16
20
  const socket = new Socket(endpoint.toString(), {
17
- params: { api_key: options.apiKey },
21
+ params: { api_key: options.apiKey, on_refusal: "close" },
18
22
  transport: WebSocket,
19
23
  });
20
24
  const channel = socket.channel("functions", () => cursor === undefined ? {} : { after_seq: cursor });
21
- channel.on("execution", (raw) => {
25
+ let account;
26
+ let checked = false;
27
+ let held = [];
28
+ const gated = (handle) => (raw) => {
29
+ if (closed)
30
+ return;
31
+ if (!checked)
32
+ return void held.push(() => handle(raw));
33
+ handle(raw);
34
+ };
35
+ channel.on("execution", gated((raw) => {
22
36
  const result = decodeEvent(raw);
23
37
  if (Result.isFailure(result))
24
38
  return handlers.fail(result.failure);
@@ -27,26 +41,110 @@ function connect(options, handlers) {
27
41
  return;
28
42
  cursor = event.seq;
29
43
  handlers.message({ _tag: "Execution", event });
30
- });
31
- channel.on("gap", (raw) => {
44
+ }));
45
+ channel.on("gap", gated((raw) => {
32
46
  const result = decodeGap(raw);
33
47
  if (Result.isFailure(result))
34
48
  return handlers.fail(result.failure);
35
49
  cursor = result.success.resumeAfter;
36
50
  handlers.message({ _tag: "Gap", gap: result.success });
51
+ }));
52
+ let closed = false;
53
+ let timer;
54
+ const stop = () => {
55
+ closed = true;
56
+ held = [];
57
+ clearTimeout(timer);
58
+ channel.leave();
59
+ socket.disconnect();
60
+ };
61
+ const fail = (message, cause, reason) => {
62
+ if (closed)
63
+ return;
64
+ stop();
65
+ handlers.fail(new NetworkError({
66
+ url: endpoint.toString(),
67
+ message,
68
+ cause,
69
+ ...(reason === undefined ? {} : { reason }),
70
+ }));
71
+ };
72
+ channel.on("status", gated((raw) => {
73
+ const status = raw;
74
+ if (status?.available === false && status.reason === "functions_disabled")
75
+ fail("Functions access was removed from this account: the server closed the stream", raw, "functions_disabled");
76
+ }));
77
+ channel.onClose((cause) => fail(`The server closed the stream: subscribe again with afterSeq ${cursor ?? "(unset)"}`, cause, "channel_closed"));
78
+ let closes = 0;
79
+ let pending = false;
80
+ const reconnect = () => {
81
+ if (pending)
82
+ return;
83
+ pending = true;
84
+ socket.disconnect();
85
+ timer = setTimeout(() => {
86
+ pending = false;
87
+ socket.connect();
88
+ }, reconnectDelay(closes++));
89
+ };
90
+ let refusedSince;
91
+ const refused = (reason, message, cause) => {
92
+ const now = Date.now();
93
+ refusedSince ??= now;
94
+ if (now - refusedSince >= window)
95
+ return fail(message, cause, reason);
96
+ reconnect();
97
+ };
98
+ socket.onClose((event) => {
99
+ if (closed || pending)
100
+ return;
101
+ if (event?.code === refusedKey)
102
+ return refused("invalid_key", `The API key is no longer valid (revoked, unknown, or its account is not active): the server refused it for ${Math.round(window / 1000)} s`, event);
103
+ if (event?.code === 1000)
104
+ reconnect();
105
+ });
106
+ const transient = (error) => queueMicrotask(() => {
107
+ if (!closed && refusedSince === undefined)
108
+ handlers.transient(error);
109
+ });
110
+ socket.onError(transient);
111
+ channel.onError((error) => {
112
+ checked = false;
113
+ transient(error);
37
114
  });
38
- socket.onError((error) => handlers.transient(error));
39
- channel.onError((error) => handlers.transient(error));
40
115
  socket.connect();
41
- channel.join().receive("error", (reason) => handlers.fail(new NetworkError({
42
- url: endpoint.toString(),
43
- message: `Stream join refused: ${JSON.stringify(reason)}`,
44
- cause: reason,
45
- })));
116
+ channel
117
+ .join()
118
+ .receive("ok", (reply) => {
119
+ if (closed)
120
+ return;
121
+ const named = reply?.account;
122
+ if (typeof named === "string") {
123
+ account ??= named;
124
+ if (named !== account)
125
+ return fail("The API key now belongs to another account: the stream of the previous account ended (its events are not followed on the new one)", reply, "account_changed");
126
+ }
127
+ checked = true;
128
+ closes = 0;
129
+ refusedSince = undefined;
130
+ const waiting = held;
131
+ held = [];
132
+ for (const handle of waiting)
133
+ if (!closed)
134
+ handle();
135
+ })
136
+ .receive("error", (reason) => {
137
+ if (closed)
138
+ return;
139
+ const code = reason?.reason;
140
+ const message = `Stream join refused: ${JSON.stringify(reason)}`;
141
+ if (code === "unauthorized")
142
+ return refused(code, message, reason);
143
+ fail(message, reason, typeof code === "string" ? code : undefined);
144
+ });
46
145
  return {
47
146
  close() {
48
- channel.leave();
49
- socket.disconnect();
147
+ stop();
50
148
  },
51
149
  get cursor() {
52
150
  return cursor;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@johannc-dev/zyrk",
3
- "version": "0.2.0",
4
- "description": "SDK and CLI for Zyrk hosted functions (funrun v2)",
3
+ "version": "0.2.1",
4
+ "description": "SDK and CLI for Zyrk hosted functions",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
7
7
  "access": "public",
@@ -39,7 +39,7 @@
39
39
  "scripts": {
40
40
  "build": "node scripts/build.mjs",
41
41
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
42
- "test": "npm run build && npm run typecheck && node --test test/*.test.ts",
42
+ "test": "npm run build && npm run typecheck && node --test --test-timeout=60000 --test-force-exit test/*.test.ts",
43
43
  "format": "prettier --write src types scripts test examples package.json tsconfig.json tsconfig.test.json README.md",
44
44
  "format:check": "prettier --check src types scripts test examples package.json tsconfig.json tsconfig.test.json README.md",
45
45
  "prepack": "npm run build"