Your own Chrome, driven from Officer. Not a headless one: it attaches to the browser you are already
signed into, which is the whole difference from /scrape, and /scrape stays behind in the platform.
The relay is a sidecar now, and that is the point. It was a second Bun.serve inside the platform
process on its own port, which is exactly why it was switched off on 2026-08-13 — "the platform serves
one port". A sidecar is a process allowed its own listeners, so the objection stops applying instead of
being argued with.
The boundary across it is deliberately small: two questions, who is connected and what tabs they have,
because only those live in memory on the relay side. Screenshots, evaluation and navigation still go
straight down cdp.ts's own WebSocket to the relay's /cdp endpoint, exactly as before — routing page
bytes through the platform would be work for nothing.
Three normally-breaking changes taken now because now they are free, each already recommended in a
comment by whoever switched the relay off. Its own signing key instead of borrowing getKey('jwt'). Its
own table instead of the platform's user_integrations. And the port kept at 18792 on purpose, because
the port is an input to the token HMAC as well as an address — moving it fails every pairing silently,
and the extension blames the address. user_integrations held zero browser-relay rows, checked in
Postgres rather than assumed, so there was nothing to unpair.
The screen became panels. BrowserScreen.tsx rendered TabList and TabPreview through appType: null
placeholders; both components were already self-contained and took no props, so the screen had nothing
to pass and simply went.
Not tested end to end — no browser has been paired since the move, and that needs a Chrome and a
person. BLOCKERS.md says so rather than the commit implying otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
7.7 KiB
TypeScript
199 lines
7.7 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { createRouter } from '@@/create-router';
|
|
import * as errors from '@@/custom-errors';
|
|
import { sendSidecarCommand } from '@@/sidecar-registry';
|
|
import { getPairingSalt, setPairingSalt, clearPairing } from '../db/queries';
|
|
import { deriveRelayToken } from '../sidecar/relay-auth';
|
|
import { captureScreenshot, evaluateJS, navigateTo } from '../sidecar/cdp';
|
|
import { BROWSER_SIDECAR_KIND, RELAY_PORT, type BrowserEvent } from '../sidecar/protocol';
|
|
|
|
export const router = createRouter();
|
|
|
|
/**
|
|
* Ask the relay process something, and unwrap its reply.
|
|
*
|
|
* Only the two questions whose answer is in-memory on the relay side go this way — who is connected, and
|
|
* what tabs they have. Everything else the CDP socket already does directly.
|
|
*/
|
|
async function ask<T extends BrowserEvent['type']>(
|
|
type: 'relay:status' | 'relay:targets' | 'relay:token-add' | 'relay:token-remove',
|
|
payload: Record<string, unknown>,
|
|
): Promise<Extract<BrowserEvent, { type: T }>> {
|
|
const reply = await sendSidecarCommand<BrowserEvent>(BROWSER_SIDECAR_KIND, { type, id: randomUUID(), ...payload });
|
|
if (reply.type === 'error') throw errors.INTERNAL_SERVER_ERROR(reply.error);
|
|
return reply as Extract<BrowserEvent, { type: T }>;
|
|
}
|
|
|
|
/**
|
|
* The port and token for a direct CDP connection.
|
|
*
|
|
* Derived HERE as well as in the relay, from the same key and the same inputs — not fetched. A token that
|
|
* crossed the sidecar channel would be a live credential in whatever traced those messages, and deriving
|
|
* is cheaper than asking anyway.
|
|
*
|
|
* Registers the token with the relay on the way past, because a token nothing has registered is one the
|
|
* relay will refuse — that is what `relay:token-add` is for.
|
|
*/
|
|
async function getOpts(userId: number) {
|
|
let salt = await getPairingSalt(userId);
|
|
if (!salt) {
|
|
salt = randomUUID();
|
|
await setPairingSalt(userId, salt);
|
|
}
|
|
await ask('relay:token-add', { userId, salt });
|
|
return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) };
|
|
}
|
|
|
|
// GET /extension-download used to zip the extension directory on the fly, shelling out to `zip`. It had
|
|
// no caller: BrowserRelay.tsx links straight at /browser-relay-extension.zip, a real file in public/.
|
|
|
|
router.get('/status', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const { extensionConnected, targetCount } = await ask<'relay:status'>('relay:status', { userId: user.id });
|
|
return ctx.json({ extensionConnected, targetCount });
|
|
});
|
|
|
|
// The pair of values the extension is configured with. Shown as often as asked — unlike an API key, this
|
|
// is derived from a stored salt rather than generated, so there is nothing lost by showing it twice.
|
|
router.get('/relay-token', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
let salt = await getPairingSalt(user.id);
|
|
if (!salt) {
|
|
salt = randomUUID();
|
|
await setPairingSalt(user.id, salt);
|
|
}
|
|
const { token, port } = await ask<'relay:token'>('relay:token-add', { userId: user.id, salt });
|
|
return ctx.json({ token, port });
|
|
});
|
|
|
|
// Revoke and re-issue. The OLD token is unregistered first: the salt row is replaced by the write below,
|
|
// so if this ran the other way round the relay would keep accepting the old one until it restarted.
|
|
router.post('/relay-token/regenerate', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const oldSalt = await getPairingSalt(user.id);
|
|
if (oldSalt) await ask('relay:token-remove', { userId: user.id, salt: oldSalt });
|
|
|
|
const newSalt = randomUUID();
|
|
await setPairingSalt(user.id, newSalt);
|
|
const { token, port } = await ask<'relay:token'>('relay:token-add', { userId: user.id, salt: newSalt });
|
|
return ctx.json({ token, port });
|
|
});
|
|
|
|
router.delete('/relay-token', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const salt = await getPairingSalt(user.id);
|
|
if (salt) {
|
|
await ask('relay:token-remove', { userId: user.id, salt });
|
|
await clearPairing(user.id);
|
|
}
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
router.get('/targets', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
|
|
return ctx.json(
|
|
targets.map((t) => ({
|
|
id: t.targetId,
|
|
sessionId: t.sessionId,
|
|
type: t.targetInfo.type ?? 'page',
|
|
title: t.targetInfo.title ?? '',
|
|
url: t.targetInfo.url ?? '',
|
|
})),
|
|
);
|
|
});
|
|
|
|
router.get('/targets/:id/screenshot', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const targetId = ctx.req.param('id');
|
|
const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png';
|
|
|
|
const opts = await getOpts(user.id);
|
|
// The session id, not the target id — CDP addresses a session. Asked of the relay, which is where the
|
|
// live map is; this used to be an in-process lookup.
|
|
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
|
|
const target = targets.find((t) => t.targetId === targetId);
|
|
if (!target) throw errors.NOT_FOUND('Target not found');
|
|
|
|
try {
|
|
const data = await captureScreenshot({ ...opts, targetId: target.sessionId }, format);
|
|
return ctx.json({ data, format });
|
|
} catch (err) {
|
|
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Screenshot failed');
|
|
}
|
|
});
|
|
|
|
router.post('/targets/:id/evaluate', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const targetId = ctx.req.param('id');
|
|
const body = ctx.get('body') as { expression?: string };
|
|
|
|
if (!body.expression) throw errors.BAD_REQUEST('expression is required');
|
|
|
|
const opts = await getOpts(user.id);
|
|
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
|
|
const target = targets.find((t) => t.targetId === targetId);
|
|
if (!target) throw errors.NOT_FOUND('Target not found');
|
|
|
|
try {
|
|
const result = await evaluateJS({ ...opts, targetId: target.sessionId }, body.expression);
|
|
return ctx.json({ result });
|
|
} catch (err) {
|
|
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Evaluation failed');
|
|
}
|
|
});
|
|
|
|
router.post('/targets/:id/navigate', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const targetId = ctx.req.param('id');
|
|
const body = ctx.get('body') as { url?: string };
|
|
|
|
if (!body.url) throw errors.BAD_REQUEST('url is required');
|
|
|
|
const opts = await getOpts(user.id);
|
|
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
|
|
const target = targets.find((t) => t.targetId === targetId);
|
|
if (!target) throw errors.NOT_FOUND('Target not found');
|
|
|
|
try {
|
|
await navigateTo({ ...opts, targetId: target.sessionId }, body.url);
|
|
return ctx.json({ ok: true });
|
|
} catch (err) {
|
|
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Navigation failed');
|
|
}
|
|
});
|
|
|
|
router.post('/targets/:id/activate', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const targetId = ctx.req.param('id');
|
|
|
|
const { relayPort, userToken } = await getOpts(user.id);
|
|
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${relayPort}/json/activate/${encodeURIComponent(targetId)}`, {
|
|
headers: { 'x-officer-relay-token': userToken },
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return ctx.json({ ok: true });
|
|
} catch (err) {
|
|
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Activate failed');
|
|
}
|
|
});
|
|
|
|
router.post('/targets/:id/close', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const targetId = ctx.req.param('id');
|
|
|
|
const { relayPort, userToken } = await getOpts(user.id);
|
|
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${relayPort}/json/close/${encodeURIComponent(targetId)}`, {
|
|
headers: { 'x-officer-relay-token': userToken },
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return ctx.json({ ok: true });
|
|
} catch (err) {
|
|
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Close failed');
|
|
}
|
|
});
|