cabalspy-id 0.5.1 → 0.5.2

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +146 -112
  2. package/LICENSE +21 -21
  3. package/README.md +505 -475
  4. package/dist/api.cjs +5 -3
  5. package/dist/api.cjs.map +1 -1
  6. package/dist/api.d.cts +1 -1
  7. package/dist/api.d.ts +1 -1
  8. package/dist/api.js +16 -14
  9. package/dist/chunk-A2ZAFAWC.cjs.map +1 -1
  10. package/dist/chunk-ABFIPCBY.cjs +109 -0
  11. package/dist/chunk-ABFIPCBY.cjs.map +1 -0
  12. package/dist/{chunk-Z3UPDOBY.js → chunk-DBYCRBVS.js} +9 -7
  13. package/dist/chunk-DBYCRBVS.js.map +1 -0
  14. package/dist/{chunk-P3MDLWLN.cjs → chunk-PKFIMBPW.cjs} +113 -54
  15. package/dist/chunk-PKFIMBPW.cjs.map +1 -0
  16. package/dist/{chunk-L4RHSWOA.cjs → chunk-ULEBNY2A.cjs} +121 -212
  17. package/dist/chunk-ULEBNY2A.cjs.map +1 -0
  18. package/dist/{chunk-X3OFGGSE.cjs → chunk-VDFCMYLR.cjs} +50 -48
  19. package/dist/chunk-VDFCMYLR.cjs.map +1 -0
  20. package/dist/chunk-VOTYIQAY.js +109 -0
  21. package/dist/chunk-VOTYIQAY.js.map +1 -0
  22. package/dist/{chunk-3LKLY5WQ.js → chunk-XLBO2RSL.js} +15 -106
  23. package/dist/chunk-XLBO2RSL.js.map +1 -0
  24. package/dist/{chunk-3P7QK2PG.js → chunk-Z23B5TY6.js} +89 -30
  25. package/dist/chunk-Z23B5TY6.js.map +1 -0
  26. package/dist/index.cjs +13 -5
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +3 -3
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +25 -17
  31. package/dist/index.js.map +1 -1
  32. package/dist/react.cjs +55 -2
  33. package/dist/react.cjs.map +1 -1
  34. package/dist/react.d.cts +28 -5
  35. package/dist/react.d.ts +28 -5
  36. package/dist/react.js +55 -2
  37. package/dist/react.js.map +1 -1
  38. package/dist/thesis.cjs +11 -3
  39. package/dist/thesis.cjs.map +1 -1
  40. package/dist/thesis.d.cts +42 -11
  41. package/dist/thesis.d.ts +42 -11
  42. package/dist/thesis.js +24 -16
  43. package/dist/{users-BDLkwW1p.d.cts → users-3DAw88_U.d.cts} +4 -4
  44. package/dist/{users-CDlDRuyv.d.ts → users-B4r33i4O.d.ts} +4 -4
  45. package/dist/widget.cjs +28 -12
  46. package/dist/widget.cjs.map +1 -1
  47. package/dist/widget.d.cts +37 -6
  48. package/dist/widget.d.ts +37 -6
  49. package/dist/widget.js +17 -1
  50. package/dist/widget.js.map +1 -1
  51. package/package.json +147 -147
  52. package/dist/chunk-3LKLY5WQ.js.map +0 -1
  53. package/dist/chunk-3P7QK2PG.js.map +0 -1
  54. package/dist/chunk-L4RHSWOA.cjs.map +0 -1
  55. package/dist/chunk-P3MDLWLN.cjs.map +0 -1
  56. package/dist/chunk-X3OFGGSE.cjs.map +0 -1
  57. package/dist/chunk-Z3UPDOBY.js.map +0 -1
@@ -3,6 +3,7 @@
3
3
 
4
4
 
5
5
 
6
+ var _chunkULEBNY2Acjs = require('./chunk-ULEBNY2A.cjs');
6
7
 
7
8
 
8
9
 
@@ -11,28 +12,41 @@
11
12
 
12
13
 
13
14
 
14
- var _chunkL4RHSWOAcjs = require('./chunk-L4RHSWOA.cjs');
15
+
16
+
17
+ var _chunkABFIPCBYcjs = require('./chunk-ABFIPCBY.cjs');
15
18
 
16
19
  // src/thesis.ts
17
20
  var DEFAULT_THESIS_URL = "https://id.cabalspy.xyz/thesis/v1";
18
21
  var DEFAULT_THESIS_WS_URL = "wss://id.cabalspy.xyz/thesis/v1/ws";
