@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/README.md +5 -5
- package/dist/index.cjs +359 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +74 -23
- package/dist/index.d.ts +74 -23
- package/dist/index.global.js +11 -2
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +359 -59
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @wavehouse/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript client for [WaveHouse](https://github.com/Wave-RF/WaveHouse) — schema-aware real-time API gateway for ClickHouse. One runtime dependency (`eventsource-parser`, ~1.4 KB gzipped, itself dependency-free).
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Zero-dependency TypeScript client for [WaveHouse](https://github.com/Wave-RF/Wav
|
|
|
8
8
|
npm install @wavehouse/sdk
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Requires Node 22 or newer — the only line this SDK is tested against; Node 18 and 20 are past upstream end-of-life.
|
|
11
|
+
Requires Node 22 or newer — the only line this SDK is tested against; Node 18 and 20 are past upstream end-of-life. Neither browsers nor Node need a polyfill: streaming runs on `fetch`, like the rest of the SDK — see [Runtime support](https://wavehouse.dev/sdk/#runtime-support).
|
|
12
12
|
|
|
13
13
|
This works in any framework that uses a bundler — React, Vue, Svelte, Angular, Astro, SolidJS, or plain Vite — with `import { createClient } from '@wavehouse/sdk'`.
|
|
14
14
|
|
|
@@ -44,7 +44,7 @@ Pin a version for production (`https://esm.sh/@wavehouse/sdk@0.1.0`). jsDelivr (
|
|
|
44
44
|
|
|
45
45
|
**Versioning.** A bare CDN URL serves the latest published **release**; pin for production (`@wavehouse/sdk@0.1.0`) or float on a range (`@0` for the newest 0.x, `@0.1` for 0.1.x). Builds from `main` are published under the `dev` tag — `@wavehouse/sdk@dev` — for trying unreleased changes.
|
|
46
46
|
|
|
47
|
-
Streaming (`.stream()`)
|
|
47
|
+
Streaming (`.stream()`) runs on `fetch`, so it works in both forms with no polyfill.
|
|
48
48
|
|
|
49
49
|
## Quick Start
|
|
50
50
|
|
|
@@ -57,7 +57,7 @@ const wh = createClient({
|
|
|
57
57
|
});
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
`baseURL` may include a path prefix (`https://app.example.com/api/
|
|
60
|
+
`baseURL` may include a path prefix (`https://app.example.com/api/wavehouse`) when WaveHouse is served under one — see [Serving under a path prefix](https://wavehouse.dev/sdk#serving-under-a-path-prefix).
|
|
61
61
|
|
|
62
62
|
### Query Data
|
|
63
63
|
|
|
@@ -131,7 +131,7 @@ const { data } = await wh.from('clicks').select('page').limit(10);
|
|
|
131
131
|
|
|
132
132
|
Async request methods return a `Result<T>` object — destructure it as `{ data, error }` — and never throw for anything the server returns. `.stream()` and `.liveQuery()` return controllers instead, reporting failures through the subscriber's `error` callback.
|
|
133
133
|
|
|
134
|
-
The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report
|
|
134
|
+
The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report a terminal `SSE_CONNECT_ERROR`), `.stream()` / `.liveQuery()` in a runtime with no global `fetch` and no `options.fetch`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call, and on a stream is reported as a retryable `SSE_AUTH_ERROR`. A dropped stream *is* re-dialed by the SDK, on a jittered backoff; only non-retryable failures end it. One more exception can escape an SDK call, though it is yours rather than ours: a subscriber's `status` handler throwing on the first, synchronous `.subscribe()` / `.liveQuery()` call — see [Error Handling](https://wavehouse.dev/sdk/reference#error-handling).
|
|
135
135
|
|
|
136
136
|
```ts
|
|
137
137
|
const { data, error } = await wh.from('clicks').fetch();
|
package/dist/index.cjs
CHANGED
|
@@ -103,6 +103,13 @@ function mergeHeaders(base, extra) {
|
|
|
103
103
|
}
|
|
104
104
|
return merged;
|
|
105
105
|
}
|
|
106
|
+
function abortedResult() {
|
|
107
|
+
return {
|
|
108
|
+
data: null,
|
|
109
|
+
error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
|
|
110
|
+
headers: new Headers()
|
|
111
|
+
};
|
|
112
|
+
}
|
|
106
113
|
async function request(ctx, opts) {
|
|
107
114
|
const url = resolveURL(ctx.baseURL, opts.path, opts.params).toString();
|
|
108
115
|
const headers = {
|
|
@@ -152,16 +159,19 @@ async function request(ctx, opts) {
|
|
|
152
159
|
}
|
|
153
160
|
return { data: null, error, headers: res.headers };
|
|
154
161
|
} catch (e) {
|
|
155
|
-
if (
|
|
156
|
-
return
|
|
157
|
-
data: null,
|
|
158
|
-
error: { status: 0, code: "ABORTED", message: "Request aborted", retryable: false },
|
|
159
|
-
headers: new Headers()
|
|
160
|
-
};
|
|
162
|
+
if (opts.signal?.aborted) {
|
|
163
|
+
return abortedResult();
|
|
161
164
|
}
|
|
162
165
|
lastError = networkError(e);
|
|
163
166
|
if (attempt < maxAttempts - 1) {
|
|
164
|
-
|
|
167
|
+
try {
|
|
168
|
+
await sleep(backoff(attempt), opts.signal);
|
|
169
|
+
} catch (abort) {
|
|
170
|
+
if (opts.signal?.aborted) {
|
|
171
|
+
return abortedResult();
|
|
172
|
+
}
|
|
173
|
+
throw abort;
|
|
174
|
+
}
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
177
|
}
|
|
@@ -555,11 +565,50 @@ var StreamController = class {
|
|
|
555
565
|
};
|
|
556
566
|
|
|
557
567
|
// src/stream/sse.ts
|
|
568
|
+
var import_eventsource_parser = require("eventsource-parser");
|
|
558
569
|
var activeSSEConnections = 0;
|
|
559
570
|
var SSE_WARN_THRESHOLD = 5;
|
|
571
|
+
var MAX_BUFFER_CHARS = 16 * 1024 * 1024;
|
|
572
|
+
var MAX_BACKOFF_MS = 3e4;
|
|
573
|
+
var STABLE_CONNECTION_MS = 3e3;
|
|
574
|
+
function backoff2(attempt, retryFloorMs) {
|
|
575
|
+
const nominal = Math.max(retryFloorMs, Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS));
|
|
576
|
+
const low = Math.max(retryFloorMs, nominal / 2);
|
|
577
|
+
return low + Math.random() * (nominal - low);
|
|
578
|
+
}
|
|
579
|
+
function releaseBody(res) {
|
|
580
|
+
if (res.body && !res.body.locked) res.body.cancel().catch(() => {
|
|
581
|
+
});
|
|
582
|
+
}
|
|
560
583
|
var SSETransport = class {
|
|
561
584
|
_opts;
|
|
562
|
-
|
|
585
|
+
_abort = null;
|
|
586
|
+
/**
|
|
587
|
+
* Set by `disconnect()` before it calls `abort()` — both synchronously — so
|
|
588
|
+
* a rejection caused by our own abort always observes this as `true`. That
|
|
589
|
+
* makes it a complete and precise test for "did we cause this?", which is
|
|
590
|
+
* why the catch blocks read it directly instead of matching on the error
|
|
591
|
+
* type: an `AbortError` we *didn't* raise (a custom `fetch` enforcing a
|
|
592
|
+
* per-attempt deadline, an `auth()` call cancelled by its own controller) is
|
|
593
|
+
* a transient failure to re-dial, exactly as on the REST path. Treating one
|
|
594
|
+
* as a teardown killed the stream permanently, and silently.
|
|
595
|
+
*/
|
|
596
|
+
_closed = false;
|
|
597
|
+
_started = false;
|
|
598
|
+
_counted = false;
|
|
599
|
+
/**
|
|
600
|
+
* Most recent non-empty event id, replayed as `Last-Event-ID` on reconnect.
|
|
601
|
+
*
|
|
602
|
+
* Deliberately ignores empty ids. The server emits a blank `id:` line for
|
|
603
|
+
* passthrough payloads, and per the SSE spec an empty `id` field *clears* the
|
|
604
|
+
* last-event-id — so tracking it faithfully would silently discard the
|
|
605
|
+
* resumption point mid-stream and re-open from the beginning.
|
|
606
|
+
*/
|
|
607
|
+
_lastEventId = null;
|
|
608
|
+
/** Reconnect floor most recently requested by the server via `retry:`. */
|
|
609
|
+
_retryFloorMs = 0;
|
|
610
|
+
/** Cuts a reconnect gap short when set — see `_sleep`. */
|
|
611
|
+
_wake = null;
|
|
563
612
|
onEvent = null;
|
|
564
613
|
onStatus = null;
|
|
565
614
|
onError = null;
|
|
@@ -567,79 +616,332 @@ var SSETransport = class {
|
|
|
567
616
|
this._opts = opts;
|
|
568
617
|
}
|
|
569
618
|
connect() {
|
|
570
|
-
if (
|
|
619
|
+
if (this._closed || this._started) return;
|
|
620
|
+
this._started = true;
|
|
621
|
+
if (!this._opts.fetch && typeof fetch === "undefined") {
|
|
571
622
|
throw new Error(
|
|
572
|
-
"[wavehouse]
|
|
623
|
+
"[wavehouse] global fetch is not available in this environment. Upgrade to Node 22+ or supply one via `options.fetch`."
|
|
573
624
|
);
|
|
574
625
|
}
|
|
575
|
-
|
|
576
|
-
|
|
626
|
+
activeSSEConnections++;
|
|
627
|
+
this._counted = true;
|
|
628
|
+
if (activeSSEConnections > SSE_WARN_THRESHOLD) {
|
|
629
|
+
console.warn(
|
|
630
|
+
`[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
this._run().catch((err2) => {
|
|
634
|
+
this._emitError({
|
|
577
635
|
status: 0,
|
|
578
636
|
code: "SSE_CONNECT_ERROR",
|
|
579
637
|
message: err2 instanceof Error ? err2.message : String(err2),
|
|
580
|
-
retryable:
|
|
638
|
+
retryable: false
|
|
581
639
|
});
|
|
640
|
+
this.disconnect();
|
|
582
641
|
});
|
|
583
642
|
}
|
|
584
643
|
disconnect() {
|
|
585
|
-
if (this.
|
|
586
|
-
this.
|
|
587
|
-
this.
|
|
588
|
-
|
|
644
|
+
if (!this._closed) {
|
|
645
|
+
this._closed = true;
|
|
646
|
+
this._abort?.abort();
|
|
647
|
+
this._abort = null;
|
|
648
|
+
this._wake?.();
|
|
649
|
+
if (this._counted) {
|
|
650
|
+
activeSSEConnections = Math.max(0, activeSSEConnections - 1);
|
|
651
|
+
this._counted = false;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
this.onStatus?.("closed");
|
|
655
|
+
} catch (e) {
|
|
656
|
+
console.error("[wavehouse] SSE status handler threw:", e);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** Connect/read/reconnect until the stream is closed or hits a terminal error. */
|
|
661
|
+
async _run() {
|
|
662
|
+
await Promise.resolve();
|
|
663
|
+
let attempt = 0;
|
|
664
|
+
while (!this._closed) {
|
|
665
|
+
const { liveMs, terminal } = await this._attempt();
|
|
666
|
+
if (this._closed) return;
|
|
667
|
+
if (terminal) {
|
|
668
|
+
this.disconnect();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (liveMs >= STABLE_CONNECTION_MS) attempt = 0;
|
|
672
|
+
this._emitStatus("reconnecting");
|
|
673
|
+
await this._sleep(backoff2(attempt, this._retryFloorMs));
|
|
674
|
+
attempt++;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/** One connection attempt: request, validate, then pump frames until it ends. */
|
|
678
|
+
async _attempt() {
|
|
679
|
+
const ac = new AbortController();
|
|
680
|
+
this._abort = ac;
|
|
681
|
+
let target;
|
|
682
|
+
try {
|
|
683
|
+
target = this._url();
|
|
684
|
+
} catch (e) {
|
|
685
|
+
this._emitError({
|
|
686
|
+
status: 0,
|
|
687
|
+
code: "SSE_CONNECT_ERROR",
|
|
688
|
+
message: e instanceof Error ? e.message : String(e),
|
|
689
|
+
retryable: false
|
|
690
|
+
});
|
|
691
|
+
return { liveMs: 0, terminal: true };
|
|
692
|
+
}
|
|
693
|
+
let init;
|
|
694
|
+
try {
|
|
695
|
+
init = await this._init(ac.signal);
|
|
696
|
+
} catch (e) {
|
|
697
|
+
if (this._closed) return { liveMs: 0, terminal: true };
|
|
698
|
+
this._emitError({
|
|
699
|
+
status: 0,
|
|
700
|
+
code: "SSE_AUTH_ERROR",
|
|
701
|
+
message: e instanceof Error ? e.message : String(e),
|
|
702
|
+
retryable: true
|
|
703
|
+
});
|
|
704
|
+
return { liveMs: 0, terminal: false };
|
|
705
|
+
}
|
|
706
|
+
let res;
|
|
707
|
+
try {
|
|
708
|
+
const doFetch = this._opts.fetch;
|
|
709
|
+
res = doFetch ? await doFetch(target, init) : await fetch(target, init);
|
|
710
|
+
} catch (e) {
|
|
711
|
+
if (this._closed) return { liveMs: 0, terminal: true };
|
|
712
|
+
this._emitError({
|
|
713
|
+
status: 0,
|
|
714
|
+
code: "SSE_NETWORK_ERROR",
|
|
715
|
+
message: e instanceof Error ? e.message : String(e),
|
|
716
|
+
retryable: true
|
|
717
|
+
});
|
|
718
|
+
return { liveMs: 0, terminal: false };
|
|
719
|
+
}
|
|
720
|
+
if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
|
|
721
|
+
this._emitError({
|
|
722
|
+
status: res.status,
|
|
723
|
+
code: "SSE_REDIRECT",
|
|
724
|
+
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`.",
|
|
725
|
+
retryable: false
|
|
726
|
+
});
|
|
727
|
+
releaseBody(res);
|
|
728
|
+
return { liveMs: 0, terminal: true };
|
|
729
|
+
}
|
|
730
|
+
if (!res.ok) {
|
|
731
|
+
const error = await parseErrorResponse(res);
|
|
732
|
+
this._emitError(error);
|
|
733
|
+
return { liveMs: 0, terminal: !error.retryable };
|
|
734
|
+
}
|
|
735
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
736
|
+
if (!contentType.toLowerCase().split(";")[0].trim().startsWith("text/event-stream")) {
|
|
737
|
+
this._emitError({
|
|
738
|
+
status: res.status,
|
|
739
|
+
code: "SSE_BAD_CONTENT_TYPE",
|
|
740
|
+
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.`,
|
|
741
|
+
retryable: false
|
|
742
|
+
});
|
|
743
|
+
releaseBody(res);
|
|
744
|
+
return { liveMs: 0, terminal: true };
|
|
745
|
+
}
|
|
746
|
+
const body = res.body;
|
|
747
|
+
if (!body || typeof body.getReader !== "function" || body.locked || res.bodyUsed) {
|
|
748
|
+
this._emitError({
|
|
749
|
+
status: res.status,
|
|
750
|
+
code: "SSE_NO_STREAM_BODY",
|
|
751
|
+
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()`.",
|
|
752
|
+
retryable: false
|
|
753
|
+
});
|
|
754
|
+
releaseBody(res);
|
|
755
|
+
return { liveMs: 0, terminal: true };
|
|
589
756
|
}
|
|
590
|
-
this.
|
|
757
|
+
this._emitStatus("live");
|
|
758
|
+
const openedAt = Date.now();
|
|
759
|
+
await this._pump(body);
|
|
760
|
+
return { liveMs: Date.now() - openedAt, terminal: false };
|
|
591
761
|
}
|
|
592
|
-
|
|
762
|
+
/** Resolve the stream URL. Throws on a `baseURL` that isn't a usable http(s) origin. */
|
|
763
|
+
_url() {
|
|
593
764
|
const url = resolveURL(this._opts.baseURL, "/v1/stream");
|
|
765
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`baseURL must use http or https, got "${url.protocol}". Streaming is plain HTTP \u2014 there is no WebSocket endpoint.`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
594
770
|
url.searchParams.set("table", this._opts.table);
|
|
595
771
|
if (this._opts.since) {
|
|
596
772
|
url.searchParams.set("since", this._opts.since);
|
|
597
773
|
}
|
|
774
|
+
return url.toString();
|
|
775
|
+
}
|
|
776
|
+
/** Build the request init, minting a token for this attempt. */
|
|
777
|
+
async _init(signal) {
|
|
778
|
+
const base = { Accept: "text/event-stream" };
|
|
598
779
|
if (this._opts.auth) {
|
|
599
780
|
const token = await this._opts.auth();
|
|
600
781
|
if (token) {
|
|
601
|
-
|
|
782
|
+
base.Authorization = `Bearer ${token}`;
|
|
602
783
|
}
|
|
603
784
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
console.warn(
|
|
607
|
-
`[wavehouse] ${activeSSEConnections} SSE connections open. Browsers limit HTTP/1.1 to 6 connections per domain.`
|
|
608
|
-
);
|
|
785
|
+
if (this._lastEventId) {
|
|
786
|
+
base["Last-Event-ID"] = this._lastEventId;
|
|
609
787
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
this.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
788
|
+
const credentialed = base.Authorization !== void 0 || Object.keys(this._opts.headers ?? {}).length > 0;
|
|
789
|
+
const init = {
|
|
790
|
+
...this._opts.fetchOptions,
|
|
791
|
+
method: "GET",
|
|
792
|
+
headers: mergeHeaders(base, this._opts.headers),
|
|
793
|
+
signal,
|
|
794
|
+
// Owned by the SDK rather than left to `fetchOptions`, unlike the REST
|
|
795
|
+
// path, because on a stream these are correctness rather than preference:
|
|
796
|
+
//
|
|
797
|
+
// cache — as an init field, not a `Cache-Control` header. The header
|
|
798
|
+
// form is not in the server's Access-Control-Allow-Headers,
|
|
799
|
+
// so it would fail every cross-origin preflight.
|
|
800
|
+
// redirect — followed only when this request carries no credential.
|
|
801
|
+
// Platforms strip `Authorization` on a cross-origin redirect
|
|
802
|
+
// (whatwg/fetch#1544) and forward other headers intact, so a
|
|
803
|
+
// credentialed hop either silently downgrades the stream to
|
|
804
|
+
// the default role — this endpoint answers an unauthenticated
|
|
805
|
+
// caller rather than rejecting them — or delivers a configured
|
|
806
|
+
// secret to whatever the redirect names. Neither is worth a
|
|
807
|
+
// convenience. Without either, redirects are followed, so CDN
|
|
808
|
+
// canonicalization, geo/LB indirection, and an http→https
|
|
809
|
+
// upgrade all just work. The test is deliberately that
|
|
810
|
+
// concrete pair and not "is this authenticated": cookies do
|
|
811
|
+
// not appear in it. A cookie is re-derived per
|
|
812
|
+
// destination rather than carried, so under the default
|
|
813
|
+
// same-origin credentials mode it survives a redirect only
|
|
814
|
+
// while the hop stays inside both the request's origin and
|
|
815
|
+
// the cookie's Path; outside either the stream is
|
|
816
|
+
// silently unauthed. See #478 and the SDK guide under
|
|
817
|
+
// "Supplying your own fetch".
|
|
818
|
+
// `manual` over `error` for the credentialed case so the
|
|
819
|
+
// outcome stays inspectable: `error` rejects with a bare
|
|
820
|
+
// TypeError indistinguishable from a connection failure, which
|
|
821
|
+
// the loop above would retry forever against a redirect that
|
|
822
|
+
// is never going to stop happening.
|
|
823
|
+
cache: "no-store",
|
|
824
|
+
redirect: credentialed ? "manual" : "follow",
|
|
825
|
+
// Neutralized like the REST path does, and for a sharper reason here: a
|
|
826
|
+
// body on a GET makes `fetch` throw, which reads as a network failure and
|
|
827
|
+
// would be retried forever against a request that can never be built.
|
|
828
|
+
body: void 0
|
|
626
829
|
};
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
830
|
+
if (!("window" in globalThis)) {
|
|
831
|
+
delete init.credentials;
|
|
832
|
+
}
|
|
833
|
+
return init;
|
|
834
|
+
}
|
|
835
|
+
/** Decode and parse frames until the stream ends, aborts, or errors. */
|
|
836
|
+
async _pump(body) {
|
|
837
|
+
let overflowed = false;
|
|
838
|
+
const parser = (0, import_eventsource_parser.createParser)({
|
|
839
|
+
onEvent: (msg) => {
|
|
840
|
+
if (msg.id) this._lastEventId = msg.id;
|
|
841
|
+
if (!msg.data) return;
|
|
842
|
+
this._dispatch(msg.data);
|
|
843
|
+
},
|
|
844
|
+
onRetry: (ms) => {
|
|
845
|
+
this._retryFloorMs = Math.min(Math.max(ms, 0), MAX_BACKOFF_MS);
|
|
846
|
+
},
|
|
847
|
+
onError: (err2) => {
|
|
848
|
+
if (err2.type === "max-buffer-size-exceeded") overflowed = true;
|
|
849
|
+
this._emitError({
|
|
636
850
|
status: 0,
|
|
637
|
-
code: "
|
|
638
|
-
message:
|
|
851
|
+
code: "SSE_PARSE_ERROR",
|
|
852
|
+
message: err2.message,
|
|
639
853
|
retryable: true
|
|
640
854
|
});
|
|
855
|
+
},
|
|
856
|
+
maxBufferSize: MAX_BUFFER_CHARS
|
|
857
|
+
});
|
|
858
|
+
const reader = body.getReader();
|
|
859
|
+
const decoder = new TextDecoder();
|
|
860
|
+
try {
|
|
861
|
+
for (; ; ) {
|
|
862
|
+
const { done, value } = await reader.read();
|
|
863
|
+
if (done) return;
|
|
864
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
865
|
+
if (overflowed) return;
|
|
641
866
|
}
|
|
642
|
-
}
|
|
867
|
+
} catch (e) {
|
|
868
|
+
if (this._closed) return;
|
|
869
|
+
this._emitError({
|
|
870
|
+
status: 0,
|
|
871
|
+
code: "SSE_READ_ERROR",
|
|
872
|
+
message: e instanceof Error ? e.message : String(e),
|
|
873
|
+
retryable: true
|
|
874
|
+
});
|
|
875
|
+
} finally {
|
|
876
|
+
reader.cancel().catch(() => {
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Turn one frame's `data` into a StreamEvent.
|
|
882
|
+
*
|
|
883
|
+
* Guarded like `_emitError` and `_emitStatus`: `parser.feed()` dispatches
|
|
884
|
+
* every complete frame in a chunk synchronously, so a subscriber that closes
|
|
885
|
+
* from inside its own `next` — "read until I see my event, then stop" —
|
|
886
|
+
* would otherwise keep receiving the rest of that chunk. `EventSource` ended
|
|
887
|
+
* delivery on `close()`, and gap-fill replay routinely lands many frames in
|
|
888
|
+
* one chunk.
|
|
889
|
+
*/
|
|
890
|
+
_dispatch(data) {
|
|
891
|
+
if (this._closed) return;
|
|
892
|
+
let event;
|
|
893
|
+
try {
|
|
894
|
+
const msg = JSON.parse(data);
|
|
895
|
+
event = { table: msg.table_name, timestamp: msg.received_timestamp, data: msg.data };
|
|
896
|
+
} catch {
|
|
897
|
+
console.warn("[wavehouse] SSE received malformed message:", data);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
this.onEvent?.(event);
|
|
902
|
+
} catch (e) {
|
|
903
|
+
console.error("[wavehouse] SSE event handler threw:", e);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/** Suppress callbacks that race a `disconnect()`. */
|
|
907
|
+
_emitError(error) {
|
|
908
|
+
if (this._closed) return;
|
|
909
|
+
try {
|
|
910
|
+
this.onError?.(error);
|
|
911
|
+
} catch (e) {
|
|
912
|
+
console.error("[wavehouse] SSE error handler threw:", e);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Same guard for status. Aborting does not retract an already-resolved
|
|
917
|
+
* `Response`, so a `disconnect()` landing between the fetch settling and the
|
|
918
|
+
* "live" below would otherwise flip a closed controller back to live — and it
|
|
919
|
+
* stays there, since the loop then exits without emitting anything further.
|
|
920
|
+
* `disconnect()` calls `onStatus` directly; that terminal "closed" is the one
|
|
921
|
+
* status that must always get through.
|
|
922
|
+
*/
|
|
923
|
+
_emitStatus(status) {
|
|
924
|
+
if (this._closed) return;
|
|
925
|
+
try {
|
|
926
|
+
this.onStatus?.(status);
|
|
927
|
+
} catch (e) {
|
|
928
|
+
console.error("[wavehouse] SSE status handler threw:", e);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/** Wait out a reconnect gap, cut short by `disconnect()`. */
|
|
932
|
+
_sleep(ms) {
|
|
933
|
+
if (this._closed) return Promise.resolve();
|
|
934
|
+
return new Promise((resolve) => {
|
|
935
|
+
const timer = setTimeout(() => {
|
|
936
|
+
this._wake = null;
|
|
937
|
+
resolve();
|
|
938
|
+
}, ms);
|
|
939
|
+
this._wake = () => {
|
|
940
|
+
clearTimeout(timer);
|
|
941
|
+
this._wake = null;
|
|
942
|
+
resolve();
|
|
943
|
+
};
|
|
944
|
+
});
|
|
643
945
|
}
|
|
644
946
|
};
|
|
645
947
|
|
|
@@ -1177,16 +1479,14 @@ var WaveHouseClient = class {
|
|
|
1177
1479
|
}
|
|
1178
1480
|
/** @internal Create a stream for the given table. */
|
|
1179
1481
|
_createStream(table, opts) {
|
|
1180
|
-
if (typeof EventSource === "undefined") {
|
|
1181
|
-
throw new Error(
|
|
1182
|
-
"[WaveHouse SDK] Native EventSource is not available in this environment. Please provide a global polyfill (e.g., `globalThis.EventSource = require('eventsource')`)."
|
|
1183
|
-
);
|
|
1184
|
-
}
|
|
1185
1482
|
const transport = new SSETransport({
|
|
1186
1483
|
baseURL: this._ctx.baseURL,
|
|
1187
1484
|
table,
|
|
1188
1485
|
since: opts?.since,
|
|
1189
|
-
auth: this._ctx.auth
|
|
1486
|
+
auth: this._ctx.auth,
|
|
1487
|
+
fetch: this._ctx.options.fetch,
|
|
1488
|
+
headers: this._ctx.options.headers,
|
|
1489
|
+
fetchOptions: this._ctx.options.fetchOptions
|
|
1190
1490
|
});
|
|
1191
1491
|
const controller = new StreamController(transport);
|
|
1192
1492
|
if (opts?.signal) controller.attachSignal(opts.signal);
|