@wavehouse/sdk 0.0.0-dev.h70dbf5d8f5ed → 0.0.0-dev.h7362ab3863f6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -66,6 +66,13 @@ function mergeHeaders(base, extra) {
66
66
  }
67
67
  return merged;
68
68
  }
69
+ function abortedResult() {
70
+ return {
71
+ data: null,
72
+ error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
73
+ headers: new Headers()
74
+ };
75
+ }
69
76
  async function request(ctx, opts) {
70
77
  const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
71
78
  const headers = {
@@ -115,16 +122,19 @@ async function request(ctx, opts) {
115
122
  }
116
123
  return { data: null, error, headers: res.headers };
117
124
  } catch (e) {
118
- if (e instanceof DOMException && e.name === "AbortError") {
119
- return {
120
- data: null,
121
- error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
122
- headers: new Headers()
123
- };
125
+ if (opts.signal?.aborted) {
126
+ return abortedResult();
124
127
  }
125
128
  lastError = networkError(e);
126
129
  if (attempt < maxAttempts - 1) {
127
- await sleep(backoff(attempt), opts.signal);
130
+ try {
131
+ await sleep(backoff(attempt), opts.signal);
132
+ } catch (abort) {
133
+ if (opts.signal?.aborted) {
134
+ return abortedResult();
135
+ }
136
+ throw abort;
137
+ }
128
138
  }
129
139
  }
130
140
  }
@@ -518,11 +528,50 @@ var StreamController = class {
518
528
  };
519
529
 
520
530
  // src/stream/sse.ts
531
+ import { createParser } from "eventsource-parser";
521
532
  var activeSSEConnections = 0;
522
533
  var SSE_WARN_THRESHOLD = 5;
534
+ var MAX_BUFFER_CHARS = 16 * 1024 * 1024;
535
+ var MAX_BACKOFF_MS = 3e4;
536
+ var STABLE_CONNECTION_MS = 3e3;
537
+ function backoff2(attempt, retryFloorMs) {
538
+ const nominal = Math.max(retryFloorMs, Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS));
539
+ const low = Math.max(retryFloorMs, nominal / 2);
540
+ return low + Math.random() * (nominal - low);
541
+ }
542
+ function releaseBody(res) {
543
+ if (res.body && !res.body.locked) res.body.cancel().catch(() => {
544
+ });
545
+ }
523
546
  var SSETransport = class {
524
547
  _opts;
525
- _es = null;
548
+ _abort = null;
549
+ /**
550
+ * Set by `disconnect()` before it calls `abort()` — both synchronously — so
551
+ * a rejection caused by our own abort always observes this as `true`. That
552
+ * makes it a complete and precise test for "did we cause this?", which is
553
+ * why the catch blocks read it directly instead of matching on the error
554
+ * type: an `AbortError` we *didn't* raise (a custom `fetch` enforcing a
555
+ * per-attempt deadline, an `auth()` call cancelled by its own controller) is
556
+ * a transient failure to re-dial, exactly as on the REST path. Treating one
557
+ * as a teardown killed the stream permanently, and silently.
558
+ */
559
+ _closed = false;
560
+ _started = false;
561
+ _counted = false;
562
+ /**
563
+ * Most recent non-empty event id, replayed as `Last-Event-ID` on reconnect.
564
+ *
565
+ * Deliberately ignores empty ids. The server emits a blank `id:` line for
566
+ * passthrough payloads, and per the SSE spec an empty `id` field *clears* the
567
+ * last-event-id — so tracking it faithfully would silently discard the
568
+ * resumption point mid-stream and re-open from the beginning.
569
+ */
570
+ _lastEventId = null;
571
+ /** Reconnect floor most recently requested by the server via `retry:`. */
572
+ _retryFloorMs = 0;
573
+ /** Cuts a reconnect gap short when set — see `_sleep`. */
574
+ _wake = null;
526
575
  onEvent = null;
527
576
  onStatus = null;
528
577
  onError = null;
@@ -530,79 +579,332 @@ var SSETransport = class {
530
579
  this._opts = opts;
531
580
  }
532
581
  connect() {
533
- if (typeof EventSource === "undefined") {
582
+ if (this._closed || this._started) return;
583
+ this._started = true;
584
+ if (!this._opts.fetch && typeof fetch === "undefined") {
534
585
  throw new Error(
535
- "[wavehouse] EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
586
+ "[wavehouse] global fetch is not available in this environment. Upgrade to Node 22+ or supply one via `options.fetch`."
536
587
  );
537
588
  }
538
- this._doConnect().catch((err2) => {
539
- this.onError?.({
589
+ activeSSEConnections++;
590
+ this._counted = true;
591
+ if (activeSSEConnections > SSE_WARN_THRESHOLD) {
592
+ console.warn(
593
+ `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
594
+ );
595
+ }
596
+ this._run().catch((err2) => {
597
+ this._emitError({
540
598
  status: 0,
541
599
  code: "SSE_CONNECT_ERROR",
542
600
  message: err2 instanceof Error ? err2.message : String(err2),
543
- retryable: true
601
+ retryable: false
544
602
  });
603
+ this.disconnect();
545
604
  });
546
605
  }
547
606
  disconnect() {
548
- if (this._es) {
549
- this._es.close();
550
- this._es = null;
551
- activeSSEConnections = Math.max(0, activeSSEConnections - 1);
607
+ if (!this._closed) {
608
+ this._closed = true;
609
+ this._abort?.abort();
610
+ this._abort = null;
611
+ this._wake?.();
612
+ if (this._counted) {
613
+ activeSSEConnections = Math.max(0, activeSSEConnections - 1);
614
+ this._counted = false;
615
+ }
616
+ try {
617
+ this.onStatus?.("closed");
618
+ } catch (e) {
619
+ console.error("[wavehouse] SSE status handler threw:", e);
620
+ }
621
+ }
622
+ }
623
+ /** Connect/read/reconnect until the stream is closed or hits a terminal error. */
624
+ async _run() {
625
+ await Promise.resolve();
626
+ let attempt = 0;
627
+ while (!this._closed) {
628
+ const { liveMs, terminal } = await this._attempt();
629
+ if (this._closed) return;
630
+ if (terminal) {
631
+ this.disconnect();
632
+ return;
633
+ }
634
+ if (liveMs >= STABLE_CONNECTION_MS) attempt = 0;
635
+ this._emitStatus("reconnecting");
636
+ await this._sleep(backoff2(attempt, this._retryFloorMs));
637
+ attempt++;
638
+ }
639
+ }
640
+ /** One connection attempt: request, validate, then pump frames until it ends. */
641
+ async _attempt() {
642
+ const ac = new AbortController();
643
+ this._abort = ac;
644
+ let target;
645
+ try {
646
+ target = this._url();
647
+ } catch (e) {
648
+ this._emitError({
649
+ status: 0,
650
+ code: "SSE_CONNECT_ERROR",
651
+ message: e instanceof Error ? e.message : String(e),
652
+ retryable: false
653
+ });
654
+ return { liveMs: 0, terminal: true };
655
+ }
656
+ let init;
657
+ try {
658
+ init = await this._init(ac.signal);
659
+ } catch (e) {
660
+ if (this._closed) return { liveMs: 0, terminal: true };
661
+ this._emitError({
662
+ status: 0,
663
+ code: "SSE_AUTH_ERROR",
664
+ message: e instanceof Error ? e.message : String(e),
665
+ retryable: true
666
+ });
667
+ return { liveMs: 0, terminal: false };
668
+ }
669
+ let res;
670
+ try {
671
+ const doFetch = this._opts.fetch;
672
+ res = doFetch ? await doFetch(target, init) : await fetch(target, init);
673
+ } catch (e) {
674
+ if (this._closed) return { liveMs: 0, terminal: true };
675
+ this._emitError({
676
+ status: 0,
677
+ code: "SSE_NETWORK_ERROR",
678
+ message: e instanceof Error ? e.message : String(e),
679
+ retryable: true
680
+ });
681
+ return { liveMs: 0, terminal: false };
682
+ }
683
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
684
+ this._emitError({
685
+ status: res.status,
686
+ code: "SSE_REDIRECT",
687
+ message: "The stream endpoint redirected and the SDK did not follow it. Redirects are refused when the request carries a credential \u2014 an `auth` token or configured `headers` \u2014 because a cross-origin hop strips the Authorization header, and this endpoint answers an unauthenticated caller with a reduced view instead of an error, while forwarding any configured headers to wherever the redirect points. If this request carries neither, `fetch` was left to follow the redirect itself, so arriving here means a response it will not follow (a 304, or a 3xx with no Location header) or an `options.fetch` that returned the redirect verbatim. Either way, set `baseURL` to the final URL; most often this is an http\u2192https upgrade or a canonical-host redirect at a proxy. To follow a credentialed redirect anyway, supply an `options.fetch` that overrides `redirect`.",
688
+ retryable: false
689
+ });
690
+ releaseBody(res);
691
+ return { liveMs: 0, terminal: true };
692
+ }
693
+ if (!res.ok) {
694
+ const error = await parseErrorResponse(res);
695
+ this._emitError(error);
696
+ return { liveMs: 0, terminal: !error.retryable };
697
+ }
698
+ const contentType = res.headers.get("content-type") ?? "";
699
+ if (!contentType.toLowerCase().split(";")[0].trim().startsWith("text/event-stream")) {
700
+ this._emitError({
701
+ status: res.status,
702
+ code: "SSE_BAD_CONTENT_TYPE",
703
+ message: `Expected \`text/event-stream\`, got \`${contentType || "(none)"}\`. Something between the client and WaveHouse answered this request \u2014 an auth gateway's login page is the usual cause.`,
704
+ retryable: false
705
+ });
706
+ releaseBody(res);
707
+ return { liveMs: 0, terminal: true };
708
+ }
709
+ const body = res.body;
710
+ if (!body || typeof body.getReader !== "function" || body.locked || res.bodyUsed) {
711
+ this._emitError({
712
+ status: res.status,
713
+ code: "SSE_NO_STREAM_BODY",
714
+ message: "Streaming requires a response with a readable body. The configured `options.fetch` returned one whose body is absent or already read \u2014 an implementation that buffers or consumes the response cannot be used for `.stream()` or `.liveQuery()`.",
715
+ retryable: false
716
+ });
717
+ releaseBody(res);
718
+ return { liveMs: 0, terminal: true };
552
719
  }
553
- this.onStatus?.("closed");
720
+ this._emitStatus("live");
721
+ const openedAt = Date.now();
722
+ await this._pump(body);
723
+ return { liveMs: Date.now() - openedAt, terminal: false };
554
724
  }
555
- async _doConnect() {
725
+ /** Resolve the stream URL. Throws on a `baseURL` that isn't a usable http(s) origin. */
726
+ _url() {
556
727
  const url = resolveURL(this._opts.baseURL, "/v1/stream");
728
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
729
+ throw new Error(
730
+ `baseURL must use http or https, got "${url.protocol}". Streaming is plain HTTP \u2014 there is no WebSocket endpoint.`
731
+ );
732
+ }
557
733
  url.searchParams.set("table", this._opts.table);
558
734
  if (this._opts.since) {
559
735
  url.searchParams.set("since", this._opts.since);
560
736
  }
737
+ return url.toString();
738
+ }
739
+ /** Build the request init, minting a token for this attempt. */
740
+ async _init(signal) {
741
+ const base = { Accept: "text/event-stream" };
561
742
  if (this._opts.auth) {
562
743
  const token = await this._opts.auth();
563
744
  if (token) {
564
- url.searchParams.set("token", token);
745
+ base.Authorization = `Bearer ${token}`;
565
746
  }
566
747
  }
567
- activeSSEConnections++;
568
- if (activeSSEConnections > SSE_WARN_THRESHOLD) {
569
- console.warn(
570
- `[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
571
- );
748
+ if (this._lastEventId) {
749
+ base["Last-Event-ID"] = this._lastEventId;
572
750
  }
573
- this._es = new EventSource(url.toString());
574
- this._es.onopen = () => {
575
- this.onStatus?.("live");
576
- };
577
- this._es.onmessage = (e) => {
578
- try {
579
- const msg = JSON.parse(e.data);
580
- const event = {
581
- table: msg.table_name,
582
- timestamp: msg.received_timestamp,
583
- data: msg.data
584
- };
585
- this.onEvent?.(event);
586
- } catch {
587
- console.warn("[wavehouse] SSE received malformed message:", e.data);
588
- }
751
+ const credentialed = base.Authorization !== void 0 || Object.keys(this._opts.headers ?? {}).length > 0;
752
+ const init = {
753
+ ...this._opts.fetchOptions,
754
+ method: "GET",
755
+ headers: mergeHeaders(base, this._opts.headers),
756
+ signal,
757
+ // Owned by the SDK rather than left to `fetchOptions`, unlike the REST
758
+ // path, because on a stream these are correctness rather than preference:
759
+ //
760
+ // cache — as an init field, not a `Cache-Control` header. The header
761
+ // form is not in the server's Access-Control-Allow-Headers,
762
+ // so it would fail every cross-origin preflight.
763
+ // redirect — followed only when this request carries no credential.
764
+ // Platforms strip `Authorization` on a cross-origin redirect
765
+ // (whatwg/fetch#1544) and forward other headers intact, so a
766
+ // credentialed hop either silently downgrades the stream to
767
+ // the default role — this endpoint answers an unauthenticated
768
+ // caller rather than rejecting them — or delivers a configured
769
+ // secret to whatever the redirect names. Neither is worth a
770
+ // convenience. Without either, redirects are followed, so CDN
771
+ // canonicalization, geo/LB indirection, and an http→https
772
+ // upgrade all just work. The test is deliberately that
773
+ // concrete pair and not "is this authenticated": cookies do
774
+ // not appear in it. A cookie is re-derived per
775
+ // destination rather than carried, so under the default
776
+ // same-origin credentials mode it survives a redirect only
777
+ // while the hop stays inside both the request's origin and
778
+ // the cookie's Path; outside either the stream is
779
+ // silently unauthed. See #478 and the SDK guide under
780
+ // "Supplying your own fetch".
781
+ // `manual` over `error` for the credentialed case so the
782
+ // outcome stays inspectable: `error` rejects with a bare
783
+ // TypeError indistinguishable from a connection failure, which
784
+ // the loop above would retry forever against a redirect that
785
+ // is never going to stop happening.
786
+ cache: "no-store",
787
+ redirect: credentialed ? "manual" : "follow",
788
+ // Neutralized like the REST path does, and for a sharper reason here: a
789
+ // body on a GET makes `fetch` throw, which reads as a network failure and
790
+ // would be retried forever against a request that can never be built.
791
+ body: void 0
589
792
  };
590
- this._es.onerror = () => {
591
- if (this._es?.readyState === EventSource.CONNECTING) {
592
- this.onStatus?.("reconnecting");
593
- } else if (this._es?.readyState === EventSource.CLOSED) {
594
- this.onStatus?.("closed");
595
- } else if (this._es?.readyState === EventSource.OPEN) {
596
- this.onStatus?.("live");
597
- } else {
598
- this.onError?.({
793
+ if (!("window" in globalThis)) {
794
+ delete init.credentials;
795
+ }
796
+ return init;
797
+ }
798
+ /** Decode and parse frames until the stream ends, aborts, or errors. */
799
+ async _pump(body) {
800
+ let overflowed = false;
801
+ const parser = createParser({
802
+ onEvent: (msg) => {
803
+ if (msg.id) this._lastEventId = msg.id;
804
+ if (!msg.data) return;
805
+ this._dispatch(msg.data);
806
+ },
807
+ onRetry: (ms) => {
808
+ this._retryFloorMs = Math.min(Math.max(ms, 0), MAX_BACKOFF_MS);
809
+ },
810
+ onError: (err2) => {
811
+ if (err2.type === "max-buffer-size-exceeded") overflowed = true;
812
+ this._emitError({
599
813
  status: 0,
600
- code: "SSE_ERROR",
601
- message: "SSE connection error",
814
+ code: "SSE_PARSE_ERROR",
815
+ message: err2.message,
602
816
  retryable: true
603
817
  });
818
+ },
819
+ maxBufferSize: MAX_BUFFER_CHARS
820
+ });
821
+ const reader = body.getReader();
822
+ const decoder = new TextDecoder();
823
+ try {
824
+ for (; ; ) {
825
+ const { done, value } = await reader.read();
826
+ if (done) return;
827
+ parser.feed(decoder.decode(value, { stream: true }));
828
+ if (overflowed) return;
604
829
  }
605
- };
830
+ } catch (e) {
831
+ if (this._closed) return;
832
+ this._emitError({
833
+ status: 0,
834
+ code: "SSE_READ_ERROR",
835
+ message: e instanceof Error ? e.message : String(e),
836
+ retryable: true
837
+ });
838
+ } finally {
839
+ reader.cancel().catch(() => {
840
+ });
841
+ }
842
+ }
843
+ /**
844
+ * Turn one frame's `data` into a StreamEvent.
845
+ *
846
+ * Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
847
+ * every complete frame in a chunk synchronously, so a subscriber that closes
848
+ * from inside its own `next` — "read until I see my event, then stop" —
849
+ * would otherwise keep receiving the rest of that chunk. `EventSource` ended
850
+ * delivery on `close()`, and gap-fill replay routinely lands many frames in
851
+ * one chunk.
852
+ */
853
+ _dispatch(data) {
854
+ if (this._closed) return;
855
+ let event;
856
+ try {
857
+ const msg = JSON.parse(data);
858
+ event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
859
+ } catch {
860
+ console.warn("[wavehouse] SSE received malformed message:", data);
861
+ return;
862
+ }
863
+ try {
864
+ this.onEvent?.(event);
865
+ } catch (e) {
866
+ console.error("[wavehouse] SSE event handler threw:", e);
867
+ }
868
+ }
869
+ /** Suppress callbacks that race a `disconnect()`. */
870
+ _emitError(error) {
871
+ if (this._closed) return;
872
+ try {
873
+ this.onError?.(error);
874
+ } catch (e) {
875
+ console.error("[wavehouse] SSE error handler threw:", e);
876
+ }
877
+ }
878
+ /**
879
+ * Same guard for status. Aborting does not retract an already-resolved
880
+ * `Response`, so a `disconnect()` landing between the fetch settling and the
881
+ * "live" below would otherwise flip a closed controller back to live — and it
882
+ * stays there, since the loop then exits without emitting anything further.
883
+ * `disconnect()` calls `onStatus` directly; that terminal "closed" is the one
884
+ * status that must always get through.
885
+ */
886
+ _emitStatus(status) {
887
+ if (this._closed) return;
888
+ try {
889
+ this.onStatus?.(status);
890
+ } catch (e) {
891
+ console.error("[wavehouse] SSE status handler threw:", e);
892
+ }
893
+ }
894
+ /** Wait out a reconnect gap, cut short by `disconnect()`. */
895
+ _sleep(ms) {
896
+ if (this._closed) return Promise.resolve();
897
+ return new Promise((resolve) => {
898
+ const timer = setTimeout(() => {
899
+ this._wake = null;
900
+ resolve();
901
+ }, ms);
902
+ this._wake = () => {
903
+ clearTimeout(timer);
904
+ this._wake = null;
905
+ resolve();
906
+ };
907
+ });
606
908
  }
607
909
  };
608
910
 
@@ -1140,16 +1442,14 @@ var WaveHouseClient = class {
1140
1442
  }
1141
1443
  /** @internal Create a stream for the given table. */
1142
1444
  _createStream(table, opts) {
1143
- if (typeof EventSource === "undefined") {
1144
- throw new Error(
1145
- "[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
1146
- );
1147
- }
1148
1445
  const transport = new SSETransport({
1149
1446
  baseURL: this._ctx.baseURL,
1150
1447
  table,
1151
1448
  since: opts?.since,
1152
- auth: this._ctx.auth
1449
+ auth: this._ctx.auth,
1450
+ fetch: this._ctx.options.fetch,
1451
+ headers: this._ctx.options.headers,
1452
+ fetchOptions: this._ctx.options.fetchOptions
1153
1453
  });
1154
1454
  const controller = new StreamController(transport);
1155
1455
  if (opts?.signal) controller.attachSignal(opts.signal);