@pramen/client 0.0.14 → 0.0.15

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.d.ts CHANGED
@@ -19,13 +19,27 @@ export interface ClientOptions {
19
19
  /** Override for non-browser environments (defaults to globals). */
20
20
  WebSocketImpl?: typeof WebSocket;
21
21
  fetchImpl?: typeof fetch;
22
+ /** Give up reconnecting the live socket after this many consecutive failed
23
+ * attempts and surface a connection error to every subscriber (default 8). */
24
+ maxReconnectAttempts?: number;
22
25
  }
23
26
  export interface SubHandlers<T> {
24
27
  onData: (result: T) => void;
28
+ /** Called when the server rejects THIS subscription (a `{type:"error"}` frame). */
25
29
  onError?: (err: {
26
30
  error: string;
27
31
  code: string;
28
32
  }) => void;
33
+ /** Called when the underlying live connection itself fails — no WebSocket
34
+ * implementation is available, or the socket has closed and stayed down past
35
+ * `maxReconnectAttempts`. Distinct from `onError` (a per-subscription server error):
36
+ * this is a transport-level failure that affects every sub on the connection.
37
+ * Optional and additive — omitting it preserves the prior (silent) behavior for
38
+ * existing callers. */
39
+ onConnectionError?: (err: {
40
+ error: string;
41
+ code: string;
42
+ }) => void;
29
43
  }
30
44
  /** Result of a file upload (the persisted blob's storage metadata). */
