@benmore/bm 2.7.317 → 2.7.330
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/index.d.ts +76 -6
- package/index.js +516 -112
- package/package.json +1 -1
package/index.d.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface ListOpts {
|
|
|
36
36
|
export interface ApiResult<T = unknown> {
|
|
37
37
|
ok: boolean;
|
|
38
38
|
status: number;
|
|
39
|
+
/**
|
|
40
|
+
* Parsed JSON; the text of a 2xx text/* answer; a Blob for a 2xx answer of
|
|
41
|
+
* any other type (an image, PDF or ZIP); {error, body} for a failed answer
|
|
42
|
+
* that is not JSON (body is the text as received; error is that text when it
|
|
43
|
+
* is one short line of plain text, else "Request failed (HTTP <status>)", so
|
|
44
|
+
* an HTML error page never becomes a thrown Error's message); null for an
|
|
45
|
+
* empty body.
|
|
46
|
+
*/
|
|
39
47
|
data: T | null;
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -66,6 +74,11 @@ export interface AuthCalls {
|
|
|
66
74
|
me(): Promise<User | null>;
|
|
67
75
|
signOut(): Promise<void>;
|
|
68
76
|
refreshMe(): Promise<User | null>;
|
|
77
|
+
/**
|
|
78
|
+
* When the sign-in still needs a step ({otp_required | mfa_required |
|
|
79
|
+
* mfa_setup_required, redirect}), the browser goes to that page (/verify-otp,
|
|
80
|
+
* /verify-mfa, /mfa-setup, keeping ?next=) and the promise does not settle.
|
|
81
|
+
*/
|
|
69
82
|
signIn(identifier: string, password: string): Promise<User | null>;
|
|
70
83
|
signUp(fields: { email: string; password: string; [extraField: string]: unknown }): Promise<User | null>;
|
|
71
84
|
/** Passkeys (WebAuthn): Face ID, Touch ID, Windows Hello, a phone. */
|
|
@@ -115,11 +128,17 @@ export interface VersionHistory {
|
|
|
115
128
|
id: string;
|
|
116
129
|
versions: VersionMetadata[];
|
|
117
130
|
}
|
|
131
|
+
export interface RevertKeptColumn {
|
|
132
|
+
column: string;
|
|
133
|
+
reason: 'command_owned' | 'workflow_state' | 'protected' | 'hidden' | 'not_in_version';
|
|
134
|
+
}
|
|
118
135
|
export interface RevertReceipt {
|
|
119
136
|
status: 'reverted';
|
|
120
137
|
id: string;
|
|
121
138
|
reverted_to_version: string;
|
|
122
139
|
owned_fields_kept: string[] | null;
|
|
140
|
+
/** Columns that kept their current value instead of the version's, and why. */
|
|
141
|
+
kept: RevertKeptColumn[];
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
export interface TableClient<Row = Record<string, unknown>> {
|
|
@@ -135,13 +154,44 @@ export interface TableClient<Row = Record<string, unknown>> {
|
|
|
135
154
|
revertTo(id: number | string, version: number): Promise<RevertReceipt>;
|
|
136
155
|
}
|
|
137
156
|
|
|
157
|
+
/** bm.room() connection state, delivered as the 'bm:status' event (from is null). */
|
|
158
|
+
export interface RoomStatus {
|
|
159
|
+
state: 'open' | 'reconnecting' | 'closed';
|
|
160
|
+
/** 'open' after a reconnect: messages sent while it was down are not replayed. */
|
|
161
|
+
reconnected?: boolean;
|
|
162
|
+
/** 'reconnecting': retry number, delay in ms, and the socket's close code. */
|
|
163
|
+
attempt?: number;
|
|
164
|
+
retryIn?: number;
|
|
165
|
+
code?: number;
|
|
166
|
+
/** 'reconnecting' after a failed handshake: the GET /ws answer (429, 502, ...). */
|
|
167
|
+
http?: number;
|
|
168
|
+
/** 'closed': why the room ended for good. */
|
|
169
|
+
reason?: 'left' | 'refused' | 'unauthorized' | 'forbidden';
|
|
170
|
+
/** 'closed': queued messages that were never sent. */
|
|
171
|
+
unsent?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A refused join or handshake, delivered as the 'bm:error' event (from is null). The room then closes. */
|
|
175
|
+
export interface RoomError {
|
|
176
|
+
code: 'not_member' | 'too_many_rooms' | 'join_refused' | 'unauthorized' | 'forbidden';
|
|
177
|
+
message: string;
|
|
178
|
+
room: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
138
181
|
export interface RoomClient {
|
|
139
182
|
readonly name: string;
|
|
183
|
+
/** Reconnects with backoff after a drop; 'closed' is final (leave, refused join, 401/403). */
|
|
184
|
+
readonly state: 'connecting' | 'open' | 'reconnecting' | 'closed';
|
|
185
|
+
on(kind: 'bm:status', fn: (status: RoomStatus, from: null) => void): this;
|
|
186
|
+
on(kind: 'bm:error', fn: (error: RoomError, from: null) => void): this;
|
|
140
187
|
on(kind: string, fn: (payload: unknown, from: number | string) => void): this;
|
|
141
|
-
onAny(fn: (payload: unknown, from: number | string, kind: string) => void): this;
|
|
188
|
+
onAny(fn: (payload: unknown, from: number | string | null, kind: string) => void): this;
|
|
142
189
|
off(kind: string, fn: Function): this;
|
|
143
|
-
|
|
190
|
+
/** true: sent; false: queued (16 max) until the room is open again.
|
|
191
|
+
* Throws (code 'room_closed' or 'room_queue_full') instead of dropping. */
|
|
192
|
+
send(payload: unknown): boolean;
|
|
144
193
|
leave(): void;
|
|
194
|
+
/** The current WebSocket; a reconnect replaces it. */
|
|
145
195
|
readonly raw: WebSocket;
|
|
146
196
|
}
|
|
147
197
|
|
|
@@ -323,6 +373,13 @@ export namespace live {
|
|
|
323
373
|
}
|
|
324
374
|
|
|
325
375
|
export function room(name: string): RoomClient;
|
|
376
|
+
/**
|
|
377
|
+
* Call a flow by name. params fill the route's :path segments; a GET or HEAD
|
|
378
|
+
* flow sends the other params as its query string (it has no body). Resolves
|
|
379
|
+
* the parsed JSON answer, the text of a text/* answer (a CSV), or a Blob for
|
|
380
|
+
* any other type (the image or PDF of run: proxy / serve_file); an async flow
|
|
381
|
+
* resolves a Job.
|
|
382
|
+
*/
|
|
326
383
|
export const flows: Record<string, (params?: Record<string, unknown>, body?: unknown) => Promise<unknown | Job>>;
|
|
327
384
|
export function workflow(table: string, id: number | string): WorkflowClient;
|
|
328
385
|
export function aggregate<Result = unknown>(name: string): Promise<Result | null>;
|
|
@@ -358,9 +415,12 @@ export const broadcast: {
|
|
|
358
415
|
};
|
|
359
416
|
export const webrtc: { iceServers(): Promise<IceServer[]> };
|
|
360
417
|
export const mfa: {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
418
|
+
/** From a signed-in session pass the current password; on the /mfa-setup page after a sign-in that requires enrollment, pass nothing. */
|
|
419
|
+
enroll(password?: string): Promise<{ secret: string; otpauth_uri: string; backup_codes: string[] }>;
|
|
420
|
+
/** After a sign-in that required enrollment this also signs the person in (token, user_id, email). */
|
|
421
|
+
verify(code: string): Promise<{ status: "mfa_enabled"; token?: string; user_id?: number; email?: string }>;
|
|
422
|
+
/** Turns the factor off. Takes a current 6-digit code from the authenticator (not the password, not a backup code). */
|
|
423
|
+
disable(code: string): Promise<{ status: "mfa_disabled" }>;
|
|
364
424
|
};
|
|
365
425
|
export function html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml;
|
|
366
426
|
export function raw(value: string): SafeHtml;
|
|
@@ -374,8 +434,11 @@ export const query: {
|
|
|
374
434
|
fetch<T = unknown>(key: unknown, fetcher: () => Promise<T> | T, opts?: QueryFetchOpts): Promise<T>;
|
|
375
435
|
refetch<T = unknown>(key: unknown): Promise<T | undefined>;
|
|
376
436
|
invalidate(match: unknown | ((rawKey: unknown, stableKey: string) => boolean), opts?: { refetch?: boolean }): string[];
|
|
377
|
-
|
|
437
|
+
/** The key table(table, opts) caches under, for the same arguments: opts.key when given, else derived from the request. Pass it to subscribe/get/set/invalidate/mutate. */
|
|
438
|
+
tableKey(table: string, opts?: Omit<QuerySpec, "table"> & QueryFetchOpts): unknown;
|
|
439
|
+
/** Cached under opts.key when given: name one to subscribe to the result. */
|
|
378
440
|
read<T = unknown>(spec: QuerySpec, opts?: QueryFetchOpts): Promise<T[]>;
|
|
441
|
+
/** Cached under tableKey(table, opts). */
|
|
379
442
|
table<T = unknown>(table: string, opts?: Omit<QuerySpec, "table"> & QueryFetchOpts): Promise<T[]>;
|
|
380
443
|
invalidateTable(table: string, opts?: { refetch?: boolean }): string[];
|
|
381
444
|
mutate<T = unknown>(opts: QueryMutateOpts<T>): Promise<T>;
|
|
@@ -389,7 +452,14 @@ declare const cache: {
|
|
|
389
452
|
persistent(name: string, version: string | number): CacheClient | null;
|
|
390
453
|
};
|
|
391
454
|
|
|
455
|
+
export function captureError(err: unknown, options?: { tags?: Record<string, string> }): void;
|
|
456
|
+
export function captureMessage(message: string, level?: 'warning' | 'error' | 'fatal'): void;
|
|
457
|
+
export function setTag(key: string, value: string): void;
|
|
458
|
+
|
|
392
459
|
declare const bm: {
|
|
460
|
+
captureError: typeof captureError;
|
|
461
|
+
captureMessage: typeof captureMessage;
|
|
462
|
+
setTag: typeof setTag;
|
|
393
463
|
api: typeof api;
|
|
394
464
|
auth: typeof auth;
|
|
395
465
|
users: typeof users;
|
package/index.js
CHANGED
|
@@ -15,11 +15,22 @@
|
|
|
15
15
|
|
|
16
16
|
const CSRF_META = () => document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
17
17
|
|
|
18
|
+
// Requests the SDK sends with nobody at the tab (a live query's refetch after
|
|
19
|
+
// a realtime event, heartbeats, the refresh when a tab becomes visible) carry
|
|
20
|
+
// X-Benmore-Background: 1, so they do not count as activity for
|
|
21
|
+
// auth.idle_timeout. inBackground marks every request fn starts synchronously.
|
|
22
|
+
let _backgroundDepth = 0;
|
|
23
|
+
function inBackground(fn) {
|
|
24
|
+
_backgroundDepth++;
|
|
25
|
+
try { return fn(); } finally { _backgroundDepth--; }
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
async function request(method, path, body, options) {
|
|
19
29
|
const headers = new Headers({ Accept: 'application/json' });
|
|
20
30
|
if (options?.headers) {
|
|
21
31
|
new Headers(options.headers).forEach((value, name) => headers.set(name, value));
|
|
22
32
|
}
|
|
33
|
+
if (_backgroundDepth > 0 || options?.background) headers.set('X-Benmore-Background', '1');
|
|
23
34
|
const init = { method, credentials: 'same-origin', headers };
|
|
24
35
|
if (options?.signal !== undefined) init.signal = options.signal;
|
|
25
36
|
if (body !== undefined && body !== null) {
|
|
@@ -29,30 +40,73 @@ async function request(method, path, body, options) {
|
|
|
29
40
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
30
41
|
headers.set('X-CSRF-Token', CSRF_META());
|
|
31
42
|
}
|
|
32
|
-
const
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(path, init);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
captureRequest(method, path, 0, started, null, err);
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
33
51
|
if (res.status === 204) {
|
|
52
|
+
captureRequest(method, path, res.status, started);
|
|
34
53
|
observeSessionResponse(method, path, true, null);
|
|
35
54
|
return { ok: true, status: 204, data: null };
|
|
36
55
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} else {
|
|
44
|
-
data = { error: text };
|
|
45
|
-
}
|
|
56
|
+
let data;
|
|
57
|
+
try {
|
|
58
|
+
data = await responseData(res, method);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
captureRequest(method, path, res.status, started, res.headers.get('X-Request-Id'), err);
|
|
61
|
+
throw err;
|
|
46
62
|
}
|
|
47
63
|
observeSessionResponse(method, path, res.ok, data);
|
|
64
|
+
captureRequest(method, path, res.status, started, (data && typeof data === 'object' && typeof data.request_id === 'string' && data.request_id) || res.headers.get('X-Request-Id'));
|
|
48
65
|
return { ok: res.ok, status: res.status, data, redirected: !!res.redirected, url: res.url || '' };
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// responseData reads a response body by its Content-Type:
|
|
69
|
+
// JSON (application/json, any +json) the parsed value ({error: text} if it does not parse;
|
|
70
|
+
// a failed one as below)
|
|
71
|
+
// 2xx text/* (CSV, plain text, HTML) the text, a string
|
|
72
|
+
// 2xx of any other type a Blob: an image, audio, PDF or ZIP from
|
|
73
|
+
// run: proxy or run: serve_file
|
|
74
|
+
// non-2xx that is not JSON {error, body}: see failedText
|
|
75
|
+
// empty body, HEAD null
|
|
76
|
+
// A non-JSON success used to come back as {error: text}, which made a flow
|
|
77
|
+
// that answers an image resolve to {error: "<PNG bytes>"} with the bytes lost.
|
|
78
|
+
async function responseData(res, method) {
|
|
79
|
+
if (method === 'HEAD') return null;
|
|
80
|
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
81
|
+
const type = ct.split(';', 1)[0].trim();
|
|
82
|
+
const json = ct.includes('application/json') || type.endsWith('+json');
|
|
83
|
+
if (res.ok && type && !json && !type.startsWith('text/')) return res.blob();
|
|
84
|
+
const text = await res.text();
|
|
85
|
+
if (!text) return null;
|
|
86
|
+
if (json) {
|
|
87
|
+
try { return JSON.parse(text); } catch { return res.ok ? { error: text } : failedText(res, type, text); }
|
|
88
|
+
}
|
|
89
|
+
return res.ok ? text : failedText(res, type, text);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// failedText is a failed answer whose body is not JSON (a 404 page, a
|
|
93
|
+
// proxy's error page, a plain-text error) as {error, body}: body is the text
|
|
94
|
+
// as received; error, which becomes the thrown Error's message, is that text
|
|
95
|
+
// only when it is one short line of plain text, else "Request failed (HTTP
|
|
96
|
+
// <status>)". The whole HTML page used to become the message, and a sign-in
|
|
97
|
+
// form showed it to the person.
|
|
98
|
+
function failedText(res, type, text) {
|
|
99
|
+
const line = text.trim();
|
|
100
|
+
const plain = type !== 'text/html' && line.length <= 200 && !/[\r\n<]/.test(line);
|
|
101
|
+
return { error: plain && line ? line : `Request failed (HTTP ${res.status})`, body: text };
|
|
102
|
+
}
|
|
103
|
+
|
|
51
104
|
// apiError builds the Error a failed request throws. The framework's JSON
|
|
52
105
|
// errors carry a short code in `error` and, when there is more to say, the
|
|
53
106
|
// explanation in `message` (a refused write, a flow that failed): the Error's
|
|
54
107
|
// message is that explanation, else the code, else fallback, and err.code is
|
|
55
|
-
// the code. err.status and err.body are the response as received
|
|
108
|
+
// the code. err.status and err.body are the response as received (for a body
|
|
109
|
+
// that is not JSON, {error, body} with the text in body: see failedText).
|
|
56
110
|
function apiError(res, fallback) {
|
|
57
111
|
const data = res.data;
|
|
58
112
|
let text = fallback;
|
|
@@ -151,6 +205,11 @@ function resetSessionState(profile = undefined) {
|
|
|
151
205
|
const bus = _liveConns.get('__bus__');
|
|
152
206
|
if (bus) bus.reconnect(profile !== null);
|
|
153
207
|
_sessionListeners.forEach(reset => reset(profile !== null));
|
|
208
|
+
// Error context belongs to the session that recorded it: the next
|
|
209
|
+
// account's reports must not carry its console text, paths or tags.
|
|
210
|
+
errorCrumbs.length = 0;
|
|
211
|
+
errorSent.length = 0;
|
|
212
|
+
for (const key of Object.keys(errorTags)) delete errorTags[key];
|
|
154
213
|
}
|
|
155
214
|
|
|
156
215
|
// These native endpoints change the cookie session or its active tenant. A
|
|
@@ -206,9 +265,12 @@ async function signIn(identifier, password) {
|
|
|
206
265
|
}
|
|
207
266
|
|
|
208
267
|
// A sign-in or sign-up that still needs a second step (the emailed or texted
|
|
209
|
-
// code with auth.otp, an authenticator code with MFA
|
|
210
|
-
//
|
|
211
|
-
//
|
|
268
|
+
// code with auth.otp, an authenticator code with MFA, enrolling an
|
|
269
|
+
// authenticator for a role in auth.require_mfa_for_roles) has not made a
|
|
270
|
+
// session: the server answers {otp_required: true, redirect} (or mfa_required,
|
|
271
|
+
// or mfa_setup_required), or redirects the request to the step's page. The
|
|
272
|
+
// app serves those pages (the scaffold's static/verify-otp.html,
|
|
273
|
+
// verify-mfa.html, mfa-setup.html). signInStepURL returns that page,
|
|
212
274
|
// same-origin and carrying the current page's ?next=, else null (also when
|
|
213
275
|
// there is no page: outside a browser the calls resolve null as before).
|
|
214
276
|
// Callers send the browser there, as a form post would, and do not return: code written as
|
|
@@ -218,7 +280,7 @@ function signInStepURL(path, res, loc) {
|
|
|
218
280
|
if (!loc || typeof loc.assign !== 'function') return null;
|
|
219
281
|
const data = res && res.data;
|
|
220
282
|
let to = '';
|
|
221
|
-
if (data && typeof data === 'object' && (data.otp_required || data.mfa_required) && typeof data.redirect === 'string') {
|
|
283
|
+
if (data && typeof data === 'object' && (data.otp_required || data.mfa_required || data.mfa_setup_required) && typeof data.redirect === 'string') {
|
|
222
284
|
to = data.redirect;
|
|
223
285
|
} else if (res && res.redirected && typeof res.url === 'string' && loc.origin && res.url.startsWith(loc.origin + '/')) {
|
|
224
286
|
const target = res.url.slice(loc.origin.length);
|
|
@@ -330,7 +392,8 @@ const passkey = {
|
|
|
330
392
|
},
|
|
331
393
|
}, 'Passkey sign-in failed');
|
|
332
394
|
// With auth.otp the passkey proves the account and the emailed code
|
|
333
|
-
// still has to be entered before there is a session
|
|
395
|
+
// still has to be entered before there is a session; a role that requires
|
|
396
|
+
// MFA enrollment goes to the setup page first.
|
|
334
397
|
const step = signInStepURL('/api/_auth/passkeys/login', { data }, _pageLocation());
|
|
335
398
|
if (step) return _goToSignInStep(step);
|
|
336
399
|
return data.user || null;
|
|
@@ -650,7 +713,7 @@ live.scoped = function liveScoped(tableOrStar, fetchAndRender, opts) {
|
|
|
650
713
|
if (running) { queued = true; return; }
|
|
651
714
|
running = true;
|
|
652
715
|
try {
|
|
653
|
-
const result = fetchAndRender
|
|
716
|
+
const result = inBackground(fetchAndRender);
|
|
654
717
|
if (result && typeof result.then === 'function') {
|
|
655
718
|
await result;
|
|
656
719
|
}
|
|
@@ -714,64 +777,216 @@ live.scoped = function liveScoped(tableOrStar, fetchAndRender, opts) {
|
|
|
714
777
|
// const room = bm.room('lobby');
|
|
715
778
|
// room.on('hello', (payload, from) => greet(from));
|
|
716
779
|
// room.onAny((payload, from) => log(payload));
|
|
780
|
+
// room.on('bm:status', s => setBadge(s.state)); // open / reconnecting / closed
|
|
781
|
+
// room.on('bm:error', e => toast(e.message)); // e.g. not a member
|
|
717
782
|
// room.send({ kind: 'hello' });
|
|
718
783
|
// room.leave();
|
|
784
|
+
//
|
|
785
|
+
// One WebSocket per room. A dropped socket (app restart, env change, the
|
|
786
|
+
// server's heartbeat close after sign-out or a tenant switch) reconnects with
|
|
787
|
+
// jittered exponential backoff (0.5 s doubling, 30 s ceiling; ten stable
|
|
788
|
+
// seconds start it over) and joins the room again. Messages others sent
|
|
789
|
+
// while it was down are not replayed: refetch durable state on
|
|
790
|
+
// {state:'open', reconnected:true}. It stops for good after leave(), a
|
|
791
|
+
// refused join ({type:'error'} from the server: ws_rooms membership) or a
|
|
792
|
+
// refused handshake (401/403). Sends while not open are queued (16 max,
|
|
793
|
+
// sent in order after the join); send() throws once the room is closed or
|
|
794
|
+
// the queue is full, so nothing is dropped silently. SDK events carry
|
|
795
|
+
// from === null; peers' frames never do.
|
|
719
796
|
// =================================================================
|
|
720
797
|
|
|
798
|
+
const ROOM_QUEUE_MAX = 16; // below the server's 20 frames/s per connection
|
|
799
|
+
const ROOM_RETRY_BASE_MS = 500;
|
|
800
|
+
const ROOM_RETRY_MAX_MS = 30000;
|
|
801
|
+
const ROOM_STABLE_MS = 10000;
|
|
802
|
+
|
|
803
|
+
// The server's refused-join bodies (websocket.go), as stable codes.
|
|
804
|
+
function roomErrorCode(body) {
|
|
805
|
+
if (body === 'not a member of this room') return 'not_member';
|
|
806
|
+
if (body === 'too many rooms on this connection') return 'too_many_rooms';
|
|
807
|
+
return 'join_refused';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function roomError(code, message, name) {
|
|
811
|
+
const err = new Error(message);
|
|
812
|
+
err.code = code;
|
|
813
|
+
err.room = name;
|
|
814
|
+
return err;
|
|
815
|
+
}
|
|
816
|
+
|
|
721
817
|
export function room(name) {
|
|
722
818
|
if (!name || typeof name !== 'string') {
|
|
723
819
|
throw new Error('bm.room(name) requires a string room name');
|
|
724
820
|
}
|
|
725
821
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
726
|
-
const
|
|
822
|
+
const url = `${proto}://${location.host}/ws`;
|
|
727
823
|
const namedHandlers = new Map(); // kind -> Set<fn>
|
|
728
824
|
const anyHandlers = new Set();
|
|
729
|
-
let queue = [];
|
|
730
|
-
let
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
let msg;
|
|
740
|
-
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
741
|
-
if (msg.type !== 'message' && msg.type !== 'presence') return;
|
|
742
|
-
const payload = msg.payload || {};
|
|
743
|
-
const from = msg.from;
|
|
744
|
-
const kind = payload.kind || msg.type; // 'presence' has no kind
|
|
825
|
+
let queue = []; // serialized broadcast frames waiting for the next join
|
|
826
|
+
let ws = null;
|
|
827
|
+
let state = 'connecting'; // 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
828
|
+
let closedReason = null;
|
|
829
|
+
let attempt = 0;
|
|
830
|
+
let everOpened = false;
|
|
831
|
+
let timer = null;
|
|
832
|
+
let gen = 0; // bumps on every new socket or close; stale callbacks compare
|
|
833
|
+
|
|
834
|
+
const dispatch = (kind, payload, from) => {
|
|
745
835
|
const set = namedHandlers.get(kind);
|
|
746
|
-
if (set) set.forEach(fn => { try { fn(payload, from); } catch (e) { console.error(e); } });
|
|
747
|
-
anyHandlers.forEach(fn => { try { fn(payload, from, kind); } catch (e) { console.error(e); } });
|
|
836
|
+
if (set) [...set].forEach(fn => { try { fn(payload, from); } catch (e) { console.error(e); } });
|
|
837
|
+
[...anyHandlers].forEach(fn => { try { fn(payload, from, kind); } catch (e) { console.error(e); } });
|
|
838
|
+
};
|
|
839
|
+
const setStatus = (next, info) => {
|
|
840
|
+
state = next;
|
|
841
|
+
dispatch('bm:status', Object.assign({ state: next }, info), null);
|
|
842
|
+
};
|
|
843
|
+
const dropSocket = () => {
|
|
844
|
+
gen++;
|
|
845
|
+
if (timer !== null) { clearTimeout(timer); timer = null; }
|
|
846
|
+
if (ws && ws.readyState <= WebSocket.OPEN) { try { ws.close(1000); } catch { /* already closing */ } }
|
|
748
847
|
};
|
|
848
|
+
const finish = (reason, info) => {
|
|
849
|
+
if (state === 'closed') return;
|
|
850
|
+
if (reason === 'left' && ws && ws.readyState === WebSocket.OPEN) {
|
|
851
|
+
try { ws.send(JSON.stringify({ type: 'leave', room: name })); } catch { /* closing anyway */ }
|
|
852
|
+
}
|
|
853
|
+
dropSocket();
|
|
854
|
+
if (reason === 'left') _sessionListeners.delete(onSession);
|
|
855
|
+
const unsent = queue.length;
|
|
856
|
+
queue = [];
|
|
857
|
+
closedReason = reason;
|
|
858
|
+
setStatus('closed', Object.assign({ reason }, info, unsent ? { unsent } : {}));
|
|
859
|
+
};
|
|
860
|
+
const retry = (info) => {
|
|
861
|
+
const ceiling = Math.min(ROOM_RETRY_MAX_MS, ROOM_RETRY_BASE_MS * Math.pow(2, attempt));
|
|
862
|
+
const delay = Math.round(ceiling / 2 + Math.random() * ceiling / 2);
|
|
863
|
+
attempt++;
|
|
864
|
+
// Arm the timer before telling handlers, so a leave() from a status
|
|
865
|
+
// handler cancels it.
|
|
866
|
+
timer = setTimeout(connect, delay);
|
|
867
|
+
setStatus('reconnecting', Object.assign({ attempt, retryIn: delay }, info));
|
|
868
|
+
};
|
|
869
|
+
// A handshake that failed before opening: browsers hide the HTTP status,
|
|
870
|
+
// so ask /ws with a plain GET. 401/403 are the upgrade's own refusals and
|
|
871
|
+
// cannot change by retrying; 400 (it would upgrade), 429, 5xx and network
|
|
872
|
+
// errors (a restart in progress) are transient.
|
|
873
|
+
const probe = () => (typeof fetch === 'function'
|
|
874
|
+
? fetch('/ws', { credentials: 'same-origin', cache: 'no-store' }).then(r => r.status, () => 0)
|
|
875
|
+
: Promise.resolve(0));
|
|
876
|
+
|
|
877
|
+
function connect() {
|
|
878
|
+
timer = null;
|
|
879
|
+
if (state === 'closed') return;
|
|
880
|
+
const my = ++gen;
|
|
881
|
+
let openedAt = -1;
|
|
882
|
+
const sock = ws = new WebSocket(url);
|
|
883
|
+
sock.onopen = () => {
|
|
884
|
+
if (my !== gen) return;
|
|
885
|
+
openedAt = Date.now();
|
|
886
|
+
sock.send(JSON.stringify({ type: 'join', room: name }));
|
|
887
|
+
const pending = queue;
|
|
888
|
+
queue = [];
|
|
889
|
+
pending.forEach(frame => sock.send(frame));
|
|
890
|
+
const reconnected = everOpened;
|
|
891
|
+
everOpened = true;
|
|
892
|
+
setStatus('open', reconnected ? { reconnected: true } : {});
|
|
893
|
+
};
|
|
894
|
+
sock.onmessage = (ev) => {
|
|
895
|
+
if (my !== gen) return;
|
|
896
|
+
let msg;
|
|
897
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
898
|
+
if (!msg || typeof msg !== 'object') return;
|
|
899
|
+
if (msg.type === 'error') {
|
|
900
|
+
// An error frame with `tables` answers a table subscribe sent on
|
|
901
|
+
// this socket (unknown table names): it is not about the room.
|
|
902
|
+
if (Array.isArray(msg.tables)) return;
|
|
903
|
+
// Every other error frame refuses a join; on this room's own
|
|
904
|
+
// connection that refusal is final.
|
|
905
|
+
const message = typeof msg.body === 'string' && msg.body ? msg.body : 'room error';
|
|
906
|
+
const code = roomErrorCode(msg.body);
|
|
907
|
+
dispatch('bm:error', { code, message, room: msg.room || name }, null);
|
|
908
|
+
if (!msg.room || msg.room === name) finish('refused');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (msg.type !== 'message' && msg.type !== 'presence') return;
|
|
912
|
+
const payload = msg.payload || {};
|
|
913
|
+
const from = msg.from;
|
|
914
|
+
const kind = payload.kind || msg.type; // 'presence' has no kind
|
|
915
|
+
dispatch(kind, payload, from);
|
|
916
|
+
};
|
|
917
|
+
sock.onerror = () => { /* onclose follows */ };
|
|
918
|
+
sock.onclose = (ev) => {
|
|
919
|
+
if (my !== gen) return;
|
|
920
|
+
const code = ev && ev.code;
|
|
921
|
+
if (openedAt >= 0) {
|
|
922
|
+
if (Date.now() - openedAt >= ROOM_STABLE_MS) attempt = 0;
|
|
923
|
+
retry({ code });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
probe().then((status) => {
|
|
927
|
+
if (my !== gen || state === 'closed') return;
|
|
928
|
+
if (status === 401 || status === 403) {
|
|
929
|
+
const reason = status === 401 ? 'unauthorized' : 'forbidden';
|
|
930
|
+
dispatch('bm:error', { code: reason, message: `the server refused the WebSocket connection (HTTP ${status})`, room: name }, null);
|
|
931
|
+
finish(reason);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
retry(status ? { code, http: status } : { code });
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
}
|
|
749
938
|
|
|
750
|
-
|
|
939
|
+
// The socket carries the session it opened with. When the page's session
|
|
940
|
+
// changes (bm.auth sign-in/out, tenant switch, act-as), open a fresh one,
|
|
941
|
+
// like the bm.live stream; a refused room gets another try with the new
|
|
942
|
+
// identity. A room the app left stays closed.
|
|
943
|
+
function onSession() {
|
|
944
|
+
if (closedReason === 'left') return;
|
|
945
|
+
dropSocket();
|
|
946
|
+
attempt = 0;
|
|
947
|
+
closedReason = null;
|
|
948
|
+
if (everOpened) setStatus('reconnecting', { attempt: 0, retryIn: 0 });
|
|
949
|
+
else state = 'connecting';
|
|
950
|
+
connect();
|
|
951
|
+
}
|
|
952
|
+
_sessionListeners.add(onSession);
|
|
953
|
+
connect();
|
|
954
|
+
|
|
955
|
+
const client = {
|
|
751
956
|
name,
|
|
957
|
+
/** 'connecting' | 'open' | 'reconnecting' | 'closed' */
|
|
958
|
+
get state() { return state; },
|
|
959
|
+
/** The current (or last) WebSocket; it changes on reconnect. */
|
|
960
|
+
get raw() { return ws; },
|
|
752
961
|
on(kind, fn) {
|
|
753
962
|
if (!namedHandlers.has(kind)) namedHandlers.set(kind, new Set());
|
|
754
963
|
namedHandlers.get(kind).add(fn);
|
|
755
|
-
return
|
|
964
|
+
return client;
|
|
756
965
|
},
|
|
757
|
-
onAny(fn) { anyHandlers.add(fn); return
|
|
966
|
+
onAny(fn) { anyHandlers.add(fn); return client; },
|
|
758
967
|
off(kind, fn) {
|
|
759
968
|
namedHandlers.get(kind)?.delete(fn);
|
|
760
|
-
return
|
|
969
|
+
return client;
|
|
761
970
|
},
|
|
971
|
+
// true: written to the open socket; false: queued for the next join.
|
|
762
972
|
send(payload) {
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
} else {
|
|
766
|
-
queue.push(payload);
|
|
973
|
+
if (state === 'closed') {
|
|
974
|
+
throw roomError('room_closed', `bm.room('${name}') is closed (${closedReason}); call bm.room() to join again`, name);
|
|
767
975
|
}
|
|
976
|
+
const frame = JSON.stringify({ type: 'broadcast', room: name, payload });
|
|
977
|
+
if (state === 'open' && ws && ws.readyState === WebSocket.OPEN) {
|
|
978
|
+
ws.send(frame);
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
if (queue.length >= ROOM_QUEUE_MAX) {
|
|
982
|
+
throw roomError('room_queue_full', `bm.room('${name}') is not connected and already holds ${ROOM_QUEUE_MAX} unsent messages`, name);
|
|
983
|
+
}
|
|
984
|
+
queue.push(frame);
|
|
985
|
+
return false;
|
|
768
986
|
},
|
|
769
|
-
leave() {
|
|
770
|
-
if (open) ws.send(JSON.stringify({ type: 'leave', room: name }));
|
|
771
|
-
ws.close();
|
|
772
|
-
},
|
|
773
|
-
raw: ws,
|
|
987
|
+
leave() { finish('left'); },
|
|
774
988
|
};
|
|
989
|
+
return client;
|
|
775
990
|
}
|
|
776
991
|
|
|
777
992
|
// =================================================================
|
|
@@ -782,6 +997,12 @@ export function room(name) {
|
|
|
782
997
|
// looking up a runtime registry written by the type generator at
|
|
783
998
|
// /_internal/flows.json. We Proxy() the lookup so unknown names
|
|
784
999
|
// throw a clear runtime error to match the TS error.
|
|
1000
|
+
//
|
|
1001
|
+
// params fill the route's :path segments; a GET or HEAD flow takes the rest
|
|
1002
|
+
// as its query string (it has no body): bm.flows.img({ id: 7, kind: 'png' })
|
|
1003
|
+
// → GET /api/img/7?kind=png. The call resolves what responseData reads: the
|
|
1004
|
+
// parsed JSON, the text of a text/* answer (a CSV), or a Blob for any other
|
|
1005
|
+
// type (the image or PDF a run: proxy or run: serve_file flow answers).
|
|
785
1006
|
// =================================================================
|
|
786
1007
|
|
|
787
1008
|
let _flowsRegistry = null;
|
|
@@ -813,6 +1034,41 @@ function flowRegistryEntry(reg, name) {
|
|
|
813
1034
|
return own(name) || own(camelizeFlowName(name));
|
|
814
1035
|
}
|
|
815
1036
|
|
|
1037
|
+
// flowRequestPath builds the URL a flow call requests. Each `:name` path
|
|
1038
|
+
// segment takes params[name] (else query[name]) as one encoded segment (never '.' or '..').
|
|
1039
|
+
// For a GET or HEAD flow (`query` not null) the params no segment used go in
|
|
1040
|
+
// the query string, where the flow binds them, followed by the fields of
|
|
1041
|
+
// `query` (the body a caller passed; FlowForm and FlowButton pass one) that
|
|
1042
|
+
// params did not name. Objects and arrays travel as JSON text, as a JSON body
|
|
1043
|
+
// binds them; null and undefined are left out. A flow with a body passes null.
|
|
1044
|
+
function flowRequestPath(template, params, query) {
|
|
1045
|
+
const used = new Set();
|
|
1046
|
+
let path = String(template || '').split('/').map((seg) => {
|
|
1047
|
+
if (seg[0] !== ':') return seg;
|
|
1048
|
+
const name = seg.slice(1);
|
|
1049
|
+
// A GET body naming the path parameter fills it too: repeating it in the
|
|
1050
|
+
// query would conflict with the routed value (400).
|
|
1051
|
+
const own = (o) => o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, name);
|
|
1052
|
+
const src = own(params) ? params : (own(query) && query[name] != null ? query : null);
|
|
1053
|
+
if (!src) return seg;
|
|
1054
|
+
used.add(name);
|
|
1055
|
+
return _seg(src[name]);
|
|
1056
|
+
}).join('/');
|
|
1057
|
+
if (!query) return path;
|
|
1058
|
+
const pairs = [];
|
|
1059
|
+
const add = (k, v) => {
|
|
1060
|
+
if (used.has(k) || v === undefined || v === null) return;
|
|
1061
|
+
used.add(k);
|
|
1062
|
+
const text = typeof v !== 'object' ? String(v)
|
|
1063
|
+
: typeof v.toJSON === 'function' ? String(v.toJSON()) : JSON.stringify(v);
|
|
1064
|
+
pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(text));
|
|
1065
|
+
};
|
|
1066
|
+
for (const [k, v] of Object.entries(params)) add(k, v);
|
|
1067
|
+
for (const [k, v] of Object.entries(query)) add(k, v);
|
|
1068
|
+
if (pairs.length) path += (path.includes('?') ? '&' : '?') + pairs.join('&');
|
|
1069
|
+
return path;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
816
1072
|
export const flows = new Proxy({}, {
|
|
817
1073
|
get(_target, flowName) {
|
|
818
1074
|
if (typeof flowName !== 'string') return undefined;
|
|
@@ -839,13 +1095,13 @@ export const flows = new Proxy({}, {
|
|
|
839
1095
|
`(file name, camelCased) - snake/kebab spellings of the same name also resolve.`
|
|
840
1096
|
);
|
|
841
1097
|
}
|
|
842
|
-
// Substitute :path params, each one encoded segment (never '.'/'..').
|
|
843
|
-
let path = entry.path;
|
|
844
|
-
for (const [k, v] of Object.entries(params)) {
|
|
845
|
-
if (path.includes(`:${k}`)) path = path.replace(`:${k}`, _seg(v));
|
|
846
|
-
}
|
|
847
1098
|
const method = (entry.method || 'POST').toUpperCase();
|
|
848
|
-
const
|
|
1099
|
+
const noBody = method === 'GET' || method === 'HEAD';
|
|
1100
|
+
if (noBody && body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) {
|
|
1101
|
+
throw new Error(`bm.flows.${flowName} is a ${method} flow: pass its inputs as fields of params, which are sent as the query string (a ${method} request has no body).`);
|
|
1102
|
+
}
|
|
1103
|
+
const path = flowRequestPath(entry.path, params || {}, noBody ? (body || {}) : null);
|
|
1104
|
+
const res = await request(method, path, noBody ? undefined : body);
|
|
849
1105
|
if (!res.ok) throw apiError(res, `flow ${flowName} failed (${res.status})`);
|
|
850
1106
|
// Async flows return {job_id, status_url} - wrap into a Job handle.
|
|
851
1107
|
if (res.data && res.data.job_id && res.data.status_url) {
|
|
@@ -1059,8 +1315,9 @@ export async function upload(file, opts = {}) {
|
|
|
1059
1315
|
body: fd,
|
|
1060
1316
|
});
|
|
1061
1317
|
if (!res.ok) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1318
|
+
// Same Error shape as bm.api: a proxy's HTML "413 Request Entity Too
|
|
1319
|
+
// Large" page stays in err.body, not in the message.
|
|
1320
|
+
throw apiError({ status: res.status, data: await responseData(res, 'POST') }, `upload failed (HTTP ${res.status})`);
|
|
1064
1321
|
}
|
|
1065
1322
|
return res.json();
|
|
1066
1323
|
}
|
|
@@ -1358,7 +1615,7 @@ export const broadcast = {
|
|
|
1358
1615
|
// Keep the slug claimed while live: a publisher that stops
|
|
1359
1616
|
// heartbeating is treated as crashed and its slug can be taken over.
|
|
1360
1617
|
const heartbeat = setInterval(() => {
|
|
1361
|
-
if (!stopped) request('POST', '/api/_broadcast/heartbeat', { slug }).catch(() => {});
|
|
1618
|
+
if (!stopped) request('POST', '/api/_broadcast/heartbeat', { slug }, { background: true }).catch(() => {});
|
|
1362
1619
|
}, 60000);
|
|
1363
1620
|
return {
|
|
1364
1621
|
slug,
|
|
@@ -1473,7 +1730,9 @@ export const mfa = {
|
|
|
1473
1730
|
// setup token path (require_mfa_for_roles) needs none.
|
|
1474
1731
|
enroll: (password) => api.post('/api/_auth/mfa/setup', password ? { password } : {}),
|
|
1475
1732
|
verify: (code) => api.post('/api/_auth/mfa/verify', { code }),
|
|
1476
|
-
|
|
1733
|
+
// Turning the factor off takes a current authenticator code (not the
|
|
1734
|
+
// password, and not a backup code): the endpoint reads { code }.
|
|
1735
|
+
disable: (code) => api.post('/api/_auth/mfa/disable', { code }),
|
|
1477
1736
|
};
|
|
1478
1737
|
|
|
1479
1738
|
// =================================================================
|
|
@@ -1686,7 +1945,7 @@ function presence(slug) {
|
|
|
1686
1945
|
let heartbeatTimer = null;
|
|
1687
1946
|
let onChangeUnsub = null;
|
|
1688
1947
|
|
|
1689
|
-
const post = (action) => api.post('/api/_presence/heartbeat', { slug, action }).catch(() => {});
|
|
1948
|
+
const post = (action) => api.post('/api/_presence/heartbeat', { slug, action }, action === 'heartbeat' ? { background: true } : undefined).catch(() => {});
|
|
1690
1949
|
const sendBeaconLeave = () => {
|
|
1691
1950
|
// Beacon survives tab close - fetch with keepalive is the right
|
|
1692
1951
|
// primitive but sendBeacon is the only one with universal support.
|
|
@@ -1995,6 +2254,26 @@ function rememberTableKey(tableName, key) {
|
|
|
1995
2254
|
_queryTableKeys.get(tableName).add(k);
|
|
1996
2255
|
}
|
|
1997
2256
|
|
|
2257
|
+
// The key a table read is cached under, and the request it sends. One
|
|
2258
|
+
// function answers both bm.query.table and bm.query.tableKey, so a subscriber
|
|
2259
|
+
// to tableKey(name, opts) reads the entry table(name, opts) writes.
|
|
2260
|
+
function defaultReadKey(spec) {
|
|
2261
|
+
return ['query', spec];
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function tableRead(tableName, opts) {
|
|
2265
|
+
// Separate client control options (which never affect the server result)
|
|
2266
|
+
// from the server-affecting query spec. Only the spec may influence the
|
|
2267
|
+
// cache key, otherwise two identical reads that differ only in staleTime/
|
|
2268
|
+
// force/live would fragment into distinct cache entries and defeat dedupe.
|
|
2269
|
+
const { staleTime, staleMs, force, live, key, ...serverOpts } = opts || {};
|
|
2270
|
+
const spec = { ...serverOpts, table: tableName };
|
|
2271
|
+
// The default is read()'s ['query', spec] key shape, so that a
|
|
2272
|
+
// bm.query.table('posts') and bm.query.read({ table: 'posts' }) targeting
|
|
2273
|
+
// identical rows collapse onto one shared cache entry.
|
|
2274
|
+
return { spec, key: key || defaultReadKey(spec), control: { staleTime, staleMs, force, live } };
|
|
2275
|
+
}
|
|
2276
|
+
|
|
1998
2277
|
function ensureQueryLiveInvalidation() {
|
|
1999
2278
|
if (_queryLiveStop || typeof live !== 'function' || typeof EventSource === 'undefined') return;
|
|
2000
2279
|
const stop = live('*', (ev) => {
|
|
@@ -2010,7 +2289,7 @@ function ensureQueryLiveInvalidation() {
|
|
|
2010
2289
|
}
|
|
2011
2290
|
const t = setTimeout(() => {
|
|
2012
2291
|
_queryLiveTimers.delete(table);
|
|
2013
|
-
const opts = { refetch: _queryLiveConfig.refetch };
|
|
2292
|
+
const opts = { refetch: _queryLiveConfig.refetch, background: true };
|
|
2014
2293
|
if (table) query.invalidateTable(table, opts);
|
|
2015
2294
|
else {
|
|
2016
2295
|
const keys = new Set([..._queryTableKeys.values()].flatMap(keys => [...keys]));
|
|
@@ -2059,7 +2338,7 @@ export const query = {
|
|
|
2059
2338
|
e.staleTime = staleTime;
|
|
2060
2339
|
const revision = e.revision;
|
|
2061
2340
|
const pending = Promise.resolve()
|
|
2062
|
-
.then(fetcher)
|
|
2341
|
+
.then(opts.background ? () => inBackground(fetcher) : fetcher)
|
|
2063
2342
|
.then((data) => {
|
|
2064
2343
|
if (e.revision === revision) {
|
|
2065
2344
|
e.revision++;
|
|
@@ -2087,10 +2366,10 @@ export const query = {
|
|
|
2087
2366
|
notifyQuery(e);
|
|
2088
2367
|
return pending;
|
|
2089
2368
|
},
|
|
2090
|
-
async refetch(key) {
|
|
2369
|
+
async refetch(key, opts = {}) {
|
|
2091
2370
|
const e = queryEntry(key);
|
|
2092
2371
|
if (!e.fetcher) return e.data;
|
|
2093
|
-
return query.fetch(e.rawKey, e.fetcher, { force: true, staleTime: e.staleTime });
|
|
2372
|
+
return query.fetch(e.rawKey, e.fetcher, { force: true, staleTime: e.staleTime, background: !!opts.background });
|
|
2094
2373
|
},
|
|
2095
2374
|
invalidate(match, opts = {}) {
|
|
2096
2375
|
const keys = [];
|
|
@@ -2099,7 +2378,7 @@ export const query = {
|
|
|
2099
2378
|
// We deliberately avoid raw string startsWith on the stable-stringified key
|
|
2100
2379
|
// because that produced false positives (e.g. {a:1} matching {a:1,b:2}, or
|
|
2101
2380
|
// ['user'] matching ['username']). Array-prefix is predictable for the
|
|
2102
|
-
//
|
|
2381
|
+
// ['query', spec] key shape used here and for an app's own array keys.
|
|
2103
2382
|
const matchExact = typeof match !== 'function' ? queryKey(match) : null;
|
|
2104
2383
|
const matchPrefix = (typeof match !== 'function' && Array.isArray(match)) ? match : null;
|
|
2105
2384
|
const isArrayPrefix = (prefix, raw) => {
|
|
@@ -2119,16 +2398,19 @@ export const query = {
|
|
|
2119
2398
|
e.updatedAt = 0;
|
|
2120
2399
|
keys.push(stable);
|
|
2121
2400
|
notifyQuery(e);
|
|
2122
|
-
if (opts.refetch && e.fetcher) query.refetch(e.rawKey).catch(() => {});
|
|
2401
|
+
if (opts.refetch && e.fetcher) query.refetch(e.rawKey, { background: !!opts.background }).catch(() => {});
|
|
2123
2402
|
}
|
|
2124
2403
|
return keys;
|
|
2125
2404
|
},
|
|
2126
|
-
|
|
2127
|
-
|
|
2405
|
+
// The key bm.query.table(tableName, opts) caches under, for subscribe/get/
|
|
2406
|
+
// set/invalidate/mutate: opts.key when the caller names one, otherwise the
|
|
2407
|
+
// key derived from the request.
|
|
2408
|
+
tableKey(tableName, opts = {}) {
|
|
2409
|
+
return tableRead(tableName, opts).key;
|
|
2128
2410
|
},
|
|
2129
2411
|
async read(spec, opts = {}) {
|
|
2130
2412
|
if (!spec || !spec.table) throw new Error('bm.query.read({ table, ... }) requires table');
|
|
2131
|
-
const key = opts.key ||
|
|
2413
|
+
const key = opts.key || defaultReadKey(spec);
|
|
2132
2414
|
if (opts.live !== false) {
|
|
2133
2415
|
rememberTableKey(spec.table, key);
|
|
2134
2416
|
ensureQueryLiveInvalidation();
|
|
@@ -2139,17 +2421,10 @@ export const query = {
|
|
|
2139
2421
|
}, opts);
|
|
2140
2422
|
},
|
|
2141
2423
|
table(tableName, opts = {}) {
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
const { staleTime, staleMs, force, live, key, ...serverOpts } = opts;
|
|
2147
|
-
const spec = { ...serverOpts, table: tableName };
|
|
2148
|
-
// Route through read() with the same ['query', spec] key shape so that a
|
|
2149
|
-
// bm.query.table('posts') and bm.query.read({ table: 'posts' }) targeting
|
|
2150
|
-
// identical rows collapse onto one shared cache entry. Forward every client
|
|
2151
|
-
// control option (staleTime/staleMs/force/live) so none are silently dropped.
|
|
2152
|
-
return query.read(spec, { key: key, staleTime, staleMs, force, live });
|
|
2424
|
+
// Forward every client control option (staleTime/staleMs/force/live) so
|
|
2425
|
+
// none are silently dropped.
|
|
2426
|
+
const { spec, key, control } = tableRead(tableName, opts);
|
|
2427
|
+
return query.read(spec, { ...control, key });
|
|
2153
2428
|
},
|
|
2154
2429
|
invalidateTable(tableName, opts = {}) {
|
|
2155
2430
|
const keys = _queryTableKeys.get(tableName);
|
|
@@ -2233,7 +2508,7 @@ const bm = {
|
|
|
2233
2508
|
flows, workflow, aggregate, aggregates, jobs,
|
|
2234
2509
|
notifications, upload, audit, permissions, signedUrl,
|
|
2235
2510
|
t, mfa, webrtc, broadcast, markdown, markdownBatch, presence, cache,
|
|
2236
|
-
createStore, query, html, raw,
|
|
2511
|
+
createStore, query, html, raw, captureError, captureMessage, setTag,
|
|
2237
2512
|
};
|
|
2238
2513
|
export default bm;
|
|
2239
2514
|
|
|
@@ -2241,55 +2516,184 @@ export default bm;
|
|
|
2241
2516
|
if (typeof window !== 'undefined') window.bm = bm;
|
|
2242
2517
|
|
|
2243
2518
|
// =================================================================
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
2246
|
-
// Installs window.onerror + unhandledrejection (the two ways JS
|
|
2247
|
-
// surfaces an uncaught failure - the 404-from-a-fetch case is an
|
|
2248
|
-
// unhandled rejection, so BOTH are required). Throttled + deduped so
|
|
2249
|
-
// an error loop can't spam the endpoint.
|
|
2519
|
+
// Same-origin error reporting. Context stays bounded and excludes URL
|
|
2520
|
+
// queries and form contents; the app scrubs it again before export.
|
|
2250
2521
|
// =================================================================
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2522
|
+
const errorTags = Object.create(null),
|
|
2523
|
+
errorCrumbs = [],
|
|
2524
|
+
errorSent = [];
|
|
2525
|
+
const errorTagRE = /^[a-z0-9_.]{1,32}$/,
|
|
2526
|
+
errorRequestIDRE = /^[0-9a-f]{8,32}$/;
|
|
2527
|
+
|
|
2528
|
+
function validErrorTags(input) {
|
|
2529
|
+
const out = Object.create(null);
|
|
2530
|
+
if (!input || typeof input !== 'object') return out;
|
|
2531
|
+
for (const [key, value] of Object.entries(input)) {
|
|
2532
|
+
if (Object.keys(out).length >= 10) break;
|
|
2533
|
+
if (errorTagRE.test(key) && typeof value === 'string' && Array.from(value).length <= 200) out[key] = value;
|
|
2534
|
+
}
|
|
2535
|
+
return out;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
export function setTag(key, value) {
|
|
2539
|
+
if (typeof key !== 'string' || !errorTagRE.test(key) || typeof value !== 'string' || Array.from(value).length > 200) return;
|
|
2540
|
+
if (!Object.hasOwn(errorTags, key) && Object.keys(errorTags).length >= 10) return;
|
|
2541
|
+
errorTags[key] = value;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function errorPath(value) {
|
|
2545
|
+
try {
|
|
2546
|
+
return new URL(value, typeof location === 'undefined' ? 'https://invalid.example' : location.href).pathname;
|
|
2547
|
+
} catch {
|
|
2548
|
+
return '';
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function addErrorCrumb(type, message, data) {
|
|
2553
|
+
errorCrumbs.push({
|
|
2554
|
+
timestamp: new Date(Date.now()).toISOString(),
|
|
2555
|
+
type,
|
|
2556
|
+
message: String(message).slice(0, 300),
|
|
2557
|
+
...(data ? { data } : {}),
|
|
2558
|
+
});
|
|
2559
|
+
if (errorCrumbs.length > 30) errorCrumbs.shift();
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
function postError(payload) {
|
|
2563
|
+
if (typeof fetch !== 'function') return;
|
|
2564
|
+
try {
|
|
2565
|
+
const now = Date.now();
|
|
2566
|
+
while (errorSent.length && errorSent[0].time <= now - 600000) errorSent.shift();
|
|
2567
|
+
const top = (payload.stack || '').split('\n').find((line) => /^\s*at\s/.test(line) || /@.*:\d+/.test(line)) || payload.source || '';
|
|
2568
|
+
// A local warning must not hide a later error/fatal occurrence.
|
|
2569
|
+
const key = (payload.level || 'error') + '|' + payload.message + '|' + top;
|
|
2570
|
+
if (errorSent.length >= 25 || errorSent.some((item) => item.key === key)) return;
|
|
2571
|
+
errorSent.push({ time: now, key });
|
|
2572
|
+
const body = {
|
|
2573
|
+
...payload,
|
|
2574
|
+
url: typeof location === 'undefined' ? '' : errorPath(location.href),
|
|
2575
|
+
tags: validErrorTags({ ...payload.tags, ...errorTags }),
|
|
2576
|
+
breadcrumbs: errorCrumbs.slice(),
|
|
2577
|
+
};
|
|
2578
|
+
Promise.resolve(
|
|
2260
2579
|
fetch('/api/_errors', {
|
|
2261
2580
|
method: 'POST',
|
|
2262
|
-
|
|
2263
|
-
|
|
2581
|
+
// A report is not activity: a background refetch that keeps failing
|
|
2582
|
+
// must not hold an idle session open (auth.idle_timeout).
|
|
2583
|
+
headers: { 'Content-Type': 'application/json', 'X-Benmore-Background': '1' },
|
|
2584
|
+
body: JSON.stringify(body),
|
|
2264
2585
|
keepalive: true,
|
|
2265
2586
|
credentials: 'same-origin',
|
|
2266
|
-
})
|
|
2267
|
-
|
|
2587
|
+
}),
|
|
2588
|
+
).catch(() => {});
|
|
2589
|
+
} catch (_) {} // Reporting must never create another failure.
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function errorPayload(err) {
|
|
2593
|
+
return {
|
|
2594
|
+
message: String(err?.message || (typeof err === 'string' ? err : 'Unknown error')).slice(0, 2000),
|
|
2595
|
+
stack: typeof err?.stack === 'string' ? err.stack.slice(0, 8000) : '',
|
|
2596
|
+
...(typeof err?.status === 'number' ? { status: err.status } : {}),
|
|
2597
|
+
...(errorRequestIDRE.test(err?.body?.request_id || '') ? { request_id: err.body.request_id } : {}),
|
|
2268
2598
|
};
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
export function captureError(err, options = {}) {
|
|
2602
|
+
try {
|
|
2603
|
+
postError({ ...errorPayload(err), kind: 'manual', level: 'error', tags: options?.tags });
|
|
2604
|
+
} catch (_) {}
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
export function captureMessage(message, level = 'error') {
|
|
2608
|
+
try {
|
|
2609
|
+
postError({
|
|
2610
|
+
kind: 'message',
|
|
2611
|
+
message: String(message).slice(0, 2000),
|
|
2612
|
+
level: ['warning', 'error', 'fatal'].includes(level) ? level : 'error',
|
|
2613
|
+
});
|
|
2614
|
+
} catch (_) {}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function captureRequest(method, path, status, started, requestID, err) {
|
|
2618
|
+
try {
|
|
2619
|
+
if (typeof location === 'undefined') return;
|
|
2620
|
+
const url = new URL(path, location.href);
|
|
2621
|
+
if (url.origin !== location.origin || url.pathname === '/api/_errors') return;
|
|
2622
|
+
const cleanPath = url.pathname;
|
|
2623
|
+
addErrorCrumb('http', method + ' ' + cleanPath, {
|
|
2624
|
+
method,
|
|
2625
|
+
path: cleanPath.slice(0, 300),
|
|
2626
|
+
status,
|
|
2627
|
+
ms: Math.max(0, Date.now() - started),
|
|
2628
|
+
});
|
|
2629
|
+
if (status >= 500 || err)
|
|
2630
|
+
postError({
|
|
2631
|
+
kind: 'http',
|
|
2632
|
+
level: 'error',
|
|
2633
|
+
message: method + ' ' + cleanPath + (err ? ' network failure' : ' ' + status),
|
|
2634
|
+
method,
|
|
2635
|
+
path: cleanPath,
|
|
2636
|
+
status,
|
|
2637
|
+
...(errorRequestIDRE.test(requestID || '') ? { request_id: requestID } : {}),
|
|
2638
|
+
});
|
|
2639
|
+
} catch (_) {}
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
if (typeof window !== 'undefined') {
|
|
2269
2643
|
window.addEventListener('error', (e) => {
|
|
2270
2644
|
// Resource-load errors (img/script 404s) have no .message - skip them.
|
|
2271
2645
|
if (!e || !e.message) return;
|
|
2272
|
-
|
|
2646
|
+
postError({
|
|
2273
2647
|
kind: 'error',
|
|
2274
2648
|
message: String(e.message).slice(0, 2000),
|
|
2275
2649
|
stack: e.error && e.error.stack ? String(e.error.stack).slice(0, 8000) : '',
|
|
2276
2650
|
source: e.filename || '',
|
|
2277
2651
|
line: e.lineno || 0,
|
|
2278
2652
|
col: e.colno || 0,
|
|
2279
|
-
url: location.href,
|
|
2280
2653
|
});
|
|
2281
2654
|
});
|
|
2282
2655
|
window.addEventListener('unhandledrejection', (e) => {
|
|
2283
2656
|
const r = e && e.reason;
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
message: String(msg).slice(0, 2000),
|
|
2288
|
-
stack: r && r.stack ? String(r.stack).slice(0, 8000) : '',
|
|
2289
|
-
source: '',
|
|
2290
|
-
line: 0,
|
|
2291
|
-
col: 0,
|
|
2292
|
-
url: location.href,
|
|
2293
|
-
});
|
|
2657
|
+
try {
|
|
2658
|
+
postError({ ...errorPayload(r), kind: 'unhandledrejection' });
|
|
2659
|
+
} catch (_) {}
|
|
2294
2660
|
});
|
|
2661
|
+
for (const name of ['pushState', 'replaceState']) {
|
|
2662
|
+
const original = window.history?.[name];
|
|
2663
|
+
if (typeof original !== 'function') continue;
|
|
2664
|
+
window.history[name] = function (...args) {
|
|
2665
|
+
const result = original.apply(this, args);
|
|
2666
|
+
addErrorCrumb('navigation', errorPath(window.location.href));
|
|
2667
|
+
return result;
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
window.addEventListener('popstate', () => addErrorCrumb('navigation', errorPath(window.location.href)));
|
|
2671
|
+
for (const name of ['error', 'warn']) {
|
|
2672
|
+
const original = console[name];
|
|
2673
|
+
console[name] = function (...args) {
|
|
2674
|
+
try {
|
|
2675
|
+
const message = args
|
|
2676
|
+
.map((arg) => {
|
|
2677
|
+
try {
|
|
2678
|
+
return typeof arg === 'string' ? arg : JSON.stringify(arg);
|
|
2679
|
+
} catch {
|
|
2680
|
+
return '[unserializable]';
|
|
2681
|
+
}
|
|
2682
|
+
})
|
|
2683
|
+
.join(' ')
|
|
2684
|
+
.slice(0, 300);
|
|
2685
|
+
addErrorCrumb('console', message, { level: name });
|
|
2686
|
+
} catch (_) {}
|
|
2687
|
+
return original.apply(this, args);
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
window.document?.addEventListener(
|
|
2691
|
+
'click',
|
|
2692
|
+
(e) => {
|
|
2693
|
+
const target = e.target;
|
|
2694
|
+
if (!target || typeof target.getAttribute !== 'function') return;
|
|
2695
|
+
addErrorCrumb('click', target.getAttribute('data-testid') || String(target.tagName || '') + (target.id ? '#' + target.id : ''));
|
|
2696
|
+
},
|
|
2697
|
+
true,
|
|
2698
|
+
);
|
|
2295
2699
|
}
|