propline 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -805,6 +805,7 @@ Each POST carries these headers:
805
805
  | `X-PropLine-Timestamp` | Unix seconds |
806
806
  | `X-PropLine-Signature` | HMAC-SHA256 over `${timestamp}.` + body |
807
807
  | `X-PropLine-Delivery` | Stable delivery id (use for idempotency) |
808
+ | `X-PropLine-Sequence` | Your subscription's own event counter (use for replay) |
808
809
 
809
810
  ```ts
810
811
  import express from "express";
@@ -936,6 +937,75 @@ await client.listWebhookDeliveries(whId, { limit: 200, beforeId: 123456 });
936
937
  await client.deleteWebhook(whId);
937
938
  ```
938
939
 
940
+ ### Catching up after an outage
941
+
942
+ Every delivery carries `X-PropLine-Sequence` — a counter monotonic *within your
943
+ subscription*. Store the highest one you processed, then read forward from it.
944
+ Do not use `X-PropLine-Delivery` as the cursor: that id is global across all
945
+ subscriptions, so its gaps are other customers' traffic.
946
+
947
+ ```ts
948
+ let cursor = await loadMyCursor(); // highest X-PropLine-Sequence processed
949
+
950
+ for (;;) {
951
+ const page = await client.replayWebhookEvents(whId, { sinceSeq: cursor, limit: 100 });
952
+
953
+ if (page.truncated) {
954
+ // Events after your cursor aged out of retention and are gone.
955
+ // Resync from the REST endpoints rather than assume you are current.
956
+ await resyncFromRest();
957
+ }
958
+
959
+ for (const ev of page.events) { // oldest first
960
+ await handle(ev.event_type, ev.data);
961
+ }
962
+
963
+ cursor = page.next_seq;
964
+ await saveMyCursor(cursor);
965
+ if (!page.has_more) {
966
+ console.log(`behind by ${page.latest_seq - cursor} events`);
967
+ break;
968
+ }
969
+ }
970
+ ```
971
+
972
+ ### Websocket streaming
973
+
974
+ If your stack already speaks websockets — or you can't host a public HTTPS
975
+ endpoint — connect a socket instead of receiving POSTs. Same events, same
976
+ filters, same `seq`: a stream and a webhook are the **same subscription with a
977
+ different transport**.
978
+
979
+ ```ts
980
+ const wh = await client.createWebhook({
981
+ transport: "websocket", // no url — there is nowhere to POST
982
+ events: ["line_movement"],
983
+ filterSportKey: "baseball_mlb",
984
+ });
985
+
986
+ for await (const ev of client.stream({ webhookId: wh.id, sinceSeq: myCursor })) {
987
+ await handle(ev.event_type, ev.data);
988
+ myCursor = ev.seq; // persist it; this is your resume point
989
+ }
990
+ ```
991
+
992
+ Reconnects and resumes from the last `seq` automatically, so a dropped
993
+ connection is not a gap in your data. `onTruncated` fires when events after
994
+ your cursor aged out of retention — the one case streaming cannot make you
995
+ whole, where you should resync from REST. Zero dependencies: it uses Node's
996
+ built-in `WebSocket` (Node 22+).
997
+
998
+ Concurrent connections are capped per plan (Streaming Lite 2, Streaming 5).
999
+ Delivered events are **not** metered.
1000
+
1001
+ Replay is bounded by delivery retention: 2 days, and at most 5,000 deliveries
1002
+ per subscription. `latest_seq` is **not** subject to retention, so
1003
+ `latest_seq - next_seq` stays honest even after the rows are pruned. Sequence
1004
+ numbers always increase and never repeat but are not guaranteed to be dense —
1005
+ treat a skipped number as normal, and read `truncated` for real loss. Neither
1006
+ `replayWebhookEvents` nor `listWebhookDeliveries` counts against your daily
1007
+ quota.
1008
+
939
1009
  ## Error handling
940
1010
 
