@flitt/api 1.2.0 → 1.3.0

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/src/client.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type FetchLike,
10
10
  type RequestOptions,
11
11
  } from "./http.js";
12
+ import { FlittEventStream, type EventStreamOptions } from "./events.js";
12
13
  import type {
13
14
  AccountLink,
14
15
  CommandInput,
@@ -164,6 +165,7 @@ export class Flitt {
164
165
  readonly votes: VotesResource;
165
166
  /** Published reviews. Account key or server token. */
166
167
  readonly reviews: ReviewsResource;
168
+ readonly events: EventsResource;
167
169
 
168
170
  constructor(options: FlittOptions = {}) {
169
171
  const apiKey = (options.apiKey ?? envVar("FLITT_API_KEY") ?? "").trim();
@@ -209,6 +211,7 @@ export class Flitt {
209
211
  this.commands = new CommandsResource(http, base);
210
212
  this.votes = new VotesResource(votesHttp, base);
211
213
  this.reviews = new ReviewsResource(votesHttp, base);
214
+ this.events = new EventsResource(apiKey || serverToken, normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL), options.dangerouslyAllowBrowser === true);
212
215
  }
213
216
 
214
217
  /** Quota state from the last response (`null` before the first request). */
@@ -225,6 +228,35 @@ export class Flitt {
225
228
  }
226
229
  }
227
230
 
231
+ class EventsResource {
232
+ #token: string;
233
+ #baseUrl: string;
234
+ #allowBrowser: boolean;
235
+
236
+ constructor(token: string, baseUrl: string, allowBrowser: boolean) {
237
+ this.#token = token;
238
+ this.#baseUrl = baseUrl;
239
+ this.#allowBrowser = allowBrowser;
240
+ }
241
+
242
+ connect(options: EventStreamOptions = {}): FlittEventStream {
243
+ if (isBrowserLike() && !this.#allowBrowser) {
244
+ throw new FlittConfigError(
245
+ "The Flitt client is running in a browser: your secret key would be exposed to every visitor. " +
246
+ "Call Flitt from your server (Node.js, Deno, Bun, a Worker…) or set dangerouslyAllowBrowser: true if you understand the risk.",
247
+ );
248
+ }
249
+ return new FlittEventStream(this.#token, this.#baseUrl, options);
250
+ }
251
+
252
+ toJSON() {
253
+ return { resource: "events", credentials: "[redacted]" };
254
+ }
255
+ [INSPECT]() {
256
+ return "EventsResource { credentials: [redacted] }";
257
+ }
258
+ }
259
+
228
260
  class ServersResource {
229
261
  constructor(private readonly http: Transport, private readonly base: string) {}
230
262
 
package/src/events.ts ADDED
@@ -0,0 +1,354 @@
1
+ import { apiErrorFor, FlittConfigError, FlittConnectionError, FlittError } from "./errors.js";
2
+ import type { WebhookEvent, WebhookEventType } from "./types.js";
3
+
4
+ export type WebSocketLike = {
5
+ readonly readyState: number;
6
+ send(data: string): void;
7
+ close(code?: number, reason?: string): void;
8
+ addEventListener(type: string, listener: (event: any) => void): void;
9
+ };
10
+
11
+ export type WebSocketConstructor = new (url: string) => WebSocketLike;
12
+
13
+ export type FlittEvent<T = Record<string, unknown> | null> = WebhookEvent<T> & {
14
+ id: string;
15
+ replay: boolean;
16
+ };
17
+
18
+ export type EventStreamOptions = {
19
+ servers?: string[];
20
+ events?: WebhookEventType[];
21
+ resume?: boolean;
22
+ lastEventIds?: Record<string, string>;
23
+ WebSocket?: WebSocketConstructor;
24
+ signal?: AbortSignal;
25
+ maxReconnectDelayMs?: number;
26
+ bufferSize?: number;
27
+ };
28
+
29
+ export type EventStreamReady = { servers: string[]; events: WebhookEventType[] };
30
+ export type EventStreamReconnect = { attempt: number; delayMs: number; code: number };
31
+ export type EventStreamGap = { serverId: string };
32
+ export type EventStreamClose = { code: number; reason: string };
33
+
34
+ type Listener = (payload: any) => void;
35
+
36
+ const PATH = "/v1/public-api/events";
37
+ const PING_EVERY_MS = 30_000;
38
+ const IDLE_TIMEOUT_MS = 75_000;
39
+ const OPEN_TIMEOUT_MS = 20_000;
40
+ const FATAL: Record<number, { status: number; code: string; message: string }> = {
41
+ 4001: { status: 401, code: "UNAUTHORIZED", message: "Invalid, revoked or expired credentials" },
42
+ 4002: { status: 400, code: "BAD_REQUEST", message: "Invalid subscription (servers, events or resume)" },
43
+ 4003: { status: 403, code: "FORBIDDEN", message: "Your plan or your account does not allow real-time events" },
44
+ 4004: { status: 404, code: "NOT_FOUND", message: "No accessible server or event for this subscription" },
45
+ };
46
+ const STREAM_ID = /^\d{1,15}-\d{1,10}$/;
47
+
48
+ function compareIds(a: string, b: string): number {
49
+ const [am, as] = a.split("-");
50
+ const [bm, bs] = b.split("-");
51
+ const dm = Number(am) - Number(bm);
52
+ return dm !== 0 ? dm : Number(as) - Number(bs);
53
+ }
54
+
55
+ function safeClose(ws: WebSocketLike, code: number, reason: string) {
56
+ try {
57
+ ws.close(code, reason);
58
+ } catch {
59
+ return;
60
+ }
61
+ }
62
+
63
+ export function eventsUrl(baseUrl: string): string {
64
+ return baseUrl.replace(/^http/, "ws") + PATH;
65
+ }
66
+
67
+ export function resolveWebSocket(custom?: WebSocketConstructor): WebSocketConstructor {
68
+ if (custom) return custom;
69
+ const ws = (globalThis as { WebSocket?: WebSocketConstructor }).WebSocket;
70
+ if (typeof ws !== "function") {
71
+ throw new FlittConfigError("No global WebSocket in this runtime: use Node.js 22+, or pass { WebSocket } from the \"ws\" package (npm i ws).");
72
+ }
73
+ return ws;
74
+ }
75
+
76
+ export class FlittEventStream implements AsyncIterable<FlittEvent> {
77
+ #token: string;
78
+ #url: string;
79
+ #WebSocket: WebSocketConstructor;
80
+ #servers: string[] | undefined;
81
+ #events: WebhookEventType[] | undefined;
82
+ #resume: boolean;
83
+ #maxDelay: number;
84
+ #bufferSize: number;
85
+ #lastIds = new Map<string, string>();
86
+ #listeners = new Map<string, Set<Listener>>();
87
+ #iterators = new Set<{ queue: FlittEvent[]; wake: (() => void) | null }>();
88
+ #ws: WebSocketLike | null = null;
89
+ #ready = false;
90
+ #closed = false;
91
+ #fatal: FlittError | null = null;
92
+ #attempt = 0;
93
+ #lastRx = 0;
94
+ #openedAt = 0;
95
+ #retry: ReturnType<typeof setTimeout> | null = null;
96
+ #timer: ReturnType<typeof setInterval> | null = null;
97
+ #onAbort: (() => void) | null = null;
98
+ #signal: AbortSignal | undefined;
99
+
100
+ constructor(token: string, baseUrl: string, options: EventStreamOptions = {}) {
101
+ this.#token = token;
102
+ this.#url = eventsUrl(baseUrl);
103
+ this.#WebSocket = resolveWebSocket(options.WebSocket);
104
+ this.#servers = options.servers?.length ? [...new Set(options.servers.map((s) => String(s).trim().toLowerCase()))] : undefined;
105
+ this.#events = options.events?.length ? [...new Set(options.events)] : undefined;
106
+ this.#resume = options.resume !== false;
107
+ this.#maxDelay = Math.max(1000, Math.min(300_000, options.maxReconnectDelayMs ?? 30_000));
108
+ this.#bufferSize = Math.max(1, Math.min(100_000, options.bufferSize ?? 1000));
109
+ for (const [serverId, id] of Object.entries(options.lastEventIds ?? {})) {
110
+ if (STREAM_ID.test(id)) this.#lastIds.set(serverId, id);
111
+ }
112
+ if (this.#servers && this.#servers.length > 200) throw new FlittConfigError("servers: 200 at most per stream.");
113
+ this.#signal = options.signal;
114
+ if (this.#signal?.aborted) {
115
+ this.#closed = true;
116
+ return;
117
+ }
118
+ if (this.#signal) {
119
+ this.#onAbort = () => this.close();
120
+ this.#signal.addEventListener("abort", this.#onAbort, { once: true });
121
+ }
122
+ this.#timer = setInterval(() => this.#watchdog(), 5_000);
123
+ this.#connect();
124
+ }
125
+
126
+ get connected(): boolean {
127
+ return this.#ready;
128
+ }
129
+
130
+ get closed(): boolean {
131
+ return this.#closed;
132
+ }
133
+
134
+ get lastEventIds(): Record<string, string> {
135
+ return Object.fromEntries(this.#lastIds);
136
+ }
137
+
138
+ on(type: "event", listener: (event: FlittEvent) => void): this;
139
+ on(type: WebhookEventType, listener: (event: FlittEvent) => void): this;
140
+ on(type: "ready", listener: (info: EventStreamReady) => void): this;
141
+ on(type: "reconnecting", listener: (info: EventStreamReconnect) => void): this;
142
+ on(type: "gap", listener: (info: EventStreamGap) => void): this;
143
+ on(type: "error", listener: (error: FlittError) => void): this;
144
+ on(type: "close", listener: (info: EventStreamClose) => void): this;
145
+ on(type: string, listener: Listener): this {
146
+ let set = this.#listeners.get(type);
147
+ if (!set) {
148
+ set = new Set();
149
+ this.#listeners.set(type, set);
150
+ }
151
+ set.add(listener);
152
+ return this;
153
+ }
154
+
155
+ off(type: string, listener: Listener): this {
156
+ this.#listeners.get(type)?.delete(listener);
157
+ return this;
158
+ }
159
+
160
+ once(type: string, listener: Listener): this {
161
+ const wrapped: Listener = (payload) => {
162
+ this.off(type, wrapped);
163
+ listener(payload);
164
+ };
165
+ return this.on(type as "event", wrapped);
166
+ }
167
+
168
+ close(): void {
169
+ if (this.#closed) return;
170
+ this.#closed = true;
171
+ this.#shutdown(1000, "closed by client");
172
+ }
173
+
174
+ async *[Symbol.asyncIterator](): AsyncIterator<FlittEvent> {
175
+ const it = { queue: [] as FlittEvent[], wake: null as (() => void) | null };
176
+ this.#iterators.add(it);
177
+ try {
178
+ while (true) {
179
+ if (it.queue.length > 0) {
180
+ yield it.queue.shift() as FlittEvent;
181
+ continue;
182
+ }
183
+ if (this.#fatal) throw this.#fatal;
184
+ if (this.#closed) return;
185
+ await new Promise<void>((resolve) => (it.wake = resolve));
186
+ it.wake = null;
187
+ }
188
+ } finally {
189
+ this.#iterators.delete(it);
190
+ }
191
+ }
192
+
193
+ toJSON() {
194
+ return { stream: "FlittEventStream", connected: this.#ready, credentials: "[redacted]" };
195
+ }
196
+
197
+ #emit(type: string, payload: unknown) {
198
+ const set = this.#listeners.get(type);
199
+ if (!set) return;
200
+ for (const fn of [...set]) {
201
+ try {
202
+ fn(payload);
203
+ } catch {
204
+ continue;
205
+ }
206
+ }
207
+ }
208
+
209
+ #wakeIterators() {
210
+ for (const it of this.#iterators) it.wake?.();
211
+ }
212
+
213
+ #connect() {
214
+ if (this.#closed) return;
215
+ this.#retry = null;
216
+ this.#ready = false;
217
+ this.#openedAt = Date.now();
218
+ let ws: WebSocketLike;
219
+ try {
220
+ ws = new this.#WebSocket(this.#url);
221
+ } catch (err) {
222
+ this.#scheduleReconnect(1006, err);
223
+ return;
224
+ }
225
+ this.#ws = ws;
226
+ ws.addEventListener("open", () => {
227
+ if (this.#ws !== ws) return;
228
+ this.#lastRx = Date.now();
229
+ const hello: Record<string, unknown> = { op: "hello", token: this.#token };
230
+ if (this.#servers) hello.servers = this.#servers;
231
+ if (this.#events) hello.events = this.#events;
232
+ if (this.#resume && this.#lastIds.size > 0) hello.resume = Object.fromEntries(this.#lastIds);
233
+ try {
234
+ ws.send(JSON.stringify(hello));
235
+ } catch {
236
+ return;
237
+ }
238
+ });
239
+ ws.addEventListener("message", (ev: { data: unknown }) => {
240
+ if (this.#ws !== ws) return;
241
+ this.#lastRx = Date.now();
242
+ if (typeof ev.data === "string") this.#onFrame(ev.data);
243
+ });
244
+ ws.addEventListener("close", (ev: { code?: number; reason?: string }) => {
245
+ if (this.#ws !== ws) return;
246
+ this.#onClose(Number(ev?.code ?? 1006), String(ev?.reason ?? ""));
247
+ });
248
+ ws.addEventListener("error", () => undefined);
249
+ }
250
+
251
+ #onFrame(text: string) {
252
+ let msg: any;
253
+ try {
254
+ msg = JSON.parse(text);
255
+ } catch {
256
+ return;
257
+ }
258
+ if (!msg || typeof msg !== "object") return;
259
+ if (msg.op === "event") {
260
+ const serverId = String(msg.server?.id ?? "");
261
+ const id = String(msg.id ?? "");
262
+ if (!serverId || !STREAM_ID.test(id)) return;
263
+ const last = this.#lastIds.get(serverId);
264
+ if (last && compareIds(id, last) <= 0) return;
265
+ this.#lastIds.set(serverId, id);
266
+ const event: FlittEvent = {
267
+ id,
268
+ replay: msg.replay === true,
269
+ event: msg.event,
270
+ timestamp: msg.timestamp,
271
+ server: { id: serverId, name: msg.server?.name ?? null },
272
+ data: msg.data ?? null,
273
+ };
274
+ for (const it of this.#iterators) {
275
+ it.queue.push(event);
276
+ if (it.queue.length > this.#bufferSize) it.queue.shift();
277
+ }
278
+ this.#wakeIterators();
279
+ this.#emit("event", event);
280
+ this.#emit(event.event, event);
281
+ } else if (msg.op === "ready") {
282
+ this.#ready = true;
283
+ this.#attempt = 0;
284
+ this.#emit("ready", { servers: Array.isArray(msg.servers) ? msg.servers : [], events: Array.isArray(msg.events) ? msg.events : [] });
285
+ } else if (msg.op === "gap" && typeof msg.serverId === "string") {
286
+ this.#emit("gap", { serverId: msg.serverId });
287
+ }
288
+ }
289
+
290
+ #onClose(code: number, reason: string) {
291
+ this.#ws = null;
292
+ this.#ready = false;
293
+ if (this.#closed) return;
294
+ const fatal = FATAL[code];
295
+ if (fatal) {
296
+ this.#fatal = apiErrorFor({ ...fatal, method: "GET", path: PATH });
297
+ this.#emit("error", this.#fatal);
298
+ this.#closed = true;
299
+ this.#shutdown(code, reason || fatal.message);
300
+ return;
301
+ }
302
+ this.#scheduleReconnect(code);
303
+ }
304
+
305
+ #scheduleReconnect(code: number, cause?: unknown) {
306
+ if (this.#closed || this.#retry) return;
307
+ this.#attempt += 1;
308
+ const base = code === 1012 || code === 1001 ? 500 : code === 4029 ? 30_000 : Math.min(this.#maxDelay, 1000 * 2 ** Math.min(this.#attempt - 1, 8));
309
+ const delayMs = Math.min(this.#maxDelay, Math.round(base * (1 + Math.random() * 0.3)));
310
+ if (cause !== undefined) this.#emit("error", new FlittConnectionError("Event stream connection failed", { cause }));
311
+ this.#emit("reconnecting", { attempt: this.#attempt, delayMs, code });
312
+ this.#retry = setTimeout(() => this.#connect(), delayMs);
313
+ }
314
+
315
+ #watchdog() {
316
+ const ws = this.#ws;
317
+ if (!ws || this.#closed) return;
318
+ const now = Date.now();
319
+ if (!this.#ready) {
320
+ if (now - this.#openedAt > OPEN_TIMEOUT_MS) this.#drop(ws);
321
+ return;
322
+ }
323
+ if (now - this.#lastRx > IDLE_TIMEOUT_MS) return this.#drop(ws);
324
+ if (now - this.#lastRx > PING_EVERY_MS) {
325
+ try {
326
+ ws.send('{"op":"ping"}');
327
+ } catch {
328
+ this.#drop(ws);
329
+ }
330
+ }
331
+ }
332
+
333
+ #drop(ws: WebSocketLike) {
334
+ this.#ws = null;
335
+ safeClose(ws, 4000, "timeout");
336
+ this.#ready = false;
337
+ this.#scheduleReconnect(1006);
338
+ }
339
+
340
+ #shutdown(code: number, reason: string) {
341
+ if (this.#retry) clearTimeout(this.#retry);
342
+ this.#retry = null;
343
+ if (this.#timer) clearInterval(this.#timer);
344
+ this.#timer = null;
345
+ if (this.#onAbort) this.#signal?.removeEventListener("abort", this.#onAbort);
346
+ this.#onAbort = null;
347
+ const ws = this.#ws;
348
+ this.#ws = null;
349
+ this.#ready = false;
350
+ if (ws) safeClose(ws, 1000, "closed by client");
351
+ this.#wakeIterators();
352
+ this.#emit("close", { code, reason });
353
+ }
354
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,17 @@
5
5
  export { Flitt, FlittVotes, DEFAULT_BASE_URL } from "./client.js";
6
6
  export type { ClientOptions, FlittOptions, FlittVotesOptions } from "./client.js";
7
7
  export type { FetchLike, RequestOptions } from "./http.js";
8
+ export { FlittEventStream } from "./events.js";
9
+ export type {
10
+ FlittEvent,
11
+ EventStreamOptions,
12
+ EventStreamReady,
13
+ EventStreamReconnect,
14
+ EventStreamGap,
15
+ EventStreamClose,
16
+ WebSocketConstructor,
17
+ WebSocketLike,
18
+ } from "./events.js";
8
19
  export { verifyWebhook, FlittWebhookError } from "./webhooks.js";
9
20
  export type { VerifyWebhookOptions, HeaderSource } from "./webhooks.js";
10
21
  export {
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Package version, kept in sync with package.json by scripts/sync-version.mjs. */
2
- export const VERSION = "1.2.0";
2
+ export const VERSION = "1.3.0";