@awesomate/sdk 0.3.0 → 0.5.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
@@ -77,6 +77,52 @@ Every failure is an `AwesomateError` with a `code`: `unauthenticated`, `forbidde
77
77
  ## Keep the token on a server
78
78
 
79
79
  The token is your account's hosting token. Use it in a server, a script or a scheduled job, never
80
- in a browser. Sign-in for your own app's users comes in a later version.
80
+ in a browser. For a browser, sign your app's own users in (below).
81
+
82
+ ## Your app's users sign in (Pro and above)
83
+
84
+ Set the app up first (Claude Code: `awesomate_crm_apps`): its name, the origins it runs on, and
85
+ whether anyone may sign up or only people you add. That gives you a publishable key, which is not a
86
+ secret. Then say who may read and change each kind (`awesomate_crm_kinds`, `set_access`), for
87
+ example members read jobs whose customer is them and the memos on those jobs.
88
+
89
+ ```ts
90
+ import { createAppClient } from '@awesomate/sdk';
91
+
92
+ const app = createAppClient({ publishableKey: 'pk_...' });
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` });
96
+
97
+ // /signed-in: exchanges the link for a session and clears it from the address bar.
98
+ const user = await app.auth.completeSignIn();
99
+
100
+ // Everywhere else: the same calls as the server client, as this person.
101
+ const { rows } = await app.query('job', { orderBy: ['created_at', 'desc'] });
102
+ await app.write('forum_post', { body: 'Booked for Tuesday' }, { links: { job: rows[0].id } });
103
+ app.auth.onChange((u) => render(u));
104
+ await app.auth.signOut();
105
+ ```
106
+
107
+ ### Lists that keep themselves current
108
+
109
+ ```ts
110
+ const stop = app.live('job', { orderBy: ['created_at', 'desc'], limit: 50 }, (rows) => render(rows));
111
+ // later: stop();
112
+ ```
113
+
114
+ `live()` calls you with the whole list at first and again whenever a row in it is added, changed,
115
+ removed, or stops being one this person may read (a job moved to another customer leaves their
116
+ list, with its memos). Changes made anywhere reach the list: in this app, by your team in Claude
117
+ Code, by an n8n workflow. One connection serves every list on the client; it reconnects by itself
118
+ and takes a fresh copy when it does. Up to 200 rows a list and 20 lists a connection. Pass
119
+ `{ onRows, onError }` to hear about a list the server refused (a column that does not exist).
120
+ Outside a browser, give it a WebSocket: `createAppClient({ publishableKey, WebSocket })`.
121
+
122
+ The rules are kept in your own database, which applies them to every read and write, so a mistake
123
+ in the app cannot show anyone more than their role allows. A disabled person is refused on their
124
+ next call. Sessions are kept in `localStorage`; in a Capacitor app pass `storage` (Preferences) and
125
+ call `completeSignIn(url)` from the deep link that opened the app. Your own server can verify
126
+ `await app.auth.accessToken()` against `https://hub.awesomate.ai/api/sdk/v1/jwks.json`.
81
127
 
82
128
  MIT licence.
package/dist/index.d.ts CHANGED
@@ -9,12 +9,20 @@
9
9
  * const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
10
10
  * await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
11
11
  *
12
+ * In the browser, an app's own users sign in by email link and read only what the account's
13
+ * access rules let them (Pro and above):
14
+ * const app = createAppClient({ publishableKey: 'pk_...' });
15
+ * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
+ * await app.auth.completeSignIn(); // on /signed-in
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
19
+ *
12
20
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
13
21
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
14
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
15
23
  * crm:write, Support Plus and above.
16
24
  */
17
- export declare const VERSION = "0.3.0";
25
+ export declare const VERSION = "0.5.0";
18
26
  /** Augmented by the generated awesomate.d.ts, so each kind's rows are typed. */
19
27
  export interface Kinds {
20
28
  }
@@ -119,7 +127,8 @@ export interface Page<R> {
119
127
  };
120
128
  asAt: string;
121
129
  }
