Files
openchamber/packages/web/server/lib/browser-control/broker.js
T

155 lines
5.7 KiB
JavaScript
Raw Normal View History

/**
* Request/response broker between the agent tool and the in-app browser.
*
* The browser lives in the renderer, not the server, so the server cannot act
* on a page directly. It publishes a request over the existing OpenChamber
* event stream and waits for the client that owns the browser view to post the
* result back.
*
* The request goes to every client that could serve it, because the server
* cannot know which one is showing a page. Exactly one must act on it, so a
* client claims the request before touching anything and only the first claim
* is granted. Without that, two connected desktop clients would both click, and
* the losing one's late result would not undo what it had already done.
*
* Two failure modes matter and are handled explicitly rather than as timeouts:
*
* - No client is listening. The agent is told immediately that the browser is
* not open, instead of blocking for the full timeout and then reporting
* something ambiguous.
* - The client accepted the request and then went away. That still times out,
* because the alternative — assuming success — would be a lie.
*/
const DEFAULT_TIMEOUT_MS = 20_000;
const MAX_TIMEOUT_MS = 120_000;
export class BrowserControlError extends Error {
constructor(message, status = 400) {
super(message);
this.name = 'BrowserControlError';
this.status = status;
}
}
export const createBrowserControlBroker = ({
emitRequest,
createId,
setTimer = setTimeout,
clearTimer = clearTimeout,
} = {}) => {
if (typeof emitRequest !== 'function') {
throw new TypeError('emitRequest is required');
}
const pending = new Map();
const settle = (requestId, outcome) => {
const entry = pending.get(requestId);
if (!entry) return false;
pending.delete(requestId);
clearTimer(entry.timer);
entry.finish(outcome);
return true;
};
return {
/** Number of requests still awaiting a client response. */
get pendingCount() {
return pending.size;
},
/**
* Publishes one browser action and resolves with the client's result.
* Rejects with a BrowserControlError the agent can act on.
*/
request(action, parameters = {}, { timeoutMs = DEFAULT_TIMEOUT_MS, signal } = {}) {
const requestId = typeof createId === 'function' ? createId() : `browser-${Date.now()}-${pending.size}`;
const boundedTimeout = Math.min(Math.max(1_000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
const listenerCount = emitRequest({ requestId, action, parameters });
if (!listenerCount) {
// Written for the agent reading it, not the user: state what this
// environment can do, and leave deciding whether it matters to the
// caller rather than handing it an instruction it cannot carry out.
return Promise.reject(new BrowserControlError(
'No OpenChamber client connected here can control a page. Reading and '
+ 'interacting with a page works when OpenChamber runs as its desktop '
+ 'application; a web browser tab can display a page but cannot be '
+ 'driven. Nothing was changed. Mention this to the user only if it '
+ 'affects what they asked for.',
503,
));
}
return new Promise((resolve, reject) => {
const finish = (outcome) => {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
if (outcome.ok) resolve(outcome.data ?? null);
else reject(new BrowserControlError(outcome.message || 'Browser action failed', outcome.status || 400));
};
const onAbort = signal
? () => settle(requestId, { ok: false, message: 'Browser action was cancelled', status: 499 })
: null;
if (signal) {
if (signal.aborted) {
reject(new BrowserControlError('Browser action was cancelled', 499));
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
const timer = setTimer(() => {
settle(requestId, {
ok: false,
message: `The in-app browser did not respond within ${Math.round(boundedTimeout / 1000)}s`,
status: 504,
});
}, boundedTimeout);
pending.set(requestId, { finish, timer, claimed: false });
});
},
/**
* Grants the right to perform one request, to one client.
*
* The first caller wins; everyone else is told no and must do nothing. An
* unknown id is also a refusal: the request has already been settled, and
* acting on it now would change a page nobody is waiting on.
*/
claim(requestId) {
if (typeof requestId !== 'string' || !requestId) return false;
const entry = pending.get(requestId);
if (!entry || entry.claimed) return false;
entry.claimed = true;
return true;
},
/**
* Accepts a result posted by the client. Returns false for an unknown id,
* which is the normal outcome for a response that lost a race with the
* timeout and must not be treated as an error.
*/
resolve(requestId, result) {
if (typeof requestId !== 'string' || !requestId) return false;
if (result && result.ok === true) {
return settle(requestId, { ok: true, data: result.data ?? null });
}
return settle(requestId, {
ok: false,
message: typeof result?.error === 'string' && result.error ? result.error : 'Browser action failed',
status: 400,
});
},
/** Fails everything in flight, e.g. when the owning client disconnects. */
rejectAll(message) {
for (const requestId of [...pending.keys()]) {
settle(requestId, { ok: false, message, status: 503 });
}
},
};
};