941
1011
  ```ts
package/dist/index.cjs CHANGED
@@ -818,6 +818,139 @@ var PropLine = class {
818
818
  { params: { limit: options.limit ?? 50, before_id: options.beforeId } }
819
819
  );
820
820
  }
821
+ /**
822
+ * Re-read this subscription's events in order, from a cursor.
823
+ *
824
+ * Answers "my endpoint was down — what did I miss?". Every delivery carries
825
+ * an `X-PropLine-Sequence` header: a counter monotonic *within your
826
+ * subscription*. Store the highest one you processed and pass it as
827
+ * `sinceSeq`.
828
+ *
829
+ * Do NOT use `X-PropLine-Delivery` as the cursor — that id is global across
830
+ * every subscription, so its gaps are other customers' traffic.
831
+ *
832
+ * Events come back oldest-first (the opposite of `listWebhookDeliveries`,
833
+ * which is a newest-first debugging log). Page by passing `next_seq` back
834
+ * as `sinceSeq` while `has_more` is true.
835
+ *
836
+ * **Check `truncated`.** True means events after your cursor have aged out
837
+ * of retention (2 days, max 5,000 deliveries per subscription) and are gone
838
+ * — resync from the REST endpoints instead of assuming you are current.
839
+ *
840
+ * Does not count against your daily request quota.
841
+ */
842
+ replayWebhookEvents(webhookId, options = {}) {
843
+ return this._request(
844
+ "GET",
845
+ `/webhooks/${webhookId}/replay`,
846
+ { params: { since_seq: options.sinceSeq ?? 0, limit: options.limit ?? 100 } }
847
+ );
848
+ }
849
+ /**
850
+ * Stream a websocket subscription as an async iterable.
851
+ *
852
+ * ```ts
853
+ * for await (const ev of client.stream({ webhookId: 12, sinceSeq: 4180 })) {
854
+ * console.log(ev.seq, ev.event_type, ev.data);
855
+ * }
856
+ * ```
857
+ *
858
+ * The subscription must have been created with `transport: "websocket"`.
859
+ * Same events, same filters, same `seq` as an HTTP webhook — one
860
+ * subscription, different transport.
861
+ *
862
+ * **Reconnects automatically and resumes from the last `seq` it saw**, which
863
+ * is the whole point of the sequence: a dropped connection does not become a
864
+ * gap in your data. Set `reconnect: false` to get a single connection that
865
+ * ends when the socket closes.
866
+ *
867
+ * If the server reports `truncated` — events after your cursor aged out of
868
+ * retention and are gone — `onTruncated` fires. Handle it: that is the one
869
+ * case where the stream cannot make you whole and you should resync from the
870
+ * REST endpoints.
871
+ */
872
+ async *stream(options) {
873
+ const wsBase = (options.wsUrl ?? this.baseUrl).replace(/^http:/, "ws:").replace(/^https:/, "wss:").replace(/\/v1\/?$/, "");
874
+ const url = `${wsBase}/v1/stream`;
875
+ let cursor = options.sinceSeq ?? 0;
876
+ let attempt = 0;
877
+ for (; ; ) {
878
+ const queue = [];
879
+ let notify = null;
880
+ let closed = null;
881
+ let opened = false;
882
+ const ws = new WebSocket(url);
883
+ const wake = () => {
884
+ const n = notify;
885
+ notify = null;
886
+ n?.();
887
+ };
888
+ ws.addEventListener("open", () => {
889
+ opened = true;
890
+ ws.send(JSON.stringify({
891
+ type: "auth",
892
+ api_key: this.apiKey,
893
+ webhook_id: options.webhookId,
894
+ since_seq: cursor
895
+ }));
896
+ });
897
+ ws.addEventListener("message", (e) => {
898
+ let msg;
899
+ try {
900
+ msg = JSON.parse(String(e.data));
901
+ } catch {
902
+ return;
903
+ }
904
+ if (msg.type === "ready") {
905
+ attempt = 0;
906
+ if (msg.truncated) options.onTruncated?.(msg);
907
+ options.onReady?.(msg);
908
+ } else if (msg.type === "event") {
909
+ queue.push(msg);
910
+ wake();
911
+ }
912
+ });
913
+ ws.addEventListener("close", (e) => {
914
+ const terminal = [4400, 4401, 4403, 4404].includes(e.code);
915
+ closed = new PropLineError(
916
+ e.code,
917
+ `stream closed${e.reason ? `: ${e.reason}` : ""}`
918
+ );
919
+ closed.terminal = terminal;
920
+ wake();
921
+ });
922
+ ws.addEventListener("error", () => {
923
+ if (!closed) closed = new PropLineError(0, "stream connection error");
924
+ wake();
925
+ });
926
+ try {
927
+ for (; ; ) {
928
+ while (queue.length) {
929
+ const ev = queue.shift();
930
+ cursor = ev.seq;
931
+ yield ev;
932
+ }
933
+ if (closed) break;
934
+ await new Promise((r) => {
935
+ notify = r;
936
+ });
937
+ }
938
+ } finally {
939
+ try {
940
+ ws.close();
941
+ } catch {
942
+ }
943
+ }
944
+ const err = closed;
945
+ if (err?.terminal) throw err;
946
+ if (options.reconnect === false) {
947
+ if (err && !opened) throw err;
948
+ return;
949
+ }
950
+ const delayMs = Math.min(3e4, 500 * 2 ** attempt++);
951
+ await new Promise((r) => setTimeout(r, delayMs));
952
+ }
953
+ }
821
954
  /**
822
955
  * Grade placed bets against their closing lines (CLV).
823
956
  *
@@ -958,7 +1091,7 @@ var Bookmakers = {
958
1091
  POLYMARKET: "polymarket",
959
1092
  PRIZEPICKS: "prizepicks"
960
1093
  };
961
- var VERSION = "0.44.0";
1094
+ var VERSION = "0.46.0";
962
1095
  // Annotate the CommonJS export names for ESM import in node:
963
1096
  0 && (module.exports = {
964
1097
  AuthError,