mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
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:
@@ -1914,6 +1914,98 @@ async function start() {
|
||||
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) ────
|
||||
// Must be checked BEFORE the blanket root-only auth gate below,
|
||||
// because scoped tokens from /connect are valid for /command.
|
||||
|
||||
Reference in New Issue
Block a user