bunnyquery 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bunnyquery.js +158 -47
  2. package/package.json +1 -1
package/bunnyquery.js CHANGED
@@ -34,9 +34,12 @@
34
34
 
35
35
  // ---- MCP OAuth constants (mirrors src/code/mcp_oauth.ts) ---------------
36
36
  // The embed has no build-time env, so we always point at the production
37
- // MCP host. Override BunnyQuery.MCP_BASE_URL before init() to swap.
37
+ // MCP host. Override BunnyQuery.MCP_BASE_URL before init() to swap, or
38
+ // pass `true` as the third arg to BunnyQuery.init() to target the dev
39
+ // host (mcp-dev.broadwayinc.computer).
38
40
  const DEFAULTS = {
39
41
  MCP_BASE_URL: 'https://mcp.broadwayinc.computer',
42
+ MCP_DEV_BASE_URL: 'https://mcp-dev.broadwayinc.computer',
40
43
  MCP_NAME: 'BunnyQuery',
41
44
  DEFAULT_CLAUDE_MODEL: 'claude-sonnet-4-6',
42
45
  DEFAULT_OPENAI_MODEL: 'gpt-5.4',
@@ -58,6 +61,11 @@
58
61
  const TOKEN_KEY = STORAGE_PREFIX + 'mcp_token';
59
62
  const STATE_KEY = STORAGE_PREFIX + 'mcp_state';
60
63
 
64
+ // Resolve the active MCP base URL each time it's needed so a runtime
65
+ // override of `BunnyQuery.MCP_BASE_URL` (e.g. via the `dev` flag on
66
+ // init()) is reflected in subsequent send/auth calls.
67
+ const _mcpBaseUrl = () => (global.BunnyQuery && global.BunnyQuery.MCP_BASE_URL) || DEFAULTS.MCP_BASE_URL;
68
+
61
69
  // ----- helpers ----------------------------------------------------------
62
70
  const $ = (tag, attrs, children) => {
63
71
  const el = document.createElement(tag);
@@ -376,7 +384,7 @@
376
384
  {
377
385
  type: 'url',
378
386
  name: DEFAULTS.MCP_NAME,
379
- url: DEFAULTS.MCP_BASE_URL,
387
+ url: _mcpBaseUrl(),
380
388
  authorization_token: '$ACCESS_TOKEN',
381
389
  },
382
390
  ],
@@ -422,7 +430,7 @@
422
430
  {
423
431
  type: 'mcp',
424
432
  server_label: DEFAULTS.MCP_NAME,
425
- server_url: DEFAULTS.MCP_BASE_URL,
433
+ server_url: _mcpBaseUrl(),
426
434
  require_approval: 'never',
427
435
  headers: { Authorization: 'Bearer $ACCESS_TOKEN' },
428
436
  },
@@ -484,6 +492,50 @@
484
492
  return 'Request failed. Please try again.';
485
493
  }
486
494
 
495
+ // Detect MCP / upstream "your access token is no longer valid" failures
496
+ // so we can transparently re-mint the MCP token from the live skapi
497
+ // session and retry the call once. Patterns observed in the wild:
498
+ // - skapi-js inside the MCP server throws "Token has expired"
499
+ // (surfaces here as part of err.message or response body text)
500
+ // - MCP returns OAuth-style error codes: invalid_token / expired_token
501
+ // - Proxy wraps it with INVALID_REQUEST + 401
502
+ function _isAuthExpiredError(input) {
503
+ if (!input) return false;
504
+ const blobs = [];
505
+ const push = (v) => { if (typeof v === 'string' && v) blobs.push(v); };
506
+ if (typeof input === 'string') push(input);
507
+ else {
508
+ push(input.message);
509
+ push(input.code);
510
+ if (input.error) {
511
+ push(input.error.message);
512
+ push(input.error.code);
513
+ push(input.error.type);
514
+ }
515
+ if (input.body) {
516
+ push(input.body.message);
517
+ if (input.body.error) {
518
+ push(input.body.error.message);
519
+ push(input.body.error.code);
520
+ push(input.body.error.type);
521
+ }
522
+ }
523
+ if (typeof input.status === 'number' && input.status === 401) return true;
524
+ if (typeof input.status_code === 'number' && input.status_code === 401) return true;
525
+ }
526
+ const hay = blobs.join(' | ').toLowerCase();
527
+ if (!hay) return false;
528
+ return (
529
+ hay.includes('token has expired') ||
530
+ hay.includes('token is expired') ||
531
+ hay.includes('expired_token') ||
532
+ hay.includes('invalid_token') ||
533
+ hay.includes('unauthorized') ||
534
+ hay.includes('not authorized') ||
535
+ (hay.includes('invalid_request') && hay.includes('token'))
536
+ );
537
+ }
538
+
487
539
  // Mirror of agent.vue's isErrorResponseBody — detects provider/proxy
488
540
  // error payloads that should render as a red error bubble even when