122
- 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];
123
132
  export declare class AwesomateError extends Error {
124
133
  readonly code: ErrorCode;
125
134
  readonly status: number;
@@ -342,5 +351,123 @@ export interface RecipeResult {
342
351
  /** Every record the recipe wrote or archived, in step order. */
343
352
  records: string[];
344
353
  }
354
+ /** Where a session is kept: localStorage, Capacitor Preferences, or anything with these three. */
355
+ export interface SessionStorageLike {
356
+ getItem(key: string): string | null | Promise<string | null>;
357
+ setItem(key: string, value: string): void | Promise<void>;
358
+ removeItem(key: string): void | Promise<void>;
359
+ }
360
+ export interface AppUser {
361
+ id: string;
362
+ email: string;
363
+ /** owner, staff, member or one of the account's own; the database re-reads it on every call */
364
+ role: string;
365
+ contact_id: string | null;
366
+ }
367
+ export interface AppClientOptions {
368
+ /** The app's publishable key (pk_...). Not a secret: it belongs in browser code. */
369
+ publishableKey: string;
370
+ baseUrl?: string;
371
+ fetch?: typeof fetch;
372
+ /** Default: localStorage in a browser, memory elsewhere. In Capacitor, pass Preferences. */
373
+ storage?: SessionStorageLike;
374
+ /** Default: one per publishable key. */
375
+ storageKey?: string;
376
+ /** For live(): the WebSocket class. Default: the browser's (and Node 22's) global WebSocket. */
377
+ WebSocket?: WebSocketLike;
378
+ }
379
+ /** The part of the WebSocket API live() uses: the browser's, Node 22's, or the ws package's. */
380
+ export interface WebSocketLike {
381
+ new (url: string): {
382
+ readyState: number;
383
+ send(data: string): void;
384
+ close(code?: number, reason?: string): void;
385
+ onopen: ((ev: unknown) => void) | null;
386
+ onmessage: ((ev: {
387
+ data: unknown;
388
+ }) => void) | null;
389
+ onclose: ((ev: {
390
+ code: number;
391
+ reason: string;
392
+ }) => void) | null;
393
+ onerror: ((ev: unknown) => void) | null;
394
+ };
395
+ }
396
+ export interface LiveChange<R> {
397
+ /** Rows that are new or changed since the last call. */
398
+ upserts: R[];
399
+ /** Ids that left the list. */
400
+ removes: string[];
401
+ }
402
+ export interface LiveHandlers<R> {
403
+ /** Called with the whole list, in the query's order, at first and after every change. */
404
+ onRows: (rows: R[], change: LiveChange<R> | null) => void;
405
+ /** A refusal for this list (a column that does not exist, too many lists); the list stops. */
406
+ onError?: (err: AwesomateError) => void;
407
+ }
408
+ /** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
409
+ export declare function signInTokenFrom(url: string): string | null;
410
+ export declare class AwesomateAppClient {
411
+ private readonly opts;
412
+ private readonly base;
413
+ private readonly doFetch;
414
+ private readonly storage;
415
+ private readonly key;
416
+ private session;
417
+ private refreshing;
418
+ private readonly listeners;
419
+ private socket;
420
+ constructor(opts: AppClientOptions);
421
+ readonly auth: {
422
+ /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
423
+ signInWithLink: (email: string, options: {
424
+ redirectTo: string;
425
+ }) => Promise<{
426
+ sent: boolean;
427
+ message: string;
428
+ }>;
429
+ /**
430
+ * Finish signing in from the link: the current page's address by default, or a URL a deep link
431
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
432
+ * token is removed from the browser's address bar.
433
+ */
434
+ completeSignIn: (url?: string) => Promise<AppUser | null>;
435
+ /** The signed-in user, or null. */
436
+ user: () => Promise<AppUser | null>;
437
+ /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
438
+ onChange: (listener: (user: AppUser | null) => void) => (() => void);
439
+ signOut: () => Promise<void>;
440
+ /** A current access token, for the app's own server to verify against the JWKS. */
441
+ accessToken: () => Promise<string | null>;
442
+ };
443
+ private request;
444
+ private read;
445
+ private load;
446
+ private save;
447
+ private clear;
448
+ /** A session whose access token has at least half a minute left, refreshing if not. */
449
+ private current;
450
+ /** One refresh at a time per client; a refresh another tab already did is picked up from storage. */
451
+ private refresh;
452
+ /** A data call as the signed-in user: refused once (a disable, a key change), it refreshes and tries again; refused twice, it signs out. */
453
+ private data;
454
+ query<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options?: QueryOptions<RowOf<K>, S>): Promise<Omit<Page<Pick<RowOf<K>, S>>, 'hidden'>>;
455
+ queryAll<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options?: Omit<QueryOptions<RowOf<K>, S>, 'after'> & {
456
+ maxRows?: number;
457
+ }): AsyncGenerator<Pick<RowOf<K>, S>>;
458
+ /** One record by id, or null when there is none this user may read. */
459
+ get<K extends KindName>(kind: K, id: string): Promise<RowOf<K> | null>;
460
+ /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
461
+ write<K extends KindName>(kind: K, data: Partial<RowOf<K>> | Record<string, unknown>, options?: WriteOptions): Promise<string>;
462
+ archive<K extends KindName>(kind: K, id: string): Promise<void>;
463
+ /**
464
+ * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
465
+ * and again whenever a row in it is added, changed, removed, or stops being one this person may
466
+ * read. One connection serves every live list on this client; it reconnects by itself and takes
467
+ * a fresh snapshot when it does. Returns stop().
468
+ */
469
+ 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;
470
+ }
471
+ export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
345
472
  export declare function createClient(options: ClientOptions): AwesomateClient;
