mirror of https://github.com/garrytan/gstack.git
feat: add POST /batch endpoint for multi-command batching
Remote agents controlling GStack Browser through a tunnel pay 2-5s of latency per HTTP round-trip. A typical "navigate and read" takes 4 sequential commands = 10-20 seconds. The /batch endpoint collapses N commands into a single HTTP round-trip, cutting a 20-tab crawl from ~60s to ~5s. Sequential execution through the full security pipeline (scope, domain, tab ownership, content wrapping). Rate limiting counts the batch as 1 request. Activity events emitted at batch level, not per-command. Max 50 commands per batch. Nested batches rejected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d384b095b1
commit
11c397138d
|
|
@ -1914,6 +1914,98 @@ async function start() {
|
||||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Batch endpoint — N commands, 1 HTTP round-trip ─────────────
|
||||||
|
// Accepts both root AND scoped tokens (same as /command).
|
||||||
|
// Executes commands sequentially through the full security pipeline.
|
||||||
|
// Designed for remote agents where tunnel latency dominates.
|
||||||
|
if (url.pathname === '/batch' && req.method === 'POST') {
|
||||||
|
const tokenInfo = getTokenInfo(req);
|
||||||
|
if (!tokenInfo) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resetIdleTimer();
|
||||||
|
const body = await req.json();
|
||||||
|
const { commands } = body;
|
||||||
|
|
||||||
|
if (!Array.isArray(commands) || commands.length === 0) {
|
||||||
|
return new Response(JSON.stringify({ error: '"commands" must be a non-empty array' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (commands.length > 50) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Max 50 commands per batch' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
emitActivity({
|
||||||
|
type: 'command_start',
|
||||||
|
command: 'batch',
|
||||||
|
args: [`${commands.length} commands`],
|
||||||
|
url: browserManager.getCurrentUrl(),
|
||||||
|
tabs: browserManager.getTabCount(),
|
||||||
|
mode: browserManager.getConnectionMode(),
|
||||||
|
clientId: tokenInfo?.clientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: Array<{ index: number; status: number; result: string; command: string; tabId?: number }> = [];
|
||||||
|
for (let i = 0; i < commands.length; i++) {
|
||||||
|
const cmd = commands[i];
|
||||||
|
if (!cmd || typeof cmd.command !== 'string') {
|
||||||
|
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), command: '' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Reject nested batches
|
||||||
|
if (cmd.command === 'batch') {
|
||||||
|
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Nested batch commands are not allowed' }), command: 'batch' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cr = await handleCommandInternal(
|
||||||
|
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId },
|
||||||
|
tokenInfo,
|
||||||
|
{ skipRateCheck: true, skipActivity: true },
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
index: i,
|
||||||
|
status: cr.status,
|
||||||
|
result: cr.result,
|
||||||
|
command: cmd.command,
|
||||||
|
tabId: cmd.tabId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
emitActivity({
|
||||||
|
type: 'command_end',
|
||||||
|
command: 'batch',
|
||||||
|
args: [`${commands.length} commands`],
|
||||||
|
url: browserManager.getCurrentUrl(),
|
||||||
|
duration,
|
||||||
|
status: 'ok',
|
||||||
|
result: `${results.filter(r => r.status === 200).length}/${commands.length} succeeded`,
|
||||||
|
tabs: browserManager.getTabCount(),
|
||||||
|
mode: browserManager.getConnectionMode(),
|
||||||
|
clientId: tokenInfo?.clientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
results,
|
||||||
|
duration,
|
||||||
|
total: commands.length,
|
||||||
|
succeeded: results.filter(r => r.status === 200).length,
|
||||||
|
failed: results.filter(r => r.status !== 200).length,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Command endpoint (accepts both root AND scoped tokens) ────
|
// ─── Command endpoint (accepts both root AND scoped tokens) ────
|
||||||
// Must be checked BEFORE the blanket root-only auth gate below,
|
// Must be checked BEFORE the blanket root-only auth gate below,
|
||||||
// because scoped tokens from /connect are valid for /command.
|
// because scoped tokens from /connect are valid for /command.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue