@foony/realtime 0.0.1

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.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Low-level WebSocket connection manager. Handles framing, request /
3
+ * response correlation, and dispatch to per-channel listeners.
4
+ *
5
+ * The class is intentionally protocol-aware but channel-agnostic — the
6
+ * Channel and Realtime classes layer the public API on top.
7
+ */
8
+
9
+ import type {
10
+ AckFrame,
11
+ AuthFrame,
12
+ ClientFrame,
13
+ ConnectedFrame,
14
+ ErrorFrame,
15
+ MessageFrame,
16
+ PresenceEventFrame,
17
+ PresenceFrame,
18
+ PublishFrame,
19
+ ServerFrame,
20
+ SubscribeFrame,
21
+ UnsubscribeFrame,
22
+ } from './wire.js';
23
+
24
+ /**
25
+ * Frames the SDK can issue with `request()`. Each carries an `id` the
26
+ * server echoes on the matching ack/err frame; Connection assigns the
27
+ * id so callers can omit it.
28
+ */
29
+ export type AckableFrame =
30
+ | Omit<SubscribeFrame, 'id'>
31
+ | Omit<UnsubscribeFrame, 'id'>
32
+ | Omit<PublishFrame, 'id'>
33
+ | Omit<PresenceFrame, 'id'>;
34
+
35
+ /** Options that control how Connection reaches the edge. */
36
+ export type ConnectionOptions = {
37
+ /** ws:// or wss:// URL pointing at the realtime edge binary. */
38
+ readonly url: string;
39
+ /**
40
+ * A Realtime API key in `appSlug.publicKeyId:privateKey` form. Convenient for trusted
41
+ * quick starts and server-side scripts; browser apps should prefer JWTs
42
+ * returned from `authCallback`.
43
+ */
44
+ readonly key?: string;
45
+ /** Optional client id to attach to a direct key-auth connection. */
46
+ readonly clientId?: string;
47
+ /**
48
+ * A static JWT to send in the auth handshake. Mutually exclusive with
49
+ * `authCallback`. Useful for local dev and short scripts.
50
+ */
51
+ readonly token?: string;
52
+ /**
53
+ * Async callback that returns a fresh JWT. Called once on connect and
54
+ * again on every reconnect. Use this when the token is short-lived
55
+ * (the production path).
56
+ */
57
+ readonly authCallback?: () => Promise<string> | string;
58
+ /**
59
+ * Override the global WebSocket constructor. Mostly useful in tests;
60
+ * defaults to `globalThis.WebSocket` which is present in browsers and
61
+ * Node 22+.
62
+ */
63
+ readonly webSocket?: typeof WebSocket;
64
+ /**
65
+ * If true, attempt to reconnect after unexpected disconnects with
66
+ * exponential backoff. Defaults to true.
67
+ */
68
+ readonly autoReconnect?: boolean;
69
+ /**
70
+ * Initial backoff for reconnects (default 1000ms). Doubles each
71
+ * attempt up to maxReconnectDelayMs.
72
+ */
73
+ readonly initialReconnectDelayMs?: number;
74
+ /** Cap on the reconnect backoff (default 30000ms). */
75
+ readonly maxReconnectDelayMs?: number;
76
+ };
77
+
78
+ /** Connection lifecycle states. */
79
+ export type ConnectionState =
80
+ | 'initialized'
81
+ | 'connecting'
82
+ | 'connected'
83
+ | 'disconnected'
84
+ | 'closing'
85
+ | 'closed'
86
+ | 'failed';
87
+
88
+ /** Listener for state transitions. */
89
+ export type ConnectionStateListener = (state: ConnectionState, reason?: Error) => void;
90
+
91
+ /** Internal record kept for every in-flight ack/err request. */
92
+ type PendingRequest = {
93
+ readonly resolve: (frame: AckFrame) => void;
94
+ readonly reject: (error: Error) => void;
95
+ };
96
+
97
+ /** Listener invoked for every message frame on a channel. */
98
+ export type MessageListener = (message: MessageFrame) => void;
99
+
100
+ /** Listener invoked for every presence event frame on a channel. */
101
+ export type PresenceEventListener = (event: PresenceEventFrame) => void;
102
+
103
+ /**
104
+ * Internal listener registry, keyed by channel name. Connection owns
105
+ * the maps so reconnect can transparently re-subscribe.
106
+ */
107
+ type ChannelListeners = {
108
+ readonly messages: Set<MessageListener>;
109
+ readonly presence: Set<PresenceEventListener>;
110
+ };
111
+
112
+ const DEFAULT_INITIAL_RECONNECT_DELAY_MS = 1_000;
113
+ const DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;
114
+ /** WebSocket.OPEN — duplicated here so we do not depend on a global. */
115
+ const READY_STATE_OPEN = 1;
116
+
117
+ /**
118
+ * Connection is the transport layer. One Realtime client owns one
119
+ * Connection; channels share it.
120
+ */
121
+ export class Connection {
122
+ readonly options: ConnectionOptions;
123
+ private socket: WebSocket | null = null;
124
+ private state: ConnectionState = 'initialized';
125
+ private connectionId: string | null = null;
126
+ private serverClientId: string | null = null;
127
+ private nextRequestId = 1;
128
+ private readonly pending = new Map<number, PendingRequest>();
129
+ private readonly channelListeners = new Map<string, ChannelListeners>();
130
+ private readonly stateListeners = new Set<ConnectionStateListener>();
131
+ private connectPromise: Promise<void> | null = null;
132
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
133
+ private reconnectAttempt = 0;
134
+ /** Channels the SDK has asked to be subscribed to; re-sent on reconnect. */
135
+ private readonly desiredSubscriptions = new Set<string>();
136
+
137
+ constructor(options: ConnectionOptions) {
138
+ const authMethods = Number(Boolean(options.token)) + Number(Boolean(options.authCallback)) + Number(Boolean(options.key));
139
+ if (authMethods !== 1) {
140
+ throw new Error('Connection: pass exactly one of options.token, options.authCallback, or options.key');
141
+ }
142
+ this.options = options;
143
+ }
144
+
145
+ /** Current connection state. */
146
+ getState(): ConnectionState {
147
+ return this.state;
148
+ }
149
+
150
+ /** The server-issued connection id, populated after a successful auth handshake. */
151
+ getConnectionId(): string | null {
152
+ return this.connectionId;
153
+ }
154
+
155
+ /** The client id encoded in the token, populated after auth. */
156
+ getClientId(): string | null {
157
+ return this.serverClientId;
158
+ }
159
+
160
+ /** Register a state-change listener. Returns an unsubscribe function. */
161
+ onStateChange(listener: ConnectionStateListener): () => void {
162
+ this.stateListeners.add(listener);
163
+ return () => this.stateListeners.delete(listener);
164
+ }
165
+
166
+ /**
167
+ * Open the WebSocket and complete the auth handshake. Idempotent —
168
+ * concurrent calls await the same in-flight connect.
169
+ */
170
+ async connect(): Promise<void> {
171
+ if (this.state === 'connected') return;
172
+ if (this.connectPromise) return this.connectPromise;
173
+ this.connectPromise = this.doConnect().finally(() => {
174
+ this.connectPromise = null;
175
+ });
176
+ return this.connectPromise;
177
+ }
178
+
179
+ /** Close the WebSocket and release resources. */
180
+ async close(): Promise<void> {
181
+ if (this.reconnectTimer) {
182
+ clearTimeout(this.reconnectTimer);
183
+ this.reconnectTimer = null;
184
+ }
185
+ this.setState('closing');
186
+ if (this.socket && this.socket.readyState === READY_STATE_OPEN) {
187
+ this.socket.close(1000, 'client close');
188
+ }
189
+ this.setState('closed');
190
+ for (const pending of this.pending.values()) {
191
+ pending.reject(new Error('connection closed'));
192
+ }
193
+ this.pending.clear();
194
+ }
195
+
196
+ /**
197
+ * Send a frame that expects an ack. Returns the matching AckFrame, or
198
+ * rejects with the server's ErrorFrame (wrapped in an Error).
199
+ */
200
+ async request(frame: AckableFrame): Promise<AckFrame> {
201
+ await this.connect();
202
+ const id = this.nextRequestId++;
203
+ const out = { ...frame, id } as ClientFrame;
204
+ return new Promise<AckFrame>((resolve, reject) => {
205
+ this.pending.set(id, { resolve, reject });
206
+ try {
207
+ this.sendRaw(out);
208
+ } catch (err) {
209
+ this.pending.delete(id);
210
+ reject(err instanceof Error ? err : new Error(String(err)));
211
+ }
212
+ });
213
+ }
214
+
215
+ /** Send a fire-and-forget frame (no ack expected). */
216
+ async send(frame: ClientFrame): Promise<void> {
217
+ await this.connect();
218
+ this.sendRaw(frame);
219
+ }
220
+
221
+ /**
222
+ * Register listeners for a channel. Connection remembers the
223
+ * registration so it can re-attach across reconnects, but actually
224
+ * issuing the `sub` frame is the caller's job (Channel does that).
225
+ */
226
+ addChannelListeners(channel: string): ChannelListeners {
227
+ let entry = this.channelListeners.get(channel);
228
+ if (!entry) {
229
+ entry = { messages: new Set(), presence: new Set() };
230
+ this.channelListeners.set(channel, entry);
231
+ }
232
+ return entry;
233
+ }
234
+
235
+ /** Forget all listeners for a channel. Called from Channel.detach. */
236
+ removeChannelListeners(channel: string): void {
237
+ this.channelListeners.delete(channel);
238
+ }
239
+
240
+ /** Add `channel` to the set of subscriptions to restore on reconnect. */
241
+ rememberSubscription(channel: string): void {
242
+ this.desiredSubscriptions.add(channel);
243
+ }
244
+
245
+ /** Stop restoring this subscription on future reconnects. */
246
+ forgetSubscription(channel: string): void {
247
+ this.desiredSubscriptions.delete(channel);
248
+ }
249
+
250
+ // ---- internals ----
251
+
252
+ private async doConnect(): Promise<void> {
253
+ this.setState('connecting');
254
+ const ws = this.makeSocket();
255
+ this.socket = ws;
256
+ const authFrame = await this.createAuthFrame();
257
+
258
+ return new Promise<void>((resolve, reject) => {
259
+ const onOpen = (): void => {
260
+ try {
261
+ ws.send(JSON.stringify(authFrame));
262
+ } catch (err) {
263
+ reject(err instanceof Error ? err : new Error(String(err)));
264
+ }
265
+ };
266
+
267
+ const onAuthMessage = (event: MessageEvent): void => {
268
+ let parsed: ServerFrame;
269
+ try {
270
+ parsed = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString()) as ServerFrame;
271
+ } catch (err) {
272
+ reject(new Error(`failed to parse auth response: ${(err as Error).message}`));
273
+ ws.close(1002, 'bad auth response');
274
+ return;
275
+ }
276
+ if (parsed.t === 'connected') {
277
+ const connected = parsed as ConnectedFrame;
278
+ this.connectionId = connected.connectionId;
279
+ this.serverClientId = connected.clientId;
280
+ this.reconnectAttempt = 0;
281
+ // Hand future frames over to the steady-state handler.
282
+ ws.removeEventListener('message', onAuthMessage as EventListener);
283
+ ws.addEventListener('message', this.handleMessage);
284
+ this.setState('connected');
285
+ resolve();
286
+ this.restoreSubscriptionsOnReconnect();
287
+ } else if (parsed.t === 'err') {
288
+ const errFrame = parsed as ErrorFrame;
289
+ ws.close(1002, `auth error ${errFrame.code}`);
290
+ reject(new Error(`auth failed: ${errFrame.code} ${errFrame.message}`));
291
+ } else {
292
+ reject(new Error(`unexpected first frame: ${parsed.t}`));
293
+ ws.close(1002, 'unexpected frame');
294
+ }
295
+ };
296
+
297
+ const onError = (event: Event): void => {
298
+ const errMessage = (event as ErrorEvent).message ?? 'websocket error';
299
+ reject(new Error(`websocket error: ${errMessage}`));
300
+ };
301
+
302
+ const onClose = (event: CloseEvent): void => {
303
+ this.handleClose(event);
304
+ // If close fires before we finished the handshake, the
305
+ // surrounding promise hasn't been settled yet — surface it as
306
+ // a connect failure.
307
+ reject(new Error(`websocket closed during handshake: ${event.code} ${event.reason}`));
308
+ };
309
+
310
+ ws.addEventListener('open', onOpen);
311
+ ws.addEventListener('message', onAuthMessage as EventListener);
312
+ ws.addEventListener('error', onError);
313
+ ws.addEventListener('close', onClose, { once: true });
314
+ });
315
+ }
316
+
317
+ private makeSocket(): WebSocket {
318
+ const ctor = this.options.webSocket ?? (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket;
319
+ if (!ctor) {
320
+ throw new Error('Connection: no WebSocket implementation available. Pass options.webSocket or upgrade to Node 22+.');
321
+ }
322
+ return new ctor(this.options.url);
323
+ }
324
+
325
+ private async createAuthFrame(): Promise<AuthFrame> {
326
+ if (this.options.key) {
327
+ return {
328
+ t: 'auth',
329
+ key: this.options.key,
330
+ ...(this.options.clientId ? { clientId: this.options.clientId } : {}),
331
+ };
332
+ }
333
+ if (this.options.token) return { t: 'auth', token: this.options.token };
334
+ if (!this.options.authCallback) {
335
+ throw new Error('Connection: missing auth method');
336
+ }
337
+ return { t: 'auth', token: await this.options.authCallback() };
338
+ }
339
+
340
+ /** Steady-state message handler; installed after a successful auth. */
341
+ private readonly handleMessage = (event: MessageEvent): void => {
342
+ let frame: ServerFrame;
343
+ try {
344
+ frame = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString()) as ServerFrame;
345
+ } catch {
346
+ return;
347
+ }
348
+ switch (frame.t) {
349
+ case 'ack': {
350
+ const pending = this.pending.get(frame.id);
351
+ if (pending) {
352
+ this.pending.delete(frame.id);
353
+ pending.resolve(frame);
354
+ }
355
+ return;
356
+ }
357
+ case 'err': {
358
+ if (frame.id != null) {
359
+ const pending = this.pending.get(frame.id);
360
+ if (pending) {
361
+ this.pending.delete(frame.id);
362
+ pending.reject(new Error(`server error ${frame.code}: ${frame.message}`));
363
+ return;
364
+ }
365
+ }
366
+ // Unscoped errors (id 0 or missing) are surfaced via state-change
367
+ // listeners; SDK consumers can subscribe via onStateChange.
368
+ for (const listener of this.stateListeners) {
369
+ listener(this.state, new Error(`server error ${frame.code}: ${frame.message}`));
370
+ }
371
+ return;
372
+ }
373
+ case 'msg': {
374
+ const listeners = this.channelListeners.get(frame.channel);
375
+ if (listeners) {
376
+ for (const listener of listeners.messages) listener(frame);
377
+ }
378
+ return;
379
+ }
380
+ case 'presEvt': {
381
+ const listeners = this.channelListeners.get(frame.channel);
382
+ if (listeners) {
383
+ for (const listener of listeners.presence) listener(frame);
384
+ }
385
+ return;
386
+ }
387
+ case 'pong':
388
+ case 'connected':
389
+ case 'histRes':
390
+ // Connected can only fire once (we removed the auth listener
391
+ // above); pong and histRes are unused in the MVP — silent
392
+ // forwarding keeps the switch exhaustive.
393
+ return;
394
+ }
395
+ };
396
+
397
+ private handleClose(_event: CloseEvent): void {
398
+ this.socket = null;
399
+ if (this.state === 'closing' || this.state === 'closed') {
400
+ this.setState('closed');
401
+ return;
402
+ }
403
+ this.setState('disconnected');
404
+ if (this.options.autoReconnect === false) return;
405
+ this.scheduleReconnect();
406
+ }
407
+
408
+ private scheduleReconnect(): void {
409
+ if (this.reconnectTimer) return;
410
+ const initial = this.options.initialReconnectDelayMs ?? DEFAULT_INITIAL_RECONNECT_DELAY_MS;
411
+ const max = this.options.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
412
+ const delay = Math.min(initial * 2 ** this.reconnectAttempt, max);
413
+ this.reconnectAttempt += 1;
414
+ this.reconnectTimer = setTimeout(() => {
415
+ this.reconnectTimer = null;
416
+ this.connect().catch(() => {
417
+ // doConnect itself drove the state machine; schedule another
418
+ // attempt unless we've been explicitly closed in the meantime.
419
+ if (this.state !== 'closed' && this.state !== 'closing') {
420
+ this.scheduleReconnect();
421
+ }
422
+ });
423
+ }, delay);
424
+ }
425
+
426
+ private restoreSubscriptionsOnReconnect(): void {
427
+ // The Channel layer is responsible for re-issuing `sub` frames; we
428
+ // expose desiredSubscriptions so it can iterate without leaking the
429
+ // set.
430
+ for (const channel of this.desiredSubscriptions) {
431
+ this.request({ t: 'sub', channel }).catch(() => {
432
+ // Failure to restore a subscription bubbles up via state
433
+ // listeners on the next request; nothing else to do here.
434
+ });
435
+ }
436
+ }
437
+
438
+ private sendRaw(frame: ClientFrame): void {
439
+ if (!this.socket || this.socket.readyState !== READY_STATE_OPEN) {
440
+ throw new Error(`Connection.sendRaw: socket not open (state=${this.state})`);
441
+ }
442
+ this.socket.send(JSON.stringify(frame));
443
+ }
444
+
445
+ private setState(state: ConnectionState): void {
446
+ if (this.state === state) return;
447
+ this.state = state;
448
+ for (const listener of this.stateListeners) listener(state);
449
+ }
450
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Public entry point for @foony/realtime.
3
+ *
4
+ * Public surface: a `Realtime` class, a `channels.get(name)` registry,
5
+ * and per-channel `subscribe` / `publish` / `presence` methods.
6
+ *
7
+ * For server-side token minting see `@foony/realtime/server`.
8
+ */
9
+
10
+ export { Realtime, type RealtimeOptions } from './realtime.js';
11
+ export { Channel, Presence, type UnsubscribeFn } from './channel.js';
12
+ export {
13
+ Connection,
14
+ type ConnectionOptions,
15
+ type ConnectionState,
16
+ type ConnectionStateListener,
17
+ type MessageListener,
18
+ type PresenceEventListener,
19
+ } from './connection.js';
20
+ export {
21
+ type AckFrame,
22
+ type AuthFrame,
23
+ type ClientFrame,
24
+ type ConnectedFrame,
25
+ type ErrorFrame,
26
+ type FrameType,
27
+ type HistoryResponseFrame,
28
+ type MessageFrame,
29
+ type PingFrame,
30
+ type PongFrame,
31
+ type PresenceAction,
32
+ type PresenceEventFrame,
33
+ type PresenceFrame,
34
+ type PublishFrame,
35
+ type ServerFrame,
36
+ type SubscribeFrame,
37
+ type UnsubscribeFrame,
38
+ ErrorCode,
39
+ type ErrorCodeName,
40
+ } from './wire.js';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Realtime is the top-level client class. It owns a Connection and a
3
+ * `channels.get(name)` registry — the public entry point for app code.
4
+ */
5
+
6
+ import { Channel } from './channel.js';
7
+ import {
8
+ Connection,
9
+ type ConnectionOptions,
10
+ type ConnectionState,
11
+ type ConnectionStateListener,
12
+ } from './connection.js';
13
+
14
+ /** Options for the Realtime client; mirrors ConnectionOptions. */
15
+ export type RealtimeOptions = ConnectionOptions;
16
+
17
+ /**
18
+ * Realtime client — call `new Realtime({ url, token })` and use
19
+ * `client.channels.get('chat:1')` to start sending and receiving.
20
+ */
21
+ export class Realtime {
22
+ private readonly connection: Connection;
23
+ private readonly channelsByName = new Map<string, Channel>();
24
+
25
+ /** Map-like accessor for channels. Stable instance per name. */
26
+ readonly channels = {
27
+ get: (name: string): Channel => {
28
+ let existing = this.channelsByName.get(name);
29
+ if (!existing) {
30
+ existing = new Channel(this.connection, name);
31
+ this.channelsByName.set(name, existing);
32
+ }
33
+ return existing;
34
+ },
35
+ release: (name: string): void => {
36
+ const channel = this.channelsByName.get(name);
37
+ if (!channel) return;
38
+ this.channelsByName.delete(name);
39
+ this.connection.removeChannelListeners(name);
40
+ channel.detach().catch(() => {});
41
+ },
42
+ };
43
+
44
+ constructor(options: RealtimeOptions) {
45
+ this.connection = new Connection(options);
46
+ }
47
+
48
+ /** Eagerly open the WebSocket. Optional — channels attach lazily. */
49
+ async connect(): Promise<void> {
50
+ await this.connection.connect();
51
+ }
52
+
53
+ /** Close the WebSocket and release every channel. */
54
+ async close(): Promise<void> {
55
+ for (const name of [...this.channelsByName.keys()]) {
56
+ this.channels.release(name);
57
+ }
58
+ await this.connection.close();
59
+ }
60
+
61
+ /** Current connection state. */
62
+ getState(): ConnectionState {
63
+ return this.connection.getState();
64
+ }
65
+
66
+ /** Server-issued connection id, populated after auth. */
67
+ getConnectionId(): string | null {
68
+ return this.connection.getConnectionId();
69
+ }
70
+
71
+ /** Server-confirmed client id (from the JWT), populated after auth. */
72
+ getClientId(): string | null {
73
+ return this.connection.getClientId();
74
+ }
75
+
76
+ /** Register a connection-state listener. Returns an unsubscribe fn. */
77
+ onStateChange(listener: ConnectionStateListener): () => void {
78
+ return this.connection.onStateChange(listener);
79
+ }
80
+ }
package/src/server.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Server-side helpers for the Foony Realtime SDK.
3
+ *
4
+ * `mintRealtimeToken` produces the same HS256 JWT layout the Go edge
5
+ * binary verifies (see `services/realtime-saas/internal/auth/jwt.go`).
6
+ * Foony's application server uses this to hand short-lived tokens to
7
+ * its own clients — the client passes the result into the SDK's
8
+ * `authCallback`.
9
+ *
10
+ * Node-only: relies on `node:crypto` for HMAC-SHA256. Browsers should
11
+ * never see this module.
12
+ */
13
+
14
+ import { createHmac } from 'node:crypto';
15
+
16
+ /** Options to mint a Foony Realtime token. */
17
+ export type MintRealtimeTokenOptions = {
18
+ /**
19
+ * The HS256 signing key — must exactly match the edge binary's
20
+ * `JWT_SIGNING_KEY` env var. 32 bytes recommended for production.
21
+ */
22
+ readonly signingKey: string | Uint8Array;
23
+ /** App id the token is scoped to. Encoded in the JWT `app` claim. */
24
+ readonly appId: string;
25
+ /** Customer-controlled identifier for the end user (the JWT `sub`). */
26
+ readonly clientId: string;
27
+ /**
28
+ * Capability JSON string scoping the token (e.g. `{"chat:*":["subscribe","publish"]}`).
29
+ * Defaults to `{"*":["*"]}` (full wildcard). The MVP edge does not
30
+ * enforce capabilities yet, but tokens minted today will be checked
31
+ * once enforcement lands.
32
+ */
33
+ readonly capability?: string;
34
+ /**
35
+ * Token TTL. Defaults to 1 hour. Must be > 0; long-lived tokens are
36
+ * an antipattern — the SDK calls `authCallback` on every reconnect
37
+ * so 5-15 minutes is usually plenty.
38
+ */
39
+ readonly ttlMs?: number;
40
+ };
41
+
42
+ const DEFAULT_TTL_MS = 60 * 60 * 1_000;
43
+ const DEFAULT_CAPABILITY = '{"*":["*"]}';
44
+
45
+ /**
46
+ * Returns a compact-encoded HS256 JWT carrying the supplied claims.
47
+ *
48
+ * The token shape matches what `services/realtime-saas/internal/auth`
49
+ * mints with `MintForDev` plus a configurable capability and TTL.
50
+ */
51
+ export function mintRealtimeToken(options: MintRealtimeTokenOptions): string {
52
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
53
+ if (ttlMs <= 0) {
54
+ throw new Error('mintRealtimeToken: ttlMs must be > 0');
55
+ }
56
+ const issuedAtSec = Math.floor(Date.now() / 1_000);
57
+ const expiresAtSec = issuedAtSec + Math.floor(ttlMs / 1_000);
58
+ const payload = {
59
+ sub: options.clientId,
60
+ app: options.appId,
61
+ cap: options.capability ?? DEFAULT_CAPABILITY,
62
+ iat: issuedAtSec,
63
+ exp: expiresAtSec,
64
+ } satisfies Record<string, unknown>;
65
+ const header = { alg: 'HS256', typ: 'JWT' } satisfies Record<string, unknown>;
66
+ const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`;
67
+ const key = typeof options.signingKey === 'string' ? Buffer.from(options.signingKey, 'utf8') : Buffer.from(options.signingKey);
68
+ const signature = createHmac('sha256', key).update(signingInput).digest();
69
+ return `${signingInput}.${base64UrlEncode(signature)}`;
70
+ }
71
+
72
+ /**
73
+ * Encode a JSON value as a base64url segment. JWT spec requires no
74
+ * padding and the URL-safe alphabet — `Buffer.toString('base64url')`
75
+ * is exactly that, available in Node 16+.
76
+ */
77
+ function encodeSegment(value: unknown): string {
78
+ return base64UrlEncode(Buffer.from(JSON.stringify(value), 'utf8'));
79
+ }
80
+
81
+ function base64UrlEncode(buffer: Buffer): string {
82
+ return buffer.toString('base64url');
83
+ }