31
45
  export interface UploadResult {
package/dist/index.js CHANGED
@@ -21,6 +21,11 @@ export class PramenError extends Error {
21
21
  export function createClient(opts) {
22
22
  const doFetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
23
23
  const WS = opts.WebSocketImpl ?? globalThis.WebSocket;
24
+ // Normalize the base url: strip any trailing slash so `${base}/rpc/...` can't become
25
+ // `//rpc/...` (which the Worker serves as a plain-text help page — a 200 that isn't an
26
+ // envelope, silently resolving `call()` to undefined for every RPC).
27
+ const baseUrl = opts.url.replace(/\/+$/, "");
28
+ const maxReconnectAttempts = opts.maxReconnectAttempts ?? 8;
24
29
  let token = opts.token;
25
30
  // D1 read-your-writes: when the backend runs on the D1 store it returns an
26
31
  // `x-pramen-d1-bookmark` header marking the latest write this client has observed.
@@ -33,7 +38,10 @@ export function createClient(opts) {
33
38
  let counter = 0;
34
39
  let reconnectAttempts = 0;
35
40
  let reconnectTimer = null;
41
+ let stableTimer = null;
36
42
  let closed = false;
43
+ // How long a connection must stay open before we trust it and reset the backoff.
44
+ const STABLE_MS = 3000;
37
45
  async function call(name, input) {
38
46
  const headers = { "content-type": "application/json" };
39
47
  if (token)
@@ -42,23 +50,29 @@ export function createClient(opts) {
42
50
  headers["x-pramen-tenant"] = opts.tenant;
43
51
  if (d1Bookmark)
44
52
  headers["x-pramen-d1-bookmark"] = d1Bookmark;
45
- const res = await doFetch(`${opts.url}/rpc/${name}`, {
53
+ const res = await doFetch(`${baseUrl}/rpc/${name}`, {
46
54
  method: "POST",
47
55
  headers,
48
56
  body: JSON.stringify(input ?? {}),
49
57
  });
50
58
  // Capture the D1 read-your-writes bookmark (D1 store only; absent on the DO path).
59
+ // Keep the MAXIMUM bookmark seen — D1 session bookmarks are lexicographically
60
+ // ordered, so a slower earlier response arriving after a mutation must not clobber a
61
+ // newer one and regress read-your-writes.
51
62
  const bookmark = res.headers.get("x-pramen-d1-bookmark");
52
- if (bookmark)
63
+ if (bookmark && (!d1Bookmark || bookmark > d1Bookmark))
53
64
  d1Bookmark = bookmark;
54
65
  const body = (await res.json().catch(() => ({})));
55
- if (!res.ok || body.ok === false) {
66
+ // Require the success envelope explicitly: only `{ ok: true }` is a success. Any
67
+ // other 2xx body (e.g. a non-JSON help page parsed to `{}`, or `ok` absent) is an
68
+ // error, so a call never silently resolves undefined.
69
+ if (!res.ok || body.ok !== true) {
56
70
  throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
57
71
  }
58
72
  return body.result;
59
73
  }
60
74
  function liveUrl() {
61
- const u = new URL(opts.url);
75
+ const u = new URL(baseUrl);
62
76
  u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
63
77
  u.pathname = "/live";
64
78
  if (opts.tenant)
@@ -67,13 +81,34 @@ export function createClient(opts) {
67
81
  u.searchParams.set("token", token);
68
82
  return u.toString();
69
83
  }
84
+ /** Surface a transport-level failure (no WS impl, or reconnects exhausted) to every
85
+ * subscriber so a `useLiveQuery` can leave its loading state instead of hanging. */
86
+ function reportConnectionError(error, code) {
87
+ for (const sub of subs.values())
88
+ sub.onConnectionError?.({ error, code });
89
+ }
70
90
  function ensureSocket() {
71
- if (closed || ws || !WS || subs.size === 0)
91
+ if (closed || ws || subs.size === 0)
92
+ return;
93
+ if (!WS) {
94
+ // No WebSocket implementation in this environment — the subscription can never
95
+ // fire. Surface it instead of returning silently (the old hang-forever behavior).
96
+ reportConnectionError("no WebSocket implementation available", "no_websocket");
72
97
  return;
98
+ }
73
99
  const socket = new WS(liveUrl());
74
100
  ws = socket;
75
101
  socket.addEventListener("open", () => {
76
- reconnectAttempts = 0;
102
+ // Don't reset the backoff on open alone: an accept-then-immediately-close server
103
+ // would otherwise hot-loop with no backoff growth. Only clear it once the
104
+ // connection has stayed up (stableTimer below).
105
+ if (stableTimer)
106
+ clearTimeout(stableTimer);
107
+ stableTimer = setTimeout(() => {
108
+ stableTimer = null;
109
+ if (ws === socket)
110
+ reconnectAttempts = 0;
111
+ }, STABLE_MS);
77
112
  for (const sub of subs.values()) {
78
113
  socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
79
114
  }
@@ -97,6 +132,10 @@ export function createClient(opts) {
97
132
  socket.addEventListener("close", () => {
98
133
  if (ws === socket)
99
134
  ws = null;
135
+ if (stableTimer) {
136
+ clearTimeout(stableTimer);
137
+ stableTimer = null;
138
+ }
100
139
  scheduleReconnect();
101
140
  });
102
141
  socket.addEventListener("error", () => {
@@ -111,7 +150,15 @@ export function createClient(opts) {
111
150
  function scheduleReconnect() {
112
151
  if (closed || reconnectTimer || subs.size === 0)
113
152
  return;
114
- const delay = Math.min(500 * 2 ** reconnectAttempts, 10_000);
153
+ if (reconnectAttempts >= maxReconnectAttempts) {
154
+ // Give up rather than reconnect forever at the 10s cap — a rejected upgrade (e.g.
155
+ // auth 403) would otherwise spin silently behind a permanent spinner.
156
+ reportConnectionError(`live connection failed after ${reconnectAttempts} attempts`, "connection_failed");
157
+ return;
158
+ }
159
+ // Exponential backoff with jitter (so a fleet of clients doesn't reconnect in lockstep).
160
+ const base = Math.min(500 * 2 ** reconnectAttempts, 10_000);
161
+ const delay = base / 2 + Math.random() * (base / 2);
115
162
  reconnectAttempts++;
116
163
  reconnectTimer = setTimeout(() => {
117
164
  reconnectTimer = null;
@@ -132,17 +179,21 @@ export function createClient(opts) {
132
179
  clearTimeout(reconnectTimer);
133
180
  reconnectTimer = null;
134
181
  }
182
+ if (stableTimer) {
183
+ clearTimeout(stableTimer);
184
+ stableTimer = null;
185
+ }
135
186
  reconnectAttempts = 0;
136
187
  ensureSocket();
137
188
  }
138
- const fileUrl = (path) => new URL(path, opts.url).toString();
189
+ const fileUrl = (path) => new URL(path, baseUrl).toString();
139
190
  async function upload(uploadUrl, body, o) {
140
191
  const headers = {};
141
192
  if (o?.contentType)
142
193
  headers["content-type"] = o.contentType;
143
194
  const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
144
195
  const j = (await res.json().catch(() => ({})));
145
- if (!res.ok || j.ok === false) {
196
+ if (!res.ok || j.ok !== true) {
146
197
  throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
147
198
  }
148
199
  return j.result;
@@ -159,6 +210,7 @@ export function createClient(opts) {
159
210
  input,
160
211
  onData: handlers.onData,
161
212
  onError: handlers.onError,
213
+ onConnectionError: handlers.onConnectionError,
162
214
  };
163
215
  subs.set(id, sub);
164
216
  if (ws && ws.readyState === 1) {
@@ -182,6 +234,8 @@ export function createClient(opts) {
182
234
  subs.clear();
183
235
  if (reconnectTimer)
184
236
  clearTimeout(reconnectTimer);
237
+ if (stableTimer)
238
+ clearTimeout(stableTimer);
185
239
  if (ws) {
186
240
  try {
187
241
  ws.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/client",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Typed client for a pramen backend — RPC over HTTP + live queries over WebSocket.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -34,11 +34,22 @@ export interface ClientOptions {
34
34
  /** Override for non-browser environments (defaults to globals). */
35
35
  WebSocketImpl?: typeof WebSocket;
36
36
  fetchImpl?: typeof fetch;
37
+ /** Give up reconnecting the live socket after this many consecutive failed
38
+ * attempts and surface a connection error to every subscriber (default 8). */
39
+ maxReconnectAttempts?: number;
37
40
  }
38
41
 
39
42
  export interface SubHandlers<T> {
40
43
  onData: (result: T) => void;
44
+ /** Called when the server rejects THIS subscription (a `{type:"error"}` frame). */
41
45
  onError?: (err: { error: string; code: string }) => void;
46
+ /** Called when the underlying live connection itself fails — no WebSocket
47
+ * implementation is available, or the socket has closed and stayed down past
48
+ * `maxReconnectAttempts`. Distinct from `onError` (a per-subscription server error):
49
+ * this is a transport-level failure that affects every sub on the connection.
50
+ * Optional and additive — omitting it preserves the prior (silent) behavior for
51
+ * existing callers. */
52
+ onConnectionError?: (err: { error: string; code: string }) => void;
42
53
  }
43
54
 
44
55
  /** Result of a file upload (the persisted blob's storage metadata). */
@@ -72,11 +83,17 @@ interface Sub {
72
83
  input: unknown;
73
84
  onData: (result: unknown) => void;
74
85
  onError?: (err: { error: string; code: string }) => void;
86
+ onConnectionError?: (err: { error: string; code: string }) => void;
75
87
  }
76
88
 
77
89
  export function createClient<Api = Record<string, never>>(opts: ClientOptions): PramenClient<Api> {
78
90
  const doFetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
79
91
  const WS = opts.WebSocketImpl ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
92
+ // Normalize the base url: strip any trailing slash so `${base}/rpc/...` can't become
93
+ // `//rpc/...` (which the Worker serves as a plain-text help page — a 200 that isn't an
94
+ // envelope, silently resolving `call()` to undefined for every RPC).
95
+ const baseUrl = opts.url.replace(/\/+$/, "");
96
+ const maxReconnectAttempts = opts.maxReconnectAttempts ?? 8;
80
97
  let token = opts.token;
81
98
 
82
99
  // D1 read-your-writes: when the backend runs on the D1 store it returns an
@@ -91,30 +108,39 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
91
108
  let counter = 0;
92
109
  let reconnectAttempts = 0;
93
110
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
111
+ let stableTimer: ReturnType<typeof setTimeout> | null = null;
94
112
  let closed = false;
113
+ // How long a connection must stay open before we trust it and reset the backoff.
114
+ const STABLE_MS = 3000;
95
115
 
96
116
  async function call(name: string, input?: unknown): Promise<unknown> {
97
117
  const headers: Record<string, string> = { "content-type": "application/json" };
98
118
  if (token) headers.authorization = `Bearer ${token}`;
99
119
  if (opts.tenant) headers["x-pramen-tenant"] = opts.tenant;
100
120
  if (d1Bookmark) headers["x-pramen-d1-bookmark"] = d1Bookmark;
101
- const res = await doFetch(`${opts.url}/rpc/${name}`, {
121
+ const res = await doFetch(`${baseUrl}/rpc/${name}`, {
102
122
  method: "POST",
103
123
  headers,
104
124
  body: JSON.stringify(input ?? {}),
105
125
  });
106
126
  // Capture the D1 read-your-writes bookmark (D1 store only; absent on the DO path).
127
+ // Keep the MAXIMUM bookmark seen — D1 session bookmarks are lexicographically
128
+ // ordered, so a slower earlier response arriving after a mutation must not clobber a
129
+ // newer one and regress read-your-writes.
107
130
  const bookmark = res.headers.get("x-pramen-d1-bookmark");
108
- if (bookmark) d1Bookmark = bookmark;
131
+ if (bookmark && (!d1Bookmark || bookmark > d1Bookmark)) d1Bookmark = bookmark;
109
132
  const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string; code?: string };
110
- if (!res.ok || body.ok === false) {
133
+ // Require the success envelope explicitly: only `{ ok: true }` is a success. Any
134
+ // other 2xx body (e.g. a non-JSON help page parsed to `{}`, or `ok` absent) is an
135
+ // error, so a call never silently resolves undefined.
136
+ if (!res.ok || body.ok !== true) {
111
137
  throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
112
138
  }
113
139
  return body.result;
114
140
  }
115
141
 
116
142
  function liveUrl(): string {
117
- const u = new URL(opts.url);
143
+ const u = new URL(baseUrl);
118
144
  u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
119
145
  u.pathname = "/live";
120
146
  if (opts.tenant) u.searchParams.set("tenant", opts.tenant);
@@ -122,12 +148,31 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
122
148
  return u.toString();
123
149
  }
124
150
 
151
+ /** Surface a transport-level failure (no WS impl, or reconnects exhausted) to every
152
+ * subscriber so a `useLiveQuery` can leave its loading state instead of hanging. */
153
+ function reportConnectionError(error: string, code: string): void {
154
+ for (const sub of subs.values()) sub.onConnectionError?.({ error, code });
155
+ }
156
+
125
157
  function ensureSocket(): void {
126
- if (closed || ws || !WS || subs.size === 0) return;
158
+ if (closed || ws || subs.size === 0) return;
159
+ if (!WS) {
160
+ // No WebSocket implementation in this environment — the subscription can never
161
+ // fire. Surface it instead of returning silently (the old hang-forever behavior).
162
+ reportConnectionError("no WebSocket implementation available", "no_websocket");
163
+ return;
164
+ }
127
165
  const socket = new WS(liveUrl());
128
166
  ws = socket;
129
167
  socket.addEventListener("open", () => {
130
- reconnectAttempts = 0;
168
+ // Don't reset the backoff on open alone: an accept-then-immediately-close server
169
+ // would otherwise hot-loop with no backoff growth. Only clear it once the
170
+ // connection has stayed up (stableTimer below).
171
+ if (stableTimer) clearTimeout(stableTimer);
172
+ stableTimer = setTimeout(() => {
173
+ stableTimer = null;
174
+ if (ws === socket) reconnectAttempts = 0;
175
+ }, STABLE_MS);
131
176
  for (const sub of subs.values()) {
132
177
  socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
133
178
  }
@@ -146,6 +191,10 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
146
191
  });
147
192
  socket.addEventListener("close", () => {
148
193
  if (ws === socket) ws = null;
194
+ if (stableTimer) {
195
+ clearTimeout(stableTimer);
196
+ stableTimer = null;
197
+ }
149
198
  scheduleReconnect();
150
199
  });
151
200
  socket.addEventListener("error", () => {
@@ -159,7 +208,15 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
159
208
 
160
209
  function scheduleReconnect(): void {
161
210
  if (closed || reconnectTimer || subs.size === 0) return;
162
- const delay = Math.min(500 * 2 ** reconnectAttempts, 10_000);
211
+ if (reconnectAttempts >= maxReconnectAttempts) {
212
+ // Give up rather than reconnect forever at the 10s cap — a rejected upgrade (e.g.
213
+ // auth 403) would otherwise spin silently behind a permanent spinner.
214
+ reportConnectionError(`live connection failed after ${reconnectAttempts} attempts`, "connection_failed");
215
+ return;
216
+ }
217
+ // Exponential backoff with jitter (so a fleet of clients doesn't reconnect in lockstep).
218
+ const base = Math.min(500 * 2 ** reconnectAttempts, 10_000);
219
+ const delay = base / 2 + Math.random() * (base / 2);
163
220
  reconnectAttempts++;
164
221
  reconnectTimer = setTimeout(() => {
165
222
  reconnectTimer = null;
@@ -180,18 +237,22 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
180
237
  clearTimeout(reconnectTimer);
181
238
  reconnectTimer = null;
182
239
  }
240
+ if (stableTimer) {
241
+ clearTimeout(stableTimer);
242
+ stableTimer = null;
243
+ }
183
244
  reconnectAttempts = 0;
184
245
  ensureSocket();
185
246
  }
186
247
 
187
- const fileUrl = (path: string): string => new URL(path, opts.url).toString();
248
+ const fileUrl = (path: string): string => new URL(path, baseUrl).toString();
188
249
 
189
250
  async function upload(uploadUrl: string, body: BodyInit, o?: { contentType?: string }): Promise<UploadResult> {
190
251
  const headers: Record<string, string> = {};
191
252
  if (o?.contentType) headers["content-type"] = o.contentType;
192
253
  const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
193
254
  const j = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: UploadResult; error?: string; code?: string };
194
- if (!res.ok || j.ok === false) {
255
+ if (!res.ok || j.ok !== true) {
195
256
  throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
196
257
  }
197
258
  return j.result as UploadResult;
@@ -209,6 +270,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
209
270
  input,
210
271
  onData: handlers.onData as (r: unknown) => void,
211
272
  onError: handlers.onError,
273
+ onConnectionError: handlers.onConnectionError,
212
274
  };
213
275
  subs.set(id, sub);
214
276
  if (ws && ws.readyState === 1) {
@@ -229,6 +291,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
229
291
  closed = true;
230
292
  subs.clear();
231
293
  if (reconnectTimer) clearTimeout(reconnectTimer);
294
+ if (stableTimer) clearTimeout(stableTimer);
232
295
  if (ws) {
233
296
  try {
234
297
  ws.close();