@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 +28 -6
- package/dist/index.d.ts +66 -6
- package/dist/index.js +309 -13
- package/package.json +1 -1
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
|
-
//
|
|
95
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
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)`
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
373
|
+
this.session = s;
|
|
318
374
|
return this.session;
|
|
319
375
|
}
|
|
320
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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