@awesomate/sdk 0.4.0 → 0.6.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/README.md CHANGED
@@ -91,23 +91,45 @@ import { createAppClient } from '@awesomate/sdk';
91
91
 
92
92
  const app = createAppClient({ publishableKey: 'pk_...' });
93
93
 
94
- // The sign-in page: an email link, no password. The answer is the same for any address.
95
- await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
94
+ // Render on every change of person: signed in, signed out, a new role.
95
+ app.auth.onChange((u) => render(u));
96
+ app.auth.onSignInError((err) => showMessage(err.message));
96
97
 
97
- // /signed-in: exchanges the link for a session and clears it from the address bar.
98
- const user = await app.auth.completeSignIn();
98
+ // The sign-in page: an email link, no password. The answer is the same for any address.
99
+ await app.auth.signInWithLink(email, { redirectTo: location.href });
99
100
 
100
101
  // Everywhere else: the same calls as the server client, as this person.
101
102
  const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'] });
102
103
  await app.write('forum_post', { body: 'Booked for Tuesday' }, { links: { job: rows[0].id } });
103
- app.auth.onChange((u) => render(u));
104
104
  await app.auth.signOut();
105
105
  ```
106
106
 
107
+ The link signs them in by itself. In a browser the client takes the link from the address bar,
108
+ on load or when it arrives in a tab that is already open (only the part after `#` changes, so
109
+ the page does not reload), clears it at once and tells `onChange`. A link that has expired or
110
+ was already used goes to `onSignInError`. `await app.auth.completeSignIn()` still works and
111
+ returns the same sign-in, never a second exchange. Pass `handleSignInLinks: false` to take links
112
+ yourself. `onChange` hears about people, not tokens: the routine token refresh is silent.
113
+
114
+ ### Lists that keep themselves current
115
+
116
+ ```ts
117
+ const stop = app.live('job', { orderBy: ['created_at', 'desc'], limit: 50 }, (rows) => render(rows));
118
+ // later: stop();
119
+ ```
120
+
121
+ `live()` calls you with the whole list at first and again whenever a row in it is added, changed,
122
+ removed, or stops being one this person may read (a job moved to another customer leaves their
123
+ list, with its memos). Changes made anywhere reach the list: in this app, by your team in Claude
124
+ Code, by an n8n workflow. One connection serves every list on the client; it reconnects by itself
125
+ and takes a fresh copy when it does. Up to 200 rows a list and 20 lists a connection. Pass
126
+ `{ onRows, onError }` to hear about a list the server refused (a column that does not exist).
127
+ Outside a browser, give it a WebSocket: `createAppClient({ publishableKey, WebSocket })`.
128
+
107
129
  The rules are kept in your own database, which applies them to every read and write, so a mistake
108
130
  in the app cannot show anyone more than their role allows. A disabled person is refused on their
109
131
  next call. Sessions are kept in `localStorage`; in a Capacitor app pass `storage` (Preferences) and
110
- call `completeSignIn(url)` from the deep link that opened the app. Your own server can verify
132
+ call `completeSignIn(url)` with the deep link that opened the app. Your own server can verify
111
133
  `await app.auth.accessToken()` against `https://hub.awesomate.ai/api/sdk/v1/jwks.json`.
112
134
 
113
135
  MIT licence.
package/dist/index.d.ts CHANGED
@@ -13,15 +13,16 @@
13
13
  * access rules let them (Pro and above):
14
14
  * const app = createAppClient({ publishableKey: 'pk_...' });
15
15
  * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
- * await app.auth.completeSignIn(); // on /signed-in
16
+ * app.auth.onChange((user) => render(user)); // the link signs them in by itself
17
17
  * const { rows } = await app.query('job', {}); // their jobs only
18
+ * const stop = app.live('job', {}, (rows) => render(rows)); // and kept current as they change
18
19
  *
19
20
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
20
21
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
21
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
22
23
  * crm:write, Support Plus and above.
23
24
  */
