@pramen/client 0.0.13 → 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,32 +21,58 @@ 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;
30
+ // D1 read-your-writes: when the backend runs on the D1 store it returns an
31
+ // `x-pramen-d1-bookmark` header marking the latest write this client has observed.
32
+ // We stash it and echo it on the next request so a fresh D1 session anchors there and
33
+ // reads our own writes (even off a lagging replica). The DO path never sets the
34
+ // header, so `d1Bookmark` stays undefined there and nothing changes.
35
+ let d1Bookmark;
25
36
  const subs = new Map();
26
37
  let ws = null;
27
38
  let counter = 0;
28
39
  let reconnectAttempts = 0;
29
40
  let reconnectTimer = null;
41
+ let stableTimer = null;
30
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;
31
45
  async function call(name, input) {
32
46
  const headers = { "content-type": "application/json" };
33
47
  if (token)
34
48
  headers.authorization = `Bearer ${token}`;
35
49
  if (opts.tenant)
36
50
  headers["x-pramen-tenant"] = opts.tenant;
37
- const res = await doFetch(`${opts.url}/rpc/${name}`, {
51
+ if (d1Bookmark)
52
+ headers["x-pramen-d1-bookmark"] = d1Bookmark;
53
+ const res = await doFetch(`${baseUrl}/rpc/${name}`, {
38
54
  method: "POST",
39
55
  headers,
40
56
  body: JSON.stringify(input ?? {}),
41
57
  });
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.
62
+ const bookmark = res.headers.get("x-pramen-d1-bookmark");
63
+ if (bookmark && (!d1Bookmark || bookmark > d1Bookmark))
64
+ d1Bookmark = bookmark;
42
65
  const body = (await res.json().catch(() => ({})));
43
- 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) {
44
70
  throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
45
71
  }
46
72
  return body.result;
47
73
  }
48
74
  function liveUrl() {
49
- const u = new URL(opts.url);
75
+ const u = new URL(baseUrl);
50
76
  u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
51
77
  u.pathname = "/live";
52
78
  if (opts.tenant)
@@ -55,13 +81,34 @@ export function createClient(opts) {
55
81
  u.searchParams.set("token", token);
56
82
  return u.toString();
57
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
+ }
58
90
  function ensureSocket() {
59
- 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");
60
97
  return;
98
+ }
61
99
  const socket = new WS(liveUrl());
62
100
  ws = socket;
63
101
  socket.addEventListener("open", () => {
64
- 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);
65
112
  for (const sub of subs.values()) {
66
113
  socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
67
114
  }
@@ -85,6 +132,10 @@ export function createClient(opts) {
85
132
  socket.addEventListener("close", () => {
86
133
  if (ws === socket)
87
134
  ws = null;
135
+ if (stableTimer) {
136
+ clearTimeout(stableTimer);
137
+ stableTimer = null;
138
+ }
88
139
  scheduleReconnect();
89
140
  });
90
141
  socket.addEventListener("error", () => {
@@ -99,7 +150,15 @@ export function createClient(opts) {
99
150
  function scheduleReconnect() {
100
151
  if (closed || reconnectTimer || subs.size === 0)
101
152
  return;
102
- 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);
103
162
  reconnectAttempts++;
104
163
  reconnectTimer = setTimeout(() => {
105
164
  reconnectTimer = null;
@@ -120,17 +179,21 @@ export function createClient(opts) {
120
179
  clearTimeout(reconnectTimer);
121
180
  reconnectTimer = null;
122
181
  }
182
+ if (stableTimer) {
183
+ clearTimeout(stableTimer);
184
+ stableTimer = null;
185
+ }
123
186
  reconnectAttempts = 0;
124
187
  ensureSocket();
125
188
  }
126
- const fileUrl = (path) => new URL(path, opts.url).toString();
189
+ const fileUrl = (path) => new URL(path, baseUrl).toString();
127
190
  async function upload(uploadUrl, body, o) {
128
191
  const headers = {};
129
192
  if (o?.contentType)
130
193
  headers["content-type"] = o.contentType;
131
194
  const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
132
195
  const j = (await res.json().catch(() => ({})));
133
- if (!res.ok || j.ok === false) {
196
+ if (!res.ok || j.ok !== true) {
134
197
  throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
135
198
  }
136
199
  return j.result;
@@ -147,6 +210,7 @@ export function createClient(opts) {
147
210
  input,
148
211
  onData: handlers.onData,
149
212
  onError: handlers.onError,
213
+ onConnectionError: handlers.onConnectionError,
150
214
  };
151
215
  subs.set(id, sub);
152
216
  if (ws && ws.readyState === 1) {
@@ -170,6 +234,8 @@ export function createClient(opts) {
170
234
  subs.clear();
171
235
  if (reconnectTimer)
172
236
  clearTimeout(reconnectTimer);
237
+ if (stableTimer)
238
+ clearTimeout(stableTimer);
173
239
  if (ws) {
174
240
  try {
175
241
  ws.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/client",
3
- "version": "0.0.13",
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,38 +83,64 @@ 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
 
99
+ // D1 read-your-writes: when the backend runs on the D1 store it returns an
100
+ // `x-pramen-d1-bookmark` header marking the latest write this client has observed.
101
+ // We stash it and echo it on the next request so a fresh D1 session anchors there and
102
+ // reads our own writes (even off a lagging replica). The DO path never sets the
103
+ // header, so `d1Bookmark` stays undefined there and nothing changes.
104
+ let d1Bookmark: string | undefined;
105
+
82
106
  const subs = new Map<string, Sub>();
83
107
  let ws: WebSocket | null = null;
84
108
  let counter = 0;
85
109
  let reconnectAttempts = 0;
86
110
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
111
+ let stableTimer: ReturnType<typeof setTimeout> | null = null;
87
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;
88
115
 
89
116
  async function call(name: string, input?: unknown): Promise<unknown> {
90
117
  const headers: Record<string, string> = { "content-type": "application/json" };
91
118
  if (token) headers.authorization = `Bearer ${token}`;
92
119
  if (opts.tenant) headers["x-pramen-tenant"] = opts.tenant;
93
- const res = await doFetch(`${opts.url}/rpc/${name}`, {
120
+ if (d1Bookmark) headers["x-pramen-d1-bookmark"] = d1Bookmark;
121
+ const res = await doFetch(`${baseUrl}/rpc/${name}`, {
94
122
  method: "POST",
95
123
  headers,
96
124
  body: JSON.stringify(input ?? {}),
97
125
  });
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.
130
+ const bookmark = res.headers.get("x-pramen-d1-bookmark");
131
+ if (bookmark && (!d1Bookmark || bookmark > d1Bookmark)) d1Bookmark = bookmark;
98
132
  const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string; code?: string };
99
- 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) {
100
137
  throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
101
138
  }
102
139
  return body.result;
103
140
  }
104
141
 
105
142
  function liveUrl(): string {
106
- const u = new URL(opts.url);
143
+ const u = new URL(baseUrl);
107
144
  u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
108
145
  u.pathname = "/live";
109
146
  if (opts.tenant) u.searchParams.set("tenant", opts.tenant);
@@ -111,12 +148,31 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
111
148
  return u.toString();
112
149
  }
113
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
+
114
157
  function ensureSocket(): void {
115
- 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
+ }
116
165
  const socket = new WS(liveUrl());
117
166
  ws = socket;
118
167
  socket.addEventListener("open", () => {
119
- 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);
120
176
  for (const sub of subs.values()) {
121
177
  socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
122
178
  }
@@ -135,6 +191,10 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
135
191
  });
136
192
  socket.addEventListener("close", () => {
137
193
  if (ws === socket) ws = null;
194
+ if (stableTimer) {
195
+ clearTimeout(stableTimer);
196
+ stableTimer = null;
197
+ }
138
198
  scheduleReconnect();
139
199
  });
140
200
  socket.addEventListener("error", () => {
@@ -148,7 +208,15 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
148
208
 
149
209
  function scheduleReconnect(): void {
150
210
  if (closed || reconnectTimer || subs.size === 0) return;
151
- 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);
152
220
  reconnectAttempts++;
153
221
  reconnectTimer = setTimeout(() => {
154
222
  reconnectTimer = null;
@@ -169,18 +237,22 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
169
237
  clearTimeout(reconnectTimer);
170
238
  reconnectTimer = null;
171
239
  }
240
+ if (stableTimer) {
241
+ clearTimeout(stableTimer);
242
+ stableTimer = null;
243
+ }
172
244
  reconnectAttempts = 0;
173
245
  ensureSocket();
174
246
  }
175
247
 
176
- const fileUrl = (path: string): string => new URL(path, opts.url).toString();
248
+ const fileUrl = (path: string): string => new URL(path, baseUrl).toString();
177
249
 
178
250
  async function upload(uploadUrl: string, body: BodyInit, o?: { contentType?: string }): Promise<UploadResult> {
179
251
  const headers: Record<string, string> = {};
180
252
  if (o?.contentType) headers["content-type"] = o.contentType;
181
253
  const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
182
254
  const j = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: UploadResult; error?: string; code?: string };
183
- if (!res.ok || j.ok === false) {
255
+ if (!res.ok || j.ok !== true) {
184
256
  throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
185
257
  }
186
258
  return j.result as UploadResult;
@@ -198,6 +270,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
198
270
  input,
199
271
  onData: handlers.onData as (r: unknown) => void,
200
272
  onError: handlers.onError,
273
+ onConnectionError: handlers.onConnectionError,
201
274
  };
202
275
  subs.set(id, sub);
203
276
  if (ws && ws.readyState === 1) {
@@ -218,6 +291,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
218
291
  closed = true;
219
292
  subs.clear();
220
293
  if (reconnectTimer) clearTimeout(reconnectTimer);
294
+ if (stableTimer) clearTimeout(stableTimer);
221
295
  if (ws) {
222
296
  try {
223
297
  ws.close();