22
+ var CLIENT_ID_HEADER = "X-CSID-Client-Id";
23
+ var CLIENT_ID_REQUIRED_MESSAGE = "ThesisClient needs your instance's public client_id (csid_..., shown in the dashboard next to the API key): new ThesisClient({ clientId: 'csid_...' }). It is not the API key; never put the API key in a browser.";
24
+ function assertClientId(clientId) {
25
+ if (typeof clientId !== "string" || !clientId.trim()) {
26
+ throw new (0, _chunkABFIPCBYcjs.AuthenticationError)(CLIENT_ID_REQUIRED_MESSAGE, {
27
+ code: "client_id_required",
28
+ parameter: "clientId",
29
+ docs: "https://docs.cabalspy.xyz/cabalspy-id/errors#client_id_required"
30
+ });
31
+ }
32
+ }
19
33
  function assertChain(chain) {
20
- if (!_chunkL4RHSWOAcjs.CHAINS.includes(chain)) {
21
- throw new (0, _chunkL4RHSWOAcjs.BadRequestError)(`Unknown blockchain '${chain}'`, { code: "invalid_parameter", parameter: "chain", allowed: [..._chunkL4RHSWOAcjs.CHAINS] });
34
+ if (!_chunkULEBNY2Acjs.CHAINS.includes(chain)) {
35
+ throw new (0, _chunkABFIPCBYcjs.BadRequestError)(`Unknown blockchain '${chain}'`, { code: "invalid_parameter", parameter: "chain", allowed: [..._chunkULEBNY2Acjs.CHAINS] });
22
36
  }
23
37
  }
24
38
  var enc = encodeURIComponent;
25
39
  function scopeQuery(scope, clientId) {
26
40
  if (scope === void 0 || scope === "global") return scope ? { scope } : {};
27
41
  if (scope !== "instance") {
28
- throw new (0, _chunkL4RHSWOAcjs.BadRequestError)(`Invalid value for 'scope': ${scope}`, {
42
+ throw new (0, _chunkABFIPCBYcjs.BadRequestError)(`Invalid value for 'scope': ${scope}`, {
29
43
  code: "invalid_parameter",
30
44
  parameter: "scope",
31
45
  allowed: ["global", "instance"]
32
46
  });
33
47
  }
34
48
  if (!clientId) {
35
- throw new (0, _chunkL4RHSWOAcjs.BadRequestError)("scope=instance needs clientId", { code: "missing_parameter", parameter: "clientId" });
49
+ throw new (0, _chunkABFIPCBYcjs.BadRequestError)("scope=instance needs clientId", { code: "missing_parameter", parameter: "clientId" });
36
50
  }
37
51
  return { scope, client_id: clientId };
38
52
  }
@@ -41,16 +55,18 @@ var ThesisClient = class {
41
55
 
42
56
 
43
57
 
58
+ /** Your instance's public client_id, sent on every call. */
44
59
 
45
60
 
46
61
 
47
- constructor(options = {}) {
62
+ constructor(options) {
63
+ assertClientId(_optionalChain([options, 'optionalAccess', _ => _.clientId]));
48
64
  this.accessToken = _nullishCoalesce(options.accessToken, () => ( null));
49
65
  this.baseUrl = (_nullishCoalesce(options.baseUrl, () => ( DEFAULT_THESIS_URL))).replace(/\/+$/, "");
50
66
  this.wsUrl = (_nullishCoalesce(options.wsUrl, () => ( DEFAULT_THESIS_WS_URL))).replace(/\/+$/, "");
51
67
  this.timeout = _nullishCoalesce(options.timeout, () => ( 15e3));
52
68
  this.scope = options.scope;
53
- this.clientId = options.clientId;
69
+ this.clientId = options.clientId.trim();
54
70
  scopeQuery(this.scope, this.clientId);
55
71
  this.fetchImpl = _nullishCoalesce(options.fetch, () => ( globalThis.fetch.bind(globalThis)));
56
72
  }
@@ -60,7 +76,7 @@ var ThesisClient = class {
60
76
  }
61
77
  async call(method, path, opts = {}) {
62
78
  if (opts.auth === "required" && !this.accessToken) {
63
- throw new (0, _chunkL4RHSWOAcjs.AuthenticationError)('Sign in with CabalSpy ID to write (an access token with the scope "thesis").', {
79
+ throw new (0, _chunkABFIPCBYcjs.AuthenticationError)('Sign in with CabalSpy ID to write (an access token with the scope "thesis").', {
64
80
  status: 401,
65
81
  code: "sign_in_required"
66
82
  });
@@ -80,7 +96,8 @@ var ThesisClient = class {
80
96
  credentials: "omit",
81
97
  headers: {
82
98
  Accept: "application/json",
83
- ...typeof window === "undefined" ? { "User-Agent": _chunkL4RHSWOAcjs.USER_AGENT } : {},
99
+ [CLIENT_ID_HEADER]: this.clientId,
100
+ ...typeof window === "undefined" ? { "User-Agent": _chunkULEBNY2Acjs.USER_AGENT } : {},
84
101
  ...this.accessToken && opts.auth ? { Authorization: `Bearer ${this.accessToken}` } : {},
85
102
  ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
86
103
  },
@@ -88,8 +105,8 @@ var ThesisClient = class {
88
105
  });
89
106
  text = await res.text();
90
107
  } catch (err) {
91
- const aborted = _optionalChain([err, 'optionalAccess', _ => _.name]) === "AbortError";
92
- throw new (0, _chunkL4RHSWOAcjs.ConnectionError)(aborted ? `Request timed out after ${this.timeout} ms: ${method} ${path}` : `Network error: ${_optionalChain([err, 'optionalAccess', _2 => _2.message])}`, {
108
+ const aborted = _optionalChain([err, 'optionalAccess', _2 => _2.name]) === "AbortError";
109
+ throw new (0, _chunkABFIPCBYcjs.ConnectionError)(aborted ? `Request timed out after ${this.timeout} ms: ${method} ${path}` : `Network error: ${_optionalChain([err, 'optionalAccess', _3 => _3.message])}`, {
93
110
  code: aborted ? "timeout" : "connection_error"
94
111
  });
95
112
  } finally {
@@ -101,19 +118,21 @@ var ThesisClient = class {
101
118
  } catch (e2) {
102
119
  parsed = null;
103
120
  }
104
- if (!res.ok || _optionalChain([parsed, 'optionalAccess', _3 => _3.success]) === false) {
105
- const err = _nullishCoalesce(_optionalChain([parsed, 'optionalAccess', _4 => _4.error]), () => ( null));
106
- throw _chunkL4RHSWOAcjs.errorFromStatus.call(void 0, res.status, err, { ..._chunkL4RHSWOAcjs.EMPTY_RATE_LIMIT }, null, `HTTP ${res.status} on ${method} ${path}`);
121
+ if (!res.ok || _optionalChain([parsed, 'optionalAccess', _4 => _4.success]) === false) {
122
+ const err = _nullishCoalesce(_optionalChain([parsed, 'optionalAccess', _5 => _5.error]), () => ( null));
123
+ const ra = res.headers.get("Retry-After");
124
+ const retryAfter = ra !== null && ra.trim() !== "" && Number.isFinite(Number(ra)) ? Number(ra) : null;
125
+ throw _chunkABFIPCBYcjs.errorFromStatus.call(void 0, res.status, err, { ..._chunkABFIPCBYcjs.EMPTY_RATE_LIMIT }, retryAfter, `HTTP ${res.status} on ${method} ${path}`);
107
126
  }
108
127
  if (!parsed || !("data" in parsed)) {
109
- throw new (0, _chunkL4RHSWOAcjs.InvalidResponseError)(`Unexpected response shape on ${method} ${path}`, { status: res.status, code: "invalid_response" });
128
+ throw new (0, _chunkABFIPCBYcjs.InvalidResponseError)(`Unexpected response shape on ${method} ${path}`, { status: res.status, code: "invalid_response" });
110
129
  }
111
130
  const { data, success: _s, ...extra } = parsed;
112
131
  return { data, extra };
113
132
  }
114
133
  tokenPath(chain, mint) {
115
134
  assertChain(chain);
116
- if (!mint) throw new (0, _chunkL4RHSWOAcjs.BadRequestError)("Parameter 'mint' is required", { code: "missing_parameter", parameter: "mint" });
135
+ if (!mint) throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Parameter 'mint' is required", { code: "missing_parameter", parameter: "mint" });
117
136
  return `/tokens/${enc(chain)}/${enc(mint)}`;
118
137
  }
119
138
  // ── reading ───────────────────────────────────────────────────────────────
@@ -142,7 +161,7 @@ var ThesisClient = class {
142
161
  while (true) {
143
162
  const page = await this.list(chain, mint, { ...opts, cursor });
144
163
  yield* page.items;
145
- if (!_optionalChain([page, 'access', _5 => _5.pagination, 'optionalAccess', _6 => _6.has_more]) || !page.pagination.next_cursor) return;
164
+ if (!_optionalChain([page, 'access', _6 => _6.pagination, 'optionalAccess', _7 => _7.has_more]) || !page.pagination.next_cursor) return;
146
165
  cursor = page.pagination.next_cursor;
147
166
  }
148
167
  }
@@ -173,27 +192,27 @@ var ThesisClient = class {
173
192
  /** Posts a thesis. Holders only (`not_a_holder` otherwise). Up to 1000 characters. */
174
193
  async post(chain, mint, params) {
175
194
  if (params.stance && params.stance !== "bull" && params.stance !== "bear") {
176
- throw new (0, _chunkL4RHSWOAcjs.BadRequestError)("stance must be bull or bear", { code: "invalid_parameter", parameter: "stance", allowed: ["bull", "bear"] });
195
+ throw new (0, _chunkABFIPCBYcjs.BadRequestError)("stance must be bull or bear", { code: "invalid_parameter", parameter: "stance", allowed: ["bull", "bear"] });
177
196
  }
178
- const body = { ...params, ...this.clientId ? { client_id: this.clientId } : {} };
197
+ const body = { ...params, client_id: this.clientId };
179
198
  return (await this.call("POST", this.tokenPath(chain, mint), { auth: "required", body })).data;
180
199
  }
181
200
  /** Replies to a thesis. Any signed-in user may reply. */
182
201
  async reply(id, body) {
183
- const payload = { body, ...this.clientId ? { client_id: this.clientId } : {} };
202
+ const payload = { body, client_id: this.clientId };
184
203
  return (await this.call("POST", `/items/${enc(id)}/replies`, { auth: "required", body: payload })).data;
185
204
  }
186
205
  /** Adds (or with on=false removes) a reaction. */
187
206
  async react(id, emoji, on = true) {
188
- if (!_chunkL4RHSWOAcjs.THESIS_EMOJIS.includes(emoji)) {
189
- throw new (0, _chunkL4RHSWOAcjs.BadRequestError)("Unknown reaction", { code: "invalid_parameter", parameter: "emoji", allowed: [..._chunkL4RHSWOAcjs.THESIS_EMOJIS] });
207
+ if (!_chunkULEBNY2Acjs.THESIS_EMOJIS.includes(emoji)) {
208
+ throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Unknown reaction", { code: "invalid_parameter", parameter: "emoji", allowed: [..._chunkULEBNY2Acjs.THESIS_EMOJIS] });
190
209
  }
191
210
  return (await this.call("POST", `/items/${enc(id)}/reactions`, { auth: "required", body: { emoji, on } })).data;
192
211
  }
193
212
  /** Reports a post (hidden for review after 3 reports). */
194
213
  async report(id, reason, note) {
195
- if (!_chunkL4RHSWOAcjs.THESIS_REPORT_REASONS.includes(reason)) {
196
- throw new (0, _chunkL4RHSWOAcjs.BadRequestError)("Choose a reason", { code: "invalid_parameter", parameter: "reason", allowed: [..._chunkL4RHSWOAcjs.THESIS_REPORT_REASONS] });
214
+ if (!_chunkULEBNY2Acjs.THESIS_REPORT_REASONS.includes(reason)) {
215
+ throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Choose a reason", { code: "invalid_parameter", parameter: "reason", allowed: [..._chunkULEBNY2Acjs.THESIS_REPORT_REASONS] });
197
216
  }
198
217
  return (await this.call("POST", `/items/${enc(id)}/report`, { auth: "required", body: { reason, note } })).data;
199
218
  }
@@ -211,7 +230,7 @@ var ThesisClient = class {
211
230
  }
212
231
  /** Appeals a moderation decision (an `appealable` notification; 10-1000 characters, within 30 days). */
213
232
  async appeal(notificationId, message) {
214
- if (!message) throw new (0, _chunkL4RHSWOAcjs.BadRequestError)("Parameter 'message' is required", { code: "missing_parameter", parameter: "message" });
233
+ if (!message) throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Parameter 'message' is required", { code: "missing_parameter", parameter: "message" });
215
234
  return (await this.call("POST", `/me/notifications/${enc(String(notificationId))}/appeal`, {
216
235
  auth: "required",
217
236
  body: { message }
@@ -219,16 +238,57 @@ var ThesisClient = class {
219
238
  }
220
239
  // ── live ──────────────────────────────────────────────────────────────────
221
240
  /**
222
- * Live updates for one token: thesis, reply, reaction, removed. With
223
- * `scope: 'instance'` (and `clientId`) only theses written on that instance.
241
+ * Live updates for one token: thesis, reply, reaction, removed. The socket URL
242
+ * carries `client_id`. With `scope: 'instance'` only theses written on your instance.
224
243
  */
225
244
  live(chain, mint, options = {}) {
226
245
  this.tokenPath(chain, mint);
227
- const sp = new URLSearchParams(scopeQuery(_nullishCoalesce(options.scope, () => ( this.scope)), _nullishCoalesce(options.clientId, () => ( this.clientId))));
228
- const qs = sp.toString();
229
- return new ThesisLive(`${this.wsUrl}/${enc(chain)}/${enc(mint)}${qs ? `?${qs}` : ""}`, options);
246
+ const sp = new URLSearchParams({
247
+ client_id: this.clientId,
248
+ ...scopeQuery(_nullishCoalesce(options.scope, () => ( this.scope)), _nullishCoalesce(options.clientId, () => ( this.clientId)))
249
+ });
250
+ return new ThesisLive(`${this.wsUrl}/${enc(chain)}/${enc(mint)}?${sp.toString()}`, options);
230
251
  }
231
252
  };
253
+ var THESIS_WS_CLOSE_CODES = {
254
+ /** client_id_required or invalid_client: final. */
255
+ CLIENT: 4401,
256
+ /** origin_not_allowed: final. */
257
+ ORIGIN: 4403,
258
+ /** rate_limited: reconnects after 60 s. */
259
+ RATE_LIMITED: 4429
260
+ };
261
+ var RATE_LIMIT_BACKOFF_MS = 6e4;
262
+ var docsFor = (code) => `https://docs.cabalspy.xyz/cabalspy-id/errors#${code}`;
263
+ function thesisCloseError(code, reason) {
264
+ if (code === 4401) {
265
+ const c = reason === "invalid_client" ? "invalid_client" : "client_id_required";
266
+ const msg = c === "invalid_client" ? "The live socket was refused (4401): unknown or inactive client_id" : "The live socket was refused (4401): your instance's public client_id is required";
267
+ return new (0, _chunkABFIPCBYcjs.AuthenticationError)(msg, { code: c, status: 401, docs: docsFor(c) });
268
+ }
269
+ if (code === 4403) {
270
+ return new (0, _chunkABFIPCBYcjs.PermissionError)("The live socket was refused (4403): this page's origin is not one of the instance's allowed origins", {
271
+ code: "origin_not_allowed",
272
+ status: 403,
273
+ docs: docsFor("origin_not_allowed")
274
+ });
275
+ }
276
+ if (code === 4429) {
277
+ return new (0, _chunkABFIPCBYcjs.RateLimitError)("The live socket was refused (4429): rate limited; reconnecting in 60 s", {
278
+ code: "rate_limited",
279
+ status: 429,
280
+ docs: docsFor("rate_limited"),
281
+ retryAfter: RATE_LIMIT_BACKOFF_MS / 1e3
282
+ });
283
+ }
284
+ if (code === 1008) {
285
+ return new (0, _chunkABFIPCBYcjs.PermissionError)("The live socket was refused (1008): invalid token address or too many sockets from this IP", {
286
+ code: "ws_refused",
287
+ status: 403
288
+ });
289
+ }
290
+ return null;
291
+ }
232
292
  var ThesisLive = (_class = class {
233
293
  constructor(url, options = {}) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);
234
294
  this.url = url;
@@ -250,7 +310,7 @@ var ThesisLive = (_class = class {
250
310
  __init6() {this.watchdog = null}
251
311
 
252
312
  get connected() {
253
- return _optionalChain([this, 'access', _7 => _7.ws, 'optionalAccess', _8 => _8.readyState]) === 1;
313
+ return _optionalChain([this, 'access', _8 => _8.ws, 'optionalAccess', _9 => _9.readyState]) === 1;
254
314
  }
255
315
  on(event, handler) {
256
316
  let set = this.handlers.get(event);
@@ -259,7 +319,7 @@ var ThesisLive = (_class = class {
259
319
  return this;
260
320
  }
261
321
  off(event, handler) {
262
- _optionalChain([this, 'access', _9 => _9.handlers, 'access', _10 => _10.get, 'call', _11 => _11(event), 'optionalAccess', _12 => _12.delete, 'call', _13 => _13(handler)]);
322
+ _optionalChain([this, 'access', _10 => _10.handlers, 'access', _11 => _11.get, 'call', _12 => _12(event), 'optionalAccess', _13 => _13.delete, 'call', _14 => _14(handler)]);
263
323
  return this;
264
324
  }
265
325
  emit(event, payload) {
@@ -267,7 +327,7 @@ var ThesisLive = (_class = class {
267
327
  try {
268
328
  h(payload);
269
329
  } catch (err) {
270
- if (event !== "error") this.emit("error", new (0, _chunkL4RHSWOAcjs.CabalSpyIDError)(String(_nullishCoalesce(_optionalChain([err, 'optionalAccess', _14 => _14.message]), () => ( err))), { code: "handler_error" }));
330
+ if (event !== "error") this.emit("error", new (0, _chunkABFIPCBYcjs.CabalSpyIDError)(String(_nullishCoalesce(_optionalChain([err, 'optionalAccess', _15 => _15.message]), () => ( err))), { code: "handler_error" }));
271
331
  }
272
332
  }
273
333
  }
@@ -277,7 +337,7 @@ var ThesisLive = (_class = class {
277
337
  const WS = _nullishCoalesce(this.options.WebSocketImpl, () => ( globalThis.WebSocket));
278
338
  if (!WS) {
279
339
  return Promise.reject(
280
- new (0, _chunkL4RHSWOAcjs.ConnectionError)("No WebSocket implementation found. On Node < 22, run `npm i ws` and pass { WebSocketImpl: WebSocket }.", {
340
+ new (0, _chunkABFIPCBYcjs.ConnectionError)("No WebSocket implementation found. On Node < 22, run `npm i ws` and pass { WebSocketImpl: WebSocket }.", {
281
341
  code: "no_websocket"
282
342
  })
283
343
  );
@@ -312,7 +372,7 @@ var ThesisLive = (_class = class {
312
372
  };
313
373
  ws.onerror = () => {
314
374
  if (this.ws !== ws) return;
315
- const err = new (0, _chunkL4RHSWOAcjs.ConnectionError)(`WebSocket error on ${this.url}`, { code: "ws_error" });
375
+ const err = new (0, _chunkABFIPCBYcjs.ConnectionError)(`WebSocket error on ${this.url}`, { code: "ws_error" });
316
376
  this.emit("error", err);
317
377
  if (!settled) {
318
378
  settled = true;
@@ -325,26 +385,22 @@ var ThesisLive = (_class = class {
325
385
  this.disarm();
326
386
  const e = _nullishCoalesce(ev, () => ( {}));
327
387
  this.emit("close", { code: e.code, reason: e.reason });
328
- const final = e.code === 1008;
329
- if (final) {
330
- this.emit(
331
- "error",
332
- new (0, _chunkL4RHSWOAcjs.PermissionError)("The live socket was refused (1008): invalid token address or too many sockets from this IP", {
333
- code: "ws_refused",
334
- status: 403
335
- })
336
- );
337
- }
388
+ const refusal = thesisCloseError(e.code, e.reason);
389
+ const rateLimited = e.code === 4429;
390
+ const final = refusal !== null && !rateLimited;
391
+ if (refusal) this.emit("error", refusal);
338
392
  if (!settled) {
339
393
  settled = true;
340
- reject(new (0, _chunkL4RHSWOAcjs.ConnectionError)(`Live socket closed before it opened (code ${_nullishCoalesce(e.code, () => ( "unknown"))})`, { code: "ws_closed_early" }));
394
+ reject(
395
+ _nullishCoalesce(refusal, () => ( new (0, _chunkABFIPCBYcjs.ConnectionError)(`Live socket closed before it opened (code ${_nullishCoalesce(e.code, () => ( "unknown"))})`, { code: "ws_closed_early" })))
396
+ );
341
397
  }
342
- if (!this.closedByUser && !final && this.opts.reconnect) this.scheduleReconnect();
398
+ if (!this.closedByUser && !final && this.opts.reconnect) this.scheduleReconnect(rateLimited ? RATE_LIMIT_BACKOFF_MS : 0);
343
399
  };
344
400
  });
345
401
  }
346
- scheduleReconnect() {
347
- const delay = Math.min(this.opts.reconnectDelay * 2 ** this.attempt, this.opts.maxReconnectDelay) + Math.random() * 250;
402
+ scheduleReconnect(minDelay = 0) {
403
+ const delay = Math.max(minDelay, Math.min(this.opts.reconnectDelay * 2 ** this.attempt, this.opts.maxReconnectDelay)) + Math.random() * 250;
348
404
  this.attempt++;
349
405
  this.reconnectTimer = setTimeout(() => {
350
406
  this.reconnectTimer = null;
@@ -355,8 +411,8 @@ var ThesisLive = (_class = class {
355
411
  if (!this.opts.heartbeatTimeout) return;
356
412
  this.disarm();
357
413
  this.watchdog = setTimeout(() => {
358
- this.emit("error", new (0, _chunkL4RHSWOAcjs.ConnectionError)("No heartbeat from the live socket; reconnecting", { code: "heartbeat_timeout" }));
359
- _optionalChain([this, 'access', _15 => _15.ws, 'optionalAccess', _16 => _16.close, 'call', _17 => _17(4e3, "heartbeat timeout")]);
414
+ this.emit("error", new (0, _chunkABFIPCBYcjs.ConnectionError)("No heartbeat from the live socket; reconnecting", { code: "heartbeat_timeout" }));
415
+ _optionalChain([this, 'access', _16 => _16.ws, 'optionalAccess', _17 => _17.close, 'call', _18 => _18(4e3, "heartbeat timeout")]);
360
416
  }, this.opts.heartbeatTimeout);
361
417
  }
362
418
  disarm() {
@@ -371,7 +427,7 @@ var ThesisLive = (_class = class {
371
427
  this.reconnectTimer = null;
372
428
  const ws = this.ws;
373
429
  this.ws = null;
374
- _optionalChain([ws, 'optionalAccess', _18 => _18.close, 'call', _19 => _19(1e3, "client closed")]);
430
+ _optionalChain([ws, 'optionalAccess', _19 => _19.close, 'call', _20 => _20(1e3, "client closed")]);
375
431
  this.emit("close", { code: 1e3, reason: "client closed" });
376
432
  }
377
433
  }, _class);
@@ -381,5 +437,8 @@ var ThesisLive = (_class = class {
381
437
 
382
438
 
383
439
 
384
- exports.DEFAULT_THESIS_URL = DEFAULT_THESIS_URL; exports.DEFAULT_THESIS_WS_URL = DEFAULT_THESIS_WS_URL; exports.ThesisClient = ThesisClient; exports.ThesisLive = ThesisLive;
385
- //# sourceMappingURL=chunk-P3MDLWLN.cjs.map
440
+
441
+
442
+
443
+ exports.DEFAULT_THESIS_URL = DEFAULT_THESIS_URL; exports.DEFAULT_THESIS_WS_URL = DEFAULT_THESIS_WS_URL; exports.CLIENT_ID_HEADER = CLIENT_ID_HEADER; exports.ThesisClient = ThesisClient; exports.THESIS_WS_CLOSE_CODES = THESIS_WS_CLOSE_CODES; exports.thesisCloseError = thesisCloseError; exports.ThesisLive = ThesisLive;
444
+ //# sourceMappingURL=chunk-PKFIMBPW.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/home/runner/work/cabalspy-id-js/cabalspy-id-js/dist/chunk-PKFIMBPW.cjs","../src/thesis.ts"],"names":[],"mappings":"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","file":"/home/runner/work/cabalspy-id-js/cabalspy-id-js/dist/chunk-PKFIMBPW.cjs","sourcesContent":[null,"// ═══════════════════════════════════════════════════════════════════════════\n// PUBLIC THESIS API — https://id.cabalspy.xyz/thesis/v1 (browser friendly)\n// ═══════════════════════════════════════════════════════════════════════════\n//\n// Every call carries your instance's PUBLIC client_id (header X-CSID-Client-Id,\n// and client_id on the websocket URL). Reading needs nothing else. Writing needs\n// the signed-in user's CabalSpy ID access token with the scope \"thesis\" (e.g.\n// from the login widget's csid:login event). No cookies are used, so this runs in\n// the browser as is (from one of the instance's allowed origins). Your API key is\n// never needed here (and must never reach a browser).\n\nimport {\n type ApiErrorBody,\n AuthenticationError,\n BadRequestError,\n CabalSpyIDError,\n ConnectionError,\n EMPTY_RATE_LIMIT,\n InvalidResponseError,\n PermissionError,\n RateLimitError,\n errorFromStatus,\n} from './errors';\nimport {\n CHAINS,\n type Chain,\n type Pagination,\n THESIS_EMOJIS,\n THESIS_REPORT_REASONS,\n type Thesis,\n type ThesisCounts,\n type ThesisEligibility,\n type ThesisEmoji,\n type ThesisNotices,\n type ThesisNotifications,\n type ThesisReactions,\n type ThesisReportReason,\n type ThesisSort,\n type ThesisStance,\n type ThesisSummary,\n type ThesisToken,\n} from './types';\nimport { USER_AGENT } from './util';\n\nexport const DEFAULT_THESIS_URL = 'https://id.cabalspy.xyz/thesis/v1';\nexport const DEFAULT_THESIS_WS_URL = 'wss://id.cabalspy.xyz/thesis/v1/ws';\n/** Header that carries the instance's public client_id on every thesis API call. */\nexport const CLIENT_ID_HEADER = 'X-CSID-Client-Id';\n\nconst CLIENT_ID_REQUIRED_MESSAGE =\n \"ThesisClient needs your instance's public client_id (csid_..., shown in the dashboard next to the API key): \" +\n \"new ThesisClient({ clientId: 'csid_...' }). It is not the API key; never put the API key in a browser.\";\n\n/** Throws an AuthenticationError with code `client_id_required` when the client_id is missing or empty. */\nexport function assertClientId(clientId: unknown): asserts clientId is string {\n if (typeof clientId !== 'string' || !clientId.trim()) {\n throw new AuthenticationError(CLIENT_ID_REQUIRED_MESSAGE, {\n code: 'client_id_required',\n parameter: 'clientId',\n docs: 'https://docs.cabalspy.xyz/cabalspy-id/errors#client_id_required',\n });\n }\n}\n\nexport interface ThesisScopeOptions {\n /**\n * `global` (default): theses written on every platform. `instance`: only the\n * ones written on the instance named by `clientId`.\n */\n scope?: 'global' | 'instance';\n /**\n * Instance whose theses `scope: 'instance'` reads. Defaults to the client's\n * own `clientId`; you rarely need to set it per call.\n */\n clientId?: string;\n}\n\nexport interface ThesisClientOptions {\n /**\n * Required. Your instance's PUBLIC client_id (`csid_...`), shown in the\n * dashboard next to the API key. It is not a secret and not the API key. Sent\n * as `X-CSID-Client-Id` on every call and as `client_id` on the live socket;\n * also used for `scope: 'instance'` and credits posts to your app.\n */\n clientId: string;\n /**\n * `global` (default): theses written on every platform. `instance`: only the\n * ones written on your instance.\n */\n scope?: 'global' | 'instance';\n /** The signed-in user's access token (scope `thesis`). Needed only for writing. */\n accessToken?: string | null;\n /** Default: https://id.cabalspy.xyz/thesis/v1 */\n baseUrl?: string;\n /** Default: wss://id.cabalspy.xyz/thesis/v1/ws */\n wsUrl?: string;\n /** Per-request timeout in ms. Default: 15_000 */\n timeout?: number;\n fetch?: typeof globalThis.fetch;\n}\n\nexport interface TokenThesesPage {\n items: Thesis[];\n pagination: Pagination;\n token: ThesisToken;\n summary: ThesisCounts;\n}\n\nexport interface ThesisPage {\n items: Thesis[];\n pagination: Pagination;\n}\n\nexport interface TokenThesesOptions extends ThesisScopeOptions {\n sort?: ThesisSort;\n stance?: ThesisStance;\n /** 1-50. Default: 20 */\n limit?: number;\n cursor?: string;\n}\n\nexport interface ThesisViewer {\n csid_id: string;\n username: string | null;\n name: string | null;\n image_url: string | null;\n}\n\n/** Eligibility of the signed-in user, who that is, and their unread moderation notices. */\nexport type ThesisMe = ThesisEligibility & { user: ThesisViewer; notices: ThesisNotices };\n\nfunction assertChain(chain: string): void {\n if (!(CHAINS as readonly string[]).includes(chain)) {\n throw new BadRequestError(`Unknown blockchain '${chain}'`, { code: 'invalid_parameter', parameter: 'chain', allowed: [...CHAINS] });\n }\n}\n\nconst enc = encodeURIComponent;\n\n/** Query parameters for a scope; instance scope needs the client_id. */\nfunction scopeQuery(scope: string | undefined, clientId: string | undefined): Record<string, string | undefined> {\n if (scope === undefined || scope === 'global') return scope ? { scope } : {};\n if (scope !== 'instance') {\n throw new BadRequestError(`Invalid value for 'scope': ${scope}`, {\n code: 'invalid_parameter',\n parameter: 'scope',\n allowed: ['global', 'instance'],\n });\n }\n if (!clientId) {\n throw new BadRequestError('scope=instance needs clientId', { code: 'missing_parameter', parameter: 'clientId' });\n }\n return { scope, client_id: clientId };\n}\n\n/**\n * Client for the public thesis API, e.g. in a browser app that builds its own\n * thesis UI.\n *\n * const theses = new ThesisClient({ clientId: 'csid_...', accessToken }); // token from csid:login\n * const page = await theses.list('solana', mint, { sort: 'top' });\n * await theses.post('solana', mint, { body: 'Strong community, early.', stance: 'bull' });\n * const live = theses.live('solana', mint);\n * live.on('thesis', (t) => render(t));\n * await live.connect();\n */\nexport class ThesisClient {\n readonly baseUrl: string;\n readonly wsUrl: string;\n readonly timeout: number;\n readonly scope?: 'global' | 'instance';\n /** Your instance's public client_id, sent on every call. */\n readonly clientId: string;\n private accessToken: string | null;\n private readonly fetchImpl: typeof globalThis.fetch;\n\n constructor(options: ThesisClientOptions) {\n assertClientId(options?.clientId);\n this.accessToken = options.accessToken ?? null;\n this.baseUrl = (options.baseUrl ?? DEFAULT_THESIS_URL).replace(/\\/+$/, '');\n this.wsUrl = (options.wsUrl ?? DEFAULT_THESIS_WS_URL).replace(/\\/+$/, '');\n this.timeout = options.timeout ?? 15_000;\n this.scope = options.scope;\n this.clientId = options.clientId.trim();\n scopeQuery(this.scope, this.clientId);\n this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);\n }\n\n /** Sets (or with null clears) the user's access token, e.g. after sign-in or sign-out. */\n setAccessToken(token: string | null | undefined): void {\n this.accessToken = token ?? null;\n }\n\n private async call<T>(\n method: 'GET' | 'POST' | 'DELETE',\n path: string,\n opts: { auth?: 'required' | 'optional'; body?: unknown; query?: Record<string, string | number | undefined> } = {},\n ): Promise<{ data: T; extra: Record<string, unknown> }> {\n if (opts.auth === 'required' && !this.accessToken) {\n throw new AuthenticationError('Sign in with CabalSpy ID to write (an access token with the scope \"thesis\").', {\n status: 401,\n code: 'sign_in_required',\n });\n }\n const sp = new URLSearchParams();\n for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined && v !== '') sp.set(k, String(v));\n const qs = sp.toString();\n const url = `${this.baseUrl}${path}${qs ? `?${qs}` : ''}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeout);\n let res: Response;\n let text: string;\n try {\n res = await this.fetchImpl(url, {\n method,\n signal: controller.signal,\n credentials: 'omit',\n headers: {\n Accept: 'application/json',\n [CLIENT_ID_HEADER]: this.clientId,\n ...(typeof window === 'undefined' ? { 'User-Agent': USER_AGENT } : {}),\n ...(this.accessToken && opts.auth ? { Authorization: `Bearer ${this.accessToken}` } : {}),\n ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),\n },\n body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,\n });\n text = await res.text();\n } catch (err) {\n const aborted = (err as Error)?.name === 'AbortError';\n throw new ConnectionError(aborted ? `Request timed out after ${this.timeout} ms: ${method} ${path}` : `Network error: ${(err as Error)?.message}`, {\n code: aborted ? 'timeout' : 'connection_error',\n });\n } finally {\n clearTimeout(timer);\n }\n let parsed: Record<string, unknown> | null = null;\n try {\n parsed = text ? (JSON.parse(text) as Record<string, unknown>) : null;\n } catch {\n parsed = null;\n }\n if (!res.ok || parsed?.success === false) {\n const err = (parsed?.error ?? null) as ApiErrorBody | null;\n const ra = res.headers.get('Retry-After');\n const retryAfter = ra !== null && ra.trim() !== '' && Number.isFinite(Number(ra)) ? Number(ra) : null;\n throw errorFromStatus(res.status, err, { ...EMPTY_RATE_LIMIT }, retryAfter, `HTTP ${res.status} on ${method} ${path}`);\n }\n if (!parsed || !('data' in parsed)) {\n throw new InvalidResponseError(`Unexpected response shape on ${method} ${path}`, { status: res.status, code: 'invalid_response' });\n }\n const { data, success: _s, ...extra } = parsed;\n return { data: data as T, extra };\n }\n\n private tokenPath(chain: Chain, mint: string): string {\n assertChain(chain);\n if (!mint) throw new BadRequestError(\"Parameter 'mint' is required\", { code: 'missing_parameter', parameter: 'mint' });\n return `/tokens/${enc(chain)}/${enc(mint)}`;\n }\n\n // ── reading ───────────────────────────────────────────────────────────────\n\n /** Theses on a token, with the token's info and counts. */\n async list(chain: Chain, mint: string, opts: TokenThesesOptions = {}): Promise<TokenThesesPage> {\n const { data, extra } = await this.call<Thesis[]>('GET', this.tokenPath(chain, mint), {\n auth: 'optional',\n query: {\n sort: opts.sort,\n stance: opts.stance,\n limit: opts.limit,\n cursor: opts.cursor,\n ...scopeQuery(opts.scope ?? this.scope, opts.clientId ?? this.clientId),\n },\n });\n return {\n items: data,\n pagination: extra.pagination as Pagination,\n token: extra.token as ThesisToken,\n summary: extra.summary as ThesisCounts,\n };\n }\n\n /** Every thesis on a token, following `next_cursor`. */\n async *iterate(chain: Chain, mint: string, opts: TokenThesesOptions = {}): AsyncGenerator<Thesis, void, void> {\n let cursor = opts.cursor;\n while (true) {\n const page = await this.list(chain, mint, { ...opts, cursor });\n yield* page.items;\n if (!page.pagination?.has_more || !page.pagination.next_cursor) return;\n cursor = page.pagination.next_cursor;\n }\n }\n\n /** Counts (theses, bull, bear, authors) and token info. */\n async summary(chain: Chain, mint: string, opts: ThesisScopeOptions = {}): Promise<ThesisSummary> {\n const query = scopeQuery(opts.scope ?? this.scope, opts.clientId ?? this.clientId);\n return (await this.call<ThesisSummary>('GET', `${this.tokenPath(chain, mint)}/summary`, { query })).data;\n }\n\n /** One thesis or reply. */\n async get(id: string): Promise<Thesis> {\n return (await this.call<Thesis>('GET', `/items/${enc(id)}`, { auth: 'optional' })).data;\n }\n\n /** Replies to a thesis. */\n async replies(id: string, opts: { limit?: number; cursor?: string } = {}): Promise<ThesisPage> {\n const { data, extra } = await this.call<Thesis[]>('GET', `/items/${enc(id)}/replies`, { auth: 'optional', query: opts });\n return { items: data, pagination: extra.pagination as Pagination };\n }\n\n /** Theses of one user (their csid_id). */\n async byUser(csidId: string, opts: { limit?: number; cursor?: string } = {}): Promise<ThesisPage> {\n const { data, extra } = await this.call<Thesis[]>('GET', `/users/${enc(csidId)}`, { auth: 'optional', query: opts });\n return { items: data, pagination: extra.pagination as Pagination };\n }\n\n // ── the signed-in user ────────────────────────────────────────────────────\n\n /** May the signed-in user post (holder) or reply on this token, and who are they. */\n async me(chain: Chain, mint: string): Promise<ThesisMe> {\n return (await this.call<ThesisMe>('GET', `${this.tokenPath(chain, mint)}/me`, { auth: 'required' })).data;\n }\n\n /** Posts a thesis. Holders only (`not_a_holder` otherwise). Up to 1000 characters. */\n async post(chain: Chain, mint: string, params: { body: string; stance?: ThesisStance }): Promise<Thesis> {\n if (params.stance && params.stance !== 'bull' && params.stance !== 'bear') {\n throw new BadRequestError('stance must be bull or bear', { code: 'invalid_parameter', parameter: 'stance', allowed: ['bull', 'bear'] });\n }\n const body = { ...params, client_id: this.clientId };\n return (await this.call<Thesis>('POST', this.tokenPath(chain, mint), { auth: 'required', body })).data;\n }\n\n /** Replies to a thesis. Any signed-in user may reply. */\n async reply(id: string, body: string): Promise<Thesis> {\n const payload = { body, client_id: this.clientId };\n return (await this.call<Thesis>('POST', `/items/${enc(id)}/replies`, { auth: 'required', body: payload })).data;\n }\n\n /** Adds (or with on=false removes) a reaction. */\n async react(id: string, emoji: ThesisEmoji, on = true): Promise<ThesisReactions> {\n if (!(THESIS_EMOJIS as readonly string[]).includes(emoji)) {\n throw new BadRequestError('Unknown reaction', { code: 'invalid_parameter', parameter: 'emoji', allowed: [...THESIS_EMOJIS] });\n }\n return (await this.call<ThesisReactions>('POST', `/items/${enc(id)}/reactions`, { auth: 'required', body: { emoji, on } })).data;\n }\n\n /** Reports a post (hidden for review after 3 reports). */\n async report(id: string, reason: ThesisReportReason, note?: string): Promise<{ reported: true }> {\n if (!(THESIS_REPORT_REASONS as readonly string[]).includes(reason)) {\n throw new BadRequestError('Choose a reason', { code: 'invalid_parameter', parameter: 'reason', allowed: [...THESIS_REPORT_REASONS] });\n }\n return (await this.call<{ reported: true }>('POST', `/items/${enc(id)}/report`, { auth: 'required', body: { reason, note } })).data;\n }\n\n /** Deletes the signed-in user's own thesis or reply. */\n async delete(id: string): Promise<{ deleted: true }> {\n return (await this.call<{ deleted: true }>('DELETE', `/items/${enc(id)}`, { auth: 'required' })).data;\n }\n\n /** The signed-in user's moderation notifications, with the unread count. */\n async notifications(): Promise<ThesisNotifications> {\n return (await this.call<ThesisNotifications>('GET', '/me/notifications', { auth: 'required' })).data;\n }\n\n /** Marks all of the signed-in user's notifications as read. */\n async markNotificationsRead(): Promise<{ read: true }> {\n return (await this.call<{ read: true }>('POST', '/me/notifications/read', { auth: 'required' })).data;\n }\n\n /** Appeals a moderation decision (an `appealable` notification; 10-1000 characters, within 30 days). */\n async appeal(notificationId: number, message: string): Promise<{ appealed: true }> {\n if (!message) throw new BadRequestError(\"Parameter 'message' is required\", { code: 'missing_parameter', parameter: 'message' });\n return (\n await this.call<{ appealed: true }>('POST', `/me/notifications/${enc(String(notificationId))}/appeal`, {\n auth: 'required',\n body: { message },\n })\n ).data;\n }\n\n // ── live ──────────────────────────────────────────────────────────────────\n\n /**\n * Live updates for one token: thesis, reply, reaction, removed. The socket URL\n * carries `client_id`. With `scope: 'instance'` only theses written on your instance.\n */\n live(chain: Chain, mint: string, options: ThesisLiveOptions & ThesisScopeOptions = {}): ThesisLive {\n this.tokenPath(chain, mint);\n const sp = new URLSearchParams({\n client_id: this.clientId,\n ...(scopeQuery(options.scope ?? this.scope, options.clientId ?? this.clientId) as Record<string, string>),\n });\n return new ThesisLive(`${this.wsUrl}/${enc(chain)}/${enc(mint)}?${sp.toString()}`, options);\n }\n}\n\n// ── live websocket ─────────────────────────────────────────────────────────\n\nexport interface ThesisLiveOptions {\n /** Default: true */\n reconnect?: boolean;\n /** Default: 1000 */\n reconnectDelay?: number;\n /** Default: 30_000 */\n maxReconnectDelay?: number;\n /** The server sends a heartbeat every 30 s; silence this long reopens the socket. 0 disables. Default: 75_000 */\n heartbeatTimeout?: number;\n /** WebSocket implementation for Node < 22 (the `ws` package). */\n WebSocketImpl?: unknown;\n}\n\nexport interface ThesisLiveEventMap {\n thesis: Thesis;\n reply: Thesis;\n reaction: ThesisReactions;\n removed: { id: string };\n heartbeat: Record<string, never>;\n open: undefined;\n close: { code?: number; reason?: string };\n error: CabalSpyIDError;\n}\n\ntype LiveHandler<T> = (payload: T) => void;\n\ninterface MinimalWebSocket {\n close(code?: number, reason?: string): void;\n readyState: number;\n onopen: ((ev: unknown) => void) | null;\n onclose: ((ev: unknown) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n}\n\n/** Close codes of the live socket for client_id checks (the close reason is the error code). */\nexport const THESIS_WS_CLOSE_CODES = {\n /** client_id_required or invalid_client: final. */\n CLIENT: 4401,\n /** origin_not_allowed: final. */\n ORIGIN: 4403,\n /** rate_limited: reconnects after 60 s. */\n RATE_LIMITED: 4429,\n} as const;\n\n/** Wait after a 4429 close before reconnecting (the server's Retry-After). */\nconst RATE_LIMIT_BACKOFF_MS = 60_000;\n\nconst docsFor = (code: string) => `https://docs.cabalspy.xyz/cabalspy-id/errors#${code}`;\n\n/** The error for a refusing close code of the live socket, or null for an ordinary close. */\nexport function thesisCloseError(code: number | undefined, reason?: string): CabalSpyIDError | null {\n if (code === 4401) {\n const c = reason === 'invalid_client' ? 'invalid_client' : 'client_id_required';\n const msg =\n c === 'invalid_client'\n ? 'The live socket was refused (4401): unknown or inactive client_id'\n : \"The live socket was refused (4401): your instance's public client_id is required\";\n return new AuthenticationError(msg, { code: c, status: 401, docs: docsFor(c) });\n }\n if (code === 4403) {\n return new PermissionError(\"The live socket was refused (4403): this page's origin is not one of the instance's allowed origins\", {\n code: 'origin_not_allowed',\n status: 403,\n docs: docsFor('origin_not_allowed'),\n });\n }\n if (code === 4429) {\n return new RateLimitError('The live socket was refused (4429): rate limited; reconnecting in 60 s', {\n code: 'rate_limited',\n status: 429,\n docs: docsFor('rate_limited'),\n retryAfter: RATE_LIMIT_BACKOFF_MS / 1000,\n });\n }\n if (code === 1008) {\n return new PermissionError('The live socket was refused (1008): invalid token address or too many sockets from this IP', {\n code: 'ws_refused',\n status: 403,\n });\n }\n return null;\n}\n\n/**\n * Public live socket for one token. Messages arrive as `thesis`, `reply`,\n * `reaction` and `removed`; it reconnects after drops or missed heartbeats.\n * Codes 1008 (invalid token address, too many sockets), 4401 (client_id\n * missing or invalid) and 4403 (origin not allowed) are final; after 4429\n * (rate limited) it waits 60 s before reconnecting.\n */\nexport class ThesisLive {\n private ws: MinimalWebSocket | null = null;\n private readonly handlers = new Map<string, Set<LiveHandler<never>>>();\n private attempt = 0;\n private closedByUser = false;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private watchdog: ReturnType<typeof setTimeout> | null = null;\n private readonly opts: Required<Omit<ThesisLiveOptions, 'WebSocketImpl'>>;\n\n constructor(\n readonly url: string,\n private readonly options: ThesisLiveOptions = {},\n ) {\n this.opts = {\n reconnect: options.reconnect ?? true,\n reconnectDelay: options.reconnectDelay ?? 1_000,\n maxReconnectDelay: options.maxReconnectDelay ?? 30_000,\n heartbeatTimeout: options.heartbeatTimeout ?? 75_000,\n };\n }\n\n get connected(): boolean {\n return this.ws?.readyState === 1;\n }\n\n on<E extends keyof ThesisLiveEventMap>(event: E, handler: LiveHandler<ThesisLiveEventMap[E]>): this {\n let set = this.handlers.get(event);\n if (!set) this.handlers.set(event, (set = new Set()));\n set.add(handler as LiveHandler<never>);\n return this;\n }\n\n off<E extends keyof ThesisLiveEventMap>(event: E, handler: LiveHandler<ThesisLiveEventMap[E]>): this {\n this.handlers.get(event)?.delete(handler as LiveHandler<never>);\n return this;\n }\n\n private emit<E extends keyof ThesisLiveEventMap>(event: E, payload: ThesisLiveEventMap[E]): void {\n for (const h of [...(this.handlers.get(event) ?? [])]) {\n try {\n (h as LiveHandler<ThesisLiveEventMap[E]>)(payload);\n } catch (err) {\n if (event !== 'error') this.emit('error', new CabalSpyIDError(String((err as Error)?.message ?? err), { code: 'handler_error' }));\n }\n }\n }\n\n /** Opens the socket; resolves once it is open. */\n connect(): Promise<void> {\n this.closedByUser = false;\n const WS = (this.options.WebSocketImpl ?? (globalThis as { WebSocket?: unknown }).WebSocket) as\n | (new (url: string) => MinimalWebSocket)\n | undefined;\n if (!WS) {\n return Promise.reject(\n new ConnectionError('No WebSocket implementation found. On Node < 22, run `npm i ws` and pass { WebSocketImpl: WebSocket }.', {\n code: 'no_websocket',\n }),\n );\n }\n return new Promise<void>((resolve, reject) => {\n let settled = false;\n const ws = new WS(this.url);\n this.ws = ws;\n ws.onopen = () => {\n this.attempt = 0;\n this.arm();\n this.emit('open', undefined);\n if (!settled) {\n settled = true;\n resolve();\n }\n };\n ws.onmessage = (ev) => {\n if (this.ws !== ws) return;\n this.arm();\n let msg: { type?: string; data?: unknown };\n try {\n msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));\n } catch {\n return;\n }\n if (msg.type === 'thesis' || msg.type === 'reply' || msg.type === 'reaction' || msg.type === 'removed') {\n this.emit(msg.type, msg.data as never);\n } else if (msg.type === 'heartbeat') {\n this.emit('heartbeat', {});\n }\n };\n ws.onerror = () => {\n if (this.ws !== ws) return;\n const err = new ConnectionError(`WebSocket error on ${this.url}`, { code: 'ws_error' });\n this.emit('error', err);\n if (!settled) {\n settled = true;\n reject(err);\n }\n };\n ws.onclose = (ev) => {\n if (this.ws !== ws) return;\n this.ws = null;\n this.disarm();\n const e = (ev ?? {}) as { code?: number; reason?: string };\n this.emit('close', { code: e.code, reason: e.reason });\n const refusal = thesisCloseError(e.code, e.reason);\n const rateLimited = e.code === 4429;\n const final = refusal !== null && !rateLimited;\n if (refusal) this.emit('error', refusal);\n if (!settled) {\n settled = true;\n reject(\n refusal ?? new ConnectionError(`Live socket closed before it opened (code ${e.code ?? 'unknown'})`, { code: 'ws_closed_early' }),\n );\n }\n if (!this.closedByUser && !final && this.opts.reconnect) this.scheduleReconnect(rateLimited ? RATE_LIMIT_BACKOFF_MS : 0);\n };\n });\n }\n\n private scheduleReconnect(minDelay = 0): void {\n const delay =\n Math.max(minDelay, Math.min(this.opts.reconnectDelay * 2 ** this.attempt, this.opts.maxReconnectDelay)) + Math.random() * 250;\n this.attempt++;\n this.reconnectTimer = setTimeout(() => {\n this.reconnectTimer = null;\n if (!this.closedByUser) this.connect().catch(() => undefined);\n }, delay);\n }\n\n private arm(): void {\n if (!this.opts.heartbeatTimeout) return;\n this.disarm();\n this.watchdog = setTimeout(() => {\n this.emit('error', new ConnectionError('No heartbeat from the live socket; reconnecting', { code: 'heartbeat_timeout' }));\n this.ws?.close(4000, 'heartbeat timeout');\n }, this.opts.heartbeatTimeout);\n }\n\n private disarm(): void {\n if (this.watchdog) clearTimeout(this.watchdog);\n this.watchdog = null;\n }\n\n /** Closes the socket and stops reconnecting. */\n close(): void {\n this.closedByUser = true;\n this.disarm();\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n const ws = this.ws;\n this.ws = null;\n ws?.close(1000, 'client closed');\n this.emit('close', { code: 1000, reason: 'client closed' });\n }\n}\n"]}