24
- export declare const VERSION = "0.4.0";
25
+ export declare const VERSION = "0.6.0";
25
26
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
26
27
  export interface Kinds {
27
28
  }
@@ -126,7 +127,8 @@ export interface Page<R> {
126
127
  };
127
128
  asAt: string;
128
129
  }
129
- export type ErrorCode = 'unauthenticated' | 'forbidden' | 'not_found' | 'validation' | 'consent_blocked' | 'rate_limited' | 'conflict' | 'unavailable';
130
+ declare const ERROR_CODES: readonly ["unauthenticated", "forbidden", "not_found", "validation", "consent_blocked", "rate_limited", "conflict", "unavailable"];
131
+ export type ErrorCode = (typeof ERROR_CODES)[number];
130
132
  export declare class AwesomateError extends Error {
131
133
  readonly code: ErrorCode;
132
134
  readonly status: number;
@@ -371,6 +373,43 @@ export interface AppClientOptions {
371
373
  storage?: SessionStorageLike;
372
374
  /** Default: one per publishable key. */
373
375
  storageKey?: string;
376
+ /** For live(): the WebSocket class. Default: the browser's (and Node 22's) global WebSocket. */
377
+ WebSocket?: WebSocketLike;
378
+ /**
379
+ * Default true in a browser: a sign-in link is taken up by itself, when the page loads with one
380
+ * and when one arrives in a tab already showing the page (only the #fragment changes then, and
381
+ * the page does not reload). Set false when another client on the page handles links.
382
+ */
383
+ handleSignInLinks?: boolean;
384
+ }
385
+ /** The part of the WebSocket API live() uses: the browser's, Node 22's, or the ws package's. */
386
+ export interface WebSocketLike {
387
+ new (url: string): {
388
+ readyState: number;
389
+ send(data: string): void;
390
+ close(code?: number, reason?: string): void;
391
+ onopen: ((ev: unknown) => void) | null;
392
+ onmessage: ((ev: {
393
+ data: unknown;
394
+ }) => void) | null;
395
+ onclose: ((ev: {
396
+ code: number;
397
+ reason: string;
398
+ }) => void) | null;
399
+ onerror: ((ev: unknown) => void) | null;
400
+ };
401
+ }
402
+ export interface LiveChange<R> {
403
+ /** Rows that are new or changed since the last call. */
404
+ upserts: R[];
405
+ /** Ids that left the list. */
406
+ removes: string[];
407
+ }
408
+ export interface LiveHandlers<R> {
409
+ /** Called with the whole list, in the query's order, at first and after every change. */
410
+ onRows: (rows: R[], change: LiveChange<R> | null) => void;
411
+ /** A refusal for this list (a column that does not exist, too many lists); the list stops. */
412
+ onError?: (err: AwesomateError) => void;
374
413
  }
375
414
  /** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
376
415
  export declare function signInTokenFrom(url: string): string | null;
@@ -383,7 +422,13 @@ export declare class AwesomateAppClient {
383
422
  private session;
384
423
  private refreshing;
385
424
  private readonly listeners;
425
+ private readonly linkErrorListeners;
426
+ /** The sign-in link being (or last) exchanged, so the same link is never exchanged twice. */
427
+ private link;
428
+ private socket;
386
429
  constructor(opts: AppClientOptions);
430
+ /** A sign-in link in the address bar, exchanged in the background; a failure goes to onSignInError. */
431
+ private takeLink;
387
432
  readonly auth: {
388
433
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
389
434
  signInWithLink: (email: string, options: {
@@ -394,14 +439,21 @@ export declare class AwesomateAppClient {
394
439
  }>;
395
440
  /**
396
441
  * Finish signing in from the link: the current page's address by default, or a URL a deep link
397
- * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
398
- * token is removed from the browser's address bar.
442
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The token
443
+ * is removed from the browser's address bar. In a browser this happens by itself (see
444
+ * handleSignInLinks); calling it as well is safe, and returns the same sign-in, never a second.
399
445
  */
400
446
  completeSignIn: (url?: string) => Promise<AppUser | null>;
401
447
  /** The signed-in user, or null. */
402
448
  user: () => Promise<AppUser | null>;
403
- /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
449
+ /**
450
+ * Called with the user when someone signs in (or their details change: a new role, a linked
451
+ * contact) and with null on sign-out, including a refresh that was refused. A routine token
452
+ * refresh does not call it.
453
+ */
404
454
  onChange: (listener: (user: AppUser | null) => void) => (() => void);
455
+ /** Called when a sign-in link could not be used (expired, already used, not this app's). */
456
+ onSignInError: (listener: (err: AwesomateError) => void) => (() => void);
405
457
  signOut: () => Promise<void>;
406
458
  /** A current access token, for the app's own server to verify against the JWKS. */
407
459
  accessToken: () => Promise<string | null>;
@@ -409,6 +461,7 @@ export declare class AwesomateAppClient {
409
461
  private request;
410
462
  private read;
411
463
  private load;
464
+ /** signedIn: a sign-in, always announced; otherwise a refresh, announced only when the person changed. */
412
465
  private save;
413
466
  private clear;
414
467
  /** A session whose access token has at least half a minute left, refreshing if not. */
@@ -426,6 +479,13 @@ export declare class AwesomateAppClient {
426
479
  /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
427
480
  write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
428
481
  archive<K extends KindName>(kind: K, id: string): Promise<void>;
482
+ /**
483
+ * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
484
+ * and again whenever a row in it is added, changed, removed, or stops being one this person may
485
+ * read. One connection serves every live list on this client; it reconnects by itself and takes
486
+ * a fresh snapshot when it does. Returns stop().
487
+ */
488
+ live<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options: Omit<QueryOptions<RowOf<K>, S>, 'after'>, handlers: LiveHandlers<Pick<RowOf<K>, S>> | ((rows: Array<Pick<RowOf<K>, S>>, change: LiveChange<Pick<RowOf<K>, S>> | null) => void)): () => void;
429
489
  }
430
490
  export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
431
491
  export declare function createClient(options: ClientOptions): AwesomateClient;
package/dist/index.js CHANGED
@@ -13,16 +13,18 @@
13
13
  * access rules let them (Pro and above):
14
14
  * const app = createAppClient({ publishableKey: 'pk_...' });
15
15
  * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
- * await app.auth.completeSignIn(); // on /signed-in
16
+ * app.auth.onChange((user) => render(user)); // the link signs them in by itself
17
17
  * const { rows } = await app.query('job', {}); // their jobs only
18
+ * const stop = app.live('job', {}, (rows) => render(rows)); // and kept current as they change
18
19
  *
19
20
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
20
21
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
21
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
22
23
  * crm:write, Support Plus and above.
23
24
  */
24
- export const VERSION = '0.4.0';
25
+ export const VERSION = '0.6.0';
25
26
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
27
+ const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
26
28
  export class AwesomateError extends Error {
27
29
  code;
28
30
  status;
@@ -231,6 +233,10 @@ export class AwesomateAppClient {
231
233
  session;
232
234
  refreshing = null;
233
235
  listeners = new Set();
236
+ linkErrorListeners = new Set();
237
+ /** The sign-in link being (or last) exchanged, so the same link is never exchanged twice. */
238
+ link = null;
239
+ socket = null;
234
240
  constructor(opts) {
235
241
  this.opts = opts;
236
242
  if (!opts?.publishableKey?.startsWith('pk_'))
@@ -243,32 +249,78 @@ export class AwesomateAppClient {
243
249
  throw new Error('No fetch available.');
244
250
  this.storage = opts.storage ?? browserStorage();
245
251
  this.key = opts.storageKey ?? `awesomate.session.${opts.publishableKey.slice(-10)}`;
252
+ const w = globalThis.window;
253
+ if (opts.handleSignInLinks !== false && w && typeof location !== 'undefined') {
254
+ this.takeLink();
255
+ w.addEventListener?.('hashchange', () => this.takeLink());
256
+ }
257
+ }
258
+ /** A sign-in link in the address bar, exchanged in the background; a failure goes to onSignInError. */
259
+ takeLink() {
260
+ if (signInTokenFrom(location.href))
261
+ void this.auth.completeSignIn().catch(() => undefined);
246
262
  }
247
263
  auth = {
248
264
  /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
249
265
  signInWithLink: (email, options) => this.request('POST', '/auth/magic-link', { email, redirect_to: options.redirectTo }),
250
266
  /**
251
267
  * Finish signing in from the link: the current page's address by default, or a URL a deep link
252
- * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
253
- * token is removed from the browser's address bar.
268
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The token
269
+ * is removed from the browser's address bar. In a browser this happens by itself (see
270
+ * handleSignInLinks); calling it as well is safe, and returns the same sign-in, never a second.
254
271
  */
255
- completeSignIn: async (url) => {
272
+ completeSignIn: (url) => {
256
273
  const here = typeof location !== 'undefined' ? location.href : '';
257
274
  const token = signInTokenFrom(url ?? here);
275
+ // The client may already have read the link from the address bar and cleared it.
258
276
  if (!token)
259
- return null;
277
+ return this.link && !this.link.settled ? this.link.promise : Promise.resolve(null);
278
+ // Before anything else: a reload must never find a link that was already used.
260
279
  if (!url && typeof history !== 'undefined' && typeof location !== 'undefined') {
261
280
  history.replaceState(history.state, '', location.pathname + location.search);
262
281
  }
263
- return (await this.save(await this.request('POST', '/auth/verify', { token }))).user;
282
+ // The same link again (a second click of the email) is the same sign-in, until a sign-out.
283
+ if (this.link?.token === token)
284
+ return this.link.promise;
285
+ const entry = { token, settled: false };
286
+ entry.promise = this.request('POST', '/auth/verify', { token }).then(async (r) => {
287
+ // Signed out, or another link taken, while this one was at the hub: it is not a sign-in any more.
288
+ if (this.link !== entry) {
289
+ void this.request('POST', '/auth/sign-out', { refresh_token: r.refresh_token }).catch(() => undefined);
290
+ return null;
291
+ }
292
+ return (await this.save(r, true)).user;
293
+ });
294
+ entry.promise.then(() => { entry.settled = true; }, (err) => {
295
+ entry.settled = true;
296
+ // Abandoned (a sign-out, or a newer link took over): nobody is waiting on this one any more.
297
+ if (this.link !== entry)
298
+ return;
299
+ // A failure is not remembered: the same link again goes back to the hub (a blip may have passed).
300
+ this.link = null;
301
+ const e = err instanceof AwesomateError ? err : new AwesomateError('unavailable', String(err?.message ?? err), 0);
302
+ for (const l of this.linkErrorListeners)
303
+ l(e);
304
+ });
305
+ this.link = entry;
306
+ return entry.promise;
264
307
  },
265
308
  /** The signed-in user, or null. */
266
309
  user: async () => (await this.load())?.user ?? null,
267
- /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
310
+ /**
311
+ * Called with the user when someone signs in (or their details change: a new role, a linked
312
+ * contact) and with null on sign-out, including a refresh that was refused. A routine token
313
+ * refresh does not call it.
314
+ */
268
315
  onChange: (listener) => {
269
316
  this.listeners.add(listener);
270
317
  return () => { this.listeners.delete(listener); };
271
318
  },
319
+ /** Called when a sign-in link could not be used (expired, already used, not this app's). */
320
+ onSignInError: (listener) => {
321
+ this.linkErrorListeners.add(listener);
322
+ return () => { this.linkErrorListeners.delete(listener); };
323
+ },
272
324
  signOut: async () => {
273
325
  const s = await this.load();
274
326
  await this.clear();
@@ -313,25 +365,41 @@ export class AwesomateAppClient {
313
365
  }
314
366
  }
315
367
  async load() {
368
+ if (this.session !== undefined)
369
+ return this.session;
370
+ const s = await this.read();
371
+ // A sign-in or sign-out that landed while storage was being read is newer than what was read.
316
372
  if (this.session === undefined)
317
- this.session = await this.read();
373
+ this.session = s;
318
374
  return this.session;
319
375
  }
320
- async save(r) {
376
+ /** signedIn: a sign-in, always announced; otherwise a refresh, announced only when the person changed. */
377
+ async save(r, signedIn = false) {
378
+ const before = this.session === undefined ? await this.read() : this.session;
321
379
  const s = { access_token: r.access_token, refresh_token: r.refresh_token, expires_at: Date.now() + r.expires_in * 1000, user: r.user };
322
380
  this.session = s;
323
381
  await this.storage.setItem(this.key, JSON.stringify(s));
324
- for (const l of this.listeners)
325
- l(s.user);
382
+ // Listeners hear about people, not tokens: a refresh that returns the same user is silent.
383
+ if (signedIn || !before || JSON.stringify(before.user) !== JSON.stringify(s.user))
384
+ for (const l of this.listeners)
385
+ l(s.user);
386
+ // A token frame carries on as the same person; anyone else needs a connection of their own.
387
+ if (before && before.user.id !== s.user.id)
388
+ this.socket?.personChanged();
389
+ else
390
+ this.socket?.tokenChanged(s);
326
391
  return s;
327
392
  }
328
393
  async clear() {
329
394
  const had = this.session !== null;
330
395
  this.session = null;
396
+ this.link = null;
331
397
  await this.storage.removeItem(this.key);
332
398
  if (had)
333
399
  for (const l of this.listeners)
334
400
  l(null);
401
+ this.socket?.signedOut();
402
+ this.socket = null;
335
403
  }
336
404
  /** A session whose access token has at least half a minute left, refreshing if not. */
337
405
  async current() {
@@ -351,11 +419,17 @@ export class AwesomateAppClient {
351
419
  return stored;
352
420
  }
353
421
  try {
354
- return await this.save(await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token }));
422
+ const r = await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token });
423
+ // Someone signed in or out while this was at the hub: theirs is the session now.
424
+ if (this.session !== s)
425
+ return this.session ?? null;
426
+ return await this.save(r);
355
427
  }
356
428
  catch (err) {
357
429
  if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
358
430
  throw err;
431
+ if (this.session !== s)
432
+ return this.session ?? null;
359
433
  const after = await this.read();
360
434
  if (after && after.refresh_token !== s.refresh_token) {
361
435
  this.session = after;
@@ -423,6 +497,228 @@ export class AwesomateAppClient {
423
497
  async archive(kind, id) {
424
498
  await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
425
499
  }
500
+ /**
501
+ * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
502
+ * and again whenever a row in it is added, changed, removed, or stops being one this person may
503
+ * read. One connection serves every live list on this client; it reconnects by itself and takes
504
+ * a fresh snapshot when it does. Returns stop().
505
+ */
506
+ live(kind, options, handlers) {
507
+ const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
508
+ this.socket ??= new LiveSocket({
509
+ url: `${this.base.replace(/^http/, 'ws')}/api/sdk/v1/socket`,
510
+ WebSocket: this.opts.WebSocket ?? globalThis.WebSocket,
511
+ session: () => this.current(),
512
+ refresh: async () => { const s = await this.load(); return s ? this.refresh(s) : null; },
513
+ });
514
+ return this.socket.add({ kind, ...options }, h);
515
+ }
516
+ }
517
+ /** Timers must never keep a Node process (a test, a server-side render) alive; browsers have no unref. */
518
+ function unref(t) {
519
+ t.unref?.();
520
+ return t;
521
+ }
522
+ /** The one WebSocket behind live(). */
523
+ class LiveSocket {
524
+ o;
525
+ ws = null;
526
+ ready = false;
527
+ entries = new Map();
528
+ seq = 0;
529
+ attempt = 0;
530
+ pinger = null;
531
+ refresher = null;
532
+ retry = null;
533
+ stopped = false;
534
+ /** One connection attempt at a time: two lists added together share it. */
535
+ connecting = false;
536
+ constructor(o) {
537
+ this.o = o;
538
+ }
539
+ add(spec, handlers) {
540
+ const id = `l${++this.seq}`;
541
+ this.entries.set(id, { spec, handlers, rows: new Map(), order: [] });
542
+ if (this.ready)
543
+ this.send({ type: 'sub', id, spec });
544
+ else if (!this.ws && !this.connecting)
545
+ void this.connect();
546
+ return () => {
547
+ if (!this.entries.delete(id))
548
+ return;
549
+ if (this.ready)
550
+ this.send({ type: 'unsub', id });
551
+ if (!this.entries.size)
552
+ this.shut();
553
+ };
554
+ }
555
+ send(f) {
556
+ if (this.ws && this.ws.readyState === 1)
557
+ this.ws.send(JSON.stringify(f));
558
+ }
559
+ async connect() {
560
+ if (this.stopped || this.ws || this.connecting || !this.entries.size)
561
+ return;
562
+ if (!this.o.WebSocket) {
563
+ this.failAll(new AwesomateError('unavailable', 'No WebSocket here: pass createAppClient({ WebSocket }).', 0));
564
+ return;
565
+ }
566
+ this.connecting = true;
567
+ let session;
568
+ try {
569
+ session = await this.o.session();
570
+ }
571
+ finally {
572
+ this.connecting = false;
573
+ }
574
+ if (!session) {
575
+ this.failAll(new AwesomateError('unauthenticated', 'Sign in first.', 401));
576
+ return;
577
+ }
578
+ if (this.stopped || this.ws || !this.entries.size)
579
+ return;
580
+ this.scheduleRefresh(session);
581
+ const ws = new this.o.WebSocket(this.o.url);
582
+ this.ws = ws;
583
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'hello', token: session.access_token }));
584
+ ws.onmessage = (ev) => this.frame(String(ev.data));
585
+ ws.onclose = (ev) => void this.closed(ws, ev.code);
586
+ ws.onerror = () => undefined;
587
+ }
588
+ frame(raw) {
589
+ let f;
590
+ try {
591
+ f = JSON.parse(raw);
592
+ }
593
+ catch {
594
+ return;
595
+ }
596
+ if (f.type === 'ready') {
597
+ this.ready = true;
598
+ this.attempt = 0;
599
+ for (const [id, e] of this.entries)
600
+ this.send({ type: 'sub', id, spec: e.spec });
601
+ this.pinger = unref(setInterval(() => this.send({ type: 'ping' }), 25_000));
602
+ return;
603
+ }
604
+ const e = typeof f.id === 'string' ? this.entries.get(f.id) : undefined;
605
+ if (f.type === 'error') {
606
+ const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
607
+ const err = new AwesomateError(code, String(f.message ?? 'Refused.'), 0, typeof f.field === 'string' ? f.field : undefined, String(f.code));
608
+ if (e && typeof f.id === 'string') {
609
+ this.entries.delete(f.id);
610
+ e.handlers.onError?.(err);
611
+ }
612
+ return;
613
+ }
614
+ if (!e)
615
+ return;
616
+ const order = Array.isArray(f.order) ? f.order.map(String) : [];
617
+ if (f.type === 'snapshot') {
618
+ e.rows = new Map((f.rows ?? []).map((r) => [String(r.id), r]));
619
+ e.order = order;
620
+ e.handlers.onRows(order.map((id) => e.rows.get(id)).filter(Boolean), null);
621
+ }
622
+ else if (f.type === 'change') {
623
+ const upserts = f.upserts ?? [];
624
+ const removes = (f.removes ?? []).map(String);
625
+ for (const r of upserts)
626
+ e.rows.set(String(r.id), r);
627
+ for (const id of removes)
628
+ e.rows.delete(id);
629
+ e.order = order;
630
+ e.handlers.onRows(order.map((id) => e.rows.get(id)).filter(Boolean), { upserts, removes });
631
+ }
632
+ }
633
+ /** A refreshed session: the open socket carries on as the same person with the new token. */
634
+ tokenChanged(s) {
635
+ if (this.ready)
636
+ this.send({ type: 'token', token: s.access_token });
637
+ this.scheduleRefresh(s);
638
+ }
639
+ /**
640
+ * Someone else signed in: the open connection said hello as the person before, so start again as
641
+ * the new one. Every list stays and takes a fresh snapshot, which the database writes for them.
642
+ */
643
+ personChanged() {
644
+ const ws = this.ws;
645
+ this.ws = null;
646
+ this.ready = false;
647
+ this.stopped = false;
648
+ this.attempt = 0;
649
+ if (this.pinger)
650
+ clearInterval(this.pinger);
651
+ if (this.retry)
652
+ clearTimeout(this.retry);
653
+ this.pinger = this.retry = null;
654
+ if (ws && ws.readyState <= 1) {
655
+ try {
656
+ ws.send(JSON.stringify({ type: 'bye' }));
657
+ }
658
+ catch { /* closing */ }
659
+ ws.close(1000, 'bye');
660
+ }
661
+ void this.connect();
662
+ }
663
+ /** Keep the socket's token fresh even when the app makes no other calls. */
664
+ scheduleRefresh(s) {
665
+ if (this.refresher)
666
+ clearTimeout(this.refresher);
667
+ const ms = Math.max(5_000, s.expires_at - Date.now() - 60_000);
668
+ this.refresher = unref(setTimeout(() => { if (this.entries.size)
669
+ void this.o.refresh(); }, ms));
670
+ }
671
+ async closed(ws, code) {
672
+ if (this.ws !== ws)
673
+ return;
674
+ this.ws = null;
675
+ this.ready = false;
676
+ if (this.pinger)
677
+ clearInterval(this.pinger);
678
+ this.pinger = null;
679
+ if (this.stopped || !this.entries.size)
680
+ return;
681
+ if (code === 4401) {
682
+ // Refused as unauthenticated: one refresh; if that is refused too, the person is signed out.
683
+ const s = await this.o.refresh().catch(() => null);
684
+ if (!s)
685
+ return this.failAll(new AwesomateError('unauthenticated', 'Sign in again.', 401));
686
+ }
687
+ if (code === 4403)
688
+ return this.failAll(new AwesomateError('forbidden', 'Live updates are not available for this app here.', 403));
689
+ const wait = Math.min(30_000, 1000 * 2 ** this.attempt++) * (code === 4429 ? 4 : 1);
690
+ this.retry = unref(setTimeout(() => void this.connect(), wait));
691
+ }
692
+ failAll(err) {
693
+ const all = [...this.entries.values()];
694
+ this.entries.clear();
695
+ for (const e of all)
696
+ e.handlers.onError?.(err);
697
+ this.shut();
698
+ }
699
+ shut() {
700
+ if (this.retry)
701
+ clearTimeout(this.retry);
702
+ if (this.refresher)
703
+ clearTimeout(this.refresher);
704
+ if (this.pinger)
705
+ clearInterval(this.pinger);
706
+ this.retry = this.refresher = this.pinger = null;
707
+ const ws = this.ws;
708
+ this.ws = null;
709
+ this.ready = false;
710
+ if (ws && ws.readyState <= 1) {
711
+ try {
712
+ ws.send(JSON.stringify({ type: 'bye' }));
713
+ }
714
+ catch { /* closing */ }
715
+ ws.close(1000, 'bye');
716
+ }
717
+ }
718
+ signedOut() {
719
+ this.stopped = true;
720
+ this.failAll(new AwesomateError('unauthenticated', 'Signed out.', 401));
721
+ }
426
722
  }
427
723
  export function createAppClient(options) {
428
724
  return new AwesomateAppClient(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Your own Awesomate data from Node and the browser: query contacts and app data with generated types, and sign your app's own users in",
5
5
  "license": "MIT",
6
6
  "type": "module",