489
541
  // no exception was thrown.
@@ -576,7 +628,7 @@
576
628
 
577
629
  this.readOnly = !!(info.opt && info.opt.freeze_database);
578
630
 
579
- this.oauth = new McpOAuth(BunnyQuery.MCP_BASE_URL || DEFAULTS.MCP_BASE_URL);
631
+ this.oauth = new McpOAuth(_mcpBaseUrl());
580
632
 
581
633
  this.messages = [];
582
634
  this.attachments = [];
@@ -585,6 +637,7 @@
585
637
  this.sending = false;
586
638
  this.uploading = false;
587
639
  this.pendingTimer = null;
640
+ this._pollingPending = false;
588
641
  this._loadingOlder = false;
589
642
 
590
643
  this.refs = {};
@@ -593,11 +646,17 @@
593
646
  this._bootstrap();
594
647
  }
595
648
 
596
- static async init(skapi, elementId) {
649
+ static async init(skapi, elementId, dev) {
597
650
  const container = typeof elementId === 'string'
598
651
  ? document.getElementById(elementId)
599
652
  : elementId;
600
653
  if (!container) throw new Error(`BunnyQuery: container "${elementId}" not found`);
654
+ // When `dev` is truthy, route every MCP call (Claude tool URL,
655
+ // OpenAI tool URL, and OAuth) to the dev host. Setting the
656
+ // static here makes the change visible to any subsequent
657
+ // BunnyQuery instance on the page; pass `false` (or omit) to
658
+ // pin back to production.
659
+ BunnyQuery.MCP_BASE_URL = dev ? DEFAULTS.MCP_DEV_BASE_URL : DEFAULTS.MCP_BASE_URL;
601
660
  const info = await skapi.__connection;
602
661
  console.log('[BunnyQuery] initializing with info', info);
603
662
  return new BunnyQuery(skapi, info, container);
@@ -1075,12 +1134,24 @@
1075
1134
 
1076
1135
  // ---- history ------------------------------------------------------
1077
1136
  async _loadFirstHistoryPage() {
1137
+ const fetchPage = () => getChatHistory(
1138
+ this.skapi,
1139
+ { service: this.serviceId, owner: this.ownerId, platform: this.platform },
1140
+ { ascending: false }
1141
+ );
1078
1142
  try {
1079
- const res = await getChatHistory(
1080
- this.skapi,
1081
- { service: this.serviceId, owner: this.ownerId, platform: this.platform },
1082
- { ascending: false }
1083
- );
1143
+ let res;
1144
+ try {
1145
+ res = await fetchPage();
1146
+ } catch (err) {
1147
+ if (_isAuthExpiredError(err)) {
1148
+ this.oauth.clearToken();
1149
+ await this.oauth.exchangeSession(this.skapi.session);
1150
+ res = await fetchPage();
1151
+ } else {
1152
+ throw err;
1153
+ }
1154
+ }
1084
1155
  this.startKeyHistory = res && res.startKeyHistory;
1085
1156
  const list = this._filterByClearHorizon((res && res.list) || []);
1086
1157
  // If the clear horizon has filtered the entire first page,
@@ -1124,46 +1195,57 @@
1124
1195
  }
1125
1196
  }
1126
1197
 
1198
+ // Polling state machine. Invariant: at most ONE of the following
1199
+ // is true at any moment — (a) `pendingTimer` is armed, or (b)
1200
+ // `_pollingPending` is true (fetch in flight). Any call to
1201
+ // `_schedulePendingPoll` while either is true is a no-op; the
1202
+ // in-flight cycle's `finally` will continue the loop.
1203
+ //
1204
+ // This replaces an earlier clear-and-rearm pattern that, under
1205
+ // overlapping callers (`_sendMessage`, `_loadFirstHistoryPage`,
1206
+ // and the poll's own `finally`), could leave two timers
1207
+ // concurrently armed. Each tick's `finally` then scheduled
1208
+ // another, and the effective polling rate kept compounding —
1209
+ // the chat looked like it was polling faster and faster.
1127
1210
  _schedulePendingPoll() {
1128
- if (this.pendingTimer) clearTimeout(this.pendingTimer);
1129
- if (!this._anyPending()) return;
1130
- // Bail if a poll is already in flight — its `finally` will
1131
- // schedule the next one. Without this guard, any caller that
1132
- // fires while we're awaiting `getChatHistory` (e.g. another
1133
- // _sendMessage, _loadFirstHistoryPage, or our own previous
1134
- // tick whose timer already elapsed) would spawn a second
1135
- // concurrent poller, and each subsequent cycle would compound
1136
- // — making the polling appear faster and faster.
1211
+ if (this.pendingTimer != null) return;
1137
1212
  if (this._pollingPending) return;
1138
- this.pendingTimer = setTimeout(async () => {
1213
+ if (!this._anyPending()) return;
1214
+ this.pendingTimer = setTimeout(() => {
1139
1215
  this.pendingTimer = null;
1140
- this._pollingPending = true;
1141
- try {
1142
- const res = await getChatHistory(
1143
- this.skapi,
1144
- { service: this.serviceId, owner: this.ownerId, platform: this.platform },
1145
- { ascending: false }
1146
- );
1147
- const fresh = mapHistoryToMessages(
1148
- this._filterByClearHorizon((res && res.list) || []),
1149
- this.platform
1150
- );
1151
- // Keep older messages whose ids are not on the freshly
1152
- // fetched first page. The fresh page is appended last so
1153
- // it always reflects the latest server state.
1154
- const freshIds = new Set(fresh.filter((m) => m.id).map((m) => m.id));
1155
- const older = this.messages.filter((m) => m.id && !freshIds.has(m.id));
1156
- this.messages = older.concat(fresh);
1157
- this._renderMessages();
1158
- } catch (err) {
1159
- console.error('[BunnyQuery] pending poll failed', err);
1160
- } finally {
1161
- this._pollingPending = false;
1162
- this._schedulePendingPoll();
1163
- }
1216
+ this._runPendingPoll();
1164
1217
  }, DEFAULTS.PENDING_POLL_INTERVAL_MS);
1165
1218
  }
1166
1219
 
1220
+ async _runPendingPoll() {
1221
+ // Defensive: if somehow re-entered, drop the duplicate.
1222
+ if (this._pollingPending) return;
1223
+ this._pollingPending = true;
1224
+ try {
1225
+ const res = await getChatHistory(
1226
+ this.skapi,
1227
+ { service: this.serviceId, owner: this.ownerId, platform: this.platform },
1228
+ { ascending: false }
1229
+ );
1230
+ const fresh = mapHistoryToMessages(
1231
+ this._filterByClearHorizon((res && res.list) || []),
1232
+ this.platform
1233
+ );
1234
+ // Keep older messages whose ids are not on the freshly
1235
+ // fetched first page. The fresh page is appended last so
1236
+ // it always reflects the latest server state.
1237
+ const freshIds = new Set(fresh.filter((m) => m.id).map((m) => m.id));
1238
+ const older = this.messages.filter((m) => m.id && !freshIds.has(m.id));
1239
+ this.messages = older.concat(fresh);
1240
+ this._renderMessages();
1241
+ } catch (err) {
1242
+ console.error('[BunnyQuery] pending poll failed', err);
1243
+ } finally {
1244
+ this._pollingPending = false;
1245
+ this._schedulePendingPoll();
1246
+ }
1247
+ }
1248
+
1167
1249
  // ---- attachments --------------------------------------------------
1168
1250
  _onAttachFromInput(e) {
1169
1251
  const files = Array.from(e.target.files || []);
@@ -1348,13 +1430,42 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
1348
1430
  model: this.model || undefined,
1349
1431
  };
1350
1432
 
1351
- const result = this.platform === 'claude'
1352
- ? await buildClaudeRequest(this.skapi, args)
1353
- : await buildOpenAIRequest(this.skapi, args);
1433
+ const argsBuilder = () => (this.platform === 'claude'
1434
+ ? buildClaudeRequest(this.skapi, args)
1435
+ : buildOpenAIRequest(this.skapi, args));
1436
+
1437
+ let result;
1438
+ try {
1439
+ result = await argsBuilder();
1440
+ } catch (err) {
1441
+ if (_isAuthExpiredError(err)) {
1442
+ // The MCP-side skapi session expired. Re-mint a fresh
1443
+ // MCP token bundle from the live host skapi session
1444
+ // (skapi-js auto-refreshes its own tokens), then retry
1445
+ // the call once.
1446
+ try {
1447
+ this.oauth.clearToken();
1448
+ await this.oauth.exchangeSession(this.skapi.session);
1449
+ } catch (reauthErr) {
1450
+ throw reauthErr;
1451
+ }
1452
+ result = await argsBuilder();
1453
+ } else {
1454
+ throw err;
1455
+ }
1456
+ }
1354
1457
 
1355
1458
  // The proxy may resolve with an error-shaped body instead of
1356
1459
  // throwing; surface that as an error bubble with the
1357
1460
  // upstream message rather than dropping it.
1461
+ if (isErrorResponseBody(result) && _isAuthExpiredError(result)) {
1462
+ try {
1463
+ this.oauth.clearToken();
1464
+ await this.oauth.exchangeSession(this.skapi.session);
1465
+ result = await argsBuilder();
1466
+ } catch (_reauthErr) { /* fall through to normal error rendering */ }
1467
+ }
1468
+
1358
1469
  if (isErrorResponseBody(result)) {
1359
1470
  const errMsg = getErrorMessage(result);
1360
1471
  const last = this.messages[this.messages.length - 1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "BunnyQuery AI chat client",
5
5
  "main": "bunnyquery.js",
6
6
  "files": [