@benmore/bm 2.7.317 → 2.7.329
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 +72 -5
- package/index.js +482 -97
- 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;
|
|
@@ -389,7 +449,14 @@ declare const cache: {
|
|
|
389
449
|
persistent(name: string, version: string | number): CacheClient | null;
|
|
390
450
|
};
|
|
391
451
|
|
|
452
|
+
export function captureError(err: unknown, options?: { tags?: Record<string, string> }): void;
|
|
453
|
+
export function captureMessage(message: string, level?: 'warning' | 'error' | 'fatal'): void;
|
|
454
|
+
export function setTag(key: string, value: string): void;
|
|
455
|
+
|
|
392
456
|
declare const bm: {
|
|
457
|
+
captureError: typeof captureError;
|
|
458
|
+
captureMessage: typeof captureMessage;
|
|
459
|
+
setTag: typeof setTag;
|
|
393
460
|
api: typeof api;
|
|
394
461
|
auth: typeof auth;
|
|
395
462
|
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,213 @@ 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);
|
|
748
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 */ } }
|
|
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
|
+
// The server's only error frames refuse a join; on this room's own
|
|
901
|
+
// connection that refusal is final.
|
|
902
|
+
const message = typeof msg.body === 'string' && msg.body ? msg.body : 'room error';
|
|
903
|
+
const code = roomErrorCode(msg.body);
|
|
904
|
+
dispatch('bm:error', { code, message, room: msg.room || name }, null);
|
|
905
|
+
if (!msg.room || msg.room === name) finish('refused');
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (msg.type !== 'message' && msg.type !== 'presence') return;
|
|
909
|
+
const payload = msg.payload || {};
|
|
910
|
+
const from = msg.from;
|
|
911
|
+
const kind = payload.kind || msg.type; // 'presence' has no kind
|
|
912
|
+
dispatch(kind, payload, from);
|
|
913
|
+
};
|
|
914
|
+
sock.onerror = () => { /* onclose follows */ };
|
|
915
|
+
sock.onclose = (ev) => {
|
|
916
|
+
if (my !== gen) return;
|
|
917
|
+
const code = ev && ev.code;
|
|
918
|
+
if (openedAt >= 0) {
|
|
919
|
+
if (Date.now() - openedAt >= ROOM_STABLE_MS) attempt = 0;
|
|
920
|
+
retry({ code });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
probe().then((status) => {
|
|
924
|
+
if (my !== gen || state === 'closed') return;
|
|
925
|
+
if (status === 401 || status === 403) {
|
|
926
|
+
const reason = status === 401 ? 'unauthorized' : 'forbidden';
|
|
927
|
+
dispatch('bm:error', { code: reason, message: `the server refused the WebSocket connection (HTTP ${status})`, room: name }, null);
|
|
928
|
+
finish(reason);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
retry(status ? { code, http: status } : { code });
|
|
932
|
+
});
|
|
933
|
+
};
|
|
934
|
+
}
|
|
749
935
|
|
|
750
|
-
|
|
936
|
+
// The socket carries the session it opened with. When the page's session
|
|
937
|
+
// changes (bm.auth sign-in/out, tenant switch, act-as), open a fresh one,
|
|
938
|
+
// like the bm.live stream; a refused room gets another try with the new
|
|
939
|
+
// identity. A room the app left stays closed.
|
|
940
|
+
function onSession() {
|
|
941
|
+
if (closedReason === 'left') return;
|
|
942
|
+
dropSocket();
|
|
943
|
+
attempt = 0;
|
|
944
|
+
closedReason = null;
|
|
945
|
+
if (everOpened) setStatus('reconnecting', { attempt: 0, retryIn: 0 });
|
|
946
|
+
else state = 'connecting';
|
|
947
|
+
connect();
|
|
948
|
+
}
|
|
949
|
+
_sessionListeners.add(onSession);
|
|
950
|
+
connect();
|
|
951
|
+
|
|
952
|
+
const client = {
|
|
751
953
|
name,
|
|
954
|
+
/** 'connecting' | 'open' | 'reconnecting' | 'closed' */
|
|
955
|
+
get state() { return state; },
|
|
956
|
+
/** The current (or last) WebSocket; it changes on reconnect. */
|
|
957
|
+
get raw() { return ws; },
|
|
752
958
|
on(kind, fn) {
|
|
753
959
|
if (!namedHandlers.has(kind)) namedHandlers.set(kind, new Set());
|
|
754
960
|
namedHandlers.get(kind).add(fn);
|
|
755
|
-
return
|
|
961
|
+
return client;
|
|
756
962
|
},
|
|
757
|
-
onAny(fn) { anyHandlers.add(fn); return
|
|
963
|
+
onAny(fn) { anyHandlers.add(fn); return client; },
|
|
758
964
|
off(kind, fn) {
|
|
759
965
|
namedHandlers.get(kind)?.delete(fn);
|
|
760
|
-
return
|
|
966
|
+
return client;
|
|
761
967
|
},
|
|
968
|
+
// true: written to the open socket; false: queued for the next join.
|
|
762
969
|
send(payload) {
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
} else {
|
|
766
|
-
queue.push(payload);
|
|
970
|
+
if (state === 'closed') {
|
|
971
|
+
throw roomError('room_closed', `bm.room('${name}') is closed (${closedReason}); call bm.room() to join again`, name);
|
|
767
972
|
}
|
|
973
|
+
const frame = JSON.stringify({ type: 'broadcast', room: name, payload });
|
|
974
|
+
if (state === 'open' && ws && ws.readyState === WebSocket.OPEN) {
|
|
975
|
+
ws.send(frame);
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
if (queue.length >= ROOM_QUEUE_MAX) {
|
|
979
|
+
throw roomError('room_queue_full', `bm.room('${name}') is not connected and already holds ${ROOM_QUEUE_MAX} unsent messages`, name);
|
|
980
|
+
}
|
|
981
|
+
queue.push(frame);
|
|
982
|
+
return false;
|
|
768
983
|
},
|
|
769
|
-
leave() {
|
|
770
|
-
if (open) ws.send(JSON.stringify({ type: 'leave', room: name }));
|
|
771
|
-
ws.close();
|
|
772
|
-
},
|
|
773
|
-
raw: ws,
|
|
984
|
+
leave() { finish('left'); },
|
|
774
985
|
};
|
|
986
|
+
return client;
|
|
775
987
|
}
|
|
776
988
|
|
|
777
989
|
// =================================================================
|
|
@@ -782,6 +994,12 @@ export function room(name) {
|
|
|
782
994
|
// looking up a runtime registry written by the type generator at
|
|
783
995
|
// /_internal/flows.json. We Proxy() the lookup so unknown names
|
|
784
996
|
// throw a clear runtime error to match the TS error.
|
|
997
|
+
//
|
|
998
|
+
// params fill the route's :path segments; a GET or HEAD flow takes the rest
|
|
999
|
+
// as its query string (it has no body): bm.flows.img({ id: 7, kind: 'png' })
|
|
1000
|
+
// → GET /api/img/7?kind=png. The call resolves what responseData reads: the
|
|
1001
|
+
// parsed JSON, the text of a text/* answer (a CSV), or a Blob for any other
|
|
1002
|
+
// type (the image or PDF a run: proxy or run: serve_file flow answers).
|
|
785
1003
|
// =================================================================
|
|
786
1004
|
|
|
787
1005
|
let _flowsRegistry = null;
|
|
@@ -813,6 +1031,41 @@ function flowRegistryEntry(reg, name) {
|
|
|
813
1031
|
return own(name) || own(camelizeFlowName(name));
|
|
814
1032
|
}
|
|
815
1033
|
|
|
1034
|
+
// flowRequestPath builds the URL a flow call requests. Each `:name` path
|
|
1035
|
+
// segment takes params[name] (else query[name]) as one encoded segment (never '.' or '..').
|
|
1036
|
+
// For a GET or HEAD flow (`query` not null) the params no segment used go in
|
|
1037
|
+
// the query string, where the flow binds them, followed by the fields of
|
|
1038
|
+
// `query` (the body a caller passed; FlowForm and FlowButton pass one) that
|
|
1039
|
+
// params did not name. Objects and arrays travel as JSON text, as a JSON body
|
|
1040
|
+
// binds them; null and undefined are left out. A flow with a body passes null.
|
|
1041
|
+
function flowRequestPath(template, params, query) {
|
|
1042
|
+
const used = new Set();
|
|
1043
|
+
let path = String(template || '').split('/').map((seg) => {
|
|
1044
|
+
if (seg[0] !== ':') return seg;
|
|
1045
|
+
const name = seg.slice(1);
|
|
1046
|
+
// A GET body naming the path parameter fills it too: repeating it in the
|
|
1047
|
+
// query would conflict with the routed value (400).
|
|
1048
|
+
const own = (o) => o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, name);
|
|
1049
|
+
const src = own(params) ? params : (own(query) && query[name] != null ? query : null);
|
|
1050
|
+
if (!src) return seg;
|
|
1051
|
+
used.add(name);
|
|
1052
|
+
return _seg(src[name]);
|
|
1053
|
+
}).join('/');
|
|
1054
|
+
if (!query) return path;
|
|
1055
|
+
const pairs = [];
|
|
1056
|
+
const add = (k, v) => {
|
|
1057
|
+
if (used.has(k) || v === undefined || v === null) return;
|
|
1058
|
+
used.add(k);
|
|
1059
|
+
const text = typeof v !== 'object' ? String(v)
|
|
1060
|
+
: typeof v.toJSON === 'function' ? String(v.toJSON()) : JSON.stringify(v);
|
|
1061
|
+
pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(text));
|
|
1062
|
+
};
|
|
1063
|
+
for (const [k, v] of Object.entries(params)) add(k, v);
|
|
1064
|
+
for (const [k, v] of Object.entries(query)) add(k, v);
|
|
1065
|
+
if (pairs.length) path += (path.includes('?') ? '&' : '?') + pairs.join('&');
|
|
1066
|
+
return path;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
816
1069
|
export const flows = new Proxy({}, {
|
|
817
1070
|
get(_target, flowName) {
|
|
818
1071
|
if (typeof flowName !== 'string') return undefined;
|
|
@@ -839,13 +1092,13 @@ export const flows = new Proxy({}, {
|
|
|
839
1092
|
`(file name, camelCased) - snake/kebab spellings of the same name also resolve.`
|
|
840
1093
|
);
|
|
841
1094
|
}
|
|
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
1095
|
const method = (entry.method || 'POST').toUpperCase();
|
|
848
|
-
const
|
|
1096
|
+
const noBody = method === 'GET' || method === 'HEAD';
|
|
1097
|
+
if (noBody && body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) {
|
|
1098
|
+
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).`);
|
|
1099
|
+
}
|
|
1100
|
+
const path = flowRequestPath(entry.path, params || {}, noBody ? (body || {}) : null);
|
|
1101
|
+
const res = await request(method, path, noBody ? undefined : body);
|
|
849
1102
|
if (!res.ok) throw apiError(res, `flow ${flowName} failed (${res.status})`);
|
|
850
1103
|
// Async flows return {job_id, status_url} - wrap into a Job handle.
|
|
851
1104
|
if (res.data && res.data.job_id && res.data.status_url) {
|
|
@@ -1059,8 +1312,9 @@ export async function upload(file, opts = {}) {
|
|
|
1059
1312
|
body: fd,
|
|
1060
1313
|
});
|
|
1061
1314
|
if (!res.ok) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1315
|
+
// Same Error shape as bm.api: a proxy's HTML "413 Request Entity Too
|
|
1316
|
+
// Large" page stays in err.body, not in the message.
|
|
1317
|
+
throw apiError({ status: res.status, data: await responseData(res, 'POST') }, `upload failed (HTTP ${res.status})`);
|
|
1064
1318
|
}
|
|
1065
1319
|
return res.json();
|
|
1066
1320
|
}
|
|
@@ -1358,7 +1612,7 @@ export const broadcast = {
|
|
|
1358
1612
|
// Keep the slug claimed while live: a publisher that stops
|
|
1359
1613
|
// heartbeating is treated as crashed and its slug can be taken over.
|
|
1360
1614
|
const heartbeat = setInterval(() => {
|
|
1361
|
-
if (!stopped) request('POST', '/api/_broadcast/heartbeat', { slug }).catch(() => {});
|
|
1615
|
+
if (!stopped) request('POST', '/api/_broadcast/heartbeat', { slug }, { background: true }).catch(() => {});
|
|
1362
1616
|
}, 60000);
|
|
1363
1617
|
return {
|
|
1364
1618
|
slug,
|
|
@@ -1473,7 +1727,9 @@ export const mfa = {
|
|
|
1473
1727
|
// setup token path (require_mfa_for_roles) needs none.
|
|
1474
1728
|
enroll: (password) => api.post('/api/_auth/mfa/setup', password ? { password } : {}),
|
|
1475
1729
|
verify: (code) => api.post('/api/_auth/mfa/verify', { code }),
|
|
1476
|
-
|
|
1730
|
+
// Turning the factor off takes a current authenticator code (not the
|
|
1731
|
+
// password, and not a backup code): the endpoint reads { code }.
|
|
1732
|
+
disable: (code) => api.post('/api/_auth/mfa/disable', { code }),
|
|
1477
1733
|
};
|
|
1478
1734
|
|
|
1479
1735
|
// =================================================================
|
|
@@ -1686,7 +1942,7 @@ function presence(slug) {
|
|
|
1686
1942
|
let heartbeatTimer = null;
|
|
1687
1943
|
let onChangeUnsub = null;
|
|
1688
1944
|
|
|
1689
|
-
const post = (action) => api.post('/api/_presence/heartbeat', { slug, action }).catch(() => {});
|
|
1945
|
+
const post = (action) => api.post('/api/_presence/heartbeat', { slug, action }, action === 'heartbeat' ? { background: true } : undefined).catch(() => {});
|
|
1690
1946
|
const sendBeaconLeave = () => {
|
|
1691
1947
|
// Beacon survives tab close - fetch with keepalive is the right
|
|
1692
1948
|
// primitive but sendBeacon is the only one with universal support.
|
|
@@ -2010,7 +2266,7 @@ function ensureQueryLiveInvalidation() {
|
|
|
2010
2266
|
}
|
|
2011
2267
|
const t = setTimeout(() => {
|
|
2012
2268
|
_queryLiveTimers.delete(table);
|
|
2013
|
-
const opts = { refetch: _queryLiveConfig.refetch };
|
|
2269
|
+
const opts = { refetch: _queryLiveConfig.refetch, background: true };
|
|
2014
2270
|
if (table) query.invalidateTable(table, opts);
|
|
2015
2271
|
else {
|
|
2016
2272
|
const keys = new Set([..._queryTableKeys.values()].flatMap(keys => [...keys]));
|
|
@@ -2059,7 +2315,7 @@ export const query = {
|
|
|
2059
2315
|
e.staleTime = staleTime;
|
|
2060
2316
|
const revision = e.revision;
|
|
2061
2317
|
const pending = Promise.resolve()
|
|
2062
|
-
.then(fetcher)
|
|
2318
|
+
.then(opts.background ? () => inBackground(fetcher) : fetcher)
|
|
2063
2319
|
.then((data) => {
|
|
2064
2320
|
if (e.revision === revision) {
|
|
2065
2321
|
e.revision++;
|
|
@@ -2087,10 +2343,10 @@ export const query = {
|
|
|
2087
2343
|
notifyQuery(e);
|
|
2088
2344
|
return pending;
|
|
2089
2345
|
},
|
|
2090
|
-
async refetch(key) {
|
|
2346
|
+
async refetch(key, opts = {}) {
|
|
2091
2347
|
const e = queryEntry(key);
|
|
2092
2348
|
if (!e.fetcher) return e.data;
|
|
2093
|
-
return query.fetch(e.rawKey, e.fetcher, { force: true, staleTime: e.staleTime });
|
|
2349
|
+
return query.fetch(e.rawKey, e.fetcher, { force: true, staleTime: e.staleTime, background: !!opts.background });
|
|
2094
2350
|
},
|
|
2095
2351
|
invalidate(match, opts = {}) {
|
|
2096
2352
|
const keys = [];
|
|
@@ -2119,7 +2375,7 @@ export const query = {
|
|
|
2119
2375
|
e.updatedAt = 0;
|
|
2120
2376
|
keys.push(stable);
|
|
2121
2377
|
notifyQuery(e);
|
|
2122
|
-
if (opts.refetch && e.fetcher) query.refetch(e.rawKey).catch(() => {});
|
|
2378
|
+
if (opts.refetch && e.fetcher) query.refetch(e.rawKey, { background: !!opts.background }).catch(() => {});
|
|
2123
2379
|
}
|
|
2124
2380
|
return keys;
|
|
2125
2381
|
},
|
|
@@ -2233,7 +2489,7 @@ const bm = {
|
|
|
2233
2489
|
flows, workflow, aggregate, aggregates, jobs,
|
|
2234
2490
|
notifications, upload, audit, permissions, signedUrl,
|
|
2235
2491
|
t, mfa, webrtc, broadcast, markdown, markdownBatch, presence, cache,
|
|
2236
|
-
createStore, query, html, raw,
|
|
2492
|
+
createStore, query, html, raw, captureError, captureMessage, setTag,
|
|
2237
2493
|
};
|
|
2238
2494
|
export default bm;
|
|
2239
2495
|
|
|
@@ -2241,55 +2497,184 @@ export default bm;
|
|
|
2241
2497
|
if (typeof window !== 'undefined') window.bm = bm;
|
|
2242
2498
|
|
|
2243
2499
|
// =================================================================
|
|
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.
|
|
2500
|
+
// Same-origin error reporting. Context stays bounded and excludes URL
|
|
2501
|
+
// queries and form contents; the app scrubs it again before export.
|
|
2250
2502
|
// =================================================================
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2503
|
+
const errorTags = Object.create(null),
|
|
2504
|
+
errorCrumbs = [],
|
|
2505
|
+
errorSent = [];
|
|
2506
|
+
const errorTagRE = /^[a-z0-9_.]{1,32}$/,
|
|
2507
|
+
errorRequestIDRE = /^[0-9a-f]{8,32}$/;
|
|
2508
|
+
|
|
2509
|
+
function validErrorTags(input) {
|
|
2510
|
+
const out = Object.create(null);
|
|
2511
|
+
if (!input || typeof input !== 'object') return out;
|
|
2512
|
+
for (const [key, value] of Object.entries(input)) {
|
|
2513
|
+
if (Object.keys(out).length >= 10) break;
|
|
2514
|
+
if (errorTagRE.test(key) && typeof value === 'string' && Array.from(value).length <= 200) out[key] = value;
|
|
2515
|
+
}
|
|
2516
|
+
return out;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
export function setTag(key, value) {
|
|
2520
|
+
if (typeof key !== 'string' || !errorTagRE.test(key) || typeof value !== 'string' || Array.from(value).length > 200) return;
|
|
2521
|
+
if (!Object.hasOwn(errorTags, key) && Object.keys(errorTags).length >= 10) return;
|
|
2522
|
+
errorTags[key] = value;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function errorPath(value) {
|
|
2526
|
+
try {
|
|
2527
|
+
return new URL(value, typeof location === 'undefined' ? 'https://invalid.example' : location.href).pathname;
|
|
2528
|
+
} catch {
|
|
2529
|
+
return '';
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function addErrorCrumb(type, message, data) {
|
|
2534
|
+
errorCrumbs.push({
|
|
2535
|
+
timestamp: new Date(Date.now()).toISOString(),
|
|
2536
|
+
type,
|
|
2537
|
+
message: String(message).slice(0, 300),
|
|
2538
|
+
...(data ? { data } : {}),
|
|
2539
|
+
});
|
|
2540
|
+
if (errorCrumbs.length > 30) errorCrumbs.shift();
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function postError(payload) {
|
|
2544
|
+
if (typeof fetch !== 'function') return;
|
|
2545
|
+
try {
|
|
2546
|
+
const now = Date.now();
|
|
2547
|
+
while (errorSent.length && errorSent[0].time <= now - 600000) errorSent.shift();
|
|
2548
|
+
const top = (payload.stack || '').split('\n').find((line) => /^\s*at\s/.test(line) || /@.*:\d+/.test(line)) || payload.source || '';
|
|
2549
|
+
// A local warning must not hide a later error/fatal occurrence.
|
|
2550
|
+
const key = (payload.level || 'error') + '|' + payload.message + '|' + top;
|
|
2551
|
+
if (errorSent.length >= 25 || errorSent.some((item) => item.key === key)) return;
|
|
2552
|
+
errorSent.push({ time: now, key });
|
|
2553
|
+
const body = {
|
|
2554
|
+
...payload,
|
|
2555
|
+
url: typeof location === 'undefined' ? '' : errorPath(location.href),
|
|
2556
|
+
tags: validErrorTags({ ...payload.tags, ...errorTags }),
|
|
2557
|
+
breadcrumbs: errorCrumbs.slice(),
|
|
2558
|
+
};
|
|
2559
|
+
Promise.resolve(
|
|
2260
2560
|
fetch('/api/_errors', {
|
|
2261
2561
|
method: 'POST',
|
|
2262
|
-
|
|
2263
|
-
|
|
2562
|
+
// A report is not activity: a background refetch that keeps failing
|
|
2563
|
+
// must not hold an idle session open (auth.idle_timeout).
|
|
2564
|
+
headers: { 'Content-Type': 'application/json', 'X-Benmore-Background': '1' },
|
|
2565
|
+
body: JSON.stringify(body),
|
|
2264
2566
|
keepalive: true,
|
|
2265
2567
|
credentials: 'same-origin',
|
|
2266
|
-
})
|
|
2267
|
-
|
|
2568
|
+
}),
|
|
2569
|
+
).catch(() => {});
|
|
2570
|
+
} catch (_) {} // Reporting must never create another failure.
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
function errorPayload(err) {
|
|
2574
|
+
return {
|
|
2575
|
+
message: String(err?.message || (typeof err === 'string' ? err : 'Unknown error')).slice(0, 2000),
|
|
2576
|
+
stack: typeof err?.stack === 'string' ? err.stack.slice(0, 8000) : '',
|
|
2577
|
+
...(typeof err?.status === 'number' ? { status: err.status } : {}),
|
|
2578
|
+
...(errorRequestIDRE.test(err?.body?.request_id || '') ? { request_id: err.body.request_id } : {}),
|
|
2268
2579
|
};
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
export function captureError(err, options = {}) {
|
|
2583
|
+
try {
|
|
2584
|
+
postError({ ...errorPayload(err), kind: 'manual', level: 'error', tags: options?.tags });
|
|
2585
|
+
} catch (_) {}
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
export function captureMessage(message, level = 'error') {
|
|
2589
|
+
try {
|
|
2590
|
+
postError({
|
|
2591
|
+
kind: 'message',
|
|
2592
|
+
message: String(message).slice(0, 2000),
|
|
2593
|
+
level: ['warning', 'error', 'fatal'].includes(level) ? level : 'error',
|
|
2594
|
+
});
|
|
2595
|
+
} catch (_) {}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function captureRequest(method, path, status, started, requestID, err) {
|
|
2599
|
+
try {
|
|
2600
|
+
if (typeof location === 'undefined') return;
|
|
2601
|
+
const url = new URL(path, location.href);
|
|
2602
|
+
if (url.origin !== location.origin || url.pathname === '/api/_errors') return;
|
|
2603
|
+
const cleanPath = url.pathname;
|
|
2604
|
+
addErrorCrumb('http', method + ' ' + cleanPath, {
|
|
2605
|
+
method,
|
|
2606
|
+
path: cleanPath.slice(0, 300),
|
|
2607
|
+
status,
|
|
2608
|
+
ms: Math.max(0, Date.now() - started),
|
|
2609
|
+
});
|
|
2610
|
+
if (status >= 500 || err)
|
|
2611
|
+
postError({
|
|
2612
|
+
kind: 'http',
|
|
2613
|
+
level: 'error',
|
|
2614
|
+
message: method + ' ' + cleanPath + (err ? ' network failure' : ' ' + status),
|
|
2615
|
+
method,
|
|
2616
|
+
path: cleanPath,
|
|
2617
|
+
status,
|
|
2618
|
+
...(errorRequestIDRE.test(requestID || '') ? { request_id: requestID } : {}),
|
|
2619
|
+
});
|
|
2620
|
+
} catch (_) {}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
if (typeof window !== 'undefined') {
|
|
2269
2624
|
window.addEventListener('error', (e) => {
|
|
2270
2625
|
// Resource-load errors (img/script 404s) have no .message - skip them.
|
|
2271
2626
|
if (!e || !e.message) return;
|
|
2272
|
-
|
|
2627
|
+
postError({
|
|
2273
2628
|
kind: 'error',
|
|
2274
2629
|
message: String(e.message).slice(0, 2000),
|
|
2275
2630
|
stack: e.error && e.error.stack ? String(e.error.stack).slice(0, 8000) : '',
|
|
2276
2631
|
source: e.filename || '',
|
|
2277
2632
|
line: e.lineno || 0,
|
|
2278
2633
|
col: e.colno || 0,
|
|
2279
|
-
url: location.href,
|
|
2280
2634
|
});
|
|
2281
2635
|
});
|
|
2282
2636
|
window.addEventListener('unhandledrejection', (e) => {
|
|
2283
2637
|
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
|
-
});
|
|
2638
|
+
try {
|
|
2639
|
+
postError({ ...errorPayload(r), kind: 'unhandledrejection' });
|
|
2640
|
+
} catch (_) {}
|
|
2294
2641
|
});
|
|
2642
|
+
for (const name of ['pushState', 'replaceState']) {
|
|
2643
|
+
const original = window.history?.[name];
|
|
2644
|
+
if (typeof original !== 'function') continue;
|
|
2645
|
+
window.history[name] = function (...args) {
|
|
2646
|
+
const result = original.apply(this, args);
|
|
2647
|
+
addErrorCrumb('navigation', errorPath(window.location.href));
|
|
2648
|
+
return result;
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
window.addEventListener('popstate', () => addErrorCrumb('navigation', errorPath(window.location.href)));
|
|
2652
|
+
for (const name of ['error', 'warn']) {
|
|
2653
|
+
const original = console[name];
|
|
2654
|
+
console[name] = function (...args) {
|
|
2655
|
+
try {
|
|
2656
|
+
const message = args
|
|
2657
|
+
.map((arg) => {
|
|
2658
|
+
try {
|
|
2659
|
+
return typeof arg === 'string' ? arg : JSON.stringify(arg);
|
|
2660
|
+
} catch {
|
|
2661
|
+
return '[unserializable]';
|
|
2662
|
+
}
|
|
2663
|
+
})
|
|
2664
|
+
.join(' ')
|
|
2665
|
+
.slice(0, 300);
|
|
2666
|
+
addErrorCrumb('console', message, { level: name });
|
|
2667
|
+
} catch (_) {}
|
|
2668
|
+
return original.apply(this, args);
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
window.document?.addEventListener(
|
|
2672
|
+
'click',
|
|
2673
|
+
(e) => {
|
|
2674
|
+
const target = e.target;
|
|
2675
|
+
if (!target || typeof target.getAttribute !== 'function') return;
|
|
2676
|
+
addErrorCrumb('click', target.getAttribute('data-testid') || String(target.tagName || '') + (target.id ? '#' + target.id : ''));
|
|
2677
|
+
},
|
|
2678
|
+
true,
|
|
2679
|
+
);
|
|
2295
2680
|
}
|