155 lines
5.7 KiB
JavaScript
155 lines
5.7 KiB
JavaScript
/**
|
|||
|
|
* 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 });
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|