@benmore/bm 0.0.0-stage → 2.7.309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/index.d.ts +392 -0
- package/index.js +2203 -0
- package/package.json +12 -4
package/index.js
ADDED
|
@@ -0,0 +1,2203 @@
|
|
|
1
|
+
// bm - Benmore frontend SDK. Auto-served at /_internal/bm.js, resolved
|
|
2
|
+
// in TSX/JS via the `import 'bm'` bare specifier (HTML pages set up an
|
|
3
|
+
// import map pointing to this file).
|
|
4
|
+
//
|
|
5
|
+
// Surface intentionally narrow. Does ONE thing: wraps the backend so
|
|
6
|
+
// the agent doesn't reinvent fetch/CSRF/SSE/WS on every app.
|
|
7
|
+
// Rendering / DOM / state are NOT in scope - bring your own.
|
|
8
|
+
//
|
|
9
|
+
// Hard cap: <5KB gzipped. Anything that creeps past goes into an opt-in
|
|
10
|
+
// /_internal/bm-<thing>.js side-module instead.
|
|
11
|
+
//
|
|
12
|
+
// =================================================================
|
|
13
|
+
// 1. fetch wrapper - auto CSRF, auto JSON, uniform { ok, status, data }
|
|
14
|
+
// =================================================================
|
|
15
|
+
|
|
16
|
+
const CSRF_META = () => document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
17
|
+
|
|
18
|
+
async function request(method, path, body, options) {
|
|
19
|
+
const headers = new Headers({ Accept: 'application/json' });
|
|
20
|
+
if (options?.headers) {
|
|
21
|
+
new Headers(options.headers).forEach((value, name) => headers.set(name, value));
|
|
22
|
+
}
|
|
23
|
+
const init = { method, credentials: 'same-origin', headers };
|
|
24
|
+
if (options?.signal !== undefined) init.signal = options.signal;
|
|
25
|
+
if (body !== undefined && body !== null) {
|
|
26
|
+
headers.set('Content-Type', 'application/json');
|
|
27
|
+
init.body = JSON.stringify(body);
|
|
28
|
+
}
|
|
29
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
30
|
+
headers.set('X-CSRF-Token', CSRF_META());
|
|
31
|
+
}
|
|
32
|
+
const res = await fetch(path, init);
|
|
33
|
+
if (res.status === 204) {
|
|
34
|
+
observeSessionResponse(method, path, true, null);
|
|
35
|
+
return { ok: true, status: 204, data: null };
|
|
36
|
+
}
|
|
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
|
+
}
|
|
46
|
+
}
|
|
47
|
+
observeSessionResponse(method, path, res.ok, data);
|
|
48
|
+
return { ok: res.ok, status: res.status, data };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Convenience: unwrap or throw. Most agent code wants this shape:
|
|
52
|
+
// const message = await bm.api.post('/api/messages', {...});
|
|
53
|
+
async function unwrap(res) {
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const msg = res.data?.error || `HTTP ${res.status}`;
|
|
56
|
+
const err = new Error(msg);
|
|
57
|
+
err.status = res.status;
|
|
58
|
+
err.body = res.data;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
return res.data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const api = {
|
|
65
|
+
// Raw forms - return { ok, status, data }, never throw on HTTP errors.
|
|
66
|
+
raw: {
|
|
67
|
+
get: (p, o) => request('GET', p, undefined, o),
|
|
68
|
+
post: (p, b, o) => request('POST', p, b, o),
|
|
69
|
+
patch: (p, b, o) => request('PATCH', p, b, o),
|
|
70
|
+
delete: (p, o) => request('DELETE', p, undefined, o),
|
|
71
|
+
},
|
|
72
|
+
// Convenience forms - throw on !ok, return data directly.
|
|
73
|
+
get: async (p, o) => unwrap(await request('GET', p, undefined, o)),
|
|
74
|
+
post: async (p, b, o) => unwrap(await request('POST', p, b, o)),
|
|
75
|
+
patch: async (p, b, o) => unwrap(await request('PATCH', p, b, o)),
|
|
76
|
+
delete: async (p, o) => unwrap(await request('DELETE', p, undefined, o)),
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Optimistic mutation + reconcile. Applies the change to local
|
|
80
|
+
* state IMMEDIATELY (so the UI is instant), races a network
|
|
81
|
+
* PATCH/POST/DELETE, and reconciles when the server responds.
|
|
82
|
+
*
|
|
83
|
+
* const handle = bm.api.optimistic({
|
|
84
|
+
* apply: () => state.markRead(channelId), // local mutation
|
|
85
|
+
* revert: (snapshot) => state.restore(snapshot), // on failure
|
|
86
|
+
* snapshot: () => state.snapshotChannel(channelId), // for revert
|
|
87
|
+
* request: () => bm.api.patch('/api/memberships/' + id, { last_read_at: new Date().toISOString() }),
|
|
88
|
+
* });
|
|
89
|
+
* // optional: await handle for the server response (or .catch for errors)
|
|
90
|
+
*
|
|
91
|
+
* Returns a thenable that resolves with the server response (or
|
|
92
|
+
* rejects with the error after the local state has been reverted).
|
|
93
|
+
*
|
|
94
|
+
* The "newer-wins" guard an earlier app build built for last_read_at maps
|
|
95
|
+
* directly onto `snapshot()` / `revert()` here - keep a snapshot of
|
|
96
|
+
* the field before the optimistic apply, restore it on rejection
|
|
97
|
+
* BUT only if no NEWER optimistic apply happened in between (track
|
|
98
|
+
* a generation counter at the caller side or via the snapshot).
|
|
99
|
+
*/
|
|
100
|
+
optimistic({ apply, revert, snapshot, request }) {
|
|
101
|
+
if (typeof apply !== 'function' || typeof request !== 'function') {
|
|
102
|
+
throw new Error('bm.api.optimistic({ apply, request, ... }) requires apply + request functions');
|
|
103
|
+
}
|
|
104
|
+
let snap;
|
|
105
|
+
try {
|
|
106
|
+
if (typeof snapshot === 'function') snap = snapshot();
|
|
107
|
+
apply();
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// apply() itself blew up - bail before sending the request.
|
|
110
|
+
return Promise.reject(err);
|
|
111
|
+
}
|
|
112
|
+
return Promise.resolve()
|
|
113
|
+
.then(() => request())
|
|
114
|
+
.catch(err => {
|
|
115
|
+
if (typeof revert === 'function') {
|
|
116
|
+
try { revert(snap); } catch (rerr) {
|
|
117
|
+
console.warn('[bm.api.optimistic] revert threw:', rerr);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// =================================================================
|
|
126
|
+
// 2. Auth - current user, sign out
|
|
127
|
+
// =================================================================
|
|
128
|
+
|
|
129
|
+
let _meCache = undefined; // distinguish "never fetched" (undefined) from "fetched, no session" (null)
|
|
130
|
+
let _authRevision = 0;
|
|
131
|
+
const _sessionListeners = new Set();
|
|
132
|
+
|
|
133
|
+
function resetSessionState(profile = undefined) {
|
|
134
|
+
_authRevision++;
|
|
135
|
+
_meCache = profile;
|
|
136
|
+
resetSessionQueries();
|
|
137
|
+
const bus = _liveConns.get('__bus__');
|
|
138
|
+
if (bus) bus.reconnect(profile !== null);
|
|
139
|
+
_sessionListeners.forEach(reset => reset(profile !== null));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// These native endpoints change the cookie session or its active tenant. A
|
|
143
|
+
// successful password/passkey proof that still needs OTP/MFA is not a session.
|
|
144
|
+
// Custom auth routes can explicitly finish with auth.refreshMe().
|
|
145
|
+
function observeSessionResponse(method, path, ok, data) {
|
|
146
|
+
if (!ok || typeof path !== 'string') return;
|
|
147
|
+
const route = path.split(/[?#]/, 1)[0];
|
|
148
|
+
if (route === '/api/_auth/act_as' && (method === 'POST' || method === 'DELETE')) {
|
|
149
|
+
resetSessionState();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (method !== 'POST') return;
|
|
153
|
+
if (route === '/logout') resetSessionState(null);
|
|
154
|
+
else if (['/login', '/signup', '/api/_auth/passkeys/login'].includes(route)) {
|
|
155
|
+
if (data?.user?.id != null) resetSessionState();
|
|
156
|
+
} else if (route === '/api/_auth/mfa/verify') {
|
|
157
|
+
if (data?.token && data?.user_id != null) resetSessionState();
|
|
158
|
+
} else if (['/api/_switch_group', '/api/_groups/create', '/api/_auth/impersonate', '/api/_auth/end-impersonation', '/verify-mfa', '/verify-otp'].includes(route)) {
|
|
159
|
+
if (!data?.otp_required && !data?.mfa_required && !data?.mfa_setup_required) resetSessionState();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function me() {
|
|
164
|
+
if (_meCache !== undefined) return _meCache;
|
|
165
|
+
const revision = _authRevision;
|
|
166
|
+
const res = await request('GET', '/api/_auth/profile');
|
|
167
|
+
if (revision !== _authRevision) return me();
|
|
168
|
+
_meCache = res.ok ? res.data : null;
|
|
169
|
+
return _meCache;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function signOut() {
|
|
173
|
+
// Framework's logout endpoint is registered at app.Paths.Logout (default /logout).
|
|
174
|
+
await unwrap(await request('POST', '/logout'));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Force-refresh the profile cache (useful right after signup).
|
|
178
|
+
async function refreshMe() {
|
|
179
|
+
resetSessionState();
|
|
180
|
+
return me();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// signIn - POST /login. `identifier` is the email/phone/username.
|
|
184
|
+
// Server side accepts both form-encoded and JSON bodies (v2.7.26+).
|
|
185
|
+
// Returns the user object on success; throws { status, body } on failure.
|
|
186
|
+
async function signIn(identifier, password) {
|
|
187
|
+
const res = await request('POST', '/login', { email: identifier, password });
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const err = new Error(res.data?.error || 'Invalid credentials');
|
|
190
|
+
err.status = res.status;
|
|
191
|
+
err.body = res.data;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
return res.data?.user || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// signUp - POST /signup. Pass any extra signup_fields (configured in
|
|
198
|
+
// app.yaml's `auth.signup_fields`) as additional keys on the object:
|
|
199
|
+
// await bm.auth.signUp({ email, password, first_name: 'Ada' })
|
|
200
|
+
async function signUp(fields) {
|
|
201
|
+
const res = await request('POST', '/signup', fields);
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const err = new Error(res.data?.error || 'Sign-up failed');
|
|
204
|
+
err.status = res.status;
|
|
205
|
+
err.body = res.data;
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
return res.data?.user || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// =================================================================
|
|
212
|
+
// Passkeys - sign in with Face ID, Touch ID, Windows Hello or a phone.
|
|
213
|
+
// =================================================================
|
|
214
|
+
// await bm.auth.passkey.register('MacBook', { password }) // signed in: add a passkey
|
|
215
|
+
// await bm.auth.passkey.signIn() // signed out: sign in
|
|
216
|
+
// await bm.auth.passkey.list(); await bm.auth.passkey.remove(id)
|
|
217
|
+
// bm.auth.passkey.supported() is false where the browser has no WebAuthn.
|
|
218
|
+
|
|
219
|
+
function _b64uToBuf(s) {
|
|
220
|
+
const pad = '='.repeat((4 - (s.length % 4)) % 4);
|
|
221
|
+
const bin = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
|
222
|
+
const out = new Uint8Array(bin.length);
|
|
223
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
224
|
+
return out.buffer;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _bufToB64u(buf) {
|
|
228
|
+
const bytes = new Uint8Array(buf);
|
|
229
|
+
let bin = '';
|
|
230
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
231
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function _passkeyCall(method, path, body, fallback) {
|
|
235
|
+
const res = await request(method, path, body);
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
const err = new Error(res.data?.error || fallback);
|
|
238
|
+
err.status = res.status;
|
|
239
|
+
err.body = res.data;
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
return res.data;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const passkey = {
|
|
246
|
+
supported() {
|
|
247
|
+
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
|
|
248
|
+
},
|
|
249
|
+
async register(name, proof) {
|
|
250
|
+
// proof: { password } or { mfa_code } - adding a passkey re-confirms the account.
|
|
251
|
+
// Optional within 10 minutes of signing in on this browser (e.g. with Google).
|
|
252
|
+
const o = await _passkeyCall('POST', '/api/_auth/passkeys/register/options', proof || {}, 'Could not start passkey setup');
|
|
253
|
+
const cred = await navigator.credentials.create({ publicKey: {
|
|
254
|
+
...o,
|
|
255
|
+
challenge: _b64uToBuf(o.challenge),
|
|
256
|
+
user: { ...o.user, id: _b64uToBuf(o.user.id) },
|
|
257
|
+
excludeCredentials: (o.excludeCredentials || []).map((c) => ({ ...c, id: _b64uToBuf(c.id) })),
|
|
258
|
+
} });
|
|
259
|
+
const r = cred.response;
|
|
260
|
+
return (await _passkeyCall('POST', '/api/_auth/passkeys/register', {
|
|
261
|
+
id: cred.id, rawId: _bufToB64u(cred.rawId), type: cred.type, name: name || '',
|
|
262
|
+
response: {
|
|
263
|
+
clientDataJSON: _bufToB64u(r.clientDataJSON),
|
|
264
|
+
attestationObject: _bufToB64u(r.attestationObject),
|
|
265
|
+
},
|
|
266
|
+
}, 'Could not save the passkey')).passkey;
|
|
267
|
+
},
|
|
268
|
+
async signIn() {
|
|
269
|
+
const o = await _passkeyCall('POST', '/api/_auth/passkeys/login/options', {}, 'Could not start passkey sign-in');
|
|
270
|
+
const cred = await navigator.credentials.get({ publicKey: {
|
|
271
|
+
...o,
|
|
272
|
+
challenge: _b64uToBuf(o.challenge),
|
|
273
|
+
allowCredentials: (o.allowCredentials || []).map((c) => ({ ...c, id: _b64uToBuf(c.id) })),
|
|
274
|
+
} });
|
|
275
|
+
const r = cred.response;
|
|
276
|
+
const data = await _passkeyCall('POST', '/api/_auth/passkeys/login', {
|
|
277
|
+
id: cred.id, rawId: _bufToB64u(cred.rawId), type: cred.type,
|
|
278
|
+
response: {
|
|
279
|
+
clientDataJSON: _bufToB64u(r.clientDataJSON),
|
|
280
|
+
authenticatorData: _bufToB64u(r.authenticatorData),
|
|
281
|
+
signature: _bufToB64u(r.signature),
|
|
282
|
+
userHandle: r.userHandle ? _bufToB64u(r.userHandle) : '',
|
|
283
|
+
},
|
|
284
|
+
}, 'Passkey sign-in failed');
|
|
285
|
+
return data.user || null;
|
|
286
|
+
},
|
|
287
|
+
async list() {
|
|
288
|
+
return (await _passkeyCall('GET', '/api/_auth/passkeys', undefined, 'Could not list passkeys')).passkeys || [];
|
|
289
|
+
},
|
|
290
|
+
async remove(id) {
|
|
291
|
+
await _passkeyCall('DELETE', '/api/_auth/passkeys/' + _seg(id), undefined, 'Could not remove the passkey');
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const auth = { me, signOut, refreshMe, signIn, signUp, passkey };
|
|
296
|
+
|
|
297
|
+
// =================================================================
|
|
298
|
+
// 3. Users - public user-lookup (v2.7.11+, default-private)
|
|
299
|
+
// =================================================================
|
|
300
|
+
|
|
301
|
+
export const users = {
|
|
302
|
+
async list(opts = {}) {
|
|
303
|
+
const qs = new URLSearchParams();
|
|
304
|
+
if (opts.limit) qs.set('limit', String(opts.limit));
|
|
305
|
+
if (opts.offset) qs.set('offset', String(opts.offset));
|
|
306
|
+
const path = '/api/_auth/users' + (qs.toString() ? '?' + qs : '');
|
|
307
|
+
const r = await api.get(path);
|
|
308
|
+
return r.users || [];
|
|
309
|
+
},
|
|
310
|
+
async get(id) {
|
|
311
|
+
return api.get(`/api/_auth/users/${_rowRef(id)}`);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// =================================================================
|
|
316
|
+
// 4. Tables - CRUD for any model in schema.prisma
|
|
317
|
+
// Usage: bm.table('messages').list() / .get(id) / .create({...})
|
|
318
|
+
// .update(id, {...}) / .delete(id)
|
|
319
|
+
//
|
|
320
|
+
// INTERCEPTORS (v2.7.65+): register transforms that mutate every
|
|
321
|
+
// read/write opts before the request fires. Use case: multi-tenant
|
|
322
|
+
// apps that need to inject `where[group_id]=N` on every query.
|
|
323
|
+
//
|
|
324
|
+
// bm.table.before('read', opts => ({
|
|
325
|
+
// ...opts,
|
|
326
|
+
// where: { ...opts.where, group_id: currentGroupId() }
|
|
327
|
+
// }));
|
|
328
|
+
//
|
|
329
|
+
// Hooks: 'read' fires for list/get/count; 'write' fires for
|
|
330
|
+
// create/update/delete. The hook receives (opts, ctx) where
|
|
331
|
+
// ctx = { table, op } so an interceptor can scope by table.
|
|
332
|
+
// Multiple hooks run in registration order, output of one feeds
|
|
333
|
+
// the next. Pre-2.7.65, multi-tenant apps had to wrap api.get in
|
|
334
|
+
// their own apiList() helper and bypass bm.table entirely; this
|
|
335
|
+
// hook lets them stay on the typed client.
|
|
336
|
+
// =================================================================
|
|
337
|
+
|
|
338
|
+
const _tableHooks = { read: [], write: [] };
|
|
339
|
+
const _runHooks = (kind, opts, ctx) => {
|
|
340
|
+
let cur = opts || {};
|
|
341
|
+
for (const fn of _tableHooks[kind]) {
|
|
342
|
+
try { cur = fn(cur, ctx) ?? cur; }
|
|
343
|
+
catch (e) { console.warn('bm.table interceptor threw:', e); }
|
|
344
|
+
}
|
|
345
|
+
return cur;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// A row id goes into the path as written (apps pass `id + '?include=...'`),
|
|
349
|
+
// so an id taken from a URL param such as `../_auth/tokens/7` aimed the
|
|
350
|
+
// CSRF-bearing PATCH/DELETE/POST at another endpoint once the browser
|
|
351
|
+
// resolved the dot segments. Refuse dot segments and backslashes, judged
|
|
352
|
+
// after removing tab, LF and CR the way the URL parser does (`.\t.` is `..`).
|
|
353
|
+
function _rowRef(id) {
|
|
354
|
+
const s = String(id);
|
|
355
|
+
const path = s.split(/[?#]/, 1)[0].replace(/[\t\n\r]/g, '');
|
|
356
|
+
if (path.includes('\\') || /(^|\/)(\.|%2e){1,2}(\/|$)/i.test(path)) {
|
|
357
|
+
throw new Error('bm.table: invalid row id ' + JSON.stringify(s));
|
|
358
|
+
}
|
|
359
|
+
return s;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// One encoded path segment. encodeURIComponent leaves '.' alone, so an id of
|
|
363
|
+
// '..' still removed the segment before it (DELETE /api/docs/../permissions/x
|
|
364
|
+
// is DELETE /api/permissions/x).
|
|
365
|
+
function _seg(v) {
|
|
366
|
+
const e = encodeURIComponent(String(v));
|
|
367
|
+
if (e === '.' || e === '..') throw new Error('bm: invalid path segment ' + JSON.stringify(String(v)));
|
|
368
|
+
return e;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function table(name) {
|
|
372
|
+
if (!name || typeof name !== 'string') {
|
|
373
|
+
throw new Error('bm.table(name) requires a string table name');
|
|
374
|
+
}
|
|
375
|
+
// Trim leading /api/ if the agent pasted a path; trim trailing slash.
|
|
376
|
+
const t = name.replace(/^\/api\//, '').replace(/\/+$/, '');
|
|
377
|
+
const base = `/api/${t}`;
|
|
378
|
+
return {
|
|
379
|
+
name: t,
|
|
380
|
+
async list(opts = {}) {
|
|
381
|
+
opts = _runHooks('read', opts, { table: t, op: 'list' });
|
|
382
|
+
const qs = new URLSearchParams();
|
|
383
|
+
if (opts.limit) qs.set('limit', String(opts.limit));
|
|
384
|
+
if (opts.offset) qs.set('offset', String(opts.offset));
|
|
385
|
+
if (opts.cursor != null) qs.set('cursor', String(opts.cursor));
|
|
386
|
+
if (opts.q) qs.set('q', String(opts.q));
|
|
387
|
+
if (opts.sort) qs.set('sort', String(opts.sort));
|
|
388
|
+
if (opts.order) qs.set('order', String(opts.order));
|
|
389
|
+
if (opts.where) {
|
|
390
|
+
for (const [k, v] of Object.entries(opts.where)) qs.set(k, String(v));
|
|
391
|
+
}
|
|
392
|
+
const path = base + (qs.toString() ? '?' + qs : '');
|
|
393
|
+
const r = await api.get(path);
|
|
394
|
+
// Offset lists are bare arrays; cursor/page lists use { data: [...] }.
|
|
395
|
+
// Retain { rows: [...] } compatibility for existing table adapters.
|
|
396
|
+
return Array.isArray(r) ? r : (r.rows || r.data || []);
|
|
397
|
+
},
|
|
398
|
+
get(id) {
|
|
399
|
+
_runHooks('read', { id }, { table: t, op: 'get' });
|
|
400
|
+
return api.get(`${base}/${_rowRef(id)}`);
|
|
401
|
+
},
|
|
402
|
+
create(body) {
|
|
403
|
+
const opts = _runHooks('write', { body }, { table: t, op: 'create' });
|
|
404
|
+
return api.post(base, opts.body ?? body);
|
|
405
|
+
},
|
|
406
|
+
update(id, body) {
|
|
407
|
+
const opts = _runHooks('write', { id, body }, { table: t, op: 'update' });
|
|
408
|
+
return api.patch(`${base}/${_rowRef(opts.id ?? id)}`, opts.body ?? body);
|
|
409
|
+
},
|
|
410
|
+
delete(id) {
|
|
411
|
+
_runHooks('write', { id }, { table: t, op: 'delete' });
|
|
412
|
+
return api.delete(`${base}/${_rowRef(id)}`);
|
|
413
|
+
},
|
|
414
|
+
// bm.table('x').count({ where, q }) - TRUE row count, no LIMIT cap.
|
|
415
|
+
//
|
|
416
|
+
// Auto-CRUD list defaults to a 50-row page cap (a real app
|
|
417
|
+
// session: "50 records showing but actual count is 693" surfaced
|
|
418
|
+
// three times before the agent hand-rolled /api/reports/stats). This
|
|
419
|
+
// hits /api/<table>?count=true which short-circuits the row fetch
|
|
420
|
+
// server-side and returns just { count: N }. WHERE filters are
|
|
421
|
+
// honored (`where`, `q`); LIMIT / page caps are not.
|
|
422
|
+
async count(opts = {}) {
|
|
423
|
+
opts = _runHooks('read', opts, { table: t, op: 'count' });
|
|
424
|
+
const qs = new URLSearchParams({ count: '1' });
|
|
425
|
+
if (opts.q) qs.set('q', String(opts.q));
|
|
426
|
+
if (opts.where) {
|
|
427
|
+
for (const [k, v] of Object.entries(opts.where)) qs.set(k, String(v));
|
|
428
|
+
}
|
|
429
|
+
const r = await api.get(`${base}?${qs}`);
|
|
430
|
+
return (r && typeof r.count === 'number') ? r.count : 0;
|
|
431
|
+
},
|
|
432
|
+
// Feature methods - TS gates them at compile time per TableFeatures.
|
|
433
|
+
// At runtime they always exist; the server returns 4xx when the
|
|
434
|
+
// table doesn't actually support the operation.
|
|
435
|
+
restore: (id) => api.post(`${base}/${_rowRef(id)}/restore`),
|
|
436
|
+
versions: (id) => api.get(`${base}/${_rowRef(id)}/versions`),
|
|
437
|
+
revertTo: (id, version) => api.post(`${base}/${_rowRef(id)}/revert/${_rowRef(version)}`),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Interceptor surface - attached to the table function itself so the
|
|
442
|
+
// call site reads `bm.table.before('read', fn)`. Two hook points:
|
|
443
|
+
//
|
|
444
|
+
// 'read' fires for list / get / count - receives (opts, ctx)
|
|
445
|
+
// 'write' fires for create / update / delete - receives (opts, ctx)
|
|
446
|
+
//
|
|
447
|
+
// Hooks run in registration order; each hook's return value feeds the
|
|
448
|
+
// next. Returning undefined keeps the previous opts (so `() => {}` is
|
|
449
|
+
// a no-op, not a wipe). Throwing logs a warning + continues - never
|
|
450
|
+
// aborts the request, never silently drops data.
|
|
451
|
+
table.before = function (kind, fn) {
|
|
452
|
+
if (kind !== 'read' && kind !== 'write') {
|
|
453
|
+
throw new Error("bm.table.before(kind, fn): kind must be 'read' or 'write'");
|
|
454
|
+
}
|
|
455
|
+
if (typeof fn !== 'function') {
|
|
456
|
+
throw new Error('bm.table.before(kind, fn): fn must be a function');
|
|
457
|
+
}
|
|
458
|
+
_tableHooks[kind].push(fn);
|
|
459
|
+
// Return an unsubscribe so a feature module can undo its own
|
|
460
|
+
// interceptor on teardown (test cleanup, dynamic provider swap).
|
|
461
|
+
return () => {
|
|
462
|
+
const i = _tableHooks[kind].indexOf(fn);
|
|
463
|
+
if (i >= 0) _tableHooks[kind].splice(i, 1);
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
table.clearHooks = function () {
|
|
467
|
+
_tableHooks.read.length = 0;
|
|
468
|
+
_tableHooks.write.length = 0;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// =================================================================
|
|
472
|
+
// 5. Live - SSE invalidation, NAMED event-aware
|
|
473
|
+
//
|
|
474
|
+
// The framework emits `event: change\ndata: {...}` - a NAMED event.
|
|
475
|
+
// EventSource.onmessage only catches DEFAULT events, so wiring onmessage
|
|
476
|
+
// alone silently drops every framework broadcast. bm.live() wires BOTH
|
|
477
|
+
// listeners so this trap is impossible.
|
|
478
|
+
//
|
|
479
|
+
// Usage:
|
|
480
|
+
// const stop = bm.live('messages', ev => render(...));
|
|
481
|
+
// stop(); // unsubscribe + close connection
|
|
482
|
+
// const stop2 = bm.live('*', ev => log(ev)); // every table
|
|
483
|
+
// =================================================================
|
|
484
|
+
|
|
485
|
+
const _liveConns = new Map(); // share one EventSource per page
|
|
486
|
+
|
|
487
|
+
function liveBus() {
|
|
488
|
+
if (_liveConns.has('__bus__')) {
|
|
489
|
+
const bus = _liveConns.get('__bus__');
|
|
490
|
+
bus.ensure();
|
|
491
|
+
return bus;
|
|
492
|
+
}
|
|
493
|
+
const subs = new Set();
|
|
494
|
+
let es = null;
|
|
495
|
+
const reconnect = (connect = true) => {
|
|
496
|
+
if (es) es.close();
|
|
497
|
+
es = null;
|
|
498
|
+
if (!connect) return;
|
|
499
|
+
const current = es = new EventSource('/sse/events');
|
|
500
|
+
const fire = (raw) => {
|
|
501
|
+
if (es !== current) return; // queued event from a previous session
|
|
502
|
+
let data;
|
|
503
|
+
try { data = JSON.parse(raw); } catch { return; }
|
|
504
|
+
subs.forEach(fn => { try { fn(data); } catch (e) { console.error(e); } });
|
|
505
|
+
};
|
|
506
|
+
current.addEventListener('change', e => fire(e.data));
|
|
507
|
+
current.onmessage = e => fire(e.data);
|
|
508
|
+
current.onerror = () => { /* EventSource auto-reconnects */ };
|
|
509
|
+
};
|
|
510
|
+
const bus = { subs, reconnect, ensure: () => { if (!es) reconnect(); }, close: () => reconnect(false) };
|
|
511
|
+
reconnect();
|
|
512
|
+
_liveConns.set('__bus__', bus);
|
|
513
|
+
return bus;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function live(tableOrStar, cb) {
|
|
517
|
+
if (typeof cb !== 'function') {
|
|
518
|
+
throw new Error('bm.live(table, callback) requires a function');
|
|
519
|
+
}
|
|
520
|
+
const bus = liveBus();
|
|
521
|
+
const handler = (data) => {
|
|
522
|
+
// table === '' means "generic refresh hint for an owner-scoped table
|
|
523
|
+
// you can't see the row of." `*` subscribers always get it.
|
|
524
|
+
if (tableOrStar === '*' ||
|
|
525
|
+
data.table === tableOrStar ||
|
|
526
|
+
data.action === 'refresh') {
|
|
527
|
+
cb(data);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
bus.subs.add(handler);
|
|
531
|
+
return () => bus.subs.delete(handler);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// bm.live.scoped(table, fetchAndRender, opts) - `live()` with the
|
|
535
|
+
// production-grade plumbing baked in:
|
|
536
|
+
//
|
|
537
|
+
// - **Serialized callbacks**: one fetchAndRender runs at a time per
|
|
538
|
+
// subscription. Events during a pending callback queue one trailing
|
|
539
|
+
// refresh, so an older callback cannot overwrite a newer callback's
|
|
540
|
+
// render. The returned promise must cover all fetch and render work.
|
|
541
|
+
//
|
|
542
|
+
// - **Trailing-edge debounce**: bursts of events coalesce into a
|
|
543
|
+
// single fetch fired `debounce` ms after the LAST event. Avoids
|
|
544
|
+
// N round-trips for N events; the framework's SSE fan-out is
|
|
545
|
+
// intentionally chatty (every CRUD mutation emits) so debouncing
|
|
546
|
+
// downstream is the right pattern.
|
|
547
|
+
//
|
|
548
|
+
// `fetchAndRender` is called with no args; it should read whatever
|
|
549
|
+
// data it needs (auto-CRUD list, custom flow, whatever) and update
|
|
550
|
+
// the DOM. Its returned promise is awaited; other values are ignored.
|
|
551
|
+
// Errors are caught + logged. Detached work and separate subscriptions
|
|
552
|
+
// are not serialized. A callback that never settles blocks its queue.
|
|
553
|
+
//
|
|
554
|
+
// Returns an unsubscribe function. Calling it stops the SSE
|
|
555
|
+
// subscription AND cancels pending/queued fetches. An already-running
|
|
556
|
+
// callback cannot be cancelled; callers must guard its teardown effects.
|
|
557
|
+
//
|
|
558
|
+
// const unsub = bm.live.scoped('messages', refreshChat, { debounce: 60 });
|
|
559
|
+
// // ... later
|
|
560
|
+
// unsub();
|
|
561
|
+
live.scoped = function liveScoped(tableOrStar, fetchAndRender, opts) {
|
|
562
|
+
if (typeof fetchAndRender !== 'function') {
|
|
563
|
+
throw new Error('bm.live.scoped(table, fetchAndRender, opts) requires a function');
|
|
564
|
+
}
|
|
565
|
+
const debounceMs = (opts && typeof opts.debounce === 'number') ? opts.debounce : 60;
|
|
566
|
+
let running = false, queued = false, stopped = false;
|
|
567
|
+
let pendingTimer = null;
|
|
568
|
+
|
|
569
|
+
const runFetch = async () => {
|
|
570
|
+
pendingTimer = null;
|
|
571
|
+
if (stopped) return;
|
|
572
|
+
if (running) { queued = true; return; }
|
|
573
|
+
running = true;
|
|
574
|
+
try {
|
|
575
|
+
const result = fetchAndRender();
|
|
576
|
+
if (result && typeof result.then === 'function') {
|
|
577
|
+
await result;
|
|
578
|
+
}
|
|
579
|
+
} catch (err) {
|
|
580
|
+
console.warn('[bm.live.scoped] fetchAndRender threw:', err);
|
|
581
|
+
} finally {
|
|
582
|
+
running = false;
|
|
583
|
+
if (queued && !stopped) {
|
|
584
|
+
queued = false;
|
|
585
|
+
schedule();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const schedule = () => {
|
|
591
|
+
if (stopped) return;
|
|
592
|
+
if (pendingTimer != null) clearTimeout(pendingTimer);
|
|
593
|
+
pendingTimer = setTimeout(runFetch, debounceMs);
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// Prime through the debounce window so the caller doesn't need to
|
|
597
|
+
// double-call: `bm.live.scoped('x', refresh); refresh();`. Just
|
|
598
|
+
// pass the renderer and we'll prime the view on mount.
|
|
599
|
+
schedule();
|
|
600
|
+
|
|
601
|
+
const unsub = live(tableOrStar, () => schedule());
|
|
602
|
+
|
|
603
|
+
// Visibility hook: when the tab returns to foreground, fire a
|
|
604
|
+
// fresh fetch - many SSE events get dropped by browsers that
|
|
605
|
+
// sleep the page while it's backgrounded. An earlier app build built
|
|
606
|
+
// this manually; bake in.
|
|
607
|
+
let visibilityHandler = null;
|
|
608
|
+
if (typeof document !== 'undefined') {
|
|
609
|
+
visibilityHandler = () => {
|
|
610
|
+
if (!document.hidden) schedule();
|
|
611
|
+
};
|
|
612
|
+
document.addEventListener('visibilitychange', visibilityHandler);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return () => {
|
|
616
|
+
stopped = true;
|
|
617
|
+
queued = false;
|
|
618
|
+
unsub();
|
|
619
|
+
if (pendingTimer != null) {
|
|
620
|
+
clearTimeout(pendingTimer);
|
|
621
|
+
pendingTimer = null;
|
|
622
|
+
}
|
|
623
|
+
if (visibilityHandler && typeof document !== 'undefined') {
|
|
624
|
+
document.removeEventListener('visibilitychange', visibilityHandler);
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// =================================================================
|
|
630
|
+
// 6. Rooms - WebSocket room broadcast (chat, presence, WebRTC signaling)
|
|
631
|
+
//
|
|
632
|
+
// As of v2.7.10 broadcasts deliver cross-user in same-app scenarios.
|
|
633
|
+
// Multi-tenant isolation (groups: config) still applies.
|
|
634
|
+
//
|
|
635
|
+
// Usage:
|
|
636
|
+
// const room = bm.room('lobby');
|
|
637
|
+
// room.on('hello', (payload, from) => greet(from));
|
|
638
|
+
// room.onAny((payload, from) => log(payload));
|
|
639
|
+
// room.send({ kind: 'hello' });
|
|
640
|
+
// room.leave();
|
|
641
|
+
// =================================================================
|
|
642
|
+
|
|
643
|
+
export function room(name) {
|
|
644
|
+
if (!name || typeof name !== 'string') {
|
|
645
|
+
throw new Error('bm.room(name) requires a string room name');
|
|
646
|
+
}
|
|
647
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
648
|
+
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
649
|
+
const namedHandlers = new Map(); // kind -> Set<fn>
|
|
650
|
+
const anyHandlers = new Set();
|
|
651
|
+
let queue = [];
|
|
652
|
+
let open = false;
|
|
653
|
+
|
|
654
|
+
ws.onopen = () => {
|
|
655
|
+
open = true;
|
|
656
|
+
ws.send(JSON.stringify({ type: 'join', room: name }));
|
|
657
|
+
queue.forEach(p => ws.send(JSON.stringify({ type: 'broadcast', room: name, payload: p })));
|
|
658
|
+
queue = null;
|
|
659
|
+
};
|
|
660
|
+
ws.onmessage = (ev) => {
|
|
661
|
+
let msg;
|
|
662
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
663
|
+
if (msg.type !== 'message' && msg.type !== 'presence') return;
|
|
664
|
+
const payload = msg.payload || {};
|
|
665
|
+
const from = msg.from;
|
|
666
|
+
const kind = payload.kind || msg.type; // 'presence' has no kind
|
|
667
|
+
const set = namedHandlers.get(kind);
|
|
668
|
+
if (set) set.forEach(fn => { try { fn(payload, from); } catch (e) { console.error(e); } });
|
|
669
|
+
anyHandlers.forEach(fn => { try { fn(payload, from, kind); } catch (e) { console.error(e); } });
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
name,
|
|
674
|
+
on(kind, fn) {
|
|
675
|
+
if (!namedHandlers.has(kind)) namedHandlers.set(kind, new Set());
|
|
676
|
+
namedHandlers.get(kind).add(fn);
|
|
677
|
+
return this;
|
|
678
|
+
},
|
|
679
|
+
onAny(fn) { anyHandlers.add(fn); return this; },
|
|
680
|
+
off(kind, fn) {
|
|
681
|
+
namedHandlers.get(kind)?.delete(fn);
|
|
682
|
+
return this;
|
|
683
|
+
},
|
|
684
|
+
send(payload) {
|
|
685
|
+
if (open) {
|
|
686
|
+
ws.send(JSON.stringify({ type: 'broadcast', room: name, payload }));
|
|
687
|
+
} else {
|
|
688
|
+
queue.push(payload);
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
leave() {
|
|
692
|
+
if (open) ws.send(JSON.stringify({ type: 'leave', room: name }));
|
|
693
|
+
ws.close();
|
|
694
|
+
},
|
|
695
|
+
raw: ws,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// =================================================================
|
|
700
|
+
// 7. Flows - per-flow methods generated from flows.yaml
|
|
701
|
+
//
|
|
702
|
+
// At runtime there's no per-flow client - the agent calls
|
|
703
|
+
// bm.flows.<flowName>(params, body), which routes to the URL by
|
|
704
|
+
// looking up a runtime registry written by the type generator at
|
|
705
|
+
// /_internal/flows.json. We Proxy() the lookup so unknown names
|
|
706
|
+
// throw a clear runtime error to match the TS error.
|
|
707
|
+
// =================================================================
|
|
708
|
+
|
|
709
|
+
let _flowsRegistry = null;
|
|
710
|
+
async function loadFlowsRegistry() {
|
|
711
|
+
if (_flowsRegistry !== null) return _flowsRegistry;
|
|
712
|
+
try {
|
|
713
|
+
const r = await fetch('/_internal/flows.json', { credentials: 'same-origin', cache: 'no-store' });
|
|
714
|
+
if (r.ok) _flowsRegistry = await r.json();
|
|
715
|
+
else _flowsRegistry = {};
|
|
716
|
+
} catch { _flowsRegistry = {}; }
|
|
717
|
+
return _flowsRegistry;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Registry keys are camelCase (company_setup / company-setup → companySetup).
|
|
721
|
+
// Accept snake_case and kebab-case aliases too - agents naturally write
|
|
722
|
+
// bm.flows.company_setup when the file is flows/company-setup.yaml, and
|
|
723
|
+
// making the obvious spelling work beats making them learn the mapping.
|
|
724
|
+
function camelizeFlowName(name) {
|
|
725
|
+
return name.replace(/[-_]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export const flows = new Proxy({}, {
|
|
729
|
+
get(_target, flowName) {
|
|
730
|
+
if (typeof flowName !== 'string') return undefined;
|
|
731
|
+
return async (params = {}, body) => {
|
|
732
|
+
let reg = await loadFlowsRegistry();
|
|
733
|
+
let entry = reg[flowName] || reg[camelizeFlowName(flowName)];
|
|
734
|
+
if (!entry) {
|
|
735
|
+
// The registry lists only flows THIS caller may call, so one loaded
|
|
736
|
+
// before sign-in (or before a role grant) can be missing the flow.
|
|
737
|
+
// Refetch once before reporting it as undeclared.
|
|
738
|
+
_flowsRegistry = null;
|
|
739
|
+
reg = await loadFlowsRegistry();
|
|
740
|
+
entry = reg[flowName] || reg[camelizeFlowName(flowName)];
|
|
741
|
+
}
|
|
742
|
+
if (!entry) {
|
|
743
|
+
const available = Object.keys(reg).sort();
|
|
744
|
+
const listing = available.length
|
|
745
|
+
? `Available flows: ${available.map(n => `bm.flows.${n}()`).join(', ')}.`
|
|
746
|
+
: 'No flows are registered (flows.yaml / flows/*.yaml define none with on.request triggers).';
|
|
747
|
+
throw new Error(
|
|
748
|
+
`bm.flows.${flowName} is not declared in flows.yaml. ${listing} ` +
|
|
749
|
+
`Note: a single-job file flows/company-setup.yaml registers as bm.flows.companySetup ` +
|
|
750
|
+
`(file name, camelCased) - snake/kebab spellings of the same name also resolve.`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
// Substitute :path params, each one encoded segment (never '.'/'..').
|
|
754
|
+
let path = entry.path;
|
|
755
|
+
for (const [k, v] of Object.entries(params)) {
|
|
756
|
+
if (path.includes(`:${k}`)) path = path.replace(`:${k}`, _seg(v));
|
|
757
|
+
}
|
|
758
|
+
const method = (entry.method || 'POST').toUpperCase();
|
|
759
|
+
const res = await request(method, path, body);
|
|
760
|
+
if (!res.ok) {
|
|
761
|
+
const err = new Error(res.data?.error || `flow ${flowName} failed (${res.status})`);
|
|
762
|
+
err.status = res.status; err.body = res.data;
|
|
763
|
+
throw err;
|
|
764
|
+
}
|
|
765
|
+
// Async flows return {job_id, status_url} - wrap into a Job handle.
|
|
766
|
+
if (res.data && res.data.job_id && res.data.status_url) {
|
|
767
|
+
return makeJob(res.data.job_id, res.data.status_url);
|
|
768
|
+
}
|
|
769
|
+
return res.data;
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// =================================================================
|
|
775
|
+
// 8. Workflows - typed state-machine client
|
|
776
|
+
// bm.workflow('orders', 7).transitionTo('shipped')
|
|
777
|
+
// bm.workflow('orders', 7).available()
|
|
778
|
+
// bm.workflow('orders', 7).current()
|
|
779
|
+
// =================================================================
|
|
780
|
+
|
|
781
|
+
export function workflow(table, id) {
|
|
782
|
+
if (!table || id === undefined || id === null) {
|
|
783
|
+
throw new Error('bm.workflow(table, id) requires both args');
|
|
784
|
+
}
|
|
785
|
+
const base = `/api/${_seg(table)}/${_seg(id)}`;
|
|
786
|
+
return {
|
|
787
|
+
table,
|
|
788
|
+
id,
|
|
789
|
+
// Server expects { to: <newState> } (workflows.go handleTransition).
|
|
790
|
+
transitionTo: (state) => api.post(`${base}/transition`, { to: state }),
|
|
791
|
+
available: () => api.get(`${base}/transitions`),
|
|
792
|
+
current: async () => {
|
|
793
|
+
const row = await api.get(base);
|
|
794
|
+
return row.state ?? row.status;
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// =================================================================
|
|
800
|
+
// 9. Aggregates - materialized SQL values
|
|
801
|
+
// bm.aggregate('total_revenue') → scalar
|
|
802
|
+
// bm.aggregates.all() → { name: value, ... }
|
|
803
|
+
// =================================================================
|
|
804
|
+
|
|
805
|
+
// The framework exposes /api/_aggregates as a single list endpoint -
|
|
806
|
+
// no per-name route. We fetch the whole list (cheap, ~N rows) and
|
|
807
|
+
// pick out the one the caller asked for. The list is cached on the
|
|
808
|
+
// server side so this is one round-trip then a hash lookup.
|
|
809
|
+
export async function aggregate(name) {
|
|
810
|
+
const r = await api.get('/api/_aggregates');
|
|
811
|
+
const list = (r && (r.aggregates || r)) || [];
|
|
812
|
+
if (Array.isArray(list)) {
|
|
813
|
+
const hit = list.find(a => a && a.name === name);
|
|
814
|
+
return hit ? (hit.value ?? null) : null;
|
|
815
|
+
}
|
|
816
|
+
// Map shape: { name: value }
|
|
817
|
+
return (r && r[name] !== undefined) ? r[name] : null;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export const aggregates = {
|
|
821
|
+
get: aggregate,
|
|
822
|
+
async all() {
|
|
823
|
+
const r = await api.get('/api/_aggregates');
|
|
824
|
+
const list = (r && (r.aggregates || r)) || [];
|
|
825
|
+
if (!Array.isArray(list)) return r;
|
|
826
|
+
return Object.fromEntries(list.map(a => [a.name, a.value]));
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// =================================================================
|
|
831
|
+
// 10. Jobs - async-flow status + wait helper
|
|
832
|
+
// =================================================================
|
|
833
|
+
|
|
834
|
+
function makeJob(jobId, statusUrl) {
|
|
835
|
+
const statusPath = statusUrl || `/api/_jobs/${_seg(jobId)}/status`;
|
|
836
|
+
return {
|
|
837
|
+
job_id: jobId,
|
|
838
|
+
status_url: statusUrl,
|
|
839
|
+
status: () => api.get(statusPath),
|
|
840
|
+
wait: (opts = {}) => waitForJob(jobId, { ...opts, statusUrl }),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function waitForJob(jobId, { intervalMs = 1000, timeoutMs = 5 * 60 * 1000, statusUrl = '' } = {}) {
|
|
845
|
+
const statusPath = statusUrl || `/api/_jobs/${_seg(jobId)}/status`;
|
|
846
|
+
const deadline = Date.now() + timeoutMs;
|
|
847
|
+
while (Date.now() < deadline) {
|
|
848
|
+
const status = await api.get(statusPath);
|
|
849
|
+
if (status.status === 'completed') return status.result;
|
|
850
|
+
if (status.status === 'failed') {
|
|
851
|
+
const err = new Error(status.error || `job ${jobId} failed`);
|
|
852
|
+
err.status = status; throw err;
|
|
853
|
+
}
|
|
854
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
855
|
+
}
|
|
856
|
+
throw new Error(`job ${jobId} timed out after ${timeoutMs}ms`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export const jobs = {
|
|
860
|
+
// statusUrl is the tokenized URL returned by an async flow's submit
|
|
861
|
+
// response. Without it, the bare /api/_jobs/{id}/status path 404s for any
|
|
862
|
+
// non-admin caller (the status endpoint requires the capability token), so
|
|
863
|
+
// always pass through the server-issued status_url when polling outside an
|
|
864
|
+
// admin session.
|
|
865
|
+
status: (jobId, statusUrl) =>
|
|
866
|
+
api.get(statusUrl || `/api/_jobs/${_seg(jobId)}/status`),
|
|
867
|
+
wait: waitForJob,
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// =================================================================
|
|
871
|
+
// 11. Notifications - in-app inbox
|
|
872
|
+
// =================================================================
|
|
873
|
+
|
|
874
|
+
export const notifications = {
|
|
875
|
+
async list(opts = {}) {
|
|
876
|
+
const qs = new URLSearchParams();
|
|
877
|
+
if (opts.limit) qs.set('limit', String(opts.limit));
|
|
878
|
+
if (opts.offset) qs.set('offset', String(opts.offset));
|
|
879
|
+
if (opts.unread) qs.set('unread', '1');
|
|
880
|
+
const path = '/api/_notifications' + (qs.toString() ? '?' + qs : '');
|
|
881
|
+
const r = await api.get(path);
|
|
882
|
+
return Array.isArray(r) ? r : (r.notifications || []);
|
|
883
|
+
},
|
|
884
|
+
markRead: (id) => api.patch(`/api/_notifications/${_seg(id)}/read`),
|
|
885
|
+
markAllRead: () => api.post('/api/_notifications/read-all'),
|
|
886
|
+
delete: (id) => api.delete(`/api/_notifications/${_seg(id)}`),
|
|
887
|
+
// Events carry no rows. Drain an ID cursor (including rows already marked
|
|
888
|
+
// read by another tab), coalescing insert events into one paginated fetch.
|
|
889
|
+
onNew(cb) {
|
|
890
|
+
let active = true, current;
|
|
891
|
+
const isCurrent = state => active && current === state && state.revision === _authRevision;
|
|
892
|
+
const initialize = state => api.get('/api/_notifications?limit=1')
|
|
893
|
+
.then((result) => {
|
|
894
|
+
if (isCurrent(state) && Number.isSafeInteger(result.latest_id) && result.latest_id >= 0) {
|
|
895
|
+
state.lastSeen = result.latest_id;
|
|
896
|
+
}
|
|
897
|
+
})
|
|
898
|
+
.catch(() => {});
|
|
899
|
+
// Each session owns its baseline, cursor and pending drain. A delayed old
|
|
900
|
+
// response must neither publish private rows nor stall the next account.
|
|
901
|
+
const reset = (enabled = true) => {
|
|
902
|
+
const state = current = { revision: _authRevision, enabled, lastSeen: null, pending: false, running: false };
|
|
903
|
+
state.ready = enabled ? initialize(state) : Promise.resolve();
|
|
904
|
+
};
|
|
905
|
+
_sessionListeners.add(reset);
|
|
906
|
+
reset();
|
|
907
|
+
const drain = async state => {
|
|
908
|
+
if (state.running || !isCurrent(state)) return;
|
|
909
|
+
state.running = true;
|
|
910
|
+
try {
|
|
911
|
+
await state.ready;
|
|
912
|
+
if (!isCurrent(state)) return;
|
|
913
|
+
if (state.lastSeen === null) {
|
|
914
|
+
state.ready = initialize(state);
|
|
915
|
+
await state.ready;
|
|
916
|
+
if (!isCurrent(state) || state.lastSeen === null) return;
|
|
917
|
+
}
|
|
918
|
+
while (isCurrent(state) && state.pending) {
|
|
919
|
+
state.pending = false;
|
|
920
|
+
while (isCurrent(state)) {
|
|
921
|
+
const result = await api.get(`/api/_notifications?limit=100&after_id=${state.lastSeen}`);
|
|
922
|
+
if (!isCurrent(state)) return;
|
|
923
|
+
const rows = result.notifications || [];
|
|
924
|
+
let advanced = false;
|
|
925
|
+
for (const n of rows) {
|
|
926
|
+
if (!isCurrent(state)) return;
|
|
927
|
+
if (!Number.isSafeInteger(n.id) || n.id <= state.lastSeen) continue;
|
|
928
|
+
state.lastSeen = n.id; advanced = true;
|
|
929
|
+
try { cb(n); } catch (e) { console.error(e); }
|
|
930
|
+
}
|
|
931
|
+
if (rows.length < 100 || !advanced) break;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} catch (_) { /* Retry from the last delivered ID on the next event. */ }
|
|
935
|
+
finally { state.running = false; }
|
|
936
|
+
};
|
|
937
|
+
const stop = live('_benmore_notifications', (ev) => {
|
|
938
|
+
if (ev.action !== 'insert' || !current.enabled) return;
|
|
939
|
+
current.pending = true;
|
|
940
|
+
void drain(current);
|
|
941
|
+
});
|
|
942
|
+
return () => { active = false; _sessionListeners.delete(reset); stop(); };
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
// =================================================================
|
|
947
|
+
// 12. Upload - multipart file upload
|
|
948
|
+
// =================================================================
|
|
949
|
+
|
|
950
|
+
// The framework's upload handler accepts CSRF in EITHER the header
|
|
951
|
+
// OR a `_csrf` form field. Sending both makes the call work whether
|
|
952
|
+
// the deployed framework version honors header-CSRF for multipart
|
|
953
|
+
// (newer) or only form-CSRF (older).
|
|
954
|
+
export async function upload(file, opts = {}) {
|
|
955
|
+
const csrf = CSRF_META();
|
|
956
|
+
const fd = new FormData();
|
|
957
|
+
fd.append('file', file, opts.filename || (file.name || 'upload'));
|
|
958
|
+
if (opts.kind) fd.append('kind', opts.kind);
|
|
959
|
+
if (csrf) fd.append('_csrf', csrf);
|
|
960
|
+
const res = await fetch('/api/_upload', {
|
|
961
|
+
method: 'POST',
|
|
962
|
+
credentials: 'same-origin',
|
|
963
|
+
headers: { 'X-CSRF-Token': csrf },
|
|
964
|
+
body: fd,
|
|
965
|
+
});
|
|
966
|
+
if (!res.ok) {
|
|
967
|
+
const text = await res.text();
|
|
968
|
+
throw new Error(`upload failed (${res.status}): ${text}`);
|
|
969
|
+
}
|
|
970
|
+
return res.json();
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// =================================================================
|
|
974
|
+
// 13. Audit log (admin endpoint)
|
|
975
|
+
// =================================================================
|
|
976
|
+
|
|
977
|
+
export const audit = {
|
|
978
|
+
async list(filter = {}) {
|
|
979
|
+
const qs = new URLSearchParams();
|
|
980
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
981
|
+
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
|
982
|
+
}
|
|
983
|
+
const path = '/api/_audit' + (qs.toString() ? '?' + qs : '');
|
|
984
|
+
const r = await api.get(path);
|
|
985
|
+
return Array.isArray(r) ? r : (r.entries || []);
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
// =================================================================
|
|
990
|
+
// 14. Permissions - per-row ACLs
|
|
991
|
+
// =================================================================
|
|
992
|
+
|
|
993
|
+
// Permission enum matches the framework's permissionLevel map:
|
|
994
|
+
// 'view' | 'edit' | 'delete' | 'admin' (see permissions.go).
|
|
995
|
+
// Body shape: { email, permission, expires_at? }.
|
|
996
|
+
export const permissions = {
|
|
997
|
+
share: (table, id, email, permission = 'view', expires_at) =>
|
|
998
|
+
api.post(`/api/${_seg(table)}/${_seg(id)}/share`,
|
|
999
|
+
{ email, permission, ...(expires_at ? { expires_at } : {}) }),
|
|
1000
|
+
revoke: (table, id, email) =>
|
|
1001
|
+
api.delete(`/api/${_seg(table)}/${_seg(id)}/permissions/${_seg(email)}`),
|
|
1002
|
+
list: (table, id) =>
|
|
1003
|
+
api.get(`/api/${_seg(table)}/${_seg(id)}/permissions`),
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// =================================================================
|
|
1007
|
+
// 15. Signed URLs - time-limited file access
|
|
1008
|
+
// =================================================================
|
|
1009
|
+
|
|
1010
|
+
// The framework exposes GET /api/_signed_url?path=...&expires_in=...
|
|
1011
|
+
// (signed_urls.go). Older docs spelled this `/api/_signed-url` (POST);
|
|
1012
|
+
// stay aligned with the live shape.
|
|
1013
|
+
// signedUrl(path, opts) - mint a time-limited URL for a file.
|
|
1014
|
+
// opts can be a number (ttlSeconds, back-compat) OR
|
|
1015
|
+
// { ttlSeconds?, record?: { table, id } }.
|
|
1016
|
+
// PRIVATE files on the platform media CDN REQUIRE `record` - the row that
|
|
1017
|
+
// owns the file. The server authorizes the signature against THAT record's
|
|
1018
|
+
// access policy (roles, perms, scopes, group/owner/ACL), not just a logged-in
|
|
1019
|
+
// session, then signs. Public / local files don't need it.
|
|
1020
|
+
// await bm.signedUrl(doc.file_url, { record: { table: 'documents', id: doc.id } })
|
|
1021
|
+
// Default lifetime: 5 minutes, except audio/video, which get the server's
|
|
1022
|
+
// playback lifetime (4h): a <video>/<audio> keeps range-requesting the URL
|
|
1023
|
+
// for as long as it plays, so a 5-minute link stalls a long video.
|
|
1024
|
+
const SIGNED_MEDIA_RE = /\.(mp4|m4v|mov|webm|ogv|mkv|3gp|mp3|m4a|aac|wav|flac|ogg|oga|opus|weba)([?#]|$)/i;
|
|
1025
|
+
export async function signedUrl(path, opts) {
|
|
1026
|
+
let ttlSeconds, record = null;
|
|
1027
|
+
if (typeof opts === 'number') ttlSeconds = opts;
|
|
1028
|
+
else if (opts && typeof opts === 'object') { ttlSeconds = opts.ttlSeconds ?? opts.ttl; record = opts.record || null; }
|
|
1029
|
+
if (ttlSeconds == null && !SIGNED_MEDIA_RE.test(String(path))) ttlSeconds = 300;
|
|
1030
|
+
const qs = new URLSearchParams({ path: String(path) });
|
|
1031
|
+
if (ttlSeconds != null) qs.set('expires_in', String(ttlSeconds));
|
|
1032
|
+
if (record && record.table && record.id != null) {
|
|
1033
|
+
qs.set('record_table', String(record.table));
|
|
1034
|
+
qs.set('record_id', String(record.id));
|
|
1035
|
+
}
|
|
1036
|
+
const r = await api.get('/api/_signed_url?' + qs.toString());
|
|
1037
|
+
// Prefer the ROOT-RELATIVE path: it inherits the page's scheme/host, so
|
|
1038
|
+
// it works on https (the absolute `url` is built http:// behind CF/TLS
|
|
1039
|
+
// termination and would be blocked as mixed-content + CSP img-src 'self').
|
|
1040
|
+
return r.path || r.url || r;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// =================================================================
|
|
1044
|
+
// 16. I18n - typed translation helper
|
|
1045
|
+
//
|
|
1046
|
+
// At runtime, the framework injects the active language's dictionary
|
|
1047
|
+
// as window.__bm_i18n.strings via the per-page bootstrap. bm.t reads
|
|
1048
|
+
// from there and falls back to the raw key when missing.
|
|
1049
|
+
// =================================================================
|
|
1050
|
+
|
|
1051
|
+
export function t(key, vars) {
|
|
1052
|
+
const dict = (typeof window !== 'undefined' && window.__bm_i18n?.strings) || {};
|
|
1053
|
+
const out = Object.prototype.hasOwnProperty.call(dict, key) ? (dict[key] ?? key) : key;
|
|
1054
|
+
if (!vars) return out;
|
|
1055
|
+
// Match the original placeholders once. Variable names and inserted values
|
|
1056
|
+
// are literal data: no regex metacharacters, replacement-dollar semantics,
|
|
1057
|
+
// inherited keys, or recursive expansion of newly inserted placeholders.
|
|
1058
|
+
return out.replace(/\{\{\s*([^{}]*?)\s*\}\}/g, (match, name) =>
|
|
1059
|
+
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// =================================================================
|
|
1063
|
+
// 17. MFA - TOTP enrollment + verification
|
|
1064
|
+
// =================================================================
|
|
1065
|
+
|
|
1066
|
+
// =================================================================
|
|
1067
|
+
// 13b. Broadcast - SFU-backed 1:N livestream (v2.7.29+)
|
|
1068
|
+
//
|
|
1069
|
+
// Peer-mesh WebRTC chokes at ~5 viewers because the publisher uploads
|
|
1070
|
+
// N copies of their media (one per viewer). bm.broadcast routes media
|
|
1071
|
+
// through Cloudflare Realtime SFU - publisher uploads ONCE, the SFU
|
|
1072
|
+
// fans out. Scales to hundreds of viewers; sub-second latency.
|
|
1073
|
+
//
|
|
1074
|
+
// Usage:
|
|
1075
|
+
//
|
|
1076
|
+
// // Publisher (the streamer):
|
|
1077
|
+
// const stream = await navigator.mediaDevices.getUserMedia({video:true, audio:true});
|
|
1078
|
+
// const broadcast = await bm.broadcast.publish('my-stream-slug', stream);
|
|
1079
|
+
// // … broadcast.stop() when done
|
|
1080
|
+
//
|
|
1081
|
+
// // Subscriber (every viewer):
|
|
1082
|
+
// const subscription = await bm.broadcast.subscribe('my-stream-slug');
|
|
1083
|
+
// videoElement.srcObject = subscription.stream;
|
|
1084
|
+
// // … subscription.stop() when leaving
|
|
1085
|
+
//
|
|
1086
|
+
// // Stats (anyone, for "N watching" pills):
|
|
1087
|
+
// const { live, viewers } = await bm.broadcast.stats('my-stream-slug');
|
|
1088
|
+
//
|
|
1089
|
+
// Falls back gracefully when the platform operator hasn't configured
|
|
1090
|
+
// an SFU app - bm.broadcast.publish() throws { status: 503,
|
|
1091
|
+
// reason: 'SFU not configured' } and apps can recover to peer-mesh.
|
|
1092
|
+
// =================================================================
|
|
1093
|
+
|
|
1094
|
+
async function broadcastIceServers() {
|
|
1095
|
+
// Re-use the same TURN/STUN resolver - SFU sessions still need ICE.
|
|
1096
|
+
try { return await webrtc.iceServers(); }
|
|
1097
|
+
catch { return [{ urls: ['stun:stun.l.google.com:19302'] }]; }
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// waitForICEGathering blocks until the PC has finished gathering ICE
|
|
1101
|
+
// candidates (or a timeout fires). Without this the SDP we send to
|
|
1102
|
+
// CF's SFU has no ICE candidates → CF can't establish the UDP
|
|
1103
|
+
// connection → SDP exchange succeeds but media never flows.
|
|
1104
|
+
// 2s timeout: ICE gathering on a healthy network completes in <500ms;
|
|
1105
|
+
// the timeout is a backstop in case `iceGatheringState === 'complete'`
|
|
1106
|
+
// never fires (rare browser bug - Firefox <90 with certain TURN setups).
|
|
1107
|
+
function waitForICEGathering(pc) {
|
|
1108
|
+
if (pc.iceGatheringState === 'complete') return Promise.resolve();
|
|
1109
|
+
return new Promise(resolve => {
|
|
1110
|
+
let done = false;
|
|
1111
|
+
const finish = () => { if (done) return; done = true; resolve(); };
|
|
1112
|
+
pc.addEventListener('icegatheringstatechange', () => {
|
|
1113
|
+
if (pc.iceGatheringState === 'complete') finish();
|
|
1114
|
+
});
|
|
1115
|
+
setTimeout(finish, 2000);
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function broadcastSDPExchange(path, slug, sdpOffer) {
|
|
1120
|
+
const res = await request('POST', path, { slug, sdp: sdpOffer });
|
|
1121
|
+
if (!res.ok) {
|
|
1122
|
+
const err = new Error(res.data?.error || 'broadcast: SDP exchange failed');
|
|
1123
|
+
err.status = res.status;
|
|
1124
|
+
err.body = res.data;
|
|
1125
|
+
throw err;
|
|
1126
|
+
}
|
|
1127
|
+
return res.data; // { sdp, session_id, slug }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// subscribeWithPolicy is the workhorse behind bm.broadcast.subscribe.
|
|
1131
|
+
// First call uses iceTransportPolicy: 'all' (default) - tries direct
|
|
1132
|
+
// path, then reflexive, then relay. If ICE state hits 'failed' or
|
|
1133
|
+
// stalls in 'checking' for >10s (typical cellular symptom - UDP packets
|
|
1134
|
+
// silently dropped by carrier NAT), we tear down and retry with
|
|
1135
|
+
// iceTransportPolicy: 'relay'. That forces every candidate to go through
|
|
1136
|
+
// TURN, which CF can ALWAYS reach because TURN gives the subscriber a
|
|
1137
|
+
// stable, externally-reachable allocation address.
|
|
1138
|
+
//
|
|
1139
|
+
// Why subscribers need this more than publishers: publishers send a
|
|
1140
|
+
// constant stream of RTP packets to CF, keeping the NAT mapping fresh.
|
|
1141
|
+
// Subscribers send only sparse RTCP feedback, so address-restricted
|
|
1142
|
+
// cone NATs (common on cellular) often refuse CF's RTP responses after
|
|
1143
|
+
// the first few seconds. TURN solves this by holding a stable allocation.
|
|
1144
|
+
async function subscribeWithPolicy(slug, policy, attempt = 1) {
|
|
1145
|
+
const ice = await broadcastIceServers();
|
|
1146
|
+
const pc = new RTCPeerConnection({ iceServers: ice, iceTransportPolicy: policy });
|
|
1147
|
+
|
|
1148
|
+
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
1149
|
+
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
1150
|
+
|
|
1151
|
+
const remoteStream = new MediaStream();
|
|
1152
|
+
pc.ontrack = (ev) => {
|
|
1153
|
+
if (ev.streams && ev.streams[0]) {
|
|
1154
|
+
ev.streams[0].getTracks().forEach(t => remoteStream.addTrack(t));
|
|
1155
|
+
} else {
|
|
1156
|
+
remoteStream.addTrack(ev.track);
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
// ICE connection-state probe. If we hit 'failed' OR stay stuck in
|
|
1161
|
+
// 'checking' / 'new' for too long, retry once with relay-only. Most
|
|
1162
|
+
// cellular failures show up as 'checking' that never advances -
|
|
1163
|
+
// UDP packets just get dropped at the carrier.
|
|
1164
|
+
let resolved = false;
|
|
1165
|
+
const failurePromise = new Promise((_, reject) => {
|
|
1166
|
+
const watchdog = setTimeout(() => {
|
|
1167
|
+
if (!resolved) reject(new Error(`subscribe ICE timeout (state=${pc.iceConnectionState}, policy=${policy})`));
|
|
1168
|
+
}, 10000);
|
|
1169
|
+
pc.addEventListener('iceconnectionstatechange', () => {
|
|
1170
|
+
const s = pc.iceConnectionState;
|
|
1171
|
+
if (s === 'connected' || s === 'completed') {
|
|
1172
|
+
resolved = true;
|
|
1173
|
+
clearTimeout(watchdog);
|
|
1174
|
+
} else if (s === 'failed' || s === 'disconnected') {
|
|
1175
|
+
clearTimeout(watchdog);
|
|
1176
|
+
reject(new Error(`subscribe ICE ${s} (policy=${policy})`));
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
const offer = await pc.createOffer();
|
|
1182
|
+
await pc.setLocalDescription(offer);
|
|
1183
|
+
await waitForICEGathering(pc);
|
|
1184
|
+
|
|
1185
|
+
const sdpExchange = await broadcastSDPExchange('/api/_broadcast/subscribe', slug, pc.localDescription.sdp);
|
|
1186
|
+
await pc.setRemoteDescription({ type: 'answer', sdp: sdpExchange.sdp });
|
|
1187
|
+
|
|
1188
|
+
// Race ICE-success against the failure/timeout watchdog. If the
|
|
1189
|
+
// browser can't establish a path within 10s, retry over TURN-relay
|
|
1190
|
+
// ONLY. Don't retry forever - second failure means the network is
|
|
1191
|
+
// genuinely unreachable and we surface the error.
|
|
1192
|
+
try {
|
|
1193
|
+
await Promise.race([
|
|
1194
|
+
failurePromise,
|
|
1195
|
+
new Promise(resolve => {
|
|
1196
|
+
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
|
1197
|
+
resolved = true;
|
|
1198
|
+
resolve();
|
|
1199
|
+
} else {
|
|
1200
|
+
const check = () => {
|
|
1201
|
+
const s = pc.iceConnectionState;
|
|
1202
|
+
if (s === 'connected' || s === 'completed') {
|
|
1203
|
+
resolved = true;
|
|
1204
|
+
pc.removeEventListener('iceconnectionstatechange', check);
|
|
1205
|
+
resolve();
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
pc.addEventListener('iceconnectionstatechange', check);
|
|
1209
|
+
}
|
|
1210
|
+
}),
|
|
1211
|
+
]);
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
try { pc.close(); } catch {}
|
|
1214
|
+
if (attempt === 1 && policy !== 'relay') {
|
|
1215
|
+
console.warn('[bm.broadcast] direct/STUN subscribe failed, retrying via TURN relay:', e?.message || e);
|
|
1216
|
+
return subscribeWithPolicy(slug, 'relay', 2);
|
|
1217
|
+
}
|
|
1218
|
+
throw e;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
let stopped = false;
|
|
1222
|
+
return {
|
|
1223
|
+
slug,
|
|
1224
|
+
sessionId: sdpExchange.session_id,
|
|
1225
|
+
pc,
|
|
1226
|
+
stream: remoteStream,
|
|
1227
|
+
policy,
|
|
1228
|
+
stop() {
|
|
1229
|
+
if (stopped) return;
|
|
1230
|
+
stopped = true;
|
|
1231
|
+
try { pc.close(); } catch {}
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
export const broadcast = {
|
|
1237
|
+
/**
|
|
1238
|
+
* Publish a MediaStream to the SFU under `slug`. Returns a handle
|
|
1239
|
+
* with .stop() to tear down. Throws when SFU isn't configured -
|
|
1240
|
+
* caller can catch and fall back to peer-mesh.
|
|
1241
|
+
*/
|
|
1242
|
+
async publish(slug, mediaStream) {
|
|
1243
|
+
const ice = await broadcastIceServers();
|
|
1244
|
+
const pc = new RTCPeerConnection({ iceServers: ice });
|
|
1245
|
+
|
|
1246
|
+
// Add every track from the supplied stream. CF infers the
|
|
1247
|
+
// direction (recv/send) from the SDP, so just sendonly tracks
|
|
1248
|
+
// for a publisher.
|
|
1249
|
+
for (const track of mediaStream.getTracks()) {
|
|
1250
|
+
pc.addTransceiver(track, { direction: 'sendonly', streams: [mediaStream] });
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const offer = await pc.createOffer();
|
|
1254
|
+
await pc.setLocalDescription(offer);
|
|
1255
|
+
// CRITICAL: wait for ICE gathering to finish so pc.localDescription
|
|
1256
|
+
// has the candidate lines baked in. Without this, CF receives an
|
|
1257
|
+
// offer with no candidates → no UDP path → media never flows even
|
|
1258
|
+
// though SDP exchange succeeds. Classic WebRTC race; symptom is
|
|
1259
|
+
// "black screen but everything looks fine in the logs".
|
|
1260
|
+
await waitForICEGathering(pc);
|
|
1261
|
+
|
|
1262
|
+
const { sdp, session_id } = await broadcastSDPExchange('/api/_broadcast/publish', slug, pc.localDescription.sdp);
|
|
1263
|
+
await pc.setRemoteDescription({ type: 'answer', sdp });
|
|
1264
|
+
|
|
1265
|
+
let stopped = false;
|
|
1266
|
+
// Keep the slug claimed while live: a publisher that stops
|
|
1267
|
+
// heartbeating is treated as crashed and its slug can be taken over.
|
|
1268
|
+
const heartbeat = setInterval(() => {
|
|
1269
|
+
if (!stopped) request('POST', '/api/_broadcast/heartbeat', { slug }).catch(() => {});
|
|
1270
|
+
}, 60000);
|
|
1271
|
+
return {
|
|
1272
|
+
slug,
|
|
1273
|
+
sessionId: session_id,
|
|
1274
|
+
pc,
|
|
1275
|
+
async stop() {
|
|
1276
|
+
if (stopped) return;
|
|
1277
|
+
stopped = true;
|
|
1278
|
+
clearInterval(heartbeat);
|
|
1279
|
+
try { pc.close(); } catch {}
|
|
1280
|
+
try { await request('POST', '/api/_broadcast/stop', { slug }); } catch {}
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
},
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Subscribe to a published slug. Returns { stream, pc, stop() }.
|
|
1287
|
+
* `stream` is a MediaStream you can wire into <video>.srcObject.
|
|
1288
|
+
*
|
|
1289
|
+
* Options:
|
|
1290
|
+
* waitForPublisher: ms to keep retrying when no publisher is live
|
|
1291
|
+
* yet (404 / 503). Defaults to 30000. Set to 0
|
|
1292
|
+
* to fail fast on the first call (legacy behavior).
|
|
1293
|
+
* Multiple apps had to hand-roll
|
|
1294
|
+
* this loop because the SDK threw on the publisher
|
|
1295
|
+
* race ("A subscribes to B before B's publish
|
|
1296
|
+
* finishes ICE gathering" - both viewer-side and
|
|
1297
|
+
* huddle-second-joiner cases).
|
|
1298
|
+
* signal: AbortSignal to cancel the retry loop early.
|
|
1299
|
+
*/
|
|
1300
|
+
async subscribe(slug, opts = {}) {
|
|
1301
|
+
const waitMs = opts.waitForPublisher === undefined ? 30000 : opts.waitForPublisher;
|
|
1302
|
+
const signal = opts.signal;
|
|
1303
|
+
const deadline = Date.now() + waitMs;
|
|
1304
|
+
let attempt = 0;
|
|
1305
|
+
let lastErr;
|
|
1306
|
+
while (true) {
|
|
1307
|
+
if (signal && signal.aborted) {
|
|
1308
|
+
throw new Error('bm.broadcast.subscribe aborted');
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
return await subscribeWithPolicy(slug, 'all');
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
lastErr = e;
|
|
1314
|
+
// Only retry on "no publisher yet" shapes: 404 (slug not in
|
|
1315
|
+
// _benmore_broadcasts), 503 ({reason: 'publisher not live'}),
|
|
1316
|
+
// or a 503-like 4xx where the server's stats endpoint would
|
|
1317
|
+
// report live:false. ICE failures, network drops, auth 401s,
|
|
1318
|
+
// CSP rejections - all propagate immediately.
|
|
1319
|
+
const status = e?.status;
|
|
1320
|
+
const transient = status === 404 || status === 503;
|
|
1321
|
+
if (!transient || Date.now() >= deadline) throw e;
|
|
1322
|
+
// Backoff: 500ms, 1s, 2s, 4s, capped at 4s. Total wait inside
|
|
1323
|
+
// waitMs naturally.
|
|
1324
|
+
const delay = Math.min(500 * Math.pow(2, attempt), 4000);
|
|
1325
|
+
attempt++;
|
|
1326
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/* eslint-disable-next-line no-unreachable */
|
|
1330
|
+
throw lastErr;
|
|
1331
|
+
},
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Returns { live: bool, viewers: number, started_at?: string }.
|
|
1335
|
+
* Safe to poll; cheap (one indexed lookup).
|
|
1336
|
+
*/
|
|
1337
|
+
async stats(slug) {
|
|
1338
|
+
const res = await request('GET', `/api/_broadcast/stats/${_seg(slug)}`);
|
|
1339
|
+
return res.data || { live: false, viewers: 0 };
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
// =================================================================
|
|
1344
|
+
// 14. WebRTC - ICE servers (STUN + optional TURN)
|
|
1345
|
+
//
|
|
1346
|
+
// Agents building peer-to-peer apps (livestream, voice, screen-share)
|
|
1347
|
+
// should NEVER hand-roll ICE config. STUN alone fails for ~30-50% of
|
|
1348
|
+
// real-world connections (symmetric NAT on cellular, double-NAT homes).
|
|
1349
|
+
// `bm.webrtc.iceServers()` returns whatever the framework has been
|
|
1350
|
+
// configured to mint - STUN by default, plus TURN when the operator
|
|
1351
|
+
// sets TURN_PROVIDER + creds via `benmore env set` on the deployed app.
|
|
1352
|
+
//
|
|
1353
|
+
// Cached in-memory per-tab for 30min (TURN creds typically last 1h
|
|
1354
|
+
// server-side; we refresh before they expire).
|
|
1355
|
+
// =================================================================
|
|
1356
|
+
|
|
1357
|
+
let _iceCache = null;
|
|
1358
|
+
let _iceCacheUntil = 0;
|
|
1359
|
+
|
|
1360
|
+
export const webrtc = {
|
|
1361
|
+
/**
|
|
1362
|
+
* Returns the configured ICE servers array, suitable to pass directly
|
|
1363
|
+
* to `new RTCPeerConnection({ iceServers: ... })`.
|
|
1364
|
+
*
|
|
1365
|
+
* Always includes STUN. Includes TURN when the operator configured
|
|
1366
|
+
* one (TURN_PROVIDER + TURN_CF_APP_ID/TURN_CF_APP_TOKEN for Cloudflare,
|
|
1367
|
+
* or TURN_URL/TURN_USERNAME/TURN_CREDENTIAL for a static provider).
|
|
1368
|
+
*/
|
|
1369
|
+
async iceServers() {
|
|
1370
|
+
if (_iceCache && Date.now() < _iceCacheUntil) return _iceCache;
|
|
1371
|
+
const res = await request('GET', '/api/_webrtc/ice');
|
|
1372
|
+
const list = res.data?.iceServers || [{ urls: ['stun:stun.l.google.com:19302'] }];
|
|
1373
|
+
_iceCache = list;
|
|
1374
|
+
_iceCacheUntil = Date.now() + 30 * 60 * 1000;
|
|
1375
|
+
return list;
|
|
1376
|
+
},
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
export const mfa = {
|
|
1380
|
+
// Signed-in enrollment re-proves the current password; the login-issued
|
|
1381
|
+
// setup token path (require_mfa_for_roles) needs none.
|
|
1382
|
+
enroll: (password) => api.post('/api/_auth/mfa/setup', password ? { password } : {}),
|
|
1383
|
+
verify: (code) => api.post('/api/_auth/mfa/verify', { code }),
|
|
1384
|
+
disable: (password) => api.post('/api/_auth/mfa/disable', { password }),
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// =================================================================
|
|
1388
|
+
// 18. Soft-delete restore + versioning helpers - patched onto the
|
|
1389
|
+
// table() return value when the table has the feature. Pure
|
|
1390
|
+
// runtime: when the developer hits .restore on a table that
|
|
1391
|
+
// doesn't have deleted_at, the server returns a clear error.
|
|
1392
|
+
// The TS types stop the call at compile time.
|
|
1393
|
+
// =================================================================
|
|
1394
|
+
|
|
1395
|
+
// (Already inside table() factory above for runtime - the type
|
|
1396
|
+
// declaration in bm.d.ts gates compile-time access.)
|
|
1397
|
+
|
|
1398
|
+
// =================================================================
|
|
1399
|
+
// Default export
|
|
1400
|
+
// =================================================================
|
|
1401
|
+
|
|
1402
|
+
// =================================================================
|
|
1403
|
+
// bm.html - XSS-safe tagged template literal (v2.7.66+)
|
|
1404
|
+
//
|
|
1405
|
+
// Every interpolated value gets HTML-escaped automatically. Stop
|
|
1406
|
+
// writing manual escapeHtml() calls; stop forgetting them. Use this
|
|
1407
|
+
// for ALL innerHTML / template-literal HTML construction.
|
|
1408
|
+
//
|
|
1409
|
+
// el.innerHTML = bm.html`<li>${user.name}</li>`; // safe
|
|
1410
|
+
// el.innerHTML = bm.html`<a href="${url}">${title}</a>`; // both escaped
|
|
1411
|
+
//
|
|
1412
|
+
// Opt-out per-value via bm.raw(html) when the value is ALREADY safe
|
|
1413
|
+
// HTML (component output, sanitized markdown, framework-rendered icons):
|
|
1414
|
+
//
|
|
1415
|
+
// el.innerHTML = bm.html`<div>${bm.raw(iconHtml)}${user.name}</div>`;
|
|
1416
|
+
//
|
|
1417
|
+
// Arrays of strings are joined automatically, so a render loop reads:
|
|
1418
|
+
//
|
|
1419
|
+
// el.innerHTML = bm.html`<ul>${rows.map(r => bm.html`<li>${r.name}</li>`)}</ul>`;
|
|
1420
|
+
//
|
|
1421
|
+
// Notes:
|
|
1422
|
+
// - Returns a plain string - drop-in replacement for normal template
|
|
1423
|
+
// literals; nothing else changes about the calling code.
|
|
1424
|
+
// - Escapes the 5 XSS-relevant characters: & < > " '
|
|
1425
|
+
// - Nested bm.html`…` values are NOT double-escaped (they're tagged
|
|
1426
|
+
// as raw via a hidden symbol so the outer template knows they're
|
|
1427
|
+
// already safe). Same idea as React/lit's html`` tag.
|
|
1428
|
+
// - null / undefined render as empty string (matches lit / Preact).
|
|
1429
|
+
// =================================================================
|
|
1430
|
+
|
|
1431
|
+
const _bmHtmlSafe = Symbol('bm.html.safe');
|
|
1432
|
+
|
|
1433
|
+
function _escForHtml(s) {
|
|
1434
|
+
const str = String(s);
|
|
1435
|
+
// Single-pass replace via charCodeAt is faster than chained .replace
|
|
1436
|
+
// for typical short strings; the difference matters in render loops
|
|
1437
|
+
// that hit thousands of cells per second.
|
|
1438
|
+
let out = '';
|
|
1439
|
+
for (let i = 0; i < str.length; i++) {
|
|
1440
|
+
const c = str.charCodeAt(i);
|
|
1441
|
+
if (c === 38) out += '&'; // &
|
|
1442
|
+
else if (c === 60) out += '<'; // <
|
|
1443
|
+
else if (c === 62) out += '>'; // >
|
|
1444
|
+
else if (c === 34) out += '"'; // "
|
|
1445
|
+
else if (c === 39) out += '''; // '
|
|
1446
|
+
else out += str[i];
|
|
1447
|
+
}
|
|
1448
|
+
return out;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function _interpolate(v) {
|
|
1452
|
+
if (v == null) return '';
|
|
1453
|
+
// Already-safe HTML (from bm.raw / nested bm.html / Array of either).
|
|
1454
|
+
if (typeof v === 'object' && v[_bmHtmlSafe] === true) return v.value;
|
|
1455
|
+
if (Array.isArray(v)) {
|
|
1456
|
+
let out = '';
|
|
1457
|
+
for (const item of v) out += _interpolate(item);
|
|
1458
|
+
return out;
|
|
1459
|
+
}
|
|
1460
|
+
return _escForHtml(v);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// A value that starts a URL attribute can't be made safe by escaping:
|
|
1464
|
+
// href="javascript:..." still runs. Same rule as server-rendered pages
|
|
1465
|
+
// (template_context.go dangerousURL): javascript:, vbscript:, livescript:
|
|
1466
|
+
// and data: other than a raster image become #blocked-unsafe-url, with the
|
|
1467
|
+
// scheme read as the URL parser reads it (leading control characters and any
|
|
1468
|
+
// tab or newline ignored). bm.raw(url) opts out.
|
|
1469
|
+
const _urlAttrOpen = /\s(?:href|src|action|formaction|xlink:href|poster|data|cite|background)\s*=\s*["']?$/i;
|
|
1470
|
+
const _rasterDataURL = /^data:\s*image\/(png|gif|jpe?g|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)[;,]/i;
|
|
1471
|
+
function _scriptURL(v) {
|
|
1472
|
+
const u = String(v).replace(/^[\x00-\x20]+/, '').replace(/[\t\n\r]/g, '');
|
|
1473
|
+
const m = /^([^:/?#]+):/.exec(u);
|
|
1474
|
+
if (!m) return false;
|
|
1475
|
+
const scheme = m[1].toLowerCase();
|
|
1476
|
+
return scheme === 'javascript' || scheme === 'vbscript' || scheme === 'livescript' ||
|
|
1477
|
+
(scheme === 'data' && !_rasterDataURL.test(u));
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
export function html(strings, ...values) {
|
|
1481
|
+
let out = strings[0];
|
|
1482
|
+
for (let i = 0; i < values.length; i++) {
|
|
1483
|
+
let v = values[i];
|
|
1484
|
+
if (v != null && typeof v !== 'object' && _urlAttrOpen.test(strings[i]) && _scriptURL(v)) v = '#blocked-unsafe-url';
|
|
1485
|
+
out += _interpolate(v) + strings[i + 1];
|
|
1486
|
+
}
|
|
1487
|
+
// Tag the result so it can be nested into another bm.html`...`
|
|
1488
|
+
// without being escaped a second time.
|
|
1489
|
+
return { [_bmHtmlSafe]: true, value: out, toString() { return out; } };
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** Mark a string as already-safe HTML - escapes the surrounding
|
|
1493
|
+
* bm.html`...` interpolation but trusts THIS value verbatim. Use for
|
|
1494
|
+
* component output, sanitized markdown, framework icon helpers. */
|
|
1495
|
+
export function raw(htmlString) {
|
|
1496
|
+
return { [_bmHtmlSafe]: true, value: String(htmlString ?? '') };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// markdown - async loader for /_internal/markdown.js. Imported lazily
|
|
1500
|
+
// on first call so apps that don't render markdown don't pay the
|
|
1501
|
+
// download cost. The renderer escapes HTML before parsing, allowlists
|
|
1502
|
+
// http/https/mailto link schemes, and supports **bold** / *italic* /
|
|
1503
|
+
// `code` / ```fenced``` / [links](url) / lists / blockquotes. Chat,
|
|
1504
|
+
// notes, comments - anywhere user-provided text becomes display HTML.
|
|
1505
|
+
//
|
|
1506
|
+
// const html = await bm.markdown(messageText);
|
|
1507
|
+
// messageEl.innerHTML = html;
|
|
1508
|
+
// markdown renders via the server-side benmark engine
|
|
1509
|
+
// (POST /_internal/markdown): robust CommonMark + GFM + syntax highlighting,
|
|
1510
|
+
// mermaid diagrams, and media embeds - one renderer shared with every other
|
|
1511
|
+
// framework surface. Results are cached per text. On network failure we fall
|
|
1512
|
+
// back to the tiny offline /_internal/markdown.js subset (and, failing that,
|
|
1513
|
+
// HTML-escaped plaintext), so a message NEVER renders as raw or unsanitized
|
|
1514
|
+
// HTML. Pass { inline: true } for the single-line chat subset.
|
|
1515
|
+
const _mdCache = new Map();
|
|
1516
|
+
let _mdFallback = null;
|
|
1517
|
+
async function _mdFallbackFn(text, inline) {
|
|
1518
|
+
if (_mdFallback == null) {
|
|
1519
|
+
try { const m = await import('/_internal/markdown.js'); _mdFallback = m.markdown || m.default; }
|
|
1520
|
+
catch (_) {
|
|
1521
|
+
_mdFallback = (t) => String(t == null ? '' : t).replace(
|
|
1522
|
+
/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
return _mdFallback(text, inline);
|
|
1526
|
+
}
|
|
1527
|
+
async function markdown(text, opts) {
|
|
1528
|
+
if (text == null) return '';
|
|
1529
|
+
const inline = !!(opts && opts.inline);
|
|
1530
|
+
const key = (inline ? '1' : '0') + String(text);
|
|
1531
|
+
if (_mdCache.has(key)) return _mdCache.get(key);
|
|
1532
|
+
try {
|
|
1533
|
+
const res = await fetch('/_internal/markdown', {
|
|
1534
|
+
method: 'POST',
|
|
1535
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1536
|
+
body: JSON.stringify({ text: String(text), inline }),
|
|
1537
|
+
});
|
|
1538
|
+
if (!res.ok) throw new Error('markdown ' + res.status);
|
|
1539
|
+
const html = await res.text();
|
|
1540
|
+
if (_mdCache.size > 500) _mdCache.clear();
|
|
1541
|
+
_mdCache.set(key, html);
|
|
1542
|
+
return html;
|
|
1543
|
+
} catch (_) {
|
|
1544
|
+
return _mdFallbackFn(text, inline);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// markdownBatch renders many strings in ONE round-trip (POST
|
|
1548
|
+
// /_internal/markdown/batch) - use it to render a whole message/notes list at
|
|
1549
|
+
// once. items is an array of strings or { text, inline } objects; returns an
|
|
1550
|
+
// array of HTML strings aligned by index. Results are added to the cache.
|
|
1551
|
+
async function markdownBatch(items) {
|
|
1552
|
+
const norm = (items || []).map((it) =>
|
|
1553
|
+
typeof it === 'string' ? { text: it, inline: false }
|
|
1554
|
+
: { text: String((it && it.text) || ''), inline: !!(it && it.inline) });
|
|
1555
|
+
if (!norm.length) return [];
|
|
1556
|
+
try {
|
|
1557
|
+
const res = await fetch('/_internal/markdown/batch', {
|
|
1558
|
+
method: 'POST',
|
|
1559
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1560
|
+
body: JSON.stringify({ items: norm }),
|
|
1561
|
+
});
|
|
1562
|
+
if (!res.ok) throw new Error('markdown batch ' + res.status);
|
|
1563
|
+
const out = (await res.json()).html || [];
|
|
1564
|
+
norm.forEach((n, i) => { if (out[i] != null) _mdCache.set((n.inline ? '1' : '0') + n.text, out[i]); });
|
|
1565
|
+
return out;
|
|
1566
|
+
} catch (_) {
|
|
1567
|
+
return Promise.all(norm.map((n) => markdown(n.text, { inline: n.inline })));
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// =================================================================
|
|
1572
|
+
// presence - "who is live in <slug> right now"
|
|
1573
|
+
//
|
|
1574
|
+
// Wraps the three-layer pattern every chat / huddle / live app
|
|
1575
|
+
// hand-rolled before v2.7.35:
|
|
1576
|
+
// - 20s heartbeat PATCH so the server knows the tab is alive
|
|
1577
|
+
// - pagehide + sendBeacon to mark "I've left" on tab close
|
|
1578
|
+
// - server cron that sweeps expired rows (presence.go)
|
|
1579
|
+
//
|
|
1580
|
+
// Usage:
|
|
1581
|
+
// const p = bm.presence('huddle:42'); // joins, starts heartbeat
|
|
1582
|
+
// p.members(); // → [{user_id, joined_at,...}]
|
|
1583
|
+
// p.leave(); // explicit; pagehide auto-fires
|
|
1584
|
+
//
|
|
1585
|
+
// Stays subscribed to `_benmore_presence` SSE events so any change
|
|
1586
|
+
// (anyone joining / leaving / sweep expiry) triggers an updated count.
|
|
1587
|
+
// =================================================================
|
|
1588
|
+
|
|
1589
|
+
function presence(slug) {
|
|
1590
|
+
if (typeof slug !== 'string' || !slug) {
|
|
1591
|
+
throw new Error('bm.presence(slug) requires a non-empty string');
|
|
1592
|
+
}
|
|
1593
|
+
let left = false;
|
|
1594
|
+
let heartbeatTimer = null;
|
|
1595
|
+
let onChangeUnsub = null;
|
|
1596
|
+
|
|
1597
|
+
const post = (action) => api.post('/api/_presence/heartbeat', { slug, action }).catch(() => {});
|
|
1598
|
+
const sendBeaconLeave = () => {
|
|
1599
|
+
// Beacon survives tab close - fetch with keepalive is the right
|
|
1600
|
+
// primitive but sendBeacon is the only one with universal support.
|
|
1601
|
+
if (left) return;
|
|
1602
|
+
left = true;
|
|
1603
|
+
try {
|
|
1604
|
+
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
1605
|
+
const blob = new Blob(
|
|
1606
|
+
[JSON.stringify({ slug, action: 'leave', _csrf: csrf })],
|
|
1607
|
+
{ type: 'application/json' }
|
|
1608
|
+
);
|
|
1609
|
+
navigator.sendBeacon?.('/api/_presence/heartbeat', blob);
|
|
1610
|
+
} catch {}
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
// Initial join + start the 18s heartbeat (slightly under the 20s
|
|
1614
|
+
// the server advertises so we're never close to the boundary).
|
|
1615
|
+
post('join');
|
|
1616
|
+
heartbeatTimer = setInterval(() => { if (!left) post('heartbeat'); }, 18000);
|
|
1617
|
+
|
|
1618
|
+
// pagehide is more reliable than beforeunload across browsers.
|
|
1619
|
+
// Both fire when the tab is closed; pagehide also fires on bfcache.
|
|
1620
|
+
if (typeof window !== 'undefined') {
|
|
1621
|
+
window.addEventListener('pagehide', sendBeaconLeave);
|
|
1622
|
+
window.addEventListener('beforeunload', sendBeaconLeave);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return {
|
|
1626
|
+
slug,
|
|
1627
|
+
async members() {
|
|
1628
|
+
const res = await api.get('/api/_presence?slug=' + encodeURIComponent(slug));
|
|
1629
|
+
return Array.isArray(res?.members) ? res.members : [];
|
|
1630
|
+
},
|
|
1631
|
+
async count() {
|
|
1632
|
+
const res = await api.get('/api/_presence?slug=' + encodeURIComponent(slug));
|
|
1633
|
+
return typeof res?.count === 'number' ? res.count : 0;
|
|
1634
|
+
},
|
|
1635
|
+
// Subscribe to SSE invalidations on the presence table; callback
|
|
1636
|
+
// fires after any join/leave/sweep so the caller can refresh.
|
|
1637
|
+
onChange(fn) {
|
|
1638
|
+
if (typeof fn !== 'function') return () => {};
|
|
1639
|
+
onChangeUnsub = live('_benmore_presence', () => fn());
|
|
1640
|
+
return onChangeUnsub;
|
|
1641
|
+
},
|
|
1642
|
+
leave() {
|
|
1643
|
+
if (left) return;
|
|
1644
|
+
left = true;
|
|
1645
|
+
clearInterval(heartbeatTimer);
|
|
1646
|
+
heartbeatTimer = null;
|
|
1647
|
+
if (onChangeUnsub) { onChangeUnsub(); onChangeUnsub = null; }
|
|
1648
|
+
post('leave');
|
|
1649
|
+
},
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// =================================================================
|
|
1654
|
+
// cache - self-busting sessionStorage / localStorage helper
|
|
1655
|
+
//
|
|
1656
|
+
// One app.s pre-paint pattern (a sessionStorage cache key) went
|
|
1657
|
+
// stale when v=6 → v=7 because the cache version was hardcoded and
|
|
1658
|
+
// never tied to the actual bundle's mtime. Result: agents shipped a
|
|
1659
|
+
// new sidebar, browsers painted the OLD cached one, debugging cost
|
|
1660
|
+
// hours.
|
|
1661
|
+
//
|
|
1662
|
+
// bm.cache.namespaced(name, version) returns a 4-method client whose
|
|
1663
|
+
// storage key includes the version. On version change, the old key
|
|
1664
|
+
// reads as miss (it's there but invisible) and a subsequent write
|
|
1665
|
+
// lands at the new key - the old entry GCs naturally when storage
|
|
1666
|
+
// pressure hits, or you can call .purgeOld() to evict eagerly.
|
|
1667
|
+
//
|
|
1668
|
+
// Usage:
|
|
1669
|
+
// const shell = bm.cache.namespaced('shell', staticAssetVersion);
|
|
1670
|
+
// shell.get(); // → cached value or undefined
|
|
1671
|
+
// shell.set(html); // store
|
|
1672
|
+
// shell.clear(); // current-version only
|
|
1673
|
+
// shell.purgeOld(); // drop every prior version of this name
|
|
1674
|
+
// =================================================================
|
|
1675
|
+
|
|
1676
|
+
function makeCacheNamespace(storage, name, version) {
|
|
1677
|
+
if (!storage || typeof name !== 'string' || !name) return null;
|
|
1678
|
+
const ver = String(version || 'v0');
|
|
1679
|
+
const key = `bm:${name}:${ver}`;
|
|
1680
|
+
const prefix = `bm:${name}:`;
|
|
1681
|
+
return {
|
|
1682
|
+
name, version: ver,
|
|
1683
|
+
get() {
|
|
1684
|
+
try { return storage.getItem(key) ?? undefined; } catch { return undefined; }
|
|
1685
|
+
},
|
|
1686
|
+
set(value) {
|
|
1687
|
+
try { storage.setItem(key, String(value)); } catch {}
|
|
1688
|
+
},
|
|
1689
|
+
clear() {
|
|
1690
|
+
try { storage.removeItem(key); } catch {}
|
|
1691
|
+
},
|
|
1692
|
+
purgeOld() {
|
|
1693
|
+
try {
|
|
1694
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
1695
|
+
const k = storage.key(i);
|
|
1696
|
+
if (k && k.startsWith(prefix) && k !== key) storage.removeItem(k);
|
|
1697
|
+
}
|
|
1698
|
+
} catch {}
|
|
1699
|
+
},
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const cache = {
|
|
1704
|
+
/** Session-scoped self-busting cache. Survives the tab but not the
|
|
1705
|
+
* browser process. The right choice for "pre-paint the shell" and
|
|
1706
|
+
* other this-session ephemera. */
|
|
1707
|
+
namespaced(name, version) {
|
|
1708
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
1709
|
+
return makeCacheNamespace(sessionStorage, name, version);
|
|
1710
|
+
},
|
|
1711
|
+
/** Persistent self-busting cache. Survives reloads + browser
|
|
1712
|
+
* restarts. Use for "remember the user's last filter" - anything
|
|
1713
|
+
* useful across sessions but invalidatable per-deploy. */
|
|
1714
|
+
persistent(name, version) {
|
|
1715
|
+
if (typeof localStorage === 'undefined') return null;
|
|
1716
|
+
return makeCacheNamespace(localStorage, name, version);
|
|
1717
|
+
},
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
// =================================================================
|
|
1721
|
+
// store - small app-state primitive (Zustand-shaped, no dependency)
|
|
1722
|
+
// =================================================================
|
|
1723
|
+
|
|
1724
|
+
function shallowEqualObject(a, b) {
|
|
1725
|
+
if (a === b) return true;
|
|
1726
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
1727
|
+
const ak = Object.keys(a), bk = Object.keys(b);
|
|
1728
|
+
if (ak.length !== bk.length) return false;
|
|
1729
|
+
for (const k of ak) if (!Object.is(a[k], b[k])) return false;
|
|
1730
|
+
return true;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function resolveStoreCache(persist) {
|
|
1734
|
+
if (!persist || !persist.name) return null;
|
|
1735
|
+
const kind = persist.storage === 'persistent' ? 'persistent' : 'namespaced';
|
|
1736
|
+
return cache[kind](persist.name, persist.version || 'v0');
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
export function createStore(initialState = {}, opts = {}) {
|
|
1740
|
+
const base = (initialState && typeof initialState === 'object') ? initialState : {};
|
|
1741
|
+
const persist = opts.persist || opts.cache;
|
|
1742
|
+
const storage = resolveStoreCache(persist);
|
|
1743
|
+
let state = base;
|
|
1744
|
+
if (storage) {
|
|
1745
|
+
try {
|
|
1746
|
+
const raw = storage.get();
|
|
1747
|
+
if (raw) {
|
|
1748
|
+
// Persisted blobs live in localStorage/sessionStorage and are therefore
|
|
1749
|
+
// attacker-controllable on the client. We only accept a plain object and
|
|
1750
|
+
// merge it over base; this is not a trust boundary (the server remains
|
|
1751
|
+
// authoritative), but it prevents a malformed/non-object blob from
|
|
1752
|
+
// replacing state wholesale or injecting a non-object root.
|
|
1753
|
+
const parsed = JSON.parse(raw);
|
|
1754
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1755
|
+
state = { ...base, ...parsed };
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
} catch {}
|
|
1759
|
+
}
|
|
1760
|
+
const initial = { ...state };
|
|
1761
|
+
const subs = new Set();
|
|
1762
|
+
|
|
1763
|
+
// Each subscriber's listener receives (nextSlice, oldSlice). The previous
|
|
1764
|
+
// value passed to listeners is the subscriber's own previously-observed slice
|
|
1765
|
+
// (which, for the default whole-state selector, IS the prior whole state), so
|
|
1766
|
+
// notify() needs no separate `prev` argument.
|
|
1767
|
+
const notify = () => {
|
|
1768
|
+
subs.forEach((sub) => {
|
|
1769
|
+
let nextSlice;
|
|
1770
|
+
try { nextSlice = sub.selector(state); } catch (e) { console.warn('[bm.store] selector threw:', e); return; }
|
|
1771
|
+
if (!sub.equal(nextSlice, sub.slice)) {
|
|
1772
|
+
const oldSlice = sub.slice;
|
|
1773
|
+
sub.slice = nextSlice;
|
|
1774
|
+
try { sub.listener(nextSlice, oldSlice); } catch (e) { console.warn('[bm.store] listener threw:', e); }
|
|
1775
|
+
}
|
|
1776
|
+
});
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
const persistState = () => {
|
|
1780
|
+
if (!storage) return;
|
|
1781
|
+
try { storage.set(JSON.stringify(state)); } catch {}
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
return {
|
|
1785
|
+
get: () => state,
|
|
1786
|
+
set(patch) {
|
|
1787
|
+
let next = (typeof patch === 'function') ? patch(state) : patch;
|
|
1788
|
+
if (next == null) return state;
|
|
1789
|
+
// State is contractually a Record<string, any>; object patches merge
|
|
1790
|
+
// shallowly. A patch (including a function result) that is an array or
|
|
1791
|
+
// other non-object would replace the whole state and break that contract,
|
|
1792
|
+
// so we reject it with a warning instead of silently clobbering state.
|
|
1793
|
+
if (typeof next !== 'object' || Array.isArray(next)) {
|
|
1794
|
+
console.warn('[bm.store] set() ignored a non-object patch; state must be a plain object');
|
|
1795
|
+
return state;
|
|
1796
|
+
}
|
|
1797
|
+
next = { ...state, ...next };
|
|
1798
|
+
if (Object.is(next, state) || shallowEqualObject(next, state)) return state;
|
|
1799
|
+
state = next;
|
|
1800
|
+
persistState();
|
|
1801
|
+
notify();
|
|
1802
|
+
return state;
|
|
1803
|
+
},
|
|
1804
|
+
subscribe(selector, listener, equal = Object.is) {
|
|
1805
|
+
if (typeof listener !== 'function') {
|
|
1806
|
+
listener = selector;
|
|
1807
|
+
selector = (s) => s;
|
|
1808
|
+
}
|
|
1809
|
+
if (typeof selector !== 'function' || typeof listener !== 'function') {
|
|
1810
|
+
throw new Error('bm.createStore().subscribe(selector, listener) requires a listener');
|
|
1811
|
+
}
|
|
1812
|
+
const sub = { selector, listener, equal, slice: selector(state) };
|
|
1813
|
+
subs.add(sub);
|
|
1814
|
+
return () => subs.delete(sub);
|
|
1815
|
+
},
|
|
1816
|
+
reset(nextState) {
|
|
1817
|
+
state = nextState && typeof nextState === 'object' ? { ...nextState } : { ...initial };
|
|
1818
|
+
persistState();
|
|
1819
|
+
notify();
|
|
1820
|
+
return state;
|
|
1821
|
+
},
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// =================================================================
|
|
1826
|
+
// query - key-based fetch/mutation cache for app data
|
|
1827
|
+
// =================================================================
|
|
1828
|
+
|
|
1829
|
+
function stableStringify(value) {
|
|
1830
|
+
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
|
1831
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
1832
|
+
const keys = Object.keys(value).sort();
|
|
1833
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function queryKey(key) {
|
|
1837
|
+
return typeof key === 'string' ? key : stableStringify(key);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const _queryCache = new Map();
|
|
1841
|
+
const _queryTableKeys = new Map();
|
|
1842
|
+
let _queryLiveStop = null;
|
|
1843
|
+
// Live-invalidation tuning. The '*' SSE subscription fires for every CRUD event
|
|
1844
|
+
// on every table the app has read, including the client's own writes, so we
|
|
1845
|
+
// coalesce invalidations per-table over a short window to avoid a refetch
|
|
1846
|
+
// storm under chatty fan-out. Refetch-on-live is opt-out via _queryLiveConfig.
|
|
1847
|
+
const _queryLiveConfig = { refetch: true, debounceMs: 50 };
|
|
1848
|
+
const _queryLiveTimers = new Map();
|
|
1849
|
+
|
|
1850
|
+
function queryEntry(key) {
|
|
1851
|
+
const k = queryKey(key);
|
|
1852
|
+
if (!_queryCache.has(k)) {
|
|
1853
|
+
_queryCache.set(k, { key: k, rawKey: key, data: undefined, error: null, updatedAt: 0, promise: null, revision: 0, rollbackOwner: null, fetcher: null, staleTime: 0, subs: new Set() });
|
|
1854
|
+
}
|
|
1855
|
+
return _queryCache.get(k);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function notifyQuery(e) {
|
|
1859
|
+
e.subs.forEach((fn) => { try { fn({ data: e.data, error: e.error, updatedAt: e.updatedAt, pending: !!e.promise }); } catch (err) { console.warn('[bm.query] subscriber threw:', err); } });
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// A fetcher may not support cancellation. Let its caller settle normally, but
|
|
1863
|
+
// revoke its right to publish data/errors or clear a newer request's pending state.
|
|
1864
|
+
function supersedeQueryFetch(e) {
|
|
1865
|
+
e.revision++;
|
|
1866
|
+
e.promise = null;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function resetSessionQueries() {
|
|
1870
|
+
if (_queryLiveStop) _queryLiveStop();
|
|
1871
|
+
_queryTableKeys.clear();
|
|
1872
|
+
const entries = [..._queryCache.values()];
|
|
1873
|
+
// Clear every entry before notifying: a subscriber may read another key.
|
|
1874
|
+
// Retain entries/subscriptions so old promises cannot find a fresh entry and
|
|
1875
|
+
// existing views can render the empty state before requesting their new data.
|
|
1876
|
+
for (const e of entries) {
|
|
1877
|
+
supersedeQueryFetch(e);
|
|
1878
|
+
e.rollbackOwner = null;
|
|
1879
|
+
e.data = undefined;
|
|
1880
|
+
e.error = null;
|
|
1881
|
+
e.updatedAt = 0;
|
|
1882
|
+
e.fetcher = null;
|
|
1883
|
+
e.staleTime = 0;
|
|
1884
|
+
}
|
|
1885
|
+
for (const e of entries) notifyQuery(e);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function setQueryValue(e, data, owner = null) {
|
|
1889
|
+
supersedeQueryFetch(e);
|
|
1890
|
+
e.rollbackOwner = owner;
|
|
1891
|
+
if (owner) owner.revision = e.revision;
|
|
1892
|
+
e.data = data;
|
|
1893
|
+
e.error = null;
|
|
1894
|
+
e.updatedAt = Date.now();
|
|
1895
|
+
notifyQuery(e);
|
|
1896
|
+
return data;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function rememberTableKey(tableName, key) {
|
|
1900
|
+
if (!tableName) return;
|
|
1901
|
+
const k = queryKey(key);
|
|
1902
|
+
if (!_queryTableKeys.has(tableName)) _queryTableKeys.set(tableName, new Set());
|
|
1903
|
+
_queryTableKeys.get(tableName).add(k);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function ensureQueryLiveInvalidation() {
|
|
1907
|
+
if (_queryLiveStop || typeof live !== 'function' || typeof EventSource === 'undefined') return;
|
|
1908
|
+
const stop = live('*', (ev) => {
|
|
1909
|
+
if (!ev || (!ev.table && ev.action !== 'refresh')) return;
|
|
1910
|
+
// Private/member-of events may hide the table name. That refresh hint
|
|
1911
|
+
// covers every registered live query, but not unrelated custom caches.
|
|
1912
|
+
const table = ev.table || '';
|
|
1913
|
+
if (_queryLiveTimers.has('') || _queryLiveTimers.has(table)) return;
|
|
1914
|
+
// A generic refresh subsumes already queued table-specific refreshes.
|
|
1915
|
+
if (!table) {
|
|
1916
|
+
for (const timer of _queryLiveTimers.values()) clearTimeout(timer);
|
|
1917
|
+
_queryLiveTimers.clear();
|
|
1918
|
+
}
|
|
1919
|
+
const t = setTimeout(() => {
|
|
1920
|
+
_queryLiveTimers.delete(table);
|
|
1921
|
+
const opts = { refetch: _queryLiveConfig.refetch };
|
|
1922
|
+
if (table) query.invalidateTable(table, opts);
|
|
1923
|
+
else {
|
|
1924
|
+
const keys = new Set([..._queryTableKeys.values()].flatMap(keys => [...keys]));
|
|
1925
|
+
query.invalidate((_raw, stable) => keys.has(stable), opts);
|
|
1926
|
+
}
|
|
1927
|
+
}, _queryLiveConfig.debounceMs);
|
|
1928
|
+
if (typeof t.unref === 'function') t.unref();
|
|
1929
|
+
_queryLiveTimers.set(table, t);
|
|
1930
|
+
});
|
|
1931
|
+
// Expose a teardown so callers/tests can stop the page-lifetime subscription.
|
|
1932
|
+
_queryLiveStop = () => {
|
|
1933
|
+
try { if (typeof stop === 'function') stop(); } catch {}
|
|
1934
|
+
for (const t of _queryLiveTimers.values()) clearTimeout(t);
|
|
1935
|
+
_queryLiveTimers.clear();
|
|
1936
|
+
_queryLiveStop = null;
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
export const query = {
|
|
1941
|
+
key: queryKey,
|
|
1942
|
+
stableStringify,
|
|
1943
|
+
get(key) {
|
|
1944
|
+
return queryEntry(key).data;
|
|
1945
|
+
},
|
|
1946
|
+
set(key, data) {
|
|
1947
|
+
return setQueryValue(queryEntry(key), data);
|
|
1948
|
+
},
|
|
1949
|
+
subscribe(key, fn) {
|
|
1950
|
+
if (typeof fn !== 'function') throw new Error('bm.query.subscribe(key, fn) requires a function');
|
|
1951
|
+
const e = queryEntry(key);
|
|
1952
|
+
e.subs.add(fn);
|
|
1953
|
+
fn({ data: e.data, error: e.error, updatedAt: e.updatedAt, pending: !!e.promise });
|
|
1954
|
+
return () => e.subs.delete(fn);
|
|
1955
|
+
},
|
|
1956
|
+
async fetch(key, fetcher, opts = {}) {
|
|
1957
|
+
if (typeof fetcher !== 'function') throw new Error('bm.query.fetch(key, fetcher) requires a fetcher');
|
|
1958
|
+
const e = queryEntry(key);
|
|
1959
|
+
const staleTime = Number(opts.staleTime || opts.staleMs || 0);
|
|
1960
|
+
if (!opts.force && e.data !== undefined && staleTime > 0 && Date.now() - e.updatedAt < staleTime) return e.data;
|
|
1961
|
+
// If a fetch is already in flight, return it without clobbering e.fetcher/
|
|
1962
|
+
// e.staleTime: a concurrent caller with a different fetcher must not have
|
|
1963
|
+
// its fetcher attached to data it didn't produce, or a later refetch(key)
|
|
1964
|
+
// would run the wrong fetcher.
|
|
1965
|
+
if (e.promise) return e.promise;
|
|
1966
|
+
e.fetcher = fetcher;
|
|
1967
|
+
e.staleTime = staleTime;
|
|
1968
|
+
const revision = e.revision;
|
|
1969
|
+
const pending = Promise.resolve()
|
|
1970
|
+
.then(fetcher)
|
|
1971
|
+
.then((data) => {
|
|
1972
|
+
if (e.revision === revision) {
|
|
1973
|
+
e.revision++;
|
|
1974
|
+
e.rollbackOwner = null;
|
|
1975
|
+
e.data = data;
|
|
1976
|
+
e.error = null;
|
|
1977
|
+
e.updatedAt = Date.now();
|
|
1978
|
+
}
|
|
1979
|
+
return data;
|
|
1980
|
+
})
|
|
1981
|
+
.catch((err) => {
|
|
1982
|
+
if (e.revision === revision) {
|
|
1983
|
+
e.revision++;
|
|
1984
|
+
e.error = err;
|
|
1985
|
+
}
|
|
1986
|
+
throw err;
|
|
1987
|
+
})
|
|
1988
|
+
.finally(() => {
|
|
1989
|
+
if (e.promise === pending) {
|
|
1990
|
+
e.promise = null;
|
|
1991
|
+
notifyQuery(e);
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
e.promise = pending;
|
|
1995
|
+
notifyQuery(e);
|
|
1996
|
+
return pending;
|
|
1997
|
+
},
|
|
1998
|
+
async refetch(key) {
|
|
1999
|
+
const e = queryEntry(key);
|
|
2000
|
+
if (!e.fetcher) return e.data;
|
|
2001
|
+
return query.fetch(e.rawKey, e.fetcher, { force: true, staleTime: e.staleTime });
|
|
2002
|
+
},
|
|
2003
|
+
invalidate(match, opts = {}) {
|
|
2004
|
+
const keys = [];
|
|
2005
|
+
// For a non-function match we support exact-key matching plus structural
|
|
2006
|
+
// array-prefix matching: ['user'] matches ['user', 1] but NOT ['users'].
|
|
2007
|
+
// We deliberately avoid raw string startsWith on the stable-stringified key
|
|
2008
|
+
// because that produced false positives (e.g. {a:1} matching {a:1,b:2}, or
|
|
2009
|
+
// ['user'] matching ['username']). Array-prefix is predictable for the
|
|
2010
|
+
// common ['table', name, spec] / ['query', spec] key shapes used here.
|
|
2011
|
+
const matchExact = typeof match !== 'function' ? queryKey(match) : null;
|
|
2012
|
+
const matchPrefix = (typeof match !== 'function' && Array.isArray(match)) ? match : null;
|
|
2013
|
+
const isArrayPrefix = (prefix, raw) => {
|
|
2014
|
+
if (!Array.isArray(raw) || raw.length < prefix.length) return false;
|
|
2015
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
2016
|
+
if (stableStringify(prefix[i]) !== stableStringify(raw[i])) return false;
|
|
2017
|
+
}
|
|
2018
|
+
return true;
|
|
2019
|
+
};
|
|
2020
|
+
const predicate = typeof match === 'function'
|
|
2021
|
+
? match
|
|
2022
|
+
: (raw, stable) => stable === matchExact || (matchPrefix !== null && isArrayPrefix(matchPrefix, raw));
|
|
2023
|
+
for (const [stable, e] of _queryCache.entries()) {
|
|
2024
|
+
if (!predicate(e.rawKey, stable)) continue;
|
|
2025
|
+
supersedeQueryFetch(e);
|
|
2026
|
+
e.rollbackOwner = null;
|
|
2027
|
+
e.updatedAt = 0;
|
|
2028
|
+
keys.push(stable);
|
|
2029
|
+
notifyQuery(e);
|
|
2030
|
+
if (opts.refetch && e.fetcher) query.refetch(e.rawKey).catch(() => {});
|
|
2031
|
+
}
|
|
2032
|
+
return keys;
|
|
2033
|
+
},
|
|
2034
|
+
tableKey(tableName, spec = {}) {
|
|
2035
|
+
return ['table', tableName, spec];
|
|
2036
|
+
},
|
|
2037
|
+
async read(spec, opts = {}) {
|
|
2038
|
+
if (!spec || !spec.table) throw new Error('bm.query.read({ table, ... }) requires table');
|
|
2039
|
+
const key = opts.key || ['query', spec];
|
|
2040
|
+
if (opts.live !== false) {
|
|
2041
|
+
rememberTableKey(spec.table, key);
|
|
2042
|
+
ensureQueryLiveInvalidation();
|
|
2043
|
+
}
|
|
2044
|
+
return query.fetch(key, async () => {
|
|
2045
|
+
const r = await api.post('/api/_query', spec);
|
|
2046
|
+
return Array.isArray(r) ? r : (r.rows || []);
|
|
2047
|
+
}, opts);
|
|
2048
|
+
},
|
|
2049
|
+
table(tableName, opts = {}) {
|
|
2050
|
+
// Separate client control options (which never affect the server result)
|
|
2051
|
+
// from the server-affecting query spec. Only the spec may influence the
|
|
2052
|
+
// cache key, otherwise two identical reads that differ only in staleTime/
|
|
2053
|
+
// force/live would fragment into distinct cache entries and defeat dedupe.
|
|
2054
|
+
const { staleTime, staleMs, force, live, key, ...serverOpts } = opts;
|
|
2055
|
+
const spec = { ...serverOpts, table: tableName };
|
|
2056
|
+
// Route through read() with the same ['query', spec] key shape so that a
|
|
2057
|
+
// bm.query.table('posts') and bm.query.read({ table: 'posts' }) targeting
|
|
2058
|
+
// identical rows collapse onto one shared cache entry. Forward every client
|
|
2059
|
+
// control option (staleTime/staleMs/force/live) so none are silently dropped.
|
|
2060
|
+
return query.read(spec, { key: key, staleTime, staleMs, force, live });
|
|
2061
|
+
},
|
|
2062
|
+
invalidateTable(tableName, opts = {}) {
|
|
2063
|
+
const keys = _queryTableKeys.get(tableName);
|
|
2064
|
+
if (!keys) return [];
|
|
2065
|
+
return query.invalidate((_raw, stable) => keys.has(stable), opts);
|
|
2066
|
+
},
|
|
2067
|
+
async mutate(opts = {}) {
|
|
2068
|
+
const authRevision = _authRevision;
|
|
2069
|
+
if (typeof opts.request !== 'function') throw new Error('bm.query.mutate({ request }) requires request');
|
|
2070
|
+
const keys = Array.isArray(opts.keys) ? opts.keys : (opts.key ? [opts.key] : []);
|
|
2071
|
+
// Snapshot the full prior entry (data + updatedAt + error), not just data,
|
|
2072
|
+
// so a failed mutation can be restored exactly without stamping a fresh
|
|
2073
|
+
// updatedAt or clearing a pre-existing error the way set() would.
|
|
2074
|
+
const snapshots = [...new Map(keys.map(key => [queryKey(key), key])).values()].map((key) => {
|
|
2075
|
+
const e = queryEntry(key);
|
|
2076
|
+
return { key, data: e.data, updatedAt: e.updatedAt, error: e.error,
|
|
2077
|
+
revision: e.revision, previous: e.rollbackOwner, state: 'pending', applied: false };
|
|
2078
|
+
});
|
|
2079
|
+
try {
|
|
2080
|
+
if (typeof opts.apply === 'function') {
|
|
2081
|
+
for (const snap of snapshots) {
|
|
2082
|
+
const data = opts.apply(snap.data, snap.key);
|
|
2083
|
+
snap.applied = true;
|
|
2084
|
+
setQueryValue(queryEntry(snap.key), data, snap);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
const result = await opts.request();
|
|
2088
|
+
if (authRevision !== _authRevision) return result;
|
|
2089
|
+
for (const snap of snapshots) {
|
|
2090
|
+
snap.state = 'succeeded';
|
|
2091
|
+
snap.previous = null;
|
|
2092
|
+
const e = queryEntry(snap.key);
|
|
2093
|
+
if (e.rollbackOwner === snap) e.rollbackOwner = null;
|
|
2094
|
+
}
|
|
2095
|
+
if (opts.invalidate) query.invalidate(opts.invalidate, { refetch: opts.refetch !== false });
|
|
2096
|
+
return result;
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
if (authRevision !== _authRevision) throw err;
|
|
2099
|
+
for (const snap of snapshots) snap.state = 'failed';
|
|
2100
|
+
for (const snap of snapshots) {
|
|
2101
|
+
const e = queryEntry(snap.key);
|
|
2102
|
+
// A later set, fetch, invalidation or edit owns its state. Neither a
|
|
2103
|
+
// default nor an application rollback may overwrite that newer work.
|
|
2104
|
+
if (snap.applied ? e.rollbackOwner !== snap : e.revision !== snap.revision) continue;
|
|
2105
|
+
// A newer failed edit may have snapshotted another pending edit. Skip
|
|
2106
|
+
// ancestors that also failed; restore a still-pending ancestor's
|
|
2107
|
+
// ownership so it can unwind if its own request subsequently fails.
|
|
2108
|
+
let prior = snap;
|
|
2109
|
+
while (prior.previous && prior.previous.state === 'failed') prior = prior.previous;
|
|
2110
|
+
const owner = prior.previous?.state === 'pending' ? prior.previous : null;
|
|
2111
|
+
if (typeof opts.rollback === 'function') {
|
|
2112
|
+
const data = opts.rollback(prior.data, err, snap.key);
|
|
2113
|
+
// The callback itself may publish a newer cache value.
|
|
2114
|
+
if (snap.applied ? e.rollbackOwner !== snap : e.revision !== snap.revision) continue;
|
|
2115
|
+
// A read begun after the optimistic edit can still supply its
|
|
2116
|
+
// authoritative result. Rolling back data must not cancel that read.
|
|
2117
|
+
if (!e.promise) supersedeQueryFetch(e);
|
|
2118
|
+
e.rollbackOwner = owner;
|
|
2119
|
+
if (owner) owner.revision = e.revision;
|
|
2120
|
+
e.data = data;
|
|
2121
|
+
e.updatedAt = Date.now();
|
|
2122
|
+
notifyQuery(e);
|
|
2123
|
+
} else if (snap.applied) {
|
|
2124
|
+
// Preserve the original timestamp and error, including stale state.
|
|
2125
|
+
if (!e.promise) supersedeQueryFetch(e);
|
|
2126
|
+
e.rollbackOwner = owner;
|
|
2127
|
+
if (owner) owner.revision = e.revision;
|
|
2128
|
+
e.data = prior.data;
|
|
2129
|
+
e.updatedAt = prior.updatedAt;
|
|
2130
|
+
if (e.error === null) e.error = prior.error;
|
|
2131
|
+
notifyQuery(e);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
throw err;
|
|
2135
|
+
}
|
|
2136
|
+
},
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
const bm = {
|
|
2140
|
+
api, auth, users, table, live, room,
|
|
2141
|
+
flows, workflow, aggregate, aggregates, jobs,
|
|
2142
|
+
notifications, upload, audit, permissions, signedUrl,
|
|
2143
|
+
t, mfa, webrtc, broadcast, markdown, markdownBatch, presence, cache,
|
|
2144
|
+
createStore, query, html, raw,
|
|
2145
|
+
};
|
|
2146
|
+
export default bm;
|
|
2147
|
+
|
|
2148
|
+
// Optional global for plain-JS pages without an import map.
|
|
2149
|
+
if (typeof window !== 'undefined') window.bm = bm;
|
|
2150
|
+
|
|
2151
|
+
// =================================================================
|
|
2152
|
+
// Client error capture → POST /api/_errors → _benmore_client_errors.
|
|
2153
|
+
// Powers the get_client_errors MCP tool + Sentry/Slack forwarding.
|
|
2154
|
+
// Installs window.onerror + unhandledrejection (the two ways JS
|
|
2155
|
+
// surfaces an uncaught failure - the 404-from-a-fetch case is an
|
|
2156
|
+
// unhandled rejection, so BOTH are required). Throttled + deduped so
|
|
2157
|
+
// an error loop can't spam the endpoint.
|
|
2158
|
+
// =================================================================
|
|
2159
|
+
if (typeof window !== 'undefined') {
|
|
2160
|
+
let _errSent = 0, _errLast = '';
|
|
2161
|
+
const _ERR_CAP = 25;
|
|
2162
|
+
const _postErr = (p) => {
|
|
2163
|
+
if (_errSent >= _ERR_CAP) return;
|
|
2164
|
+
const key = (p.message || '') + '|' + (p.source || '') + '|' + (p.line || 0);
|
|
2165
|
+
if (key === _errLast) return; // collapse immediate duplicates
|
|
2166
|
+
_errLast = key; _errSent++;
|
|
2167
|
+
try {
|
|
2168
|
+
fetch('/api/_errors', {
|
|
2169
|
+
method: 'POST',
|
|
2170
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2171
|
+
body: JSON.stringify(p),
|
|
2172
|
+
keepalive: true,
|
|
2173
|
+
credentials: 'same-origin',
|
|
2174
|
+
}).catch(() => {}); // never let reporting throw (would loop)
|
|
2175
|
+
} catch (_) {}
|
|
2176
|
+
};
|
|
2177
|
+
window.addEventListener('error', (e) => {
|
|
2178
|
+
// Resource-load errors (img/script 404s) have no .message - skip them.
|
|
2179
|
+
if (!e || !e.message) return;
|
|
2180
|
+
_postErr({
|
|
2181
|
+
kind: 'error',
|
|
2182
|
+
message: String(e.message).slice(0, 2000),
|
|
2183
|
+
stack: e.error && e.error.stack ? String(e.error.stack).slice(0, 8000) : '',
|
|
2184
|
+
source: e.filename || '',
|
|
2185
|
+
line: e.lineno || 0,
|
|
2186
|
+
col: e.colno || 0,
|
|
2187
|
+
url: location.href,
|
|
2188
|
+
});
|
|
2189
|
+
});
|
|
2190
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
2191
|
+
const r = e && e.reason;
|
|
2192
|
+
const msg = r && r.message ? r.message : (typeof r === 'string' ? r : 'Unhandled promise rejection');
|
|
2193
|
+
_postErr({
|
|
2194
|
+
kind: 'unhandledrejection',
|
|
2195
|
+
message: String(msg).slice(0, 2000),
|
|
2196
|
+
stack: r && r.stack ? String(r.stack).slice(0, 8000) : '',
|
|
2197
|
+
source: '',
|
|
2198
|
+
line: 0,
|
|
2199
|
+
col: 0,
|
|
2200
|
+
url: location.href,
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
}
|