leetcode-capture-helper 0.0.0-stage → 0.1.1
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/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +420 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2190 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +168 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1337 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +316 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
// LeetCode Capture - in-page tutor panel broker
|
|
2
|
+
//
|
|
3
|
+
// The companion (a separate local process) publishes tutor events here, and
|
|
4
|
+
// the in-page panel (the extension's panel.js, running on leetcode.com)
|
|
5
|
+
// streams them out over Server-Sent Events and posts typed messages back.
|
|
6
|
+
// This module only brokers: it never calls an LLM, and it keeps everything in
|
|
7
|
+
// memory (a bounded event buffer), so a relay restart simply starts the panel
|
|
8
|
+
// transcript fresh. Sequence numbers restart at 1 with it, so every event
|
|
9
|
+
// (and the status event) carries this run's random `epoch`: a panel that
|
|
10
|
+
// reconnects with `?after=<seq>&epoch=<old>` gets the whole new buffer
|
|
11
|
+
// instead of having it filtered against a counter from a previous run, and
|
|
12
|
+
// it resets its own `lastSeq` when it sees a new epoch.
|
|
13
|
+
//
|
|
14
|
+
// Routes (all under /panel/):
|
|
15
|
+
// GET /panel/events?after=<seq> panel SSE stream of tutor events (replays the buffer after <seq>)
|
|
16
|
+
// POST /panel/message panel a typed message for the tutor
|
|
17
|
+
// POST /panel/switch-profile panel { id }: the model switcher picked a saved model
|
|
18
|
+
// POST /panel/publish companion one event, or { events: [...] }
|
|
19
|
+
// GET /panel/inbox companion SSE stream of the panel's typed messages
|
|
20
|
+
// POST /panel/drop-problem companion { problemSlug }: forget that problem's buffered events
|
|
21
|
+
// POST /panel/trim-captures companion { before, path }: drop the capture log's first `before` bytes
|
|
22
|
+
// POST /panel/activity companion { busy }: the tutor is working on (or has queued) a turn
|
|
23
|
+
// POST /panel/rpc-result companion { id, response }: the answer to a settings call
|
|
24
|
+
// POST /settings/rpc options { method, params }: a settings call for the companion
|
|
25
|
+
//
|
|
26
|
+
// A `config` event (which provider and model the tutor uses, never a key) is
|
|
27
|
+
// not buffered with the conversation: the newest one is kept on its own and
|
|
28
|
+
// sent to every panel that connects, after the replay and before `status`.
|
|
29
|
+
//
|
|
30
|
+
// POST /panel/switch-profile is the one settings call a panel (and so any
|
|
31
|
+
// script on leetcode.com) can make: it goes to the companion as the rpc
|
|
32
|
+
// `switchProfile` with nothing but the id, and the companion accepts only an
|
|
33
|
+
// id it already has saved, which changes nothing but which saved model is in
|
|
34
|
+
// use (see companion/settings-rpc.js). The answer carries only whether it
|
|
35
|
+
// worked, a reason and the non-secret summary of the model now in use.
|
|
36
|
+
//
|
|
37
|
+
// /settings/rpc is for the extension's own options page (extension/settings.js)
|
|
38
|
+
// only: its Origin must be an extension origin (chrome-extension://<32 letters
|
|
39
|
+
// a-p> or moz-extension://<uuid>), which a web page cannot send, plus the same
|
|
40
|
+
// Host check. The call goes down the companion's inbox stream as
|
|
41
|
+
// `{ type: "rpc", id, method, params }` and the request waits (up to
|
|
42
|
+
// RPC_TIMEOUT_MS) for the companion's POST /panel/rpc-result. Settings carry
|
|
43
|
+
// API keys, so leetcode.com (whose page scripts are not ours) is refused here
|
|
44
|
+
// too. 503 when no companion is connected.
|
|
45
|
+
//
|
|
46
|
+
// Unlike /capture (which only ingests data, so a permissive CORS `*` is
|
|
47
|
+
// harmless), these routes can READ tutor replies, which contain the student's
|
|
48
|
+
// code, so any web page that could reach localhost must not be able to. Two
|
|
49
|
+
// checks run on every request:
|
|
50
|
+
// - Host must be exactly the relay's own loopback address, which defeats DNS
|
|
51
|
+
// rebinding (an attacker's hostname resolving to 127.0.0.1 arrives with
|
|
52
|
+
// the attacker's Host header).
|
|
53
|
+
// - Origin must be absent or exactly https://leetcode.com. Browsers always
|
|
54
|
+
// send Origin on a CORS request and on any POST, and a page cannot forge
|
|
55
|
+
// it, so every other website is rejected.
|
|
56
|
+
// The companion-only routes (publish, inbox, drop-problem, trim-captures, activity, rpc-result) additionally go through
|
|
57
|
+
// companionOnlyRejection below. A missing Origin alone is NOT proof of the
|
|
58
|
+
// companion: a page's `fetch(url, { mode: "no-cors" })` GET carries no Origin
|
|
59
|
+
// either. What a browser page cannot do is add a custom header without a CORS
|
|
60
|
+
// preflight, and that preflight carries an Origin and is refused (and even
|
|
61
|
+
// leetcode.com's preflight is never told this header is allowed). So these
|
|
62
|
+
// routes require COMPANION_HEADER, no Origin, and no cross-site Sec-Fetch-Site.
|
|
63
|
+
// A script on leetcode.com can therefore only read events and post messages.
|
|
64
|
+
//
|
|
65
|
+
// Text in a typed message ends up printed in the student's terminal by the
|
|
66
|
+
// companion, so C0/C1 control characters (escape sequences that could recolor
|
|
67
|
+
// the terminal, retitle the window, or worse) and invisible bidi/zero-width
|
|
68
|
+
// characters (which could disguise what the text says) are stripped at this
|
|
69
|
+
// boundary.
|
|
70
|
+
// The companion strips them again before printing anything untrusted.
|
|
71
|
+
//
|
|
72
|
+
// The number of open panel streams is capped, and only CORS-mode requests
|
|
73
|
+
// count: a page's no-cors GET (or an <img> or <iframe>) to /panel/events has
|
|
74
|
+
// no Origin, so without the Sec-Fetch-Mode check in handle() it could hold the
|
|
75
|
+
// slots open, unread, and lock the real panel out.
|
|
76
|
+
|
|
77
|
+
"use strict";
|
|
78
|
+
|
|
79
|
+
const crypto = require("crypto");
|
|
80
|
+
const fs = require("fs");
|
|
81
|
+
const path = require("path");
|
|
82
|
+
|
|
83
|
+
const PANEL_ORIGIN = "https://leetcode.com";
|
|
84
|
+
// Sent by companion/panel-bridge.js on every companion-only request.
|
|
85
|
+
const COMPANION_HEADER = "x-lc-companion";
|
|
86
|
+
|
|
87
|
+
const MAX_BUFFERED_EVENTS = 2000;
|
|
88
|
+
// A message may carry the live editor code (its `snapshot`), so the body cap
|
|
89
|
+
// is sized for a long solution, not for the typed text alone. Real solutions
|
|
90
|
+
// are a few KB; the snapshot's code and description caps (plus the typed
|
|
91
|
+
// text) fit well under the body cap as plain JSON. extension/panel.js keeps
|
|
92
|
+
// the same three numbers and leaves the snapshot out of a message rather than
|
|
93
|
+
// send a body past them (never a truncated copy of the code).
|
|
94
|
+
const MAX_MESSAGE_BYTES = 256 * 1024;
|
|
95
|
+
const MAX_SNAPSHOT_CODE_CHARS = 100_000;
|
|
96
|
+
const MAX_SNAPSHOT_DESCRIPTION_CHARS = 50_000;
|
|
97
|
+
const MAX_PUBLISH_BYTES = 5 * 1024 * 1024;
|
|
98
|
+
const MAX_TEXT_CHARS = 200_000;
|
|
99
|
+
const MAX_MESSAGE_CHARS = 8000;
|
|
100
|
+
const MAX_PANEL_STREAMS = 20;
|
|
101
|
+
const MAX_OPEN_TURNS = 100;
|
|
102
|
+
const HEARTBEAT_MS = 15_000;
|
|
103
|
+
|
|
104
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,199}$/i;
|
|
105
|
+
|
|
106
|
+
// The options page's origin: Chrome's extension ids are 32 letters a-p,
|
|
107
|
+
// Firefox's are a random UUID per install.
|
|
108
|
+
const EXTENSION_ORIGIN_RE =
|
|
109
|
+
/^(chrome-extension:\/\/[a-p]{32}|moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
110
|
+
// A connection test can wait on a slow model (settings-rpc.js gives it 90 s).
|
|
111
|
+
const RPC_TIMEOUT_MS = 120_000;
|
|
112
|
+
// A model switch answers only once the new model is in place, after the reply
|
|
113
|
+
// already running (companion/settings-rpc.js), which may take minutes. The
|
|
114
|
+
// panel waits 180 s (SWITCH_TIMEOUT_MS in extension/panel.js), so the relay
|
|
115
|
+
// waits longer and the panel is always the one that gives up; a switch that
|
|
116
|
+
// lands after that still reaches the panel as the companion's next config
|
|
117
|
+
// event, which names the active profile.
|
|
118
|
+
const SWITCH_RPC_TIMEOUT_MS = 190_000;
|
|
119
|
+
const MAX_RPC_BYTES = 64 * 1024;
|
|
120
|
+
const MAX_RPC_RESULT_BYTES = 1024 * 1024;
|
|
121
|
+
const MAX_PENDING_RPCS = 50;
|
|
122
|
+
const RPC_METHOD_RE = /^[a-z][a-zA-Z]{0,39}$/;
|
|
123
|
+
// Same as extension/shared/profiles.js's PROFILE_ID_RE.
|
|
124
|
+
const PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
125
|
+
// A switch waits for the reply already running, so it may take a while.
|
|
126
|
+
const SWITCH_STATUS = { unknown: 404, locked: 409, unavailable: 409 };
|
|
127
|
+
|
|
128
|
+
// Everything in C0 except tab and newline, plus DEL and all of C1 (which some
|
|
129
|
+
// terminals honor as 8-bit CSI/OSC introducers), plus the invisible bidi and
|
|
130
|
+
// zero-width formatting characters that make text read differently from what
|
|
131
|
+
// it is. The line and paragraph separators become a newline. The zero-width
|
|
132
|
+
// joiner and non-joiner stay (emoji sequences and several scripts need them).
|
|
133
|
+
// Same rule as extension/shared/sanitize.js, which explains each range.
|
|
134
|
+
const CONTROL_CHARS_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
|
|
135
|
+
const LINE_SEPARATORS_RE = /[\u2028\u2029]/g;
|
|
136
|
+
|
|
137
|
+
function stripControlChars(text) {
|
|
138
|
+
return text.replace(LINE_SEPARATORS_RE, "\n").replace(CONTROL_CHARS_RE, "");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Returns null when the request plausibly comes from the local companion
|
|
142
|
+
// process, or a reason string for the 403. Shared by every companion-only
|
|
143
|
+
// route; add new ones to COMPANION_ONLY_ROUTES rather than re-deriving this.
|
|
144
|
+
function companionOnlyRejection(req) {
|
|
145
|
+
if (req.headers.origin !== undefined) return "this route is for the companion only";
|
|
146
|
+
const site = req.headers["sec-fetch-site"];
|
|
147
|
+
if (site !== undefined && site !== "none") return "this route is for the companion only";
|
|
148
|
+
if (req.headers[COMPANION_HEADER] !== "1") return `this route is for the companion only (missing ${COMPANION_HEADER})`;
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const COMPANION_ONLY_ROUTES = new Set([
|
|
153
|
+
"POST /panel/publish",
|
|
154
|
+
"GET /panel/inbox",
|
|
155
|
+
"POST /panel/drop-problem",
|
|
156
|
+
"POST /panel/trim-captures",
|
|
157
|
+
"POST /panel/activity",
|
|
158
|
+
"POST /panel/rpc-result",
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const EVENT_TYPES = new Set(["user", "capture", "turn-start", "chunk", "turn-end", "notice"]);
|
|
162
|
+
|
|
163
|
+
function isShortString(value, max) {
|
|
164
|
+
return typeof value === "string" && value.length <= max;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Copies only the fields the panel knows how to render, so a malformed or
|
|
168
|
+
// oversized publish can never smuggle arbitrary data into the buffer.
|
|
169
|
+
function sanitizeEvent(raw) {
|
|
170
|
+
if (!raw || typeof raw !== "object" || !EVENT_TYPES.has(raw.type)) return null;
|
|
171
|
+
const event = { type: raw.type };
|
|
172
|
+
if (raw.problemSlug !== undefined && raw.problemSlug !== null) {
|
|
173
|
+
if (!isShortString(raw.problemSlug, 200)) return null;
|
|
174
|
+
event.problemSlug = raw.problemSlug;
|
|
175
|
+
} else {
|
|
176
|
+
event.problemSlug = null;
|
|
177
|
+
}
|
|
178
|
+
if (raw.turnId !== undefined) {
|
|
179
|
+
if (!isShortString(raw.turnId, 64)) return null;
|
|
180
|
+
event.turnId = raw.turnId;
|
|
181
|
+
}
|
|
182
|
+
if (raw.text !== undefined) {
|
|
183
|
+
if (!isShortString(raw.text, MAX_TEXT_CHARS)) return null;
|
|
184
|
+
event.text = raw.text;
|
|
185
|
+
}
|
|
186
|
+
if (raw.error !== undefined) {
|
|
187
|
+
if (!isShortString(raw.error, 2000)) return null;
|
|
188
|
+
event.error = raw.error;
|
|
189
|
+
}
|
|
190
|
+
if (raw.trigger !== undefined) {
|
|
191
|
+
if (raw.trigger !== "run" && raw.trigger !== "submit") return null;
|
|
192
|
+
event.trigger = raw.trigger;
|
|
193
|
+
}
|
|
194
|
+
if (raw.problemTitle !== undefined && raw.problemTitle !== null) {
|
|
195
|
+
if (!isShortString(raw.problemTitle, 300)) return null;
|
|
196
|
+
event.problemTitle = raw.problemTitle;
|
|
197
|
+
}
|
|
198
|
+
if (raw.attemptSeq !== undefined && raw.attemptSeq !== null) {
|
|
199
|
+
if (!Number.isInteger(raw.attemptSeq) || raw.attemptSeq < 0) return null;
|
|
200
|
+
event.attemptSeq = raw.attemptSeq;
|
|
201
|
+
}
|
|
202
|
+
// The notice of a reset on a problem switch (the panel treats it as the
|
|
203
|
+
// start of a fresh session).
|
|
204
|
+
if (raw.type === "notice" && raw.switch === true) event.switch = true;
|
|
205
|
+
return event;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The non-secret summary of the tutor's provider, as settings.js's
|
|
209
|
+
// statusSummary makes it. Anything else is dropped.
|
|
210
|
+
function sanitizeConfig(raw) {
|
|
211
|
+
if (!raw || typeof raw !== "object") return null;
|
|
212
|
+
if (typeof raw.provider !== "string" || !/^[a-z]{1,40}$/.test(raw.provider)) return null;
|
|
213
|
+
if (!isShortString(raw.label, 100)) return null;
|
|
214
|
+
const config = { type: "config", provider: raw.provider, label: stripControlChars(raw.label) };
|
|
215
|
+
if (raw.model === null || raw.model === undefined) config.model = null;
|
|
216
|
+
else if (isShortString(raw.model, 200)) config.model = stripControlChars(raw.model);
|
|
217
|
+
else return null;
|
|
218
|
+
// The saved profile in use (an id only), left out when none is.
|
|
219
|
+
if (typeof raw.profile === "string" && PROFILE_ID_RE.test(raw.profile)) config.profile = raw.profile;
|
|
220
|
+
else if (raw.profile !== null && raw.profile !== undefined) return null;
|
|
221
|
+
return config;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The live editor code a panel message may carry (read by the extension at
|
|
225
|
+
// send time), reduced to the fields the companion uses. Anything malformed
|
|
226
|
+
// is dropped and the message goes without it: the companion then falls back
|
|
227
|
+
// to the newest recorded Run or Submit. The code itself is passed on as is;
|
|
228
|
+
// the companion never prints it.
|
|
229
|
+
function sanitizeSnapshot(raw) {
|
|
230
|
+
if (!raw || typeof raw !== "object" || !isShortString(raw.code, MAX_SNAPSHOT_CODE_CHARS)) return null;
|
|
231
|
+
const snapshot = { code: raw.code };
|
|
232
|
+
if (isShortString(raw.language, 50)) snapshot.language = stripControlChars(raw.language);
|
|
233
|
+
if (isShortString(raw.problemTitle, 300)) snapshot.problemTitle = stripControlChars(raw.problemTitle);
|
|
234
|
+
if (isShortString(raw.problemDescription, MAX_SNAPSHOT_DESCRIPTION_CHARS)) {
|
|
235
|
+
snapshot.problemDescription = stripControlChars(raw.problemDescription);
|
|
236
|
+
}
|
|
237
|
+
return snapshot;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Whether two paths name the same file: the companion tails the file it was
|
|
241
|
+
// configured with, and a trim must never cut a different one (with only
|
|
242
|
+
// LEETCODE_CAPTURES_FILE or only CAPTURE_LOG_PATH set, they differ).
|
|
243
|
+
function sameFile(a, b) {
|
|
244
|
+
const real = (p) => {
|
|
245
|
+
try {
|
|
246
|
+
return fs.realpathSync(p);
|
|
247
|
+
} catch {
|
|
248
|
+
return path.resolve(p);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
return real(a) === real(b);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// captureLog, when given, is { path, trim(before) } from server.js; without
|
|
255
|
+
// it the trim route answers 404.
|
|
256
|
+
function createPanelBroker({ port, captureLog = null, rpcTimeoutMs = RPC_TIMEOUT_MS, switchTimeoutMs = SWITCH_RPC_TIMEOUT_MS }) {
|
|
257
|
+
const allowedHosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`]);
|
|
258
|
+
|
|
259
|
+
// Identifies this run of the relay; see the file header.
|
|
260
|
+
const epoch = crypto.randomBytes(8).toString("hex");
|
|
261
|
+
let nextSeq = 1;
|
|
262
|
+
let events = []; // bounded, oldest first
|
|
263
|
+
const panelClients = new Set(); // SSE responses for panels
|
|
264
|
+
const consumers = new Set(); // SSE responses for companions (inbox)
|
|
265
|
+
let nextMessageId = 1;
|
|
266
|
+
// Replies that have started but not ended, with the text streamed so far:
|
|
267
|
+
// turnId -> { problemSlug, text, closed }. The companion ends every turn it
|
|
268
|
+
// finishes, but a companion that exits mid-reply (Ctrl+C, a crash, the
|
|
269
|
+
// terminal closed) never sends that end, and the panel would show the
|
|
270
|
+
// reply as still typing forever, in every tab opened later too. When the
|
|
271
|
+
// last companion's inbox stream goes away (and after any publish that
|
|
272
|
+
// arrives while none is connected), closeOpenTurns ends each of them as
|
|
273
|
+
// interrupted. `closed` marks one ended that way, so a companion that only
|
|
274
|
+
// lost its stream and keeps streaming the same turn reopens it.
|
|
275
|
+
const openTurns = new Map();
|
|
276
|
+
// Whether the companion is working on a turn or has one queued (it says so
|
|
277
|
+
// on POST /panel/activity). Shown in the panel's header as "Processing";
|
|
278
|
+
// cleared when the last companion goes away, since nothing will clear it
|
|
279
|
+
// then.
|
|
280
|
+
let companionBusy = false;
|
|
281
|
+
// The newest config event from the companion (see the file header).
|
|
282
|
+
let latestConfig = null;
|
|
283
|
+
// Settings calls waiting for the companion's answer: id -> { req, res, timer }.
|
|
284
|
+
const pendingRpcs = new Map();
|
|
285
|
+
|
|
286
|
+
function corsHeaders(req) {
|
|
287
|
+
// Only ever echoed for the one allowed origin; a request with no Origin
|
|
288
|
+
// header (the companion) needs no CORS headers at all.
|
|
289
|
+
if (req.headers.origin !== PANEL_ORIGIN) return {};
|
|
290
|
+
return {
|
|
291
|
+
"Access-Control-Allow-Origin": PANEL_ORIGIN,
|
|
292
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
293
|
+
"Access-Control-Allow-Headers": "Content-Type, Last-Event-ID",
|
|
294
|
+
// Chrome's Private Network Access preflight asks for this when a public
|
|
295
|
+
// https page talks to a loopback address.
|
|
296
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
297
|
+
"Access-Control-Max-Age": "600",
|
|
298
|
+
Vary: "Origin",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The options page's fetch: Chrome and Firefox skip CORS for an extension
|
|
303
|
+
// page with host permission for the relay, but answering the preflight and
|
|
304
|
+
// echoing the origin keeps it working either way.
|
|
305
|
+
function settingsCorsHeaders(req) {
|
|
306
|
+
const origin = req.headers.origin;
|
|
307
|
+
if (typeof origin !== "string" || !EXTENSION_ORIGIN_RE.test(origin)) return {};
|
|
308
|
+
return {
|
|
309
|
+
"Access-Control-Allow-Origin": origin,
|
|
310
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
311
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
312
|
+
"Access-Control-Max-Age": "600",
|
|
313
|
+
Vary: "Origin",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sendJson(req, res, status, body) {
|
|
318
|
+
if (res.headersSent || res.writableEnded) return;
|
|
319
|
+
const cors = req.url.startsWith("/settings/") ? settingsCorsHeaders(req) : corsHeaders(req);
|
|
320
|
+
res.writeHead(status, { "Content-Type": "application/json", ...cors });
|
|
321
|
+
res.end(JSON.stringify(body));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function sseWrite(res, { id, data }) {
|
|
325
|
+
if (id !== undefined) res.write(`id: ${id}\n`);
|
|
326
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// openTurns lists the replies this relay has seen start and not yet end
|
|
330
|
+
// (or ended as interrupted). A panel that lost its stream marks every
|
|
331
|
+
// reply it was showing as interrupted, since a relay that died with its
|
|
332
|
+
// companion never sends their end; after a reconnect to the same run, the
|
|
333
|
+
// ones listed here are alive after all.
|
|
334
|
+
function statusEvent() {
|
|
335
|
+
const open = [];
|
|
336
|
+
for (const [turnId, turn] of openTurns) if (!turn.closed) open.push(turnId);
|
|
337
|
+
return { type: "status", companionOnline: consumers.size > 0, busy: consumers.size > 0 && companionBusy, epoch, openTurns: open };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function broadcastStatus() {
|
|
341
|
+
for (const res of panelClients) sseWrite(res, { data: statusEvent() });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function trackTurn(event) {
|
|
345
|
+
if (!event.turnId) return;
|
|
346
|
+
if (event.type === "turn-start") {
|
|
347
|
+
openTurns.delete(event.turnId);
|
|
348
|
+
openTurns.set(event.turnId, { problemSlug: event.problemSlug, text: "", closed: false });
|
|
349
|
+
// A companion that never ends its turns must not grow this forever.
|
|
350
|
+
if (openTurns.size > MAX_OPEN_TURNS) openTurns.delete(openTurns.keys().next().value);
|
|
351
|
+
} else if (event.type === "chunk") {
|
|
352
|
+
const turn = openTurns.get(event.turnId);
|
|
353
|
+
if (!turn) return;
|
|
354
|
+
turn.closed = false;
|
|
355
|
+
if (event.text && turn.text.length < MAX_TEXT_CHARS) turn.text = (turn.text + event.text).slice(0, MAX_TEXT_CHARS);
|
|
356
|
+
} else if (event.type === "turn-end") {
|
|
357
|
+
openTurns.delete(event.turnId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Ends every reply still streaming as interrupted, with the text it got
|
|
362
|
+
// so far (a turn-end replaces its chunks in the buffer, see addEvent). The
|
|
363
|
+
// panel shows the same "did not finish" note it uses for a relay restart.
|
|
364
|
+
function closeOpenTurns() {
|
|
365
|
+
for (const [turnId, turn] of openTurns) {
|
|
366
|
+
if (turn.closed) continue;
|
|
367
|
+
turn.closed = true;
|
|
368
|
+
const stored = { type: "turn-end", turnId, problemSlug: turn.problemSlug, text: turn.text, interrupted: true };
|
|
369
|
+
addEvent(stored, { track: false });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function addEvent(event, { track = true } = {}) {
|
|
374
|
+
if (track) trackTurn(event);
|
|
375
|
+
const stored = { ...event, seq: nextSeq++, epoch };
|
|
376
|
+
// A turn-end carries the turn's full authoritative text, so the chunks
|
|
377
|
+
// that led up to it are dead weight for anyone replaying the buffer.
|
|
378
|
+
if (stored.type === "turn-end" && stored.turnId) {
|
|
379
|
+
events = events.filter((e) => !(e.type === "chunk" && e.turnId === stored.turnId));
|
|
380
|
+
}
|
|
381
|
+
events.push(stored);
|
|
382
|
+
if (events.length > MAX_BUFFERED_EVENTS) events = events.slice(events.length - MAX_BUFFERED_EVENTS);
|
|
383
|
+
for (const res of panelClients) sseWrite(res, { id: stored.seq, data: stored });
|
|
384
|
+
return stored;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function openStream(req, res, onClose) {
|
|
388
|
+
res.writeHead(200, {
|
|
389
|
+
"Content-Type": "text/event-stream",
|
|
390
|
+
// no-store, not just no-cache: a stream that never finishes would
|
|
391
|
+
// otherwise hold Chrome's HTTP-cache lock for its URL, and a second
|
|
392
|
+
// identical GET (a second leetcode tab asking for /panel/events?after=0)
|
|
393
|
+
// queues behind it forever.
|
|
394
|
+
"Cache-Control": "no-store, no-transform",
|
|
395
|
+
Connection: "keep-alive",
|
|
396
|
+
"X-Accel-Buffering": "no",
|
|
397
|
+
...corsHeaders(req),
|
|
398
|
+
});
|
|
399
|
+
res.write("retry: 2000\n\n");
|
|
400
|
+
const heartbeat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
401
|
+
heartbeat.unref();
|
|
402
|
+
req.on("close", () => {
|
|
403
|
+
clearInterval(heartbeat);
|
|
404
|
+
onClose();
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// A body past maxBytes is answered with a 413, but the rest of it is still
|
|
409
|
+
// read and thrown away (up to a hard ceiling) before the connection closes:
|
|
410
|
+
// resetting the socket while the client is still uploading makes it see a
|
|
411
|
+
// network error instead of the 413 (the panel would then say the relay is
|
|
412
|
+
// unreachable rather than that the message is too long).
|
|
413
|
+
function readBody(req, res, maxBytes, onBody) {
|
|
414
|
+
let received = 0;
|
|
415
|
+
const chunks = [];
|
|
416
|
+
let aborted = false;
|
|
417
|
+
req.on("data", (chunk) => {
|
|
418
|
+
received += chunk.length;
|
|
419
|
+
if (aborted) {
|
|
420
|
+
if (received > maxBytes * 4) req.destroy();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (received > maxBytes) {
|
|
424
|
+
aborted = true;
|
|
425
|
+
chunks.length = 0;
|
|
426
|
+
res.setHeader("Connection", "close");
|
|
427
|
+
sendJson(req, res, 413, { error: "payload too large" });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
chunks.push(chunk);
|
|
431
|
+
});
|
|
432
|
+
req.on("end", () => {
|
|
433
|
+
if (aborted) return;
|
|
434
|
+
let body;
|
|
435
|
+
try {
|
|
436
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
437
|
+
} catch {
|
|
438
|
+
sendJson(req, res, 400, { error: "invalid JSON" });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
onBody(body);
|
|
442
|
+
});
|
|
443
|
+
req.on("error", () => {});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Sends a settings call down the companion's inbox and answers the request
|
|
447
|
+
// with `answer(response)` -> [status, body] once the companion has, or 504
|
|
448
|
+
// after `timeoutMs`.
|
|
449
|
+
function forwardRpc(req, res, method, params, answer, timeoutMs = rpcTimeoutMs) {
|
|
450
|
+
if (consumers.size === 0) {
|
|
451
|
+
sendJson(req, res, 503, { ok: false, error: { message: "The tutor isn't running." } });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (pendingRpcs.size >= MAX_PENDING_RPCS) {
|
|
455
|
+
sendJson(req, res, 503, { ok: false, error: { message: "Too many settings calls are waiting. Try again." } });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const id = crypto.randomBytes(12).toString("hex");
|
|
459
|
+
const timer = setTimeout(() => {
|
|
460
|
+
pendingRpcs.delete(id);
|
|
461
|
+
sendJson(req, res, 504, {
|
|
462
|
+
ok: false,
|
|
463
|
+
error: { message: `The tutor did not answer within ${Math.round(timeoutMs / 1000)} seconds.` },
|
|
464
|
+
});
|
|
465
|
+
}, timeoutMs);
|
|
466
|
+
timer.unref();
|
|
467
|
+
pendingRpcs.set(id, { req, res, timer, answer });
|
|
468
|
+
res.on("close", () => {
|
|
469
|
+
const pending = pendingRpcs.get(id);
|
|
470
|
+
if (pending && pending.res === res) {
|
|
471
|
+
clearTimeout(pending.timer);
|
|
472
|
+
pendingRpcs.delete(id);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
const message = { type: "rpc", id, method, params };
|
|
476
|
+
for (const consumer of consumers) sseWrite(consumer, { data: message });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// What a panel hears back from a model switch: nothing but whether it
|
|
480
|
+
// worked, why not, and the non-secret summary of the model in use.
|
|
481
|
+
function switchAnswer(response) {
|
|
482
|
+
if (response.ok) {
|
|
483
|
+
const result = response.result || {};
|
|
484
|
+
const active = sanitizeConfig(result.active);
|
|
485
|
+
const body = { ok: true, profile: typeof result.profile === "string" && PROFILE_ID_RE.test(result.profile) ? result.profile : null };
|
|
486
|
+
if (active) body.active = { provider: active.provider, label: active.label, model: active.model };
|
|
487
|
+
return [200, body];
|
|
488
|
+
}
|
|
489
|
+
const error = response.error || {};
|
|
490
|
+
const code = typeof error.code === "string" && SWITCH_STATUS[error.code] ? error.code : "failed";
|
|
491
|
+
const message = typeof error.message === "string" ? stripControlChars(error.message).slice(0, 300) : "The switch failed.";
|
|
492
|
+
return [SWITCH_STATUS[code] || 500, { ok: false, error: { code, message } }];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// POST /settings/rpc from the extension's options page (see the file
|
|
496
|
+
// header). Returns true once handled.
|
|
497
|
+
function handleSettings(req, res, url) {
|
|
498
|
+
if (!allowedHosts.has(req.headers.host)) {
|
|
499
|
+
sendJson(req, res, 403, { error: "forbidden host" });
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
const origin = req.headers.origin;
|
|
503
|
+
if (typeof origin !== "string" || !EXTENSION_ORIGIN_RE.test(origin)) {
|
|
504
|
+
sendJson(req, res, 403, { error: "this route is for the extension's settings page only" });
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
const fetchMode = req.headers["sec-fetch-mode"];
|
|
508
|
+
if (fetchMode !== undefined && fetchMode !== "cors") {
|
|
509
|
+
sendJson(req, res, 403, { error: "only CORS requests are accepted" });
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (url.pathname !== "/settings/rpc") {
|
|
513
|
+
sendJson(req, res, 404, { error: "not found" });
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
if (req.method === "OPTIONS") {
|
|
517
|
+
res.writeHead(204, settingsCorsHeaders(req));
|
|
518
|
+
res.end();
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
if (req.method !== "POST") {
|
|
522
|
+
sendJson(req, res, 404, { error: "not found" });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
readBody(req, res, MAX_RPC_BYTES, (body) => {
|
|
526
|
+
const method = body && body.method;
|
|
527
|
+
const params = body && body.params;
|
|
528
|
+
if (typeof method !== "string" || !RPC_METHOD_RE.test(method) || (params !== undefined && (params === null || typeof params !== "object"))) {
|
|
529
|
+
sendJson(req, res, 400, { error: "a settings `method` and optional `params` object are required" });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
forwardRpc(req, res, method, params || {}, (response) => [200, response]);
|
|
533
|
+
});
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Returns true if the request was a /panel/ or /settings/ route (and has
|
|
538
|
+
// been handled), false if the caller should keep routing it.
|
|
539
|
+
function handle(req, res) {
|
|
540
|
+
const url = new URL(req.url, "http://relay.invalid");
|
|
541
|
+
if (url.pathname.startsWith("/settings/")) return handleSettings(req, res, url);
|
|
542
|
+
if (!url.pathname.startsWith("/panel/")) return false;
|
|
543
|
+
|
|
544
|
+
if (!allowedHosts.has(req.headers.host)) {
|
|
545
|
+
sendJson(req, res, 403, { error: "forbidden host" });
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
const origin = req.headers.origin;
|
|
549
|
+
if (origin !== undefined && origin !== PANEL_ORIGIN) {
|
|
550
|
+
sendJson(req, res, 403, { error: "forbidden origin" });
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Every legitimate caller makes a CORS-mode request: the panel's fetch
|
|
555
|
+
// from leetcode.com, and the companion's Node fetch (which sends
|
|
556
|
+
// Sec-Fetch-Mode: cors too). A no-cors fetch, an <img>/<script> tag or an
|
|
557
|
+
// <iframe> on some other website sends no Origin, so the check above lets
|
|
558
|
+
// it through, and although it cannot read the stream it holds one of the
|
|
559
|
+
// MAX_PANEL_STREAMS slots: twenty of them locked leetcode.com out with a
|
|
560
|
+
// 503. Refused here, before anything is counted. A browser too old to send
|
|
561
|
+
// Sec-Fetch-Mode at all is not covered; every browser the extension
|
|
562
|
+
// supports sends it.
|
|
563
|
+
const fetchMode = req.headers["sec-fetch-mode"];
|
|
564
|
+
if (fetchMode !== undefined && fetchMode !== "cors") {
|
|
565
|
+
sendJson(req, res, 403, { error: "only CORS requests are accepted" });
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (req.method === "OPTIONS") {
|
|
570
|
+
res.writeHead(204, corsHeaders(req));
|
|
571
|
+
res.end();
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const route = `${req.method} ${url.pathname}`;
|
|
576
|
+
if (COMPANION_ONLY_ROUTES.has(route)) {
|
|
577
|
+
const rejection = companionOnlyRejection(req);
|
|
578
|
+
if (rejection) {
|
|
579
|
+
sendJson(req, res, 403, { error: rejection });
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (route === "GET /panel/events") {
|
|
585
|
+
if (panelClients.size >= MAX_PANEL_STREAMS) {
|
|
586
|
+
sendJson(req, res, 503, { error: "too many open panel streams" });
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
const lastEventId = Number(req.headers["last-event-id"]);
|
|
590
|
+
const afterParam = Number(url.searchParams.get("after"));
|
|
591
|
+
let after = Number.isFinite(lastEventId) && lastEventId > 0 ? lastEventId : Number.isFinite(afterParam) ? afterParam : 0;
|
|
592
|
+
// A client counting from a previous run of the relay must get the whole
|
|
593
|
+
// buffer: its `after` is meaningless against this run's numbering.
|
|
594
|
+
const clientEpoch = url.searchParams.get("epoch");
|
|
595
|
+
if (clientEpoch && clientEpoch !== epoch) after = 0;
|
|
596
|
+
openStream(req, res, () => panelClients.delete(res));
|
|
597
|
+
for (const event of events) {
|
|
598
|
+
if (event.seq > after) sseWrite(res, { id: event.seq, data: event });
|
|
599
|
+
}
|
|
600
|
+
if (latestConfig) sseWrite(res, { data: latestConfig });
|
|
601
|
+
sseWrite(res, { data: statusEvent() });
|
|
602
|
+
panelClients.add(res);
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (route === "GET /panel/inbox") {
|
|
607
|
+
openStream(req, res, () => {
|
|
608
|
+
consumers.delete(res);
|
|
609
|
+
if (consumers.size === 0) {
|
|
610
|
+
closeOpenTurns();
|
|
611
|
+
companionBusy = false;
|
|
612
|
+
}
|
|
613
|
+
broadcastStatus();
|
|
614
|
+
});
|
|
615
|
+
consumers.add(res);
|
|
616
|
+
broadcastStatus();
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (route === "POST /panel/message") {
|
|
621
|
+
readBody(req, res, MAX_MESSAGE_BYTES, (body) => {
|
|
622
|
+
const text = body && typeof body.text === "string" ? stripControlChars(body.text).trim() : "";
|
|
623
|
+
const slug = body && body.problemSlug;
|
|
624
|
+
if (!text || text.length > MAX_MESSAGE_CHARS || typeof slug !== "string" || !SLUG_RE.test(slug)) {
|
|
625
|
+
sendJson(req, res, 400, { error: "text (1-8000 chars) and a valid problemSlug are required" });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (consumers.size === 0) {
|
|
629
|
+
sendJson(req, res, 503, { error: "companion offline" });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const message = { id: nextMessageId++, text, problemSlug: slug };
|
|
633
|
+
const snapshot = sanitizeSnapshot(body.snapshot);
|
|
634
|
+
if (snapshot) message.snapshot = snapshot;
|
|
635
|
+
for (const consumer of consumers) sseWrite(consumer, { id: message.id, data: message });
|
|
636
|
+
sendJson(req, res, 202, { ok: true, id: message.id });
|
|
637
|
+
});
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// The model switcher (see the file header): only an id goes on.
|
|
642
|
+
if (route === "POST /panel/switch-profile") {
|
|
643
|
+
readBody(req, res, 4096, (body) => {
|
|
644
|
+
const id = body && body.id;
|
|
645
|
+
if (typeof id !== "string" || !PROFILE_ID_RE.test(id)) {
|
|
646
|
+
sendJson(req, res, 400, { ok: false, error: { code: "invalid", message: "a saved model's `id` is required" } });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
forwardRpc(req, res, "switchProfile", { id }, switchAnswer, switchTimeoutMs);
|
|
650
|
+
});
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// The companion moved on from a problem (its tutor context was reset), so
|
|
655
|
+
// that problem's transcript is no longer kept for panels that connect or
|
|
656
|
+
// reload later. Panels already showing it keep what they have.
|
|
657
|
+
if (route === "POST /panel/drop-problem") {
|
|
658
|
+
readBody(req, res, MAX_MESSAGE_BYTES, (body) => {
|
|
659
|
+
const slug = body && body.problemSlug;
|
|
660
|
+
if (typeof slug !== "string" || !SLUG_RE.test(slug)) {
|
|
661
|
+
sendJson(req, res, 400, { error: "a valid problemSlug is required" });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const before = events.length;
|
|
665
|
+
// Chunks carry no slug of their own, only their turn's id.
|
|
666
|
+
const turnIds = new Set(events.filter((e) => e.problemSlug === slug && e.turnId).map((e) => e.turnId));
|
|
667
|
+
events = events.filter((e) => e.problemSlug !== slug && !(e.turnId && turnIds.has(e.turnId)));
|
|
668
|
+
for (const [turnId, turn] of openTurns) if (turn.problemSlug === slug) openTurns.delete(turnId);
|
|
669
|
+
sendJson(req, res, 200, { ok: true, dropped: before - events.length });
|
|
670
|
+
});
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// The companion has read and handled the capture log up to `before` and
|
|
675
|
+
// asks for that prefix to go (see server.js, trimCaptureLog). It moves
|
|
676
|
+
// its own read offset back by `removed` only once this answers 200.
|
|
677
|
+
if (route === "POST /panel/trim-captures") {
|
|
678
|
+
if (!captureLog) {
|
|
679
|
+
sendJson(req, res, 404, { error: "not found" });
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
readBody(req, res, 4096, (body) => {
|
|
683
|
+
const before = body && body.before;
|
|
684
|
+
if (!Number.isSafeInteger(before) || before < 0 || typeof body.path !== "string" || !body.path) {
|
|
685
|
+
sendJson(req, res, 400, { error: "a byte offset `before` and the log's `path` are required" });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (!sameFile(body.path, captureLog.path)) {
|
|
689
|
+
sendJson(req, res, 409, { error: "this relay writes a different capture log" });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
let result;
|
|
693
|
+
try {
|
|
694
|
+
result = captureLog.trim(before);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
sendJson(req, res, 500, { error: `could not trim the capture log: ${err.message}` });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (result.error) {
|
|
700
|
+
sendJson(req, res, 409, { error: result.error });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
sendJson(req, res, 200, { ok: true, removed: result.removed, size: result.size });
|
|
704
|
+
});
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (route === "POST /panel/activity") {
|
|
709
|
+
readBody(req, res, 4096, (body) => {
|
|
710
|
+
if (!body || typeof body.busy !== "boolean") {
|
|
711
|
+
sendJson(req, res, 400, { error: "a boolean `busy` is required" });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (companionBusy !== body.busy) {
|
|
715
|
+
companionBusy = body.busy;
|
|
716
|
+
broadcastStatus();
|
|
717
|
+
}
|
|
718
|
+
sendJson(req, res, 200, { ok: true });
|
|
719
|
+
});
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// The companion's answer to a settings call (see handleSettings). The
|
|
724
|
+
// first answer wins; a late or unknown one gets a 404.
|
|
725
|
+
if (route === "POST /panel/rpc-result") {
|
|
726
|
+
readBody(req, res, MAX_RPC_RESULT_BYTES, (body) => {
|
|
727
|
+
const id = body && body.id;
|
|
728
|
+
const response = body && body.response;
|
|
729
|
+
if (typeof id !== "string" || !response || typeof response !== "object" || typeof response.ok !== "boolean") {
|
|
730
|
+
sendJson(req, res, 400, { error: "an `id` and a `response` with a boolean `ok` are required" });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const pending = pendingRpcs.get(id);
|
|
734
|
+
if (!pending) {
|
|
735
|
+
sendJson(req, res, 404, { error: "no settings call is waiting for that id" });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
pendingRpcs.delete(id);
|
|
739
|
+
clearTimeout(pending.timer);
|
|
740
|
+
const [status, answer] = pending.answer(response);
|
|
741
|
+
sendJson(pending.req, pending.res, status, answer);
|
|
742
|
+
sendJson(req, res, 200, { ok: true });
|
|
743
|
+
});
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (route === "POST /panel/publish") {
|
|
748
|
+
readBody(req, res, MAX_PUBLISH_BYTES, (body) => {
|
|
749
|
+
const rawEvents = body && Array.isArray(body.events) ? body.events : [body];
|
|
750
|
+
const clean = rawEvents.map((raw) => (raw && raw.type === "config" ? sanitizeConfig(raw) : sanitizeEvent(raw)));
|
|
751
|
+
if (clean.length === 0 || clean.some((e) => e === null)) {
|
|
752
|
+
sendJson(req, res, 400, { error: "invalid event" });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// A config event replaces the previous one and goes straight to every
|
|
756
|
+
// panel; it is not part of the conversation buffer.
|
|
757
|
+
const stored = [];
|
|
758
|
+
for (const event of clean) {
|
|
759
|
+
if (event.type === "config") {
|
|
760
|
+
latestConfig = { ...event, epoch };
|
|
761
|
+
for (const client of panelClients) sseWrite(client, { data: latestConfig });
|
|
762
|
+
} else {
|
|
763
|
+
stored.push(addEvent(event));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// A publish can land after the companion's inbox stream closed: a
|
|
767
|
+
// companion that exits sends its last batch and drops its stream at
|
|
768
|
+
// the same moment, in either order. With no companion connected
|
|
769
|
+
// nothing will end what this batch started or continued, so it is
|
|
770
|
+
// closed again right away.
|
|
771
|
+
if (consumers.size === 0) closeOpenTurns();
|
|
772
|
+
sendJson(req, res, 200, { ok: true, lastSeq: stored.length ? stored[stored.length - 1].seq : nextSeq - 1 });
|
|
773
|
+
});
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
sendJson(req, res, 404, { error: "not found" });
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// How many panels are following the event stream right now (the relay's
|
|
782
|
+
// idle exit counts an open stream as activity).
|
|
783
|
+
return { handle, openPanelStreams: () => panelClients.size };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
module.exports = {
|
|
787
|
+
createPanelBroker,
|
|
788
|
+
RPC_TIMEOUT_MS,
|
|
789
|
+
SWITCH_RPC_TIMEOUT_MS,
|
|
790
|
+
sanitizeEvent,
|
|
791
|
+
sanitizeConfig,
|
|
792
|
+
stripControlChars,
|
|
793
|
+
companionOnlyRejection,
|
|
794
|
+
EXTENSION_ORIGIN_RE,
|
|
795
|
+
PANEL_ORIGIN,
|
|
796
|
+
COMPANION_HEADER,
|
|
797
|
+
};
|