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.
- package/CHANGELOG.md +146 -112
- package/LICENSE +21 -21
- package/README.md +505 -475
- package/dist/api.cjs +5 -3
- package/dist/api.cjs.map +1 -1
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.js +16 -14
- package/dist/chunk-A2ZAFAWC.cjs.map +1 -1
- package/dist/chunk-ABFIPCBY.cjs +109 -0
- package/dist/chunk-ABFIPCBY.cjs.map +1 -0
- package/dist/{chunk-Z3UPDOBY.js → chunk-DBYCRBVS.js} +9 -7
- package/dist/chunk-DBYCRBVS.js.map +1 -0
- package/dist/{chunk-P3MDLWLN.cjs → chunk-PKFIMBPW.cjs} +113 -54
- package/dist/chunk-PKFIMBPW.cjs.map +1 -0
- package/dist/{chunk-L4RHSWOA.cjs → chunk-ULEBNY2A.cjs} +121 -212
- package/dist/chunk-ULEBNY2A.cjs.map +1 -0
- package/dist/{chunk-X3OFGGSE.cjs → chunk-VDFCMYLR.cjs} +50 -48
- package/dist/chunk-VDFCMYLR.cjs.map +1 -0
- package/dist/chunk-VOTYIQAY.js +109 -0
- package/dist/chunk-VOTYIQAY.js.map +1 -0
- package/dist/{chunk-3LKLY5WQ.js → chunk-XLBO2RSL.js} +15 -106
- package/dist/chunk-XLBO2RSL.js.map +1 -0
- package/dist/{chunk-3P7QK2PG.js → chunk-Z23B5TY6.js} +89 -30
- package/dist/chunk-Z23B5TY6.js.map +1 -0
- package/dist/index.cjs +13 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +25 -17
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +55 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +28 -5
- package/dist/react.d.ts +28 -5
- package/dist/react.js +55 -2
- package/dist/react.js.map +1 -1
- package/dist/thesis.cjs +11 -3
- package/dist/thesis.cjs.map +1 -1
- package/dist/thesis.d.cts +42 -11
- package/dist/thesis.d.ts +42 -11
- package/dist/thesis.js +24 -16
- package/dist/{users-BDLkwW1p.d.cts → users-3DAw88_U.d.cts} +4 -4
- package/dist/{users-CDlDRuyv.d.ts → users-B4r33i4O.d.ts} +4 -4
- package/dist/widget.cjs +28 -12
- package/dist/widget.cjs.map +1 -1
- package/dist/widget.d.cts +37 -6
- package/dist/widget.d.ts +37 -6
- package/dist/widget.js +17 -1
- package/dist/widget.js.map +1 -1
- package/package.json +147 -147
- package/dist/chunk-3LKLY5WQ.js.map +0 -1
- package/dist/chunk-3P7QK2PG.js.map +0 -1
- package/dist/chunk-L4RHSWOA.cjs.map +0 -1
- package/dist/chunk-P3MDLWLN.cjs.map +0 -1
- package/dist/chunk-X3OFGGSE.cjs.map +0 -1
- 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
|
-
|
|
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 (!
|
|
21
|
-
throw new (0,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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',
|
|
92
|
-
throw new (0,
|
|
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',
|
|
105
|
-
const err = _nullishCoalesce(_optionalChain([parsed, 'optionalAccess',
|
|
106
|
-
|
|
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,
|
|
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,
|
|
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',
|
|
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,
|
|
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,
|
|
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,
|
|
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 (!
|
|
189
|
-
throw new (0,
|
|
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 (!
|
|
196
|
-
throw new (0,
|
|
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,
|
|
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.
|
|
223
|
-
* `scope: '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(
|
|
228
|
-
|
|
229
|
-
|
|
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',
|
|
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',
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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(
|
|
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,
|
|
359
|
-
_optionalChain([this, 'access',
|
|
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',
|
|
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
|
-
|
|
385
|
-
|
|
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"]}
|