346
473
  export {};
package/dist/index.js CHANGED
@@ -9,13 +9,22 @@
9
9
  * const open = await db.run('open_jobs_in', { suburb: 'Carindale' }); // a saved query, by name
10
10
  * await db.call('log_visit', { customer, title: 'Possum in the roof' }); // a write recipe, one transaction
11
11
  *
12
+ * In the browser, an app's own users sign in by email link and read only what the account's
13
+ * access rules let them (Pro and above):
14
+ * const app = createAppClient({ publishableKey: 'pk_...' });
15
+ * await app.auth.signInWithLink(email, { redirectTo: `${location.origin}/signed-in` });
16
+ * await app.auth.completeSignIn(); // on /signed-in
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
19
+ *
12
20
  * The account's token (a hosting PAT with crm:read) is a secret: use it on a server, never in a
13
21
  * browser. End-user sign-in for browser apps comes in a later version. Reading and running saved
14
22
  * queries works on every plan; writing app data (kinds, records, saved queries, recipes) needs
15
23
  * crm:write, Support Plus and above.
16
24
  */
17
- export const VERSION = '0.3.0';
25
+ export const VERSION = '0.5.0';
18
26
  const DEFAULT_BASE = 'https://hub.awesomate.ai';
27
+ const ERROR_CODES = ['unauthenticated', 'forbidden', 'not_found', 'validation', 'consent_blocked', 'rate_limited', 'conflict', 'unavailable'];
19
28
  export class AwesomateError extends Error {
20
29
  code;
21
30
  status;
@@ -194,6 +203,434 @@ export class AwesomateClient {
194
203
  return this.request('POST', '/api/my-crm/v1/data/query', request);
195
204
  }
196
205
  }
