@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 +47 -1
- package/dist/index.d.ts +129 -2
- package/dist/index.js +438 -1
- package/package.json +2 -2
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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": {
|