@tt-a1i/openpi 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -59
- package/SETUP.md +23 -7
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +145 -0
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/index.ts +30 -2
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +486 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +14 -6
- package/extensions/background-terminals/src/result-delivery.ts +4 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +30 -42
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/schedule.ts +7 -1
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +146 -87
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +75 -59
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +328 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/model-info/index.ts +21 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +7 -2
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +222 -140
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +127 -131
- package/extensions/shared/activity-status.ts +36 -5
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +444 -22
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +97 -38
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +113 -0
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +42 -8
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +461 -186
- package/extensions/subagents/navigation.ts +86 -28
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +375 -66
- package/extensions/subagents/src/domain.ts +5 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +651 -536
- package/extensions/subagents/src/prompt.ts +185 -42
- package/extensions/subagents/src/result-artifact.ts +146 -0
- package/extensions/subagents/src/result-delivery.ts +7 -1
- package/extensions/subagents/src/runtime.ts +23 -6
- package/extensions/subagents/src/ui/takeover.ts +128 -337
- package/extensions/subagents/src/ui/transcript.ts +38 -501
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -44
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/web/index.ts +234 -0
- package/extensions/workflows/artifacts.ts +147 -22
- package/extensions/workflows/completion-projection.ts +457 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +62 -0
- package/extensions/workflows/dashboard.ts +458 -339
- package/extensions/workflows/handoff.ts +121 -25
- package/extensions/workflows/index.ts +1042 -492
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +131 -19
- package/extensions/workflows/navigation.ts +61 -18
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +166 -10
- package/extensions/workflows/replay-safety.ts +58 -27
- package/extensions/workflows/result-delivery.ts +253 -0
- package/extensions/workflows/retention.ts +593 -0
- package/extensions/workflows/runner.ts +388 -279
- package/extensions/workflows/sandbox-child.cjs +36 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +28 -8
- package/skills/subagents/REFERENCE.md +189 -0
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +10 -5
- package/skills/workflows/SKILL.md +53 -10
- package/web/adapter/pi-adapter.ts +661 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +789 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +462 -0
- package/web/runtime/pi-runtime.ts +991 -0
- package/web/runtime/types.ts +71 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1398 -0
- package/web/ui/index.html +139 -0
- package/web/ui/styles.css +598 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- package/extensions/subagents/src/backends/stub.ts +0 -296
- package/extensions/subagents/src/format.ts +0 -48
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
createServer,
|
|
6
|
+
type IncomingMessage,
|
|
7
|
+
type Server,
|
|
8
|
+
type ServerResponse,
|
|
9
|
+
} from "node:http";
|
|
10
|
+
import { URL } from "node:url";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts";
|
|
13
|
+
import { PiWebAdapter } from "../adapter/pi-adapter.ts";
|
|
14
|
+
import {
|
|
15
|
+
jsonByteLength,
|
|
16
|
+
WEB_MAX_EVENT_BYTES,
|
|
17
|
+
WEB_MAX_EVENTS,
|
|
18
|
+
WEB_MAX_SNAPSHOT_BYTES,
|
|
19
|
+
WEB_PROTOCOL_VERSION,
|
|
20
|
+
type WebEvent,
|
|
21
|
+
type WebSnapshot,
|
|
22
|
+
} from "../protocol/types.ts";
|
|
23
|
+
import {
|
|
24
|
+
WebRuntimeRequestError,
|
|
25
|
+
type WebRuntimeController,
|
|
26
|
+
} from "../runtime/types.ts";
|
|
27
|
+
import { elapsed, traceWeb } from "../trace.ts";
|
|
28
|
+
import { MARKED_BROWSER_URL } from "./static-assets.ts";
|
|
29
|
+
|
|
30
|
+
const HOST = "127.0.0.1";
|
|
31
|
+
const UI_ROOT = new URL("../ui/", import.meta.url);
|
|
32
|
+
const MAX_COMMAND_BYTES = 16 * 1024;
|
|
33
|
+
const MAX_SSE_CLIENTS = 8;
|
|
34
|
+
const MAX_SSE_BUFFER_BYTES = 256 * 1024;
|
|
35
|
+
const MAX_SSE_REPLAY_BYTES = MAX_SSE_BUFFER_BYTES;
|
|
36
|
+
const SERVER_CLOSE_DRAIN_MS = 500;
|
|
37
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
export interface WebHostOptions {
|
|
41
|
+
runtime: WebRuntimeController;
|
|
42
|
+
onEvent?: (type: string, detail?: Record<string, unknown>) => void;
|
|
43
|
+
port?: number;
|
|
44
|
+
token?: string;
|
|
45
|
+
allowedOrigins?: readonly string[];
|
|
46
|
+
directoryChooser?: (signal: AbortSignal) => Promise<string | undefined>;
|
|
47
|
+
shutdownTimeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class WebHost {
|
|
51
|
+
private readonly server: Server;
|
|
52
|
+
private readonly token: Buffer;
|
|
53
|
+
private readonly adapter: PiWebAdapter;
|
|
54
|
+
private readonly clients = new Set<ServerResponse>();
|
|
55
|
+
private readonly events: WebEvent[] = [];
|
|
56
|
+
private sequence = 0;
|
|
57
|
+
private port = 0;
|
|
58
|
+
private readonly runtime: WebRuntimeController;
|
|
59
|
+
private readonly requestedPort: number;
|
|
60
|
+
private readonly onEvent?: WebHostOptions["onEvent"];
|
|
61
|
+
private readonly allowedOrigins: ReadonlySet<string>;
|
|
62
|
+
private readonly directoryChooser: NonNullable<
|
|
63
|
+
WebHostOptions["directoryChooser"]
|
|
64
|
+
>;
|
|
65
|
+
private readonly shutdownTimeoutMs: number;
|
|
66
|
+
private readonly unsubscribeCapabilities: () => void;
|
|
67
|
+
private readonly unsubscribeRuntime: () => void;
|
|
68
|
+
private readonly chooserAbort = new AbortController();
|
|
69
|
+
private readonly leaseSensitiveRequests = new Set<Promise<void>>();
|
|
70
|
+
private readonly leaseSensitiveMessages = new Set<IncomingMessage>();
|
|
71
|
+
private stopping = false;
|
|
72
|
+
private stopPromise?: Promise<void>;
|
|
73
|
+
|
|
74
|
+
constructor(options: WebHostOptions) {
|
|
75
|
+
this.runtime = options.runtime;
|
|
76
|
+
this.requestedPort = options.port ?? 0;
|
|
77
|
+
this.token = options.token
|
|
78
|
+
? Buffer.from(options.token, "hex")
|
|
79
|
+
: randomBytes(32);
|
|
80
|
+
if (this.token.length !== 32)
|
|
81
|
+
throw new Error("Web host token must be 64 hexadecimal characters");
|
|
82
|
+
this.allowedOrigins = new Set(options.allowedOrigins ?? []);
|
|
83
|
+
this.directoryChooser =
|
|
84
|
+
options.directoryChooser ?? (() => this.chooseDirectory());
|
|
85
|
+
this.shutdownTimeoutMs =
|
|
86
|
+
options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
87
|
+
if (
|
|
88
|
+
!Number.isSafeInteger(this.shutdownTimeoutMs) ||
|
|
89
|
+
this.shutdownTimeoutMs <= 0
|
|
90
|
+
) {
|
|
91
|
+
throw new Error("Web host shutdown timeout must be a positive integer");
|
|
92
|
+
}
|
|
93
|
+
this.adapter = new PiWebAdapter(options.runtime);
|
|
94
|
+
this.onEvent = options.onEvent;
|
|
95
|
+
this.unsubscribeCapabilities = subscribeWebCapabilities((scope) => {
|
|
96
|
+
if (scope === this.runtime.sessionManager) this.publish("runtime_changed");
|
|
97
|
+
});
|
|
98
|
+
this.unsubscribeRuntime = this.runtime.subscribe(({ type, detail }) =>
|
|
99
|
+
this.publish(type, detail),
|
|
100
|
+
);
|
|
101
|
+
this.server = createServer((request, response) => {
|
|
102
|
+
const leaseSensitive = this.isLeaseSensitiveMutation(request);
|
|
103
|
+
if (this.stopping && leaseSensitive) {
|
|
104
|
+
request.resume();
|
|
105
|
+
response.setHeader("Connection", "close");
|
|
106
|
+
this.json(response, 503, {
|
|
107
|
+
code: "HOST_STOPPING",
|
|
108
|
+
error: "Web host is stopping",
|
|
109
|
+
});
|
|
110
|
+
response.once("finish", () => request.socket.destroy());
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const operation = this.dispatchRequest(request, response);
|
|
114
|
+
if (leaseSensitive) {
|
|
115
|
+
this.leaseSensitiveRequests.add(operation);
|
|
116
|
+
this.leaseSensitiveMessages.add(request);
|
|
117
|
+
void operation.then(
|
|
118
|
+
() => {
|
|
119
|
+
this.leaseSensitiveRequests.delete(operation);
|
|
120
|
+
this.leaseSensitiveMessages.delete(request);
|
|
121
|
+
},
|
|
122
|
+
() => {
|
|
123
|
+
this.leaseSensitiveRequests.delete(operation);
|
|
124
|
+
this.leaseSensitiveMessages.delete(request);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
void operation;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async start() {
|
|
133
|
+
await this.adapter.initialize();
|
|
134
|
+
await new Promise<void>((resolve, reject) => {
|
|
135
|
+
this.server.once("error", reject);
|
|
136
|
+
this.server.listen(this.requestedPort, HOST, () => resolve());
|
|
137
|
+
});
|
|
138
|
+
const address = this.server.address();
|
|
139
|
+
if (!address || typeof address === "string")
|
|
140
|
+
throw new Error("Web host did not expose a TCP port");
|
|
141
|
+
this.port = address.port;
|
|
142
|
+
this.publish("web_host_started", {
|
|
143
|
+
port: this.port,
|
|
144
|
+
...(this.runtime.workspaceSelected === true
|
|
145
|
+
? { cwd: this.runtime.cwd }
|
|
146
|
+
: {}),
|
|
147
|
+
mode: "local-workbench",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get origin() {
|
|
152
|
+
return `http://${HOST}:${this.port}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get url() {
|
|
156
|
+
return `${this.origin}/#token=${this.token.toString("hex")}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
publish(type: string, detail?: Record<string, unknown>) {
|
|
160
|
+
let event: WebEvent = {
|
|
161
|
+
protocolVersion: WEB_PROTOCOL_VERSION,
|
|
162
|
+
sequence: ++this.sequence,
|
|
163
|
+
type,
|
|
164
|
+
timestamp: new Date().toISOString(),
|
|
165
|
+
...(detail ? { detail } : {}),
|
|
166
|
+
};
|
|
167
|
+
let serialized = JSON.stringify(event);
|
|
168
|
+
if (Buffer.byteLength(serialized) > WEB_MAX_EVENT_BYTES) {
|
|
169
|
+
event = {
|
|
170
|
+
protocolVersion: WEB_PROTOCOL_VERSION,
|
|
171
|
+
sequence: event.sequence,
|
|
172
|
+
type: "state_invalidated",
|
|
173
|
+
timestamp: event.timestamp,
|
|
174
|
+
detail: { reason: "event_too_large", originalType: type },
|
|
175
|
+
};
|
|
176
|
+
serialized = JSON.stringify(event);
|
|
177
|
+
}
|
|
178
|
+
this.events.push(event);
|
|
179
|
+
if (this.events.length > WEB_MAX_EVENTS) this.events.shift();
|
|
180
|
+
const record = `id: ${event.sequence}\ndata: ${serialized}\n\n`;
|
|
181
|
+
for (const client of this.clients) {
|
|
182
|
+
if (
|
|
183
|
+
client.destroyed ||
|
|
184
|
+
client.writableEnded ||
|
|
185
|
+
client.writableLength > MAX_SSE_BUFFER_BYTES ||
|
|
186
|
+
!client.write(record)
|
|
187
|
+
) {
|
|
188
|
+
this.clients.delete(client);
|
|
189
|
+
client.destroy();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.onEvent?.(event.type, event.detail);
|
|
193
|
+
traceWeb("sse_event", {
|
|
194
|
+
type: event.type,
|
|
195
|
+
sequence: event.sequence,
|
|
196
|
+
detailKeys: event.detail ? Object.keys(event.detail) : [],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stop() {
|
|
201
|
+
if (this.stopPromise) return this.stopPromise;
|
|
202
|
+
this.stopping = true;
|
|
203
|
+
this.stopPromise = (async () => {
|
|
204
|
+
this.unsubscribeCapabilities();
|
|
205
|
+
this.unsubscribeRuntime();
|
|
206
|
+
this.chooserAbort.abort();
|
|
207
|
+
for (const client of this.clients) client.end();
|
|
208
|
+
this.clients.clear();
|
|
209
|
+
const closeServer = this.server.listening
|
|
210
|
+
? new Promise<void>((resolve) => {
|
|
211
|
+
const forceClose = setTimeout(
|
|
212
|
+
() => {
|
|
213
|
+
for (const request of this.leaseSensitiveMessages) {
|
|
214
|
+
request.destroy();
|
|
215
|
+
}
|
|
216
|
+
this.server.closeAllConnections();
|
|
217
|
+
},
|
|
218
|
+
SERVER_CLOSE_DRAIN_MS,
|
|
219
|
+
);
|
|
220
|
+
forceClose.unref();
|
|
221
|
+
this.server.close(() => {
|
|
222
|
+
clearTimeout(forceClose);
|
|
223
|
+
resolve();
|
|
224
|
+
});
|
|
225
|
+
this.server.closeIdleConnections();
|
|
226
|
+
})
|
|
227
|
+
: Promise.resolve();
|
|
228
|
+
const disposeRuntime = (async () => {
|
|
229
|
+
await this.drainLeaseSensitiveRequests();
|
|
230
|
+
await this.runtime.dispose();
|
|
231
|
+
})();
|
|
232
|
+
const cleanup = Promise.all([disposeRuntime, closeServer]).then(
|
|
233
|
+
() => undefined,
|
|
234
|
+
);
|
|
235
|
+
void cleanup.catch(() => undefined);
|
|
236
|
+
await new Promise<void>((resolve, reject) => {
|
|
237
|
+
const timeout = setTimeout(
|
|
238
|
+
() =>
|
|
239
|
+
reject(
|
|
240
|
+
new Error(
|
|
241
|
+
`Web runtime cleanup did not settle within ${this.shutdownTimeoutMs} ms; cleanup state is uncertain`,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
this.shutdownTimeoutMs,
|
|
245
|
+
);
|
|
246
|
+
void cleanup.then(
|
|
247
|
+
() => {
|
|
248
|
+
clearTimeout(timeout);
|
|
249
|
+
resolve();
|
|
250
|
+
},
|
|
251
|
+
(error) => {
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
reject(error);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
})();
|
|
258
|
+
return this.stopPromise;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async dispatchRequest(
|
|
262
|
+
request: IncomingMessage,
|
|
263
|
+
response: ServerResponse,
|
|
264
|
+
) {
|
|
265
|
+
try {
|
|
266
|
+
await this.handle(request, response);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (response.destroyed || response.writableEnded) return;
|
|
269
|
+
this.json(response, 500, {
|
|
270
|
+
error: error instanceof Error ? error.message : "request failed",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private isLeaseSensitiveMutation(request: IncomingMessage) {
|
|
276
|
+
if (request.method === "GET" || request.method === "HEAD") return false;
|
|
277
|
+
const pathname = new URL(request.url ?? "/", `http://${HOST}`).pathname;
|
|
278
|
+
if (pathname === "/api/prompt") return false;
|
|
279
|
+
return pathname.startsWith("/api/workspaces") ||
|
|
280
|
+
pathname.startsWith("/api/sessions") ||
|
|
281
|
+
pathname === "/api/model";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async drainLeaseSensitiveRequests() {
|
|
285
|
+
while (this.leaseSensitiveRequests.size > 0) {
|
|
286
|
+
await Promise.allSettled([...this.leaseSensitiveRequests]);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async handle(request: IncomingMessage, response: ServerResponse) {
|
|
291
|
+
const url = new URL(request.url ?? "/", `http://${HOST}`);
|
|
292
|
+
const expectedHost = `${HOST}:${this.port}`;
|
|
293
|
+
if (
|
|
294
|
+
request.headers.host !== expectedHost ||
|
|
295
|
+
(request.headers.origin &&
|
|
296
|
+
request.headers.origin !== `http://${expectedHost}` &&
|
|
297
|
+
!this.allowedOrigins.has(request.headers.origin))
|
|
298
|
+
) {
|
|
299
|
+
this.json(response, 403, { error: "invalid host or origin" });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (url.pathname === "/" && request.method === "GET") {
|
|
303
|
+
const body = await readFile(new URL("index.html", UI_ROOT));
|
|
304
|
+
response.writeHead(200, {
|
|
305
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
306
|
+
"Cache-Control": "no-store",
|
|
307
|
+
"Content-Security-Policy":
|
|
308
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
309
|
+
"Referrer-Policy": "no-referrer",
|
|
310
|
+
});
|
|
311
|
+
response.end(body);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (
|
|
315
|
+
url.pathname === "/styles.css" ||
|
|
316
|
+
url.pathname === "/app.js" ||
|
|
317
|
+
url.pathname === "/marked.js"
|
|
318
|
+
) {
|
|
319
|
+
if (request.method !== "GET")
|
|
320
|
+
return this.json(response, 405, {
|
|
321
|
+
error: "static assets accept GET only",
|
|
322
|
+
});
|
|
323
|
+
const file = url.pathname.slice(1);
|
|
324
|
+
const body = await readFile(
|
|
325
|
+
file === "marked.js"
|
|
326
|
+
? MARKED_BROWSER_URL
|
|
327
|
+
: new URL(file, UI_ROOT),
|
|
328
|
+
);
|
|
329
|
+
response.writeHead(200, {
|
|
330
|
+
"Content-Type": file.endsWith(".css")
|
|
331
|
+
? "text/css; charset=utf-8"
|
|
332
|
+
: "text/javascript; charset=utf-8",
|
|
333
|
+
"Cache-Control": "no-store",
|
|
334
|
+
});
|
|
335
|
+
response.end(body);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!this.authorized(request))
|
|
339
|
+
return this.json(response, 401, { error: "invalid or missing token" });
|
|
340
|
+
if (
|
|
341
|
+
url.pathname === "/api/workspaces/select" &&
|
|
342
|
+
request.method === "POST"
|
|
343
|
+
) {
|
|
344
|
+
const path = await this.directoryChooser(this.chooserAbort.signal);
|
|
345
|
+
if (!path) return this.json(response, 200, { cancelled: true });
|
|
346
|
+
const importedPath = await this.adapter.importWorkspace(path);
|
|
347
|
+
this.publish("workspace_imported", { path: importedPath });
|
|
348
|
+
return this.json(response, 201, { path: importedPath });
|
|
349
|
+
}
|
|
350
|
+
if (url.pathname === "/api/workspaces" && request.method === "POST") {
|
|
351
|
+
const body = await this.readJson(request);
|
|
352
|
+
if (typeof body.path !== "string" || body.path.trim().length === 0) {
|
|
353
|
+
return this.json(response, 400, {
|
|
354
|
+
error: "workspace path is required",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const path = await this.adapter.importWorkspace(body.path);
|
|
358
|
+
this.publish("workspace_imported", { path });
|
|
359
|
+
return this.json(response, 201, { path });
|
|
360
|
+
}
|
|
361
|
+
if (url.pathname === "/api/workspaces" && request.method === "PATCH") {
|
|
362
|
+
const body = await this.readJson(request);
|
|
363
|
+
if (typeof body.path !== "string" || typeof body.name !== "string") {
|
|
364
|
+
return this.json(response, 400, {
|
|
365
|
+
error: "workspace path and name are required",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const name = await this.adapter.renameWorkspace(body.path, body.name);
|
|
369
|
+
this.publish("workspace_renamed", { path: body.path, name });
|
|
370
|
+
return this.json(response, 200, { path: body.path, name });
|
|
371
|
+
}
|
|
372
|
+
if (url.pathname === "/api/workspaces" && request.method === "DELETE") {
|
|
373
|
+
const path = url.searchParams.get("path");
|
|
374
|
+
if (!path)
|
|
375
|
+
return this.json(response, 400, {
|
|
376
|
+
error: "workspace path is required",
|
|
377
|
+
});
|
|
378
|
+
await this.adapter.removeWorkspace(path);
|
|
379
|
+
this.publish("workspace_removed", { path });
|
|
380
|
+
return this.json(response, 200, { path, removed: true });
|
|
381
|
+
}
|
|
382
|
+
if (url.pathname === "/api/sessions" && request.method === "POST") {
|
|
383
|
+
const body = await this.readJson(request);
|
|
384
|
+
if (
|
|
385
|
+
typeof body.workspacePath !== "string" ||
|
|
386
|
+
typeof body.commandId !== "string" ||
|
|
387
|
+
body.commandId.length === 0 ||
|
|
388
|
+
body.commandId.length > 128
|
|
389
|
+
) {
|
|
390
|
+
return this.json(response, 400, {
|
|
391
|
+
error: "workspace path and bounded commandId are required",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const workspacePath = await this.adapter.requireWorkspace(
|
|
395
|
+
body.workspacePath,
|
|
396
|
+
);
|
|
397
|
+
const result = await this.runtime.newSession(workspacePath, {
|
|
398
|
+
commandId: body.commandId,
|
|
399
|
+
});
|
|
400
|
+
this.publish("session_created", {
|
|
401
|
+
workspacePath,
|
|
402
|
+
commandId: body.commandId,
|
|
403
|
+
...(result.sessionPath ? { sessionPath: result.sessionPath } : {}),
|
|
404
|
+
});
|
|
405
|
+
return this.json(response, 201, {
|
|
406
|
+
...result,
|
|
407
|
+
commandId: body.commandId,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (url.pathname === "/api/sessions" && request.method === "PATCH") {
|
|
411
|
+
const body = await this.readJson(request);
|
|
412
|
+
if (typeof body.path !== "string" || typeof body.name !== "string") {
|
|
413
|
+
return this.json(response, 400, {
|
|
414
|
+
error: "session path and name are required",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const name = await this.adapter.renameSession(body.path, body.name);
|
|
418
|
+
this.publish("session_renamed", { sessionPath: body.path, name });
|
|
419
|
+
return this.json(response, 200, { path: body.path, name });
|
|
420
|
+
}
|
|
421
|
+
if (url.pathname === "/api/sessions/archive" && request.method === "POST") {
|
|
422
|
+
const path = url.searchParams.get("path");
|
|
423
|
+
if (!path) return this.json(response, 400, { error: "session path is required" });
|
|
424
|
+
await this.adapter.archiveSession(path);
|
|
425
|
+
this.publish("session_archived", { sessionPath: path });
|
|
426
|
+
return this.json(response, 200, { path, archived: true });
|
|
427
|
+
}
|
|
428
|
+
if (url.pathname === "/api/sessions/select" && request.method === "POST") {
|
|
429
|
+
const body = await this.readJson(request);
|
|
430
|
+
if (typeof body.path !== "string" || body.path.trim().length === 0) {
|
|
431
|
+
return this.json(response, 400, { error: "session path is required" });
|
|
432
|
+
}
|
|
433
|
+
const session = await this.adapter.requireSession(body.path);
|
|
434
|
+
const result =
|
|
435
|
+
session.id === this.runtime.sessionManager.getSessionId()
|
|
436
|
+
? { cancelled: false }
|
|
437
|
+
: await this.runtime.switchSession(session.path);
|
|
438
|
+
this.publish("session_selected", { sessionPath: session.path });
|
|
439
|
+
return this.json(response, 200, result);
|
|
440
|
+
}
|
|
441
|
+
if (url.pathname === "/api/model" && request.method === "POST") {
|
|
442
|
+
const body = await this.readJson(request);
|
|
443
|
+
if (
|
|
444
|
+
typeof body.provider !== "string" ||
|
|
445
|
+
typeof body.modelId !== "string" ||
|
|
446
|
+
typeof body.sessionId !== "string"
|
|
447
|
+
) {
|
|
448
|
+
return this.json(response, 400, {
|
|
449
|
+
error: "provider, modelId, and sessionId are required",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (this.runtime.workspaceSelected !== true) {
|
|
453
|
+
return this.json(response, 409, {
|
|
454
|
+
code: "WORKSPACE_REQUIRED",
|
|
455
|
+
error: "Choose a workspace before using the Web runtime",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const model = await this.runtime.setModel(body.provider, body.modelId, {
|
|
460
|
+
expectedSessionId: body.sessionId,
|
|
461
|
+
});
|
|
462
|
+
this.publish("model_selected", {
|
|
463
|
+
provider: model.provider,
|
|
464
|
+
modelId: model.id,
|
|
465
|
+
});
|
|
466
|
+
return this.json(response, 200, model);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
const failure = this.runtimeRequestFailure(
|
|
469
|
+
error,
|
|
470
|
+
"MODEL_SELECTION_FAILED",
|
|
471
|
+
"model selection failed",
|
|
472
|
+
);
|
|
473
|
+
return this.json(response, failure.status, {
|
|
474
|
+
code: failure.code,
|
|
475
|
+
error: failure.error,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (url.pathname === "/api/prompt" && request.method === "POST") {
|
|
480
|
+
const requestStarted = performance.now();
|
|
481
|
+
const body = await this.readJson(request);
|
|
482
|
+
const content =
|
|
483
|
+
typeof body.content === "string" ? body.content.trim() : "";
|
|
484
|
+
if (!content || content.length > 12_000) {
|
|
485
|
+
return this.json(response, 400, {
|
|
486
|
+
error: "prompt must be 1-12000 characters",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (this.runtime.workspaceSelected !== true) {
|
|
490
|
+
return this.json(response, 409, {
|
|
491
|
+
code: "WORKSPACE_REQUIRED",
|
|
492
|
+
error: "Choose a workspace before using the Web runtime",
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (
|
|
496
|
+
typeof body.sessionId !== "string" ||
|
|
497
|
+
body.sessionId !== this.runtime.sessionManager.getSessionId()
|
|
498
|
+
) {
|
|
499
|
+
return this.json(response, 409, {
|
|
500
|
+
code: "SESSION_CONFLICT",
|
|
501
|
+
error: "Only the active Web session accepts messages",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const commandId = randomUUID();
|
|
505
|
+
traceWeb("prompt_received", {
|
|
506
|
+
commandId,
|
|
507
|
+
sessionId: body.sessionId,
|
|
508
|
+
chars: content.length,
|
|
509
|
+
});
|
|
510
|
+
try {
|
|
511
|
+
await this.runtime.sendPrompt(content, {
|
|
512
|
+
commandId,
|
|
513
|
+
expectedSessionId: body.sessionId,
|
|
514
|
+
});
|
|
515
|
+
traceWeb("prompt_admission_finished", {
|
|
516
|
+
commandId,
|
|
517
|
+
elapsedMs: elapsed(requestStarted),
|
|
518
|
+
});
|
|
519
|
+
} catch (error) {
|
|
520
|
+
const failure = this.runtimeRequestFailure(error);
|
|
521
|
+
traceWeb("prompt_admission_failed", {
|
|
522
|
+
commandId,
|
|
523
|
+
elapsedMs: elapsed(requestStarted),
|
|
524
|
+
error: failure.error,
|
|
525
|
+
});
|
|
526
|
+
return this.json(response, failure.status, {
|
|
527
|
+
code: failure.code,
|
|
528
|
+
error: failure.error,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
this.publish("prompt_accepted", { commandId, sessionId: body.sessionId });
|
|
532
|
+
traceWeb("prompt_response_sent", {
|
|
533
|
+
commandId,
|
|
534
|
+
sessionId: body.sessionId,
|
|
535
|
+
elapsedMs: elapsed(requestStarted),
|
|
536
|
+
});
|
|
537
|
+
return this.json(response, 202, {
|
|
538
|
+
id: commandId,
|
|
539
|
+
accepted: true,
|
|
540
|
+
state: "accepted",
|
|
541
|
+
cursor: this.sequence,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
if (request.method !== "GET") {
|
|
545
|
+
return this.json(response, 405, { error: "method not allowed" });
|
|
546
|
+
}
|
|
547
|
+
if (url.pathname === "/events") return this.eventsStream(request, response);
|
|
548
|
+
if (url.pathname === "/api/sessions") {
|
|
549
|
+
const projection = await this.adapter.listSessionProjection();
|
|
550
|
+
return this.json(response, 200, {
|
|
551
|
+
sessions: projection.sessions,
|
|
552
|
+
truncation: {
|
|
553
|
+
truncated: projection.omitted > 0,
|
|
554
|
+
sessionsOmitted: projection.omitted,
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (url.pathname === "/api/models")
|
|
559
|
+
return this.json(response, 200, { models: this.runtime.listModels() });
|
|
560
|
+
if (url.pathname === "/api/snapshot") {
|
|
561
|
+
const cursor = this.sequence;
|
|
562
|
+
const projection = await this.adapter.getSnapshot(
|
|
563
|
+
url.searchParams.get("path") ?? undefined,
|
|
564
|
+
);
|
|
565
|
+
const snapshot: WebSnapshot = {
|
|
566
|
+
protocolVersion: WEB_PROTOCOL_VERSION,
|
|
567
|
+
generatedAt: new Date().toISOString(),
|
|
568
|
+
cursor,
|
|
569
|
+
...projection,
|
|
570
|
+
};
|
|
571
|
+
let finalBytes = jsonByteLength(snapshot);
|
|
572
|
+
while (snapshot.truncation.bytes !== finalBytes) {
|
|
573
|
+
snapshot.truncation.bytes = finalBytes;
|
|
574
|
+
finalBytes = jsonByteLength(snapshot);
|
|
575
|
+
}
|
|
576
|
+
return this.json(response, 200, snapshot);
|
|
577
|
+
}
|
|
578
|
+
if (url.pathname === "/api/session") {
|
|
579
|
+
const path = url.searchParams.get("path");
|
|
580
|
+
if (!path)
|
|
581
|
+
return this.json(response, 400, { error: "session path is required" });
|
|
582
|
+
const session = await this.adapter.getSession(path);
|
|
583
|
+
return session
|
|
584
|
+
? this.json(response, 200, { session })
|
|
585
|
+
: this.json(response, 404, {
|
|
586
|
+
error: "session is not in the current workspace",
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
this.json(response, 404, { error: "not found" });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private async chooseDirectory() {
|
|
593
|
+
try {
|
|
594
|
+
if (process.platform === "darwin") {
|
|
595
|
+
const { stdout } = await execFileAsync(
|
|
596
|
+
"osascript",
|
|
597
|
+
[
|
|
598
|
+
"-e",
|
|
599
|
+
'tell application "System Events" to activate',
|
|
600
|
+
"-e",
|
|
601
|
+
'POSIX path of (choose folder with prompt "Choose a workspace")',
|
|
602
|
+
],
|
|
603
|
+
{ signal: this.chooserAbort.signal },
|
|
604
|
+
);
|
|
605
|
+
return stdout.trim() || undefined;
|
|
606
|
+
}
|
|
607
|
+
if (process.platform === "win32") {
|
|
608
|
+
const { stdout } = await execFileAsync(
|
|
609
|
+
"powershell.exe",
|
|
610
|
+
[
|
|
611
|
+
"-NoProfile",
|
|
612
|
+
"-Command",
|
|
613
|
+
"$dialog = New-Object -ComObject Shell.Application; $folder = $dialog.BrowseForFolder(0, 'Choose a workspace', 0); if ($folder) { $folder.Self.Path }",
|
|
614
|
+
],
|
|
615
|
+
{ signal: this.chooserAbort.signal },
|
|
616
|
+
);
|
|
617
|
+
return stdout.trim() || undefined;
|
|
618
|
+
}
|
|
619
|
+
const { stdout } = await execFileAsync(
|
|
620
|
+
"zenity",
|
|
621
|
+
["--file-selection", "--directory", "--title=Choose a workspace"],
|
|
622
|
+
{ signal: this.chooserAbort.signal },
|
|
623
|
+
);
|
|
624
|
+
return stdout.trim() || undefined;
|
|
625
|
+
} catch {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async readJson(request: IncomingMessage) {
|
|
631
|
+
const chunks: Buffer[] = [];
|
|
632
|
+
let bytes = 0;
|
|
633
|
+
for await (const chunk of request) {
|
|
634
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
635
|
+
bytes += buffer.length;
|
|
636
|
+
if (bytes > MAX_COMMAND_BYTES)
|
|
637
|
+
throw new Error("request body is too large");
|
|
638
|
+
chunks.push(buffer);
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
642
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
643
|
+
throw new Error("request body must be an object");
|
|
644
|
+
}
|
|
645
|
+
return value as Record<string, unknown>;
|
|
646
|
+
} catch (error) {
|
|
647
|
+
throw new Error(
|
|
648
|
+
error instanceof SyntaxError
|
|
649
|
+
? "request body is invalid JSON"
|
|
650
|
+
: String(error),
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private authorized(request: IncomingMessage) {
|
|
656
|
+
const value = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
|
657
|
+
if (!value || !/^[0-9a-f]{64}$/i.test(value)) return false;
|
|
658
|
+
const candidate = Buffer.from(value, "hex");
|
|
659
|
+
return (
|
|
660
|
+
candidate.length === this.token.length &&
|
|
661
|
+
timingSafeEqual(candidate, this.token)
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private eventsStream(request: IncomingMessage, response: ServerResponse) {
|
|
666
|
+
const url = new URL(request.url ?? "/events", `http://${HOST}`);
|
|
667
|
+
const queryCursor = this.parseCursor(url.searchParams.get("cursor"));
|
|
668
|
+
const headerValue = request.headers["last-event-id"];
|
|
669
|
+
const headerCursor = this.parseCursor(
|
|
670
|
+
Array.isArray(headerValue) ? headerValue[0] : headerValue,
|
|
671
|
+
);
|
|
672
|
+
if (queryCursor.invalid || headerCursor.invalid) {
|
|
673
|
+
return this.json(response, 400, {
|
|
674
|
+
code: "INVALID_CURSOR",
|
|
675
|
+
error: "cursor must be a non-negative integer",
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
if (
|
|
679
|
+
queryCursor.value !== undefined &&
|
|
680
|
+
headerCursor.value !== undefined &&
|
|
681
|
+
queryCursor.value !== headerCursor.value
|
|
682
|
+
) {
|
|
683
|
+
return this.json(response, 400, {
|
|
684
|
+
code: "CURSOR_MISMATCH",
|
|
685
|
+
error: "cursor and Last-Event-ID must match",
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const cursor = queryCursor.value ?? headerCursor.value;
|
|
689
|
+
const oldestCursor = this.events[0]?.sequence
|
|
690
|
+
? this.events[0].sequence - 1
|
|
691
|
+
: this.sequence;
|
|
692
|
+
if (
|
|
693
|
+
cursor === undefined ||
|
|
694
|
+
cursor < oldestCursor ||
|
|
695
|
+
cursor > this.sequence
|
|
696
|
+
) {
|
|
697
|
+
return this.json(response, 409, {
|
|
698
|
+
code: "RESYNC_REQUIRED",
|
|
699
|
+
error: "event history is not available for this cursor",
|
|
700
|
+
cursor: this.sequence,
|
|
701
|
+
oldestCursor,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
if (this.clients.size >= MAX_SSE_CLIENTS) {
|
|
705
|
+
return this.json(response, 503, {
|
|
706
|
+
code: "SSE_CLIENT_LIMIT",
|
|
707
|
+
error: "too many event clients",
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
const replay = this.events
|
|
711
|
+
.filter((event) => event.sequence > cursor)
|
|
712
|
+
.map(
|
|
713
|
+
(event) =>
|
|
714
|
+
`id: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`,
|
|
715
|
+
);
|
|
716
|
+
const replayBytes = replay.reduce(
|
|
717
|
+
(bytes, record) => bytes + Buffer.byteLength(record),
|
|
718
|
+
Buffer.byteLength(": connected\n\n"),
|
|
719
|
+
);
|
|
720
|
+
if (replayBytes > MAX_SSE_REPLAY_BYTES) {
|
|
721
|
+
return this.json(response, 409, {
|
|
722
|
+
code: "RESYNC_REQUIRED",
|
|
723
|
+
error: "event replay exceeds the bounded transport budget",
|
|
724
|
+
cursor: this.sequence,
|
|
725
|
+
oldestCursor,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
response.writeHead(200, {
|
|
729
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
730
|
+
"Cache-Control": "no-cache",
|
|
731
|
+
Connection: "keep-alive",
|
|
732
|
+
});
|
|
733
|
+
response.write(": connected\n\n");
|
|
734
|
+
// The complete replay is bounded before headers. A false return only means
|
|
735
|
+
// Node buffered the write; finishing this synchronous replay preserves
|
|
736
|
+
// ordering without treating normal backpressure as a broken client.
|
|
737
|
+
for (const record of replay) response.write(record);
|
|
738
|
+
this.clients.add(response);
|
|
739
|
+
response.on("close", () => this.clients.delete(response));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private parseCursor(value: string | undefined | null) {
|
|
743
|
+
if (value === undefined || value === null || value === "") {
|
|
744
|
+
return { invalid: false, value: undefined };
|
|
745
|
+
}
|
|
746
|
+
if (!/^\d+$/.test(value)) return { invalid: true, value: undefined };
|
|
747
|
+
const parsed = Number(value);
|
|
748
|
+
return Number.isSafeInteger(parsed)
|
|
749
|
+
? { invalid: false, value: parsed }
|
|
750
|
+
: { invalid: true, value: undefined };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private runtimeRequestFailure(
|
|
754
|
+
error: unknown,
|
|
755
|
+
fallbackCode = "PROMPT_ADMISSION_FAILED",
|
|
756
|
+
fallbackMessage = "prompt admission failed",
|
|
757
|
+
) {
|
|
758
|
+
if (error instanceof WebRuntimeRequestError) {
|
|
759
|
+
return {
|
|
760
|
+
status: error.statusCode,
|
|
761
|
+
code: error.code,
|
|
762
|
+
error: error.message,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
status: 500,
|
|
767
|
+
code: fallbackCode,
|
|
768
|
+
error: error instanceof Error ? error.message : fallbackMessage,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private json(response: ServerResponse, status: number, value: unknown) {
|
|
773
|
+
let body = JSON.stringify(value);
|
|
774
|
+
if (Buffer.byteLength(body) > WEB_MAX_SNAPSHOT_BYTES) {
|
|
775
|
+
status = 500;
|
|
776
|
+
body = JSON.stringify({
|
|
777
|
+
code: "RESPONSE_TOO_LARGE",
|
|
778
|
+
error: "response exceeded the Web protocol byte limit",
|
|
779
|
+
maxBytes: WEB_MAX_SNAPSHOT_BYTES,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
response.writeHead(status, {
|
|
783
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
784
|
+
"Cache-Control": "no-store",
|
|
785
|
+
"Content-Length": Buffer.byteLength(body),
|
|
786
|
+
});
|
|
787
|
+
response.end(body);
|
|
788
|
+
}
|
|
789
|
+
}
|