206
+ function memoryStorage() {
207
+ const m = new Map();
208
+ return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => { m.set(k, v); }, removeItem: (k) => { m.delete(k); } };
209
+ }
210
+ function browserStorage() {
211
+ try {
212
+ if (typeof localStorage !== 'undefined') {
213
+ localStorage.getItem('awesomate.probe');
214
+ return localStorage;
215
+ }
216
+ }
217
+ catch { /* storage blocked: a private window, a sandboxed frame */ }
218
+ return memoryStorage();
219
+ }
220
+ /** The sign-in token a link carries, from a URL's fragment (#awesomate_token=...), or null. */
221
+ export function signInTokenFrom(url) {
222
+ const hash = url.includes('#') ? url.slice(url.indexOf('#') + 1) : '';
223
+ const v = new URLSearchParams(hash).get('awesomate_token');
224
+ return v && v.length >= 20 ? v : null;
225
+ }
226
+ const REFRESH_EARLY_MS = 30_000;
227
+ export class AwesomateAppClient {
228
+ opts;
229
+ base;
230
+ doFetch;
231
+ storage;
232
+ key;
233
+ session;
234
+ refreshing = null;
235
+ listeners = new Set();
236
+ socket = null;
237
+ constructor(opts) {
238
+ this.opts = opts;
239
+ if (!opts?.publishableKey?.startsWith('pk_'))
240
+ throw new Error('createAppClient needs the app\'s publishable key (pk_...).');
241
+ if (/^amt_(pat|bs)_/.test(opts.publishableKey))
242
+ throw new Error('That is the account\'s own token. Never put it in browser code; use the app\'s publishable key.');
243
+ this.base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '');
244
+ this.doFetch = opts.fetch ?? globalThis.fetch?.bind(globalThis);
245
+ if (!this.doFetch)
246
+ throw new Error('No fetch available.');
247
+ this.storage = opts.storage ?? browserStorage();
248
+ this.key = opts.storageKey ?? `awesomate.session.${opts.publishableKey.slice(-10)}`;
249
+ }
250
+ auth = {
251
+ /** Email a sign-in link. The answer is the same whether or not the address can sign in. */
252
+ signInWithLink: (email, options) => this.request('POST', '/auth/magic-link', { email, redirect_to: options.redirectTo }),
253
+ /**
254
+ * Finish signing in from the link: the current page's address by default, or a URL a deep link
255
+ * opened (Capacitor). Returns the user, or null when the address carries no sign-in. The
256
+ * token is removed from the browser's address bar.
257
+ */
258
+ completeSignIn: async (url) => {
259
+ const here = typeof location !== 'undefined' ? location.href : '';
260
+ const token = signInTokenFrom(url ?? here);
261
+ if (!token)
262
+ return null;
263
+ if (!url && typeof history !== 'undefined' && typeof location !== 'undefined') {
264
+ history.replaceState(history.state, '', location.pathname + location.search);
265
+ }
266
+ return (await this.save(await this.request('POST', '/auth/verify', { token }))).user;
267
+ },
268
+ /** The signed-in user, or null. */
269
+ user: async () => (await this.load())?.user ?? null,
270
+ /** Called with the user on sign-in and null on sign-out (including a refresh that was refused). */
271
+ onChange: (listener) => {
272
+ this.listeners.add(listener);
273
+ return () => { this.listeners.delete(listener); };
274
+ },
275
+ signOut: async () => {
276
+ const s = await this.load();
277
+ await this.clear();
278
+ if (s)
279
+ await this.request('POST', '/auth/sign-out', { refresh_token: s.refresh_token }).catch(() => undefined);
280
+ },
281
+ /** A current access token, for the app's own server to verify against the JWKS. */
282
+ accessToken: async () => (await this.current())?.access_token ?? null,
283
+ };
284
+ async request(method, path, body, token) {
285
+ const res = await this.doFetch(`${this.base}/api/sdk/v1${path}`, {
286
+ method,
287
+ headers: {
288
+ 'x-awesomate-key': this.opts.publishableKey,
289
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
290
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
291
+ },
292
+ body: body === undefined ? undefined : JSON.stringify(body),
293
+ });
294
+ if (res.status === 204)
295
+ return undefined;
296
+ const text = await res.text();
297
+ let json = {};
298
+ try {
299
+ json = text ? JSON.parse(text) : {};
300
+ }
301
+ catch { /* a proxy's error page */ }
302
+ if (res.ok)
303
+ return json;
304
+ const server = typeof json.code === 'string' ? json.code : undefined;
305
+ const message = typeof json.error === 'string' ? json.error : `The hub answered ${res.status}.`;
306
+ throw new AwesomateError(errorCode(res.status, server), message, res.status, typeof json.field === 'string' ? json.field : undefined, server);
307
+ }
308
+ async read() {
309
+ try {
310
+ const raw = await this.storage.getItem(this.key);
311
+ const s = raw ? JSON.parse(raw) : null;
312
+ return s?.access_token && s.refresh_token && s.user ? s : null;
313
+ }
314
+ catch {
315
+ return null;
316
+ }
317
+ }
318
+ async load() {
319
+ if (this.session === undefined)
320
+ this.session = await this.read();
321
+ return this.session;
322
+ }
323
+ async save(r) {
324
+ const s = { access_token: r.access_token, refresh_token: r.refresh_token, expires_at: Date.now() + r.expires_in * 1000, user: r.user };
325
+ this.session = s;
326
+ await this.storage.setItem(this.key, JSON.stringify(s));
327
+ for (const l of this.listeners)
328
+ l(s.user);
329
+ this.socket?.tokenChanged(s);
330
+ return s;
331
+ }
332
+ async clear() {
333
+ const had = this.session !== null;
334
+ this.session = null;
335
+ await this.storage.removeItem(this.key);
336
+ if (had)
337
+ for (const l of this.listeners)
338
+ l(null);
339
+ this.socket?.signedOut();
340
+ this.socket = null;
341
+ }
342
+ /** A session whose access token has at least half a minute left, refreshing if not. */
343
+ async current() {
344
+ const s = await this.load();
345
+ if (!s)
346
+ return null;
347
+ return s.expires_at - Date.now() > REFRESH_EARLY_MS ? s : this.refresh(s);
348
+ }
349
+ /** One refresh at a time per client; a refresh another tab already did is picked up from storage. */
350
+ refresh(s) {
351
+ if (this.refreshing)
352
+ return this.refreshing;
353
+ this.refreshing = (async () => {
354
+ const stored = await this.read();
355
+ if (stored && stored.refresh_token !== s.refresh_token && stored.expires_at - Date.now() > REFRESH_EARLY_MS) {
356
+ this.session = stored;
357
+ return stored;
358
+ }
359
+ try {
360
+ return await this.save(await this.request('POST', '/auth/refresh', { refresh_token: s.refresh_token }));
361
+ }
362
+ catch (err) {
363
+ if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
364
+ throw err;
365
+ const after = await this.read();
366
+ if (after && after.refresh_token !== s.refresh_token) {
367
+ this.session = after;
368
+ return after;
369
+ }
370
+ await this.clear();
371
+ return null;
372
+ }
373
+ })().finally(() => { this.refreshing = null; });
374
+ return this.refreshing;
375
+ }
376
+ /** A data call as the signed-in user: refused once (a disable, a key change), it refreshes and tries again; refused twice, it signs out. */
377
+ async data(method, path, body) {
378
+ let s = await this.current();
379
+ if (!s)
380
+ throw new AwesomateError('unauthenticated', 'Sign in first.', 401);
381
+ try {
382
+ return await this.request(method, path, body, s.access_token);
383
+ }
384
+ catch (err) {
385
+ if (!(err instanceof AwesomateError) || err.code !== 'unauthenticated')
386
+ throw err;
387
+ s = await this.refresh(s);
388
+ if (!s)
389
+ throw err;
390
+ return this.request(method, path, body, s.access_token);
391
+ }
392
+ }
393
+ async query(kind, options = {}) {
394
+ const r = await this.data('POST', '/rows/query', { kind, ...options });
395
+ return { rows: r.rows, next: r.next, count: r.count, applied: r.applied, asAt: r.as_at };
396
+ }
397
+ async *queryAll(kind, options = {}) {
398
+ const { maxRows = 10_000, ...rest } = options;
399
+ let after;
400
+ let seen = 0;
401
+ do {
402
+ const page = await this.query(kind, { limit: 1000, ...rest, ...(after ? { after } : {}) });
403
+ for (const row of page.rows) {
404
+ if (seen++ >= maxRows)
405
+ return;
406
+ yield row;
407
+ }
408
+ after = page.next ?? undefined;
409
+ } while (after);
410
+ }
411
+ /** One record by id, or null when there is none this user may read. */
412
+ async get(kind, id) {
413
+ try {
414
+ return (await this.data('GET', `/rows/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`)).row;
415
+ }
416
+ catch (err) {
417
+ if (err instanceof AwesomateError && err.code === 'not_found')
418
+ return null;
419
+ throw err;
420
+ }
421
+ }
422
+ /** Create a record (returns its id) or change one with options.id, inside the kind's write rule for this user. */
423
+ async write(kind, data, options = {}) {
424
+ const r = await this.data('POST', '/call', {
425
+ fn: 'write_record', args: { kind, data, links: options.links ?? {}, ...(options.id ? { id: options.id } : {}) },
426
+ });
427
+ return r.result.id;
428
+ }
429
+ async archive(kind, id) {
430
+ await this.data('POST', '/call', { fn: 'archive_record', args: { kind, id } });
431
+ }
432
+ /**
433
+ * A list kept current: onRows gets the whole list (up to 200 rows, the query's order) at first
434
+ * and again whenever a row in it is added, changed, removed, or stops being one this person may
435
+ * read. One connection serves every live list on this client; it reconnects by itself and takes
436
+ * a fresh snapshot when it does. Returns stop().
437
+ */
438
+ live(kind, options, handlers) {
439
+ const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
440
+ this.socket ??= new LiveSocket({
441
+ url: `${this.base.replace(/^http/, 'ws')}/api/sdk/v1/socket`,
442
+ WebSocket: this.opts.WebSocket ?? globalThis.WebSocket,
443
+ session: () => this.current(),
444
+ refresh: async () => { const s = await this.load(); return s ? this.refresh(s) : null; },
445
+ });
446
+ return this.socket.add({ kind, ...options }, h);
447
+ }
448
+ }
449
+ /** Timers must never keep a Node process (a test, a server-side render) alive; browsers have no unref. */
450
+ function unref(t) {
451
+ t.unref?.();
452
+ return t;
453
+ }
454
+ /** The one WebSocket behind live(). */
455
+ class LiveSocket {
456
+ o;
457
+ ws = null;
458
+ ready = false;
459
+ entries = new Map();
460
+ seq = 0;
461
+ attempt = 0;
462
+ pinger = null;
463
+ refresher = null;
464
+ retry = null;
465
+ stopped = false;
466
+ /** One connection attempt at a time: two lists added together share it. */
467
+ connecting = false;
468
+ constructor(o) {
469
+ this.o = o;
470
+ }
471
+ add(spec, handlers) {
472
+ const id = `l${++this.seq}`;
473
+ this.entries.set(id, { spec, handlers, rows: new Map(), order: [] });
474
+ if (this.ready)
475
+ this.send({ type: 'sub', id, spec });
476
+ else if (!this.ws && !this.connecting)
477
+ void this.connect();
478
+ return () => {
479
+ if (!this.entries.delete(id))
480
+ return;
481
+ if (this.ready)
482
+ this.send({ type: 'unsub', id });
483
+ if (!this.entries.size)
484
+ this.shut();
485
+ };
486
+ }
487
+ send(f) {
488
+ if (this.ws && this.ws.readyState === 1)
489
+ this.ws.send(JSON.stringify(f));
490
+ }
491
+ async connect() {
492
+ if (this.stopped || this.ws || this.connecting || !this.entries.size)
493
+ return;
494
+ if (!this.o.WebSocket) {
495
+ this.failAll(new AwesomateError('unavailable', 'No WebSocket here: pass createAppClient({ WebSocket }).', 0));
496
+ return;
497
+ }
498
+ this.connecting = true;
499
+ let session;
500
+ try {
501
+ session = await this.o.session();
502
+ }
503
+ finally {
504
+ this.connecting = false;
505
+ }
506
+ if (!session) {
507
+ this.failAll(new AwesomateError('unauthenticated', 'Sign in first.', 401));
508
+ return;
509
+ }
510
+ if (this.stopped || this.ws || !this.entries.size)
511
+ return;
512
+ this.scheduleRefresh(session);
513
+ const ws = new this.o.WebSocket(this.o.url);
514
+ this.ws = ws;
515
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'hello', token: session.access_token }));
516
+ ws.onmessage = (ev) => this.frame(String(ev.data));
517
+ ws.onclose = (ev) => void this.closed(ws, ev.code);
518
+ ws.onerror = () => undefined;
519
+ }
520
+ frame(raw) {
521
+ let f;
522
+ try {
523
+ f = JSON.parse(raw);
524
+ }
525
+ catch {
526
+ return;
527
+ }
528
+ if (f.type === 'ready') {
529
+ this.ready = true;
530
+ this.attempt = 0;
531
+ for (const [id, e] of this.entries)
532
+ this.send({ type: 'sub', id, spec: e.spec });
533
+ this.pinger = unref(setInterval(() => this.send({ type: 'ping' }), 25_000));
534
+ return;
535
+ }
536
+ const e = typeof f.id === 'string' ? this.entries.get(f.id) : undefined;
537
+ if (f.type === 'error') {
538
+ const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
539
+ const err = new AwesomateError(code, String(f.message ?? 'Refused.'), 0, typeof f.field === 'string' ? f.field : undefined, String(f.code));
540
+ if (e && typeof f.id === 'string') {
541
+ this.entries.delete(f.id);
542
+ e.handlers.onError?.(err);
543
+ }
544
+ return;
545
+ }
546
+ if (!e)
547
+ return;
548
+ const order = Array.isArray(f.order) ? f.order.map(String) : [];
549
+ if (f.type === 'snapshot') {
550
+ e.rows = new Map((f.rows ?? []).map((r) => [String(r.id), r]));
551
+ e.order = order;
552
+ e.handlers.onRows(order.map((id) => e.rows.get(id)).filter(Boolean), null);
553
+ }
554
+ else if (f.type === 'change') {
555
+ const upserts = f.upserts ?? [];
556
+ const removes = (f.removes ?? []).map(String);
557
+ for (const r of upserts)
558
+ e.rows.set(String(r.id), r);
559
+ for (const id of removes)
560
+ e.rows.delete(id);
561
+ e.order = order;
562
+ e.handlers.onRows(order.map((id) => e.rows.get(id)).filter(Boolean), { upserts, removes });
563
+ }
564
+ }
565
+ /** A refreshed session: the open socket carries on as the same person with the new token. */
566
+ tokenChanged(s) {
567
+ if (this.ready)
568
+ this.send({ type: 'token', token: s.access_token });
569
+ this.scheduleRefresh(s);
570
+ }
571
+ /** Keep the socket's token fresh even when the app makes no other calls. */
572
+ scheduleRefresh(s) {
573
+ if (this.refresher)
574
+ clearTimeout(this.refresher);
575
+ const ms = Math.max(5_000, s.expires_at - Date.now() - 60_000);
576
+ this.refresher = unref(setTimeout(() => { if (this.entries.size)
577
+ void this.o.refresh(); }, ms));
578
+ }
579
+ async closed(ws, code) {
580
+ if (this.ws !== ws)
581
+ return;
582
+ this.ws = null;
583
+ this.ready = false;
584
+ if (this.pinger)
585
+ clearInterval(this.pinger);
586
+ this.pinger = null;
587
+ if (this.stopped || !this.entries.size)
588
+ return;
589
+ if (code === 4401) {
590
+ // Refused as unauthenticated: one refresh; if that is refused too, the person is signed out.
591
+ const s = await this.o.refresh().catch(() => null);
592
+ if (!s)
593
+ return this.failAll(new AwesomateError('unauthenticated', 'Sign in again.', 401));
594
+ }
595
+ if (code === 4403)
596
+ return this.failAll(new AwesomateError('forbidden', 'Live updates are not available for this app here.', 403));
597
+ const wait = Math.min(30_000, 1000 * 2 ** this.attempt++) * (code === 4429 ? 4 : 1);
598
+ this.retry = unref(setTimeout(() => void this.connect(), wait));
599
+ }
600
+ failAll(err) {
601
+ const all = [...this.entries.values()];
602
+ this.entries.clear();
603
+ for (const e of all)
604
+ e.handlers.onError?.(err);
605
+ this.shut();
606
+ }
607
+ shut() {
608
+ if (this.retry)
609
+ clearTimeout(this.retry);
610
+ if (this.refresher)
611
+ clearTimeout(this.refresher);
612
+ if (this.pinger)
613
+ clearInterval(this.pinger);
614
+ this.retry = this.refresher = this.pinger = null;
615
+ const ws = this.ws;
616
+ this.ws = null;
617
+ this.ready = false;
618
+ if (ws && ws.readyState <= 1) {
619
+ try {
620
+ ws.send(JSON.stringify({ type: 'bye' }));
621
+ }
622
+ catch { /* closing */ }
623
+ ws.close(1000, 'bye');
624
+ }
625
+ }
626
+ signedOut() {
627
+ this.stopped = true;
628
+ this.failAll(new AwesomateError('unauthenticated', 'Signed out.', 401));
629
+ }
630
+ }
631
+ export function createAppClient(options) {
632
+ return new AwesomateAppClient(options);
633
+ }
197
634
  export function createClient(options) {
198
635
  return new AwesomateClient(options);
199
636
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.3.0",
4
- "description": "Read your own Awesomate data from Node: query and page through your contact list with types generated from your fields",
3
+ "version": "0.5.0",
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",
7
7
  "publishConfig": {