@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.
Files changed (3) hide show
  1. package/index.d.ts +76 -6
  2. package/index.js +516 -112
  3. 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
- send(payload: unknown): void;
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
- enroll(): Promise<{ qr_data_url: string; secret: string; backup_codes: string[] }>;
362
- verify(code: string): Promise<{ ok: boolean }>;
363
- disable(password: string): Promise<{ ok: boolean }>;
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
- tableKey(table: string, spec?: Record<string, unknown>): unknown[];
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 res = await fetch(path, init);
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
- const text = await res.text();
38
- const ct = (res.headers.get('content-type') || '').toLowerCase();
39
- let data = null;
40
- if (text) {
41
- if (ct.includes('application/json')) {
42
- try { data = JSON.parse(text); } catch { data = { error: text }; }
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) has not made a session:
210
- // the server answers {otp_required: true, redirect} (or mfa_required), or
211
- // redirects the request to the step's page. signInStepURL returns that page,
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 ws = new WebSocket(`${proto}://${location.host}/ws`);
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 open = false;
731
-
732
- ws.onopen = () => {
733
- open = true;
734
- ws.send(JSON.stringify({ type: 'join', room: name }));
735
- queue.forEach(p => ws.send(JSON.stringify({ type: 'broadcast', room: name, payload: p })));
736
- queue = null;
737
- };
738
- ws.onmessage = (ev) => {
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
- return {
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 this;
964
+ return client;
756
965
  },
757
- onAny(fn) { anyHandlers.add(fn); return this; },
966
+ onAny(fn) { anyHandlers.add(fn); return client; },
758
967
  off(kind, fn) {
759
968
  namedHandlers.get(kind)?.delete(fn);
760
- return this;
969
+ return client;
761
970
  },
971
+ // true: written to the open socket; false: queued for the next join.
762
972
  send(payload) {
763
- if (open) {
764
- ws.send(JSON.stringify({ type: 'broadcast', room: name, payload }));
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 res = await request(method, path, body);
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
- const text = await res.text();
1063
- throw new Error(`upload failed (${res.status}): ${text}`);
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
- disable: (password) => api.post('/api/_auth/mfa/disable', { password }),
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
- // common ['table', name, spec] / ['query', spec] key shapes used here.
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
- tableKey(tableName, spec = {}) {
2127
- return ['table', tableName, spec];
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 || ['query', spec];
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
- // Separate client control options (which never affect the server result)
2143
- // from the server-affecting query spec. Only the spec may influence the
2144
- // cache key, otherwise two identical reads that differ only in staleTime/
2145
- // force/live would fragment into distinct cache entries and defeat dedupe.
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
- // Client error capture → POST /api/_errors → _benmore_client_errors.
2245
- // Powers the get_client_errors MCP tool + Sentry/Slack forwarding.
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
- if (typeof window !== 'undefined') {
2252
- let _errSent = 0, _errLast = '';
2253
- const _ERR_CAP = 25;
2254
- const _postErr = (p) => {
2255
- if (_errSent >= _ERR_CAP) return;
2256
- const key = (p.message || '') + '|' + (p.source || '') + '|' + (p.line || 0);
2257
- if (key === _errLast) return; // collapse immediate duplicates
2258
- _errLast = key; _errSent++;
2259
- try {
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
- headers: { 'Content-Type': 'application/json' },
2263
- body: JSON.stringify(p),
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
- }).catch(() => {}); // never let reporting throw (would loop)
2267
- } catch (_) {}
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
- _postErr({
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
- const msg = r && r.message ? r.message : (typeof r === 'string' ? r : 'Unhandled promise rejection');
2285
- _postErr({
2286
- kind: 'unhandledrejection',
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benmore/bm",
3
- "version": "2.7.317",
3
+ "version": "2.7.330",
4
4
  "description": "Zero-dependency browser SDK for external Benmore frontends",
5
5
  "type": "module",
6
6
  "module": "./index.js",