@awesomate/sdk 0.9.0 → 0.11.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 +18 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +95 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -146,6 +146,24 @@ and takes a fresh copy when it does. Up to 200 rows a list and 20 lists a connec
|
|
|
146
146
|
`{ onRows, onError }` to hear about a list the server refused (a column that does not exist).
|
|
147
147
|
Outside a browser, give it a WebSocket: `createAppClient({ publishableKey, WebSocket })`.
|
|
148
148
|
|
|
149
|
+
While a list is open in a browser, the connection also tells the hub whether the tab is in front.
|
|
150
|
+
If the account switched on reply emails, someone looking at the app isn't emailed about a reply,
|
|
151
|
+
but a tab left open in the background doesn't count as looking, and closing the last one counts as
|
|
152
|
+
leaving straight away.
|
|
153
|
+
|
|
154
|
+
A conversation can also show who else has it open and who is typing:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const room = app.here(job.id, (people) => showWhoIsHere(people)); // [{ user, role, name, typing, assistant? }]
|
|
158
|
+
messageBox.oninput = () => room.typing(true); // sent at most every few seconds
|
|
159
|
+
messageBox.onblur = () => room.typing(false);
|
|
160
|
+
// later: room.leave();
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Everyone else with that record open and their tab in front is listed once, and the app's
|
|
164
|
+
assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
165
|
+
keeps checking; a refusal goes to the optional third argument, `onError`. Nothing is stored.
|
|
166
|
+
|
|
149
167
|
### Staff find a customer
|
|
150
168
|
|
|
151
169
|
People in an app read no contacts but their own, so by default staff cannot put a job under a
|
package/dist/index.d.ts
CHANGED
|
@@ -418,6 +418,26 @@ export interface LiveChange<R> {
|
|
|
418
418
|
/** Ids that left the list. */
|
|
419
419
|
removes: string[];
|
|
420
420
|
}
|
|
421
|
+
/** Someone else with the same record open (app.here), as the hub sends them. */
|
|
422
|
+
export interface HerePerson {
|
|
423
|
+
/** Their app user id, or 'assistant' for the app's AI assistant. */
|
|
424
|
+
user: string;
|
|
425
|
+
role: string;
|
|
426
|
+
/** Their first name from the account's Contacts, or null. */
|
|
427
|
+
name: string | null;
|
|
428
|
+
typing: boolean;
|
|
429
|
+
/** The app's AI assistant, writing a reply. */
|
|
430
|
+
assistant?: true;
|
|
431
|
+
}
|
|
432
|
+
export interface HereHandle {
|
|
433
|
+
/**
|
|
434
|
+
* Say this person is typing (call it on each keystroke: it sends at most every few seconds) or has
|
|
435
|
+
* stopped (on send or blur). Without another call, typing ends on its own after a few seconds.
|
|
436
|
+
*/
|
|
437
|
+
typing(on: boolean): void;
|
|
438
|
+
/** Close the record: the others stop seeing this person on it. */
|
|
439
|
+
leave(): void;
|
|
440
|
+
}
|
|
421
441
|
export interface LiveHandlers<R> {
|
|
422
442
|
/** Called with the whole list, in the query's order, at first and after every change. */
|
|
423
443
|
onRows: (rows: R[], change: LiveChange<R> | null) => void;
|
|
@@ -516,6 +536,15 @@ export declare class AwesomateAppClient {
|
|
|
516
536
|
* a fresh snapshot when it does. Returns stop().
|
|
517
537
|
*/
|
|
518
538
|
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;
|
|
539
|
+
/** The one live connection behind live() and here(), made on first use. */
|
|
540
|
+
private liveSocket;
|
|
541
|
+
/**
|
|
542
|
+
* Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
|
|
543
|
+
* open with their tab in front, and who is typing, at first and on every change; the app's
|
|
544
|
+
* assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
545
|
+
* keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
|
|
546
|
+
*/
|
|
547
|
+
here(recordId: string, onPeople: (people: HerePerson[]) => void, onError?: (err: AwesomateError) => void): HereHandle;
|
|
519
548
|
}
|
|
520
549
|
export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
|
|
521
550
|
export declare function createClient(options: ClientOptions): AwesomateClient;
|
package/dist/index.js
CHANGED
|
@@ -539,20 +539,38 @@ export class AwesomateAppClient {
|
|
|
539
539
|
*/
|
|
540
540
|
live(kind, options, handlers) {
|
|
541
541
|
const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
|
|
542
|
-
this.
|
|
542
|
+
return this.liveSocket().add({ kind, ...options }, h);
|
|
543
|
+
}
|
|
544
|
+
/** The one live connection behind live() and here(), made on first use. */
|
|
545
|
+
liveSocket() {
|
|
546
|
+
return this.socket ??= new LiveSocket({
|
|
543
547
|
url: `${this.base.replace(/^http/, 'ws')}/api/sdk/v1/socket`,
|
|
544
548
|
WebSocket: this.opts.WebSocket ?? globalThis.WebSocket,
|
|
545
549
|
session: () => this.current(),
|
|
546
550
|
refresh: async () => { const s = await this.load(); return s ? this.refresh(s) : null; },
|
|
547
551
|
});
|
|
548
|
-
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
|
|
555
|
+
* open with their tab in front, and who is typing, at first and on every change; the app's
|
|
556
|
+
* assistant appears while it writes a reply. The hub checks this person may read the record, and
|
|
557
|
+
* keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
|
|
558
|
+
*/
|
|
559
|
+
here(recordId, onPeople, onError) {
|
|
560
|
+
return this.liveSocket().join(recordId, onPeople, onError);
|
|
549
561
|
}
|
|
550
562
|
}
|
|
563
|
+
/** A page saying "still typing" more often than this is not sent: the hub keeps it for six seconds. */
|
|
564
|
+
const TYPING_RESEND_MS = 2_500;
|
|
551
565
|
/** Timers must never keep a Node process (a test, a server-side render) alive; browsers have no unref. */
|
|
552
566
|
function unref(t) {
|
|
553
567
|
t.unref?.();
|
|
554
568
|
return t;
|
|
555
569
|
}
|
|
570
|
+
/** The page's document, when there is one: the part live() reads to say whether the tab is in front. */
|
|
571
|
+
function pageDocument() {
|
|
572
|
+
return globalThis.document;
|
|
573
|
+
}
|
|
556
574
|
/** The one WebSocket behind live(). */
|
|
557
575
|
class LiveSocket {
|
|
558
576
|
o;
|
|
@@ -569,6 +587,50 @@ class LiveSocket {
|
|
|
569
587
|
connecting = false;
|
|
570
588
|
constructor(o) {
|
|
571
589
|
this.o = o;
|
|
590
|
+
// Reply emails wait only while one of the person's tabs is in front, so the hub hears when this
|
|
591
|
+
// one goes to the back or comes forward. Outside a browser there is no tab, and nothing is sent.
|
|
592
|
+
pageDocument()?.addEventListener?.('visibilitychange', () => this.sendPresence());
|
|
593
|
+
}
|
|
594
|
+
/** Whether this tab is in front, to the hub (from 0.10.0; an older hub ignores the frame). */
|
|
595
|
+
sendPresence() {
|
|
596
|
+
const d = pageDocument();
|
|
597
|
+
if (this.ready && typeof d?.visibilityState === 'string')
|
|
598
|
+
this.send({ type: 'presence', visible: d.visibilityState === 'visible' });
|
|
599
|
+
}
|
|
600
|
+
/** Records open with here(): who else is on them goes to onPeople. */
|
|
601
|
+
rooms = new Map();
|
|
602
|
+
get wanted() {
|
|
603
|
+
return this.entries.size > 0 || this.rooms.size > 0;
|
|
604
|
+
}
|
|
605
|
+
join(id, onPeople, onError) {
|
|
606
|
+
const room = { onPeople, onError, typingSentAt: 0 };
|
|
607
|
+
this.rooms.set(id, room);
|
|
608
|
+
if (this.ready)
|
|
609
|
+
this.send({ type: 'here', id, on: true });
|
|
610
|
+
else if (!this.ws && !this.connecting)
|
|
611
|
+
void this.connect();
|
|
612
|
+
return {
|
|
613
|
+
typing: (on) => {
|
|
614
|
+
if (this.rooms.get(id) !== room || !this.ready)
|
|
615
|
+
return;
|
|
616
|
+
const now = Date.now();
|
|
617
|
+
if (on && now - room.typingSentAt < TYPING_RESEND_MS)
|
|
618
|
+
return;
|
|
619
|
+
if (!on && !room.typingSentAt)
|
|
620
|
+
return;
|
|
621
|
+
room.typingSentAt = on ? now : 0;
|
|
622
|
+
this.send({ type: 'typing', id, on });
|
|
623
|
+
},
|
|
624
|
+
leave: () => {
|
|
625
|
+
if (this.rooms.get(id) !== room)
|
|
626
|
+
return;
|
|
627
|
+
this.rooms.delete(id);
|
|
628
|
+
if (this.ready)
|
|
629
|
+
this.send({ type: 'here', id, on: false });
|
|
630
|
+
if (!this.wanted)
|
|
631
|
+
this.shut();
|
|
632
|
+
},
|
|
633
|
+
};
|
|
572
634
|
}
|
|
573
635
|
add(spec, handlers) {
|
|
574
636
|
const id = `l${++this.seq}`;
|
|
@@ -582,7 +644,7 @@ class LiveSocket {
|
|
|
582
644
|
return;
|
|
583
645
|
if (this.ready)
|
|
584
646
|
this.send({ type: 'unsub', id });
|
|
585
|
-
if (!this.
|
|
647
|
+
if (!this.wanted)
|
|
586
648
|
this.shut();
|
|
587
649
|
};
|
|
588
650
|
}
|
|
@@ -591,7 +653,7 @@ class LiveSocket {
|
|
|
591
653
|
this.ws.send(JSON.stringify(f));
|
|
592
654
|
}
|
|
593
655
|
async connect() {
|
|
594
|
-
if (this.stopped || this.ws || this.connecting || !this.
|
|
656
|
+
if (this.stopped || this.ws || this.connecting || !this.wanted)
|
|
595
657
|
return;
|
|
596
658
|
if (!this.o.WebSocket) {
|
|
597
659
|
this.failAll(new AwesomateError('unavailable', 'No WebSocket here: pass createAppClient({ WebSocket }).', 0));
|
|
@@ -609,7 +671,7 @@ class LiveSocket {
|
|
|
609
671
|
this.failAll(new AwesomateError('unauthenticated', 'Sign in first.', 401));
|
|
610
672
|
return;
|
|
611
673
|
}
|
|
612
|
-
if (this.stopped || this.ws || !this.
|
|
674
|
+
if (this.stopped || this.ws || !this.wanted)
|
|
613
675
|
return;
|
|
614
676
|
this.scheduleRefresh(session);
|
|
615
677
|
const ws = new this.o.WebSocket(this.o.url);
|
|
@@ -632,9 +694,30 @@ class LiveSocket {
|
|
|
632
694
|
this.attempt = 0;
|
|
633
695
|
for (const [id, e] of this.entries)
|
|
634
696
|
this.send({ type: 'sub', id, spec: e.spec });
|
|
697
|
+
this.sendPresence();
|
|
698
|
+
for (const [id, room] of this.rooms) {
|
|
699
|
+
room.typingSentAt = 0;
|
|
700
|
+
this.send({ type: 'here', id, on: true });
|
|
701
|
+
}
|
|
635
702
|
this.pinger = unref(setInterval(() => this.send({ type: 'ping' }), 25_000));
|
|
636
703
|
return;
|
|
637
704
|
}
|
|
705
|
+
if (f.type === 'people' && typeof f.id === 'string') {
|
|
706
|
+
this.rooms.get(f.id)?.onPeople(Array.isArray(f.people) ? f.people : []);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (f.type === 'error' && typeof f.id === 'string' && f.id.startsWith('here:')) {
|
|
710
|
+
const id = f.id.slice(5);
|
|
711
|
+
const room = this.rooms.get(id);
|
|
712
|
+
if (!room)
|
|
713
|
+
return;
|
|
714
|
+
this.rooms.delete(id);
|
|
715
|
+
const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
|
|
716
|
+
room.onError?.(new AwesomateError(code, String(f.message ?? 'Refused.'), 0, undefined, String(f.code)));
|
|
717
|
+
if (!this.wanted)
|
|
718
|
+
this.shut();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
638
721
|
const e = typeof f.id === 'string' ? this.entries.get(f.id) : undefined;
|
|
639
722
|
if (f.type === 'error') {
|
|
640
723
|
const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
|
|
@@ -699,7 +782,8 @@ class LiveSocket {
|
|
|
699
782
|
if (this.refresher)
|
|
700
783
|
clearTimeout(this.refresher);
|
|
701
784
|
const ms = Math.max(5_000, s.expires_at - Date.now() - 60_000);
|
|
702
|
-
|
|
785
|
+
// A socket kept open only by here() needs a fresh token too, or the hub closes it at expiry.
|
|
786
|
+
this.refresher = unref(setTimeout(() => { if (this.wanted)
|
|
703
787
|
void this.o.refresh(); }, ms));
|
|
704
788
|
}
|
|
705
789
|
async closed(ws, code) {
|
|
@@ -710,7 +794,7 @@ class LiveSocket {
|
|
|
710
794
|
if (this.pinger)
|
|
711
795
|
clearInterval(this.pinger);
|
|
712
796
|
this.pinger = null;
|
|
713
|
-
if (this.stopped || !this.
|
|
797
|
+
if (this.stopped || !this.wanted)
|
|
714
798
|
return;
|
|
715
799
|
if (code === 4401) {
|
|
716
800
|
// Refused as unauthenticated: one refresh; if that is refused too, the person is signed out.
|
|
@@ -725,9 +809,13 @@ class LiveSocket {
|
|
|
725
809
|
}
|
|
726
810
|
failAll(err) {
|
|
727
811
|
const all = [...this.entries.values()];
|
|
812
|
+
const rooms = [...this.rooms.values()];
|
|
728
813
|
this.entries.clear();
|
|
814
|
+
this.rooms.clear();
|
|
729
815
|
for (const e of all)
|
|
730
816
|
e.handlers.onError?.(err);
|
|
817
|
+
for (const r of rooms)
|
|
818
|
+
r.onError?.(err);
|
|
731
819
|
this.shut();
|
|
732
820
|
}
|
|
733
821
|
shut() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|