@remcp/runtime 0.2.185 → 0.2.186

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.186 — 2026-10-26
4
+
5
+ - Return from same-document CDP navigation promptly when Page.navigate provides no new loaderId instead of awaiting a full load event that never fires.
6
+ - Cancel and remove per-navigation CDP event waiters when aborted, completed or disconnected; retain the genuine-document navigation wait and existing browser safety policy.
7
+ - Add deterministic cancellation coverage and real isolated Chromium navigation regression tests.
8
+
3
9
  ## 0.2.185 — 2026-10-26
4
10
 
5
11
  **Make read-only browser discovery more focused and patch application fail safely.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.185",
3
+ "version": "0.2.186",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -76,35 +76,43 @@ export async function browserNavigate(args = {}) {
76
76
  return withTarget(args, async (session, target) => {
77
77
  await session.send('Page.enable');
78
78
  const timeoutMs = clamp(args.timeout_ms, 15_000, 500, 120_000);
79
+ const loadWait = new AbortController();
79
80
  const loaded = args.wait === false || ['back','forward'].includes(action)
80
81
  ? null
81
- : session.waitFor('Page.loadEventFired', timeoutMs).catch(() => null);
82
- let frameId = null;
83
- let errorText = null;
84
- let historyUrl = null;
85
- if (action === 'url') {
86
- const result = await session.send('Page.navigate', { url });
87
- requireSuccessfulBrowserNavigation(result);
88
- frameId = result.frameId || null;
89
- errorText = result.errorText || null;
90
- } else if (action === 'reload') {
91
- await session.send('Page.reload', { ignoreCache: Boolean(args.ignore_cache) });
92
- } else {
93
- const history = await session.send('Page.getNavigationHistory');
94
- const current = Number(history.currentIndex || 0);
95
- const nextIndex = action === 'back' ? current - 1 : current + 1;
96
- const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
97
- if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
98
- historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
99
- await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
100
- }
101
- if (loaded) await loaded;
102
- if (args.wait !== false && ['back','forward'].includes(action)) {
103
- await waitForHistoryReady(session, historyUrl, timeoutMs);
82
+ : session.waitFor('Page.loadEventFired', timeoutMs, loadWait.signal).catch(() => null);
83
+ try {
84
+ let frameId = null;
85
+ let errorText = null;
86
+ let historyUrl = null;
87
+ if (action === 'url') {
88
+ const result = await session.send('Page.navigate', { url });
89
+ requireSuccessfulBrowserNavigation(result);
90
+ // Same-document navigation (fragment or pushState) has no loaderId
91
+ // and does not emit Page.loadEventFired.
92
+ if (!result.loaderId) loadWait.abort();
93
+ frameId = result.frameId || null;
94
+ errorText = result.errorText || null;
95
+ } else if (action === 'reload') {
96
+ await session.send('Page.reload', { ignoreCache: Boolean(args.ignore_cache) });
97
+ } else {
98
+ const history = await session.send('Page.getNavigationHistory');
99
+ const current = Number(history.currentIndex || 0);
100
+ const nextIndex = action === 'back' ? current - 1 : current + 1;
101
+ const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
102
+ if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
103
+ historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
104
+ await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
105
+ }
106
+ if (loaded) await loaded;
107
+ if (args.wait !== false && ['back','forward'].includes(action)) {
108
+ await waitForHistoryReady(session, historyUrl, timeoutMs);
109
+ }
110
+ const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
111
+ if (currentUrl) await assertResolvedPageUrl(currentUrl);
112
+ return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
113
+ } finally {
114
+ loadWait.abort();
104
115
  }
105
- const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
106
- if (currentUrl) await assertResolvedPageUrl(currentUrl);
107
- return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
108
116
  });
109
117
  }
110
118
 
@@ -119,18 +119,31 @@ export class CdpSession {
119
119
  });
120
120
  }
121
121
 
122
- waitFor(method, timeoutMs = this.timeoutMs) {
122
+ waitFor(method, timeoutMs = this.timeoutMs, signal = null) {
123
123
  return new Promise((resolve, reject) => {
124
+ if (signal?.aborted) { reject(new Error(`${method} aborted`)); return; }
124
125
  const listeners = this.waiters.get(method) || [];
125
- const listener = { resolve, reject, timer: null };
126
- listener.timer = setTimeout(() => {
126
+ const remove = () => {
127
127
  const current = this.waiters.get(method) || [];
128
128
  const next = current.filter(item => item !== listener);
129
129
  if (next.length) this.waiters.set(method, next); else this.waiters.delete(method);
130
- reject(new Error(`${method} timed out`));
130
+ clearTimeout(listener.timer);
131
+ signal?.removeEventListener('abort', abort);
132
+ };
133
+ const listener = {
134
+ resolve:value => { signal?.removeEventListener('abort', abort); resolve(value); },
135
+ reject:error => { signal?.removeEventListener('abort', abort); reject(error); },
136
+ timer:null,
137
+ };
138
+ const abort = () => { remove(); listener.reject(new Error(`${method} aborted`)); };
139
+ listener.timer = setTimeout(() => {
140
+ remove();
141
+ listener.reject(new Error(`${method} timed out`));
131
142
  }, timeoutMs);
132
143
  listeners.push(listener);
133
144
  this.waiters.set(method, listeners);
145
+ signal?.addEventListener('abort', abort, { once:true });
146
+ if (signal?.aborted) abort();
134
147
  });
135
148
  }
136
149