cabalspy-id 0.0.0-stage → 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 -0
- package/LICENSE +21 -0
- package/README.md +504 -2
- package/dist/api.cjs +74 -0
- package/dist/api.cjs.map +1 -0
- package/dist/api.d.cts +193 -0
- package/dist/api.d.ts +193 -0
- package/dist/api.js +74 -0
- package/dist/api.js.map +1 -0
- package/dist/chunk-A2ZAFAWC.cjs +1 -0
- package/dist/chunk-A2ZAFAWC.cjs.map +1 -0
- package/dist/chunk-ABFIPCBY.cjs +109 -0
- package/dist/chunk-ABFIPCBY.cjs.map +1 -0
- package/dist/chunk-CJFIOVBA.js +1 -0
- package/dist/chunk-CJFIOVBA.js.map +1 -0
- package/dist/chunk-DBYCRBVS.js +400 -0
- package/dist/chunk-DBYCRBVS.js.map +1 -0
- package/dist/chunk-PKFIMBPW.cjs +444 -0
- package/dist/chunk-PKFIMBPW.cjs.map +1 -0
- package/dist/chunk-ULEBNY2A.cjs +1301 -0
- package/dist/chunk-ULEBNY2A.cjs.map +1 -0
- package/dist/chunk-VDFCMYLR.cjs +400 -0
- 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-XLBO2RSL.js +1301 -0
- package/dist/chunk-XLBO2RSL.js.map +1 -0
- package/dist/chunk-Z23B5TY6.js +444 -0
- package/dist/chunk-Z23B5TY6.js.map +1 -0
- package/dist/index.cjs +105 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +105 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +361 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +315 -0
- package/dist/react.d.ts +315 -0
- package/dist/react.js +333 -0
- package/dist/react.js.map +1 -0
- package/dist/thesis.cjs +76 -0
- package/dist/thesis.cjs.map +1 -0
- package/dist/thesis.d.cts +221 -0
- package/dist/thesis.d.ts +221 -0
- package/dist/thesis.js +76 -0
- package/dist/thesis.js.map +1 -0
- package/dist/types-UZeY9n1e.d.cts +689 -0
- package/dist/types-UZeY9n1e.d.ts +689 -0
- package/dist/users-3DAw88_U.d.cts +767 -0
- package/dist/users-B4r33i4O.d.ts +767 -0
- package/dist/widget.cjs +233 -0
- package/dist/widget.cjs.map +1 -0
- package/dist/widget.d.cts +226 -0
- package/dist/widget.d.ts +226 -0
- package/dist/widget.js +233 -0
- package/dist/widget.js.map +1 -0
- package/package.json +145 -4
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class;
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
var _chunkULEBNY2Acjs = require('./chunk-ULEBNY2A.cjs');
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
var _chunkABFIPCBYcjs = require('./chunk-ABFIPCBY.cjs');
|
|
18
|
+
|
|
19
|
+
// src/thesis.ts
|
|
20
|
+
var DEFAULT_THESIS_URL = "https://id.cabalspy.xyz/thesis/v1";
|
|
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
|
+
}
|
|
33
|
+
function assertChain(chain) {
|
|
34
|
+
if (!_chunkULEBNY2Acjs.CHAINS.includes(chain)) {
|
|
35
|
+
throw new (0, _chunkABFIPCBYcjs.BadRequestError)(`Unknown blockchain '${chain}'`, { code: "invalid_parameter", parameter: "chain", allowed: [..._chunkULEBNY2Acjs.CHAINS] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
var enc = encodeURIComponent;
|
|
39
|
+
function scopeQuery(scope, clientId) {
|
|
40
|
+
if (scope === void 0 || scope === "global") return scope ? { scope } : {};
|
|
41
|
+
if (scope !== "instance") {
|
|
42
|
+
throw new (0, _chunkABFIPCBYcjs.BadRequestError)(`Invalid value for 'scope': ${scope}`, {
|
|
43
|
+
code: "invalid_parameter",
|
|
44
|
+
parameter: "scope",
|
|
45
|
+
allowed: ["global", "instance"]
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (!clientId) {
|
|
49
|
+
throw new (0, _chunkABFIPCBYcjs.BadRequestError)("scope=instance needs clientId", { code: "missing_parameter", parameter: "clientId" });
|
|
50
|
+
}
|
|
51
|
+
return { scope, client_id: clientId };
|
|
52
|
+
}
|
|
53
|
+
var ThesisClient = class {
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/** Your instance's public client_id, sent on every call. */
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
constructor(options) {
|
|
63
|
+
assertClientId(_optionalChain([options, 'optionalAccess', _ => _.clientId]));
|
|
64
|
+
this.accessToken = _nullishCoalesce(options.accessToken, () => ( null));
|
|
65
|
+
this.baseUrl = (_nullishCoalesce(options.baseUrl, () => ( DEFAULT_THESIS_URL))).replace(/\/+$/, "");
|
|
66
|
+
this.wsUrl = (_nullishCoalesce(options.wsUrl, () => ( DEFAULT_THESIS_WS_URL))).replace(/\/+$/, "");
|
|
67
|
+
this.timeout = _nullishCoalesce(options.timeout, () => ( 15e3));
|
|
68
|
+
this.scope = options.scope;
|
|
69
|
+
this.clientId = options.clientId.trim();
|
|
70
|
+
scopeQuery(this.scope, this.clientId);
|
|
71
|
+
this.fetchImpl = _nullishCoalesce(options.fetch, () => ( globalThis.fetch.bind(globalThis)));
|
|
72
|
+
}
|
|
73
|
+
/** Sets (or with null clears) the user's access token, e.g. after sign-in or sign-out. */
|
|
74
|
+
setAccessToken(token) {
|
|
75
|
+
this.accessToken = _nullishCoalesce(token, () => ( null));
|
|
76
|
+
}
|
|
77
|
+
async call(method, path, opts = {}) {
|
|
78
|
+
if (opts.auth === "required" && !this.accessToken) {
|
|
79
|
+
throw new (0, _chunkABFIPCBYcjs.AuthenticationError)('Sign in with CabalSpy ID to write (an access token with the scope "thesis").', {
|
|
80
|
+
status: 401,
|
|
81
|
+
code: "sign_in_required"
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const sp = new URLSearchParams();
|
|
85
|
+
for (const [k, v] of Object.entries(_nullishCoalesce(opts.query, () => ( {})))) if (v !== void 0 && v !== "") sp.set(k, String(v));
|
|
86
|
+
const qs = sp.toString();
|
|
87
|
+
const url = `${this.baseUrl}${path}${qs ? `?${qs}` : ""}`;
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
90
|
+
let res;
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
res = await this.fetchImpl(url, {
|
|
94
|
+
method,
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
credentials: "omit",
|
|
97
|
+
headers: {
|
|
98
|
+
Accept: "application/json",
|
|
99
|
+
[CLIENT_ID_HEADER]: this.clientId,
|
|
100
|
+
...typeof window === "undefined" ? { "User-Agent": _chunkULEBNY2Acjs.USER_AGENT } : {},
|
|
101
|
+
...this.accessToken && opts.auth ? { Authorization: `Bearer ${this.accessToken}` } : {},
|
|
102
|
+
...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
103
|
+
},
|
|
104
|
+
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
|
105
|
+
});
|
|
106
|
+
text = await res.text();
|
|
107
|
+
} catch (err) {
|
|
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])}`, {
|
|
110
|
+
code: aborted ? "timeout" : "connection_error"
|
|
111
|
+
});
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
let parsed = null;
|
|
116
|
+
try {
|
|
117
|
+
parsed = text ? JSON.parse(text) : null;
|
|
118
|
+
} catch (e2) {
|
|
119
|
+
parsed = null;
|
|
120
|
+
}
|
|
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}`);
|
|
126
|
+
}
|
|
127
|
+
if (!parsed || !("data" in parsed)) {
|
|
128
|
+
throw new (0, _chunkABFIPCBYcjs.InvalidResponseError)(`Unexpected response shape on ${method} ${path}`, { status: res.status, code: "invalid_response" });
|
|
129
|
+
}
|
|
130
|
+
const { data, success: _s, ...extra } = parsed;
|
|
131
|
+
return { data, extra };
|
|
132
|
+
}
|
|
133
|
+
tokenPath(chain, mint) {
|
|
134
|
+
assertChain(chain);
|
|
135
|
+
if (!mint) throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Parameter 'mint' is required", { code: "missing_parameter", parameter: "mint" });
|
|
136
|
+
return `/tokens/${enc(chain)}/${enc(mint)}`;
|
|
137
|
+
}
|
|
138
|
+
// ── reading ───────────────────────────────────────────────────────────────
|
|
139
|
+
/** Theses on a token, with the token's info and counts. */
|
|
140
|
+
async list(chain, mint, opts = {}) {
|
|
141
|
+
const { data, extra } = await this.call("GET", this.tokenPath(chain, mint), {
|
|
142
|
+
auth: "optional",
|
|
143
|
+
query: {
|
|
144
|
+
sort: opts.sort,
|
|
145
|
+
stance: opts.stance,
|
|
146
|
+
limit: opts.limit,
|
|
147
|
+
cursor: opts.cursor,
|
|
148
|
+
...scopeQuery(_nullishCoalesce(opts.scope, () => ( this.scope)), _nullishCoalesce(opts.clientId, () => ( this.clientId)))
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
items: data,
|
|
153
|
+
pagination: extra.pagination,
|
|
154
|
+
token: extra.token,
|
|
155
|
+
summary: extra.summary
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** Every thesis on a token, following `next_cursor`. */
|
|
159
|
+
async *iterate(chain, mint, opts = {}) {
|
|
160
|
+
let cursor = opts.cursor;
|
|
161
|
+
while (true) {
|
|
162
|
+
const page = await this.list(chain, mint, { ...opts, cursor });
|
|
163
|
+
yield* page.items;
|
|
164
|
+
if (!_optionalChain([page, 'access', _6 => _6.pagination, 'optionalAccess', _7 => _7.has_more]) || !page.pagination.next_cursor) return;
|
|
165
|
+
cursor = page.pagination.next_cursor;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Counts (theses, bull, bear, authors) and token info. */
|
|
169
|
+
async summary(chain, mint, opts = {}) {
|
|
170
|
+
const query = scopeQuery(_nullishCoalesce(opts.scope, () => ( this.scope)), _nullishCoalesce(opts.clientId, () => ( this.clientId)));
|
|
171
|
+
return (await this.call("GET", `${this.tokenPath(chain, mint)}/summary`, { query })).data;
|
|
172
|
+
}
|
|
173
|
+
/** One thesis or reply. */
|
|
174
|
+
async get(id) {
|
|
175
|
+
return (await this.call("GET", `/items/${enc(id)}`, { auth: "optional" })).data;
|
|
176
|
+
}
|
|
177
|
+
/** Replies to a thesis. */
|
|
178
|
+
async replies(id, opts = {}) {
|
|
179
|
+
const { data, extra } = await this.call("GET", `/items/${enc(id)}/replies`, { auth: "optional", query: opts });
|
|
180
|
+
return { items: data, pagination: extra.pagination };
|
|
181
|
+
}
|
|
182
|
+
/** Theses of one user (their csid_id). */
|
|
183
|
+
async byUser(csidId, opts = {}) {
|
|
184
|
+
const { data, extra } = await this.call("GET", `/users/${enc(csidId)}`, { auth: "optional", query: opts });
|
|
185
|
+
return { items: data, pagination: extra.pagination };
|
|
186
|
+
}
|
|
187
|
+
// ── the signed-in user ────────────────────────────────────────────────────
|
|
188
|
+
/** May the signed-in user post (holder) or reply on this token, and who are they. */
|
|
189
|
+
async me(chain, mint) {
|
|
190
|
+
return (await this.call("GET", `${this.tokenPath(chain, mint)}/me`, { auth: "required" })).data;
|
|
191
|
+
}
|
|
192
|
+
/** Posts a thesis. Holders only (`not_a_holder` otherwise). Up to 1000 characters. */
|
|
193
|
+
async post(chain, mint, params) {
|
|
194
|
+
if (params.stance && params.stance !== "bull" && params.stance !== "bear") {
|
|
195
|
+
throw new (0, _chunkABFIPCBYcjs.BadRequestError)("stance must be bull or bear", { code: "invalid_parameter", parameter: "stance", allowed: ["bull", "bear"] });
|
|
196
|
+
}
|
|
197
|
+
const body = { ...params, client_id: this.clientId };
|
|
198
|
+
return (await this.call("POST", this.tokenPath(chain, mint), { auth: "required", body })).data;
|
|
199
|
+
}
|
|
200
|
+
/** Replies to a thesis. Any signed-in user may reply. */
|
|
201
|
+
async reply(id, body) {
|
|
202
|
+
const payload = { body, client_id: this.clientId };
|
|
203
|
+
return (await this.call("POST", `/items/${enc(id)}/replies`, { auth: "required", body: payload })).data;
|
|
204
|
+
}
|
|
205
|
+
/** Adds (or with on=false removes) a reaction. */
|
|
206
|
+
async react(id, emoji, on = true) {
|
|
207
|
+
if (!_chunkULEBNY2Acjs.THESIS_EMOJIS.includes(emoji)) {
|
|
208
|
+
throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Unknown reaction", { code: "invalid_parameter", parameter: "emoji", allowed: [..._chunkULEBNY2Acjs.THESIS_EMOJIS] });
|
|
209
|
+
}
|
|
210
|
+
return (await this.call("POST", `/items/${enc(id)}/reactions`, { auth: "required", body: { emoji, on } })).data;
|
|
211
|
+
}
|
|
212
|
+
/** Reports a post (hidden for review after 3 reports). */
|
|
213
|
+
async report(id, reason, note) {
|
|
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] });
|
|
216
|
+
}
|
|
217
|
+
return (await this.call("POST", `/items/${enc(id)}/report`, { auth: "required", body: { reason, note } })).data;
|
|
218
|
+
}
|
|
219
|
+
/** Deletes the signed-in user's own thesis or reply. */
|
|
220
|
+
async delete(id) {
|
|
221
|
+
return (await this.call("DELETE", `/items/${enc(id)}`, { auth: "required" })).data;
|
|
222
|
+
}
|
|
223
|
+
/** The signed-in user's moderation notifications, with the unread count. */
|
|
224
|
+
async notifications() {
|
|
225
|
+
return (await this.call("GET", "/me/notifications", { auth: "required" })).data;
|
|
226
|
+
}
|
|
227
|
+
/** Marks all of the signed-in user's notifications as read. */
|
|
228
|
+
async markNotificationsRead() {
|
|
229
|
+
return (await this.call("POST", "/me/notifications/read", { auth: "required" })).data;
|
|
230
|
+
}
|
|
231
|
+
/** Appeals a moderation decision (an `appealable` notification; 10-1000 characters, within 30 days). */
|
|
232
|
+
async appeal(notificationId, message) {
|
|
233
|
+
if (!message) throw new (0, _chunkABFIPCBYcjs.BadRequestError)("Parameter 'message' is required", { code: "missing_parameter", parameter: "message" });
|
|
234
|
+
return (await this.call("POST", `/me/notifications/${enc(String(notificationId))}/appeal`, {
|
|
235
|
+
auth: "required",
|
|
236
|
+
body: { message }
|
|
237
|
+
})).data;
|
|
238
|
+
}
|
|
239
|
+
// ── live ──────────────────────────────────────────────────────────────────
|
|
240
|
+
/**
|
|
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.
|
|
243
|
+
*/
|
|
244
|
+
live(chain, mint, options = {}) {
|
|
245
|
+
this.tokenPath(chain, mint);
|
|
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);
|
|
251
|
+
}
|
|
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
|
+
}
|
|
292
|
+
var ThesisLive = (_class = class {
|
|
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);
|
|
294
|
+
this.url = url;
|
|
295
|
+
this.options = options;
|
|
296
|
+
this.opts = {
|
|
297
|
+
reconnect: _nullishCoalesce(options.reconnect, () => ( true)),
|
|
298
|
+
reconnectDelay: _nullishCoalesce(options.reconnectDelay, () => ( 1e3)),
|
|
299
|
+
maxReconnectDelay: _nullishCoalesce(options.maxReconnectDelay, () => ( 3e4)),
|
|
300
|
+
heartbeatTimeout: _nullishCoalesce(options.heartbeatTimeout, () => ( 75e3))
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__init() {this.ws = null}
|
|
306
|
+
__init2() {this.handlers = /* @__PURE__ */ new Map()}
|
|
307
|
+
__init3() {this.attempt = 0}
|
|
308
|
+
__init4() {this.closedByUser = false}
|
|
309
|
+
__init5() {this.reconnectTimer = null}
|
|
310
|
+
__init6() {this.watchdog = null}
|
|
311
|
+
|
|
312
|
+
get connected() {
|
|
313
|
+
return _optionalChain([this, 'access', _8 => _8.ws, 'optionalAccess', _9 => _9.readyState]) === 1;
|
|
314
|
+
}
|
|
315
|
+
on(event, handler) {
|
|
316
|
+
let set = this.handlers.get(event);
|
|
317
|
+
if (!set) this.handlers.set(event, set = /* @__PURE__ */ new Set());
|
|
318
|
+
set.add(handler);
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
off(event, handler) {
|
|
322
|
+
_optionalChain([this, 'access', _10 => _10.handlers, 'access', _11 => _11.get, 'call', _12 => _12(event), 'optionalAccess', _13 => _13.delete, 'call', _14 => _14(handler)]);
|
|
323
|
+
return this;
|
|
324
|
+
}
|
|
325
|
+
emit(event, payload) {
|
|
326
|
+
for (const h of [..._nullishCoalesce(this.handlers.get(event), () => ( []))]) {
|
|
327
|
+
try {
|
|
328
|
+
h(payload);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (event !== "error") this.emit("error", new (0, _chunkABFIPCBYcjs.CabalSpyIDError)(String(_nullishCoalesce(_optionalChain([err, 'optionalAccess', _15 => _15.message]), () => ( err))), { code: "handler_error" }));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Opens the socket; resolves once it is open. */
|
|
335
|
+
connect() {
|
|
336
|
+
this.closedByUser = false;
|
|
337
|
+
const WS = _nullishCoalesce(this.options.WebSocketImpl, () => ( globalThis.WebSocket));
|
|
338
|
+
if (!WS) {
|
|
339
|
+
return Promise.reject(
|
|
340
|
+
new (0, _chunkABFIPCBYcjs.ConnectionError)("No WebSocket implementation found. On Node < 22, run `npm i ws` and pass { WebSocketImpl: WebSocket }.", {
|
|
341
|
+
code: "no_websocket"
|
|
342
|
+
})
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return new Promise((resolve, reject) => {
|
|
346
|
+
let settled = false;
|
|
347
|
+
const ws = new WS(this.url);
|
|
348
|
+
this.ws = ws;
|
|
349
|
+
ws.onopen = () => {
|
|
350
|
+
this.attempt = 0;
|
|
351
|
+
this.arm();
|
|
352
|
+
this.emit("open", void 0);
|
|
353
|
+
if (!settled) {
|
|
354
|
+
settled = true;
|
|
355
|
+
resolve();
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
ws.onmessage = (ev) => {
|
|
359
|
+
if (this.ws !== ws) return;
|
|
360
|
+
this.arm();
|
|
361
|
+
let msg;
|
|
362
|
+
try {
|
|
363
|
+
msg = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
|
|
364
|
+
} catch (e3) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (msg.type === "thesis" || msg.type === "reply" || msg.type === "reaction" || msg.type === "removed") {
|
|
368
|
+
this.emit(msg.type, msg.data);
|
|
369
|
+
} else if (msg.type === "heartbeat") {
|
|
370
|
+
this.emit("heartbeat", {});
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
ws.onerror = () => {
|
|
374
|
+
if (this.ws !== ws) return;
|
|
375
|
+
const err = new (0, _chunkABFIPCBYcjs.ConnectionError)(`WebSocket error on ${this.url}`, { code: "ws_error" });
|
|
376
|
+
this.emit("error", err);
|
|
377
|
+
if (!settled) {
|
|
378
|
+
settled = true;
|
|
379
|
+
reject(err);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
ws.onclose = (ev) => {
|
|
383
|
+
if (this.ws !== ws) return;
|
|
384
|
+
this.ws = null;
|
|
385
|
+
this.disarm();
|
|
386
|
+
const e = _nullishCoalesce(ev, () => ( {}));
|
|
387
|
+
this.emit("close", { code: e.code, reason: e.reason });
|
|
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);
|
|
392
|
+
if (!settled) {
|
|
393
|
+
settled = true;
|
|
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
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (!this.closedByUser && !final && this.opts.reconnect) this.scheduleReconnect(rateLimited ? RATE_LIMIT_BACKOFF_MS : 0);
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
scheduleReconnect(minDelay = 0) {
|
|
403
|
+
const delay = Math.max(minDelay, Math.min(this.opts.reconnectDelay * 2 ** this.attempt, this.opts.maxReconnectDelay)) + Math.random() * 250;
|
|
404
|
+
this.attempt++;
|
|
405
|
+
this.reconnectTimer = setTimeout(() => {
|
|
406
|
+
this.reconnectTimer = null;
|
|
407
|
+
if (!this.closedByUser) this.connect().catch(() => void 0);
|
|
408
|
+
}, delay);
|
|
409
|
+
}
|
|
410
|
+
arm() {
|
|
411
|
+
if (!this.opts.heartbeatTimeout) return;
|
|
412
|
+
this.disarm();
|
|
413
|
+
this.watchdog = setTimeout(() => {
|
|
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")]);
|
|
416
|
+
}, this.opts.heartbeatTimeout);
|
|
417
|
+
}
|
|
418
|
+
disarm() {
|
|
419
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
420
|
+
this.watchdog = null;
|
|
421
|
+
}
|
|
422
|
+
/** Closes the socket and stops reconnecting. */
|
|
423
|
+
close() {
|
|
424
|
+
this.closedByUser = true;
|
|
425
|
+
this.disarm();
|
|
426
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
427
|
+
this.reconnectTimer = null;
|
|
428
|
+
const ws = this.ws;
|
|
429
|
+
this.ws = null;
|
|
430
|
+
_optionalChain([ws, 'optionalAccess', _19 => _19.close, 'call', _20 => _20(1e3, "client closed")]);
|
|
431
|
+
this.emit("close", { code: 1e3, reason: "client closed" });
|
|
432
|
+
}
|
|
433
|
+
}, _class);
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
|
|
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":"AAAA;AACE;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACA;AC0BO,IAAM,mBAAA,EAAqB,mCAAA;AAC3B,IAAM,sBAAA,EAAwB,oCAAA;AAE9B,IAAM,iBAAA,EAAmB,kBAAA;AAEhC,IAAM,2BAAA,EACJ,oNAAA;AAIK,SAAS,cAAA,CAAe,QAAA,EAA+C;AAC5E,EAAA,GAAA,CAAI,OAAO,SAAA,IAAa,SAAA,GAAY,CAAC,QAAA,CAAS,IAAA,CAAK,CAAA,EAAG;AACpD,IAAA,MAAM,IAAI,0CAAA,CAAoB,0BAAA,EAA4B;AAAA,MACxD,IAAA,EAAM,oBAAA;AAAA,MACN,SAAA,EAAW,UAAA;AAAA,MACX,IAAA,EAAM;AAAA,IACR,CAAC,CAAA;AAAA,EACH;AACF;AAqEA,SAAS,WAAA,CAAY,KAAA,EAAqB;AACxC,EAAA,GAAA,CAAI,CAAE,wBAAA,CAA6B,QAAA,CAAS,KAAK,CAAA,EAAG;AAClD,IAAA,MAAM,IAAI,sCAAA,CAAgB,CAAA,oBAAA,EAAuB,KAAK,CAAA,CAAA,CAAA,EAAK,EAAE,IAAA,EAAM,mBAAA,EAAqB,SAAA,EAAW,OAAA,EAAS,OAAA,EAAS,CAAC,GAAG,wBAAM,EAAE,CAAC,CAAA;AAAA,EACpI;AACF;AAEA,IAAM,IAAA,EAAM,kBAAA;AAGZ,SAAS,UAAA,CAAW,KAAA,EAA2B,QAAA,EAAkE;AAC/G,EAAA,GAAA,CAAI,MAAA,IAAU,KAAA,EAAA,GAAa,MAAA,IAAU,QAAA,EAAU,OAAO,MAAA,EAAQ,EAAE,MAAM,EAAA,EAAI,CAAC,CAAA;AAC3E,EAAA,GAAA,CAAI,MAAA,IAAU,UAAA,EAAY;AACxB,IAAA,MAAM,IAAI,sCAAA,CAAgB,CAAA,2BAAA,EAA8B,KAAK,CAAA,CAAA;AACrD,MAAA;AACK,MAAA;AACmB,MAAA;AAC/B,IAAA;AACH,EAAA;AACe,EAAA;AACgD,IAAA;AAC/D,EAAA;AACoC,EAAA;AACtC;AAa0B;AACf,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AAAA;AAEA,EAAA;AACD,EAAA;AACS,EAAA;AAEyB,EAAA;AACR,IAAA;AACU,IAAA;AACa,IAAA;AACO,IAAA;AAC5B,IAAA;AACb,IAAA;AACiB,IAAA;AACF,IAAA;AACoB,IAAA;AAC1D,EAAA;AAAA;AAGuD,EAAA;AACzB,IAAA;AAC9B,EAAA;AAMwD,EAAA;AACH,IAAA;AACnB,MAAA;AACpB,QAAA;AACF,QAAA;AACP,MAAA;AACH,IAAA;AAC+B,IAAA;AAC4B,IAAA;AACpC,IAAA;AACgC,IAAA;AAChB,IAAA;AACiB,IAAA;AACpD,IAAA;AACA,IAAA;AACA,IAAA;AAC8B,MAAA;AAC9B,QAAA;AACmB,QAAA;AACN,QAAA;AACJ,QAAA;AACC,UAAA;AACiB,UAAA;AAC2B,UAAA;AACC,UAAA;AACL,UAAA;AAClD,QAAA;AAC4D,QAAA;AAC7D,MAAA;AACqB,MAAA;AACV,IAAA;AAC6B,MAAA;AACL,MAAA;AACN,QAAA;AAC7B,MAAA;AACD,IAAA;AACkB,MAAA;AACpB,IAAA;AAC6C,IAAA;AACzC,IAAA;AAC8D,MAAA;AAC1D,IAAA;AACG,MAAA;AACX,IAAA;AAC0C,IAAA;AACV,MAAA;AACU,MAAA;AACc,MAAA;AACV,MAAA;AAC9C,IAAA;AACoC,IAAA;AACH,MAAA;AACjC,IAAA;AACwC,IAAA;AACR,IAAA;AAClC,EAAA;AAEsD,EAAA;AACnC,IAAA;AACoB,IAAA;AACI,IAAA;AAC3C,EAAA;AAAA;AAAA;AAKgG,EAAA;AACtB,IAAA;AAChE,MAAA;AACC,MAAA;AACM,QAAA;AACE,QAAA;AACD,QAAA;AACC,QAAA;AAC4C,QAAA;AAC3D,MAAA;AACD,IAAA;AACM,IAAA;AACE,MAAA;AACW,MAAA;AACL,MAAA;AACE,MAAA;AACjB,IAAA;AACF,EAAA;AAAA;AAG8G,EAAA;AAC1F,IAAA;AACL,IAAA;AACiD,MAAA;AAChD,MAAA;AACuC,MAAA;AAC1B,MAAA;AAC3B,IAAA;AACF,EAAA;AAAA;AAGiG,EAAA;AACvC,IAAA;AACoB,IAAA;AAC9E,EAAA;AAAA;AAGuC,EAAA;AAC+B,IAAA;AACtE,EAAA;AAAA;AAG+F,EAAA;AACtB,IAAA;AACN,IAAA;AACnE,EAAA;AAAA;AAGkG,EAAA;AACzB,IAAA;AACN,IAAA;AACnE,EAAA;AAAA;AAAA;AAKwD,EAAA;AACiB,IAAA;AACzE,EAAA;AAAA;AAGyG,EAAA;AAC/C,IAAA;AACK,MAAA;AAC7D,IAAA;AACmD,IAAA;AACkB,IAAA;AACvE,EAAA;AAAA;AAGuD,EAAA;AACJ,IAAA;AACoB,IAAA;AACvE,EAAA;AAAA;AAGiF,EAAA;AACpB,IAAA;AACH,MAAA;AACxD,IAAA;AACkE,IAAA;AACpE,EAAA;AAAA;AAGiG,EAAA;AAC3B,IAAA;AACb,MAAA;AACvD,IAAA;AACkF,IAAA;AACpF,EAAA;AAAA;AAGqD,EAAA;AACyB,IAAA;AAC9E,EAAA;AAAA;AAGoD,EAAA;AAC+B,IAAA;AACnF,EAAA;AAAA;AAGuD,EAAA;AACuB,IAAA;AAC9E,EAAA;AAAA;AAGmF,EAAA;AACzC,IAAA;AAE+B,IAAA;AAC7D,MAAA;AACU,MAAA;AAElB,IAAA;AACJ,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQmG,EAAA;AACvE,IAAA;AACK,IAAA;AACb,MAAA;AACoC,MAAA;AACrD,IAAA;AAC6D,IAAA;AAChE,EAAA;AACF;AAwCqC;AAAA;AAE3B,EAAA;AAAA;AAEA,EAAA;AAAA;AAEM,EAAA;AAChB;AAG8B;AAEI;AAGkE;AAC/E,EAAA;AAC0C,IAAA;AAGrD,IAAA;AAEsD,IAAA;AAC9D,EAAA;AACmB,EAAA;AACU,IAAA;AACnB,MAAA;AACE,MAAA;AAC0B,MAAA;AACnC,IAAA;AACH,EAAA;AACmB,EAAA;AACS,IAAA;AAClB,MAAA;AACE,MAAA;AACoB,MAAA;AACQ,MAAA;AACrC,IAAA;AACH,EAAA;AACmB,EAAA;AACU,IAAA;AACnB,MAAA;AACE,MAAA;AACT,IAAA;AACH,EAAA;AACO,EAAA;AACT;AASwB;AAYpB,EAAA;AAFS,IAAA;AACQ,IAAA;AAEL,IAAA;AACsB,MAAA;AACU,MAAA;AACM,MAAA;AACF,MAAA;AAChD,IAAA;AACF,EAAA;AATW,EAAA;AACQ,EAAA;AAVmB,iBAAA;AAC+B,kBAAA;AACnD,kBAAA;AACK,kBAAA;AACwC,kBAAA;AACN,kBAAA;AACxC,EAAA;AAcQ,EAAA;AACQ,IAAA;AACjC,EAAA;AAEoG,EAAA;AACjE,IAAA;AACa,IAAA;AACT,IAAA;AAC9B,IAAA;AACT,EAAA;AAEqG,EAAA;AACrC,oBAAA;AACvD,IAAA;AACT,EAAA;AAEiG,EAAA;AACxC,IAAA;AACjD,MAAA;AAC+C,QAAA;AACrC,MAAA;AACkC,QAAA;AAChD,MAAA;AACF,IAAA;AACF,EAAA;AAAA;AAGyB,EAAA;AACH,IAAA;AAC8D,IAAA;AAGzE,IAAA;AACQ,MAAA;AACO,QAAA;AACZ,UAAA;AACP,QAAA;AACH,MAAA;AACF,IAAA;AAC8C,IAAA;AAC9B,MAAA;AACY,MAAA;AAChB,MAAA;AACQ,MAAA;AACD,QAAA;AACN,QAAA;AACkB,QAAA;AACb,QAAA;AACF,UAAA;AACF,UAAA;AACV,QAAA;AACF,MAAA;AACuB,MAAA;AACD,QAAA;AACX,QAAA;AACL,QAAA;AACA,QAAA;AACgD,UAAA;AAC5C,QAAA;AACN,UAAA;AACF,QAAA;AACyD,QAAA;AAClB,UAAA;AACF,QAAA;AACV,UAAA;AAC3B,QAAA;AACF,MAAA;AACmB,MAAA;AACG,QAAA;AACkC,QAAA;AAChC,QAAA;AACR,QAAA;AACF,UAAA;AACA,UAAA;AACZ,QAAA;AACF,MAAA;AACqB,MAAA;AACC,QAAA;AACV,QAAA;AACE,QAAA;AACM,QAAA;AACmC,QAAA;AACJ,QAAA;AAClB,QAAA;AACI,QAAA;AACI,QAAA;AACzB,QAAA;AACF,UAAA;AACV,UAAA;AACiC,6BAAA;AACjC,UAAA;AACF,QAAA;AACyD,QAAA;AAC3D,MAAA;AACD,IAAA;AACH,EAAA;AAE8C,EAAA;AAEJ,IAAA;AACnC,IAAA;AACkC,IAAA;AACf,MAAA;AACsC,MAAA;AACtD,IAAA;AACV,EAAA;AAEoB,EAAA;AACe,IAAA;AACrB,IAAA;AACqB,IAAA;AACQ,MAAA;AACC,sBAAA;AACb,IAAA;AAC/B,EAAA;AAEuB,EAAA;AACwB,IAAA;AAC7B,IAAA;AAClB,EAAA;AAAA;AAGc,EAAA;AACQ,IAAA;AACR,IAAA;AAC6C,IAAA;AACnC,IAAA;AACN,IAAA;AACN,IAAA;AACqB,oBAAA;AAC2B,IAAA;AAC5D,EAAA;AACF;ADjNkE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","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"]}
|