pi-interview 0.3.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 +346 -0
- package/bin/install.js +87 -0
- package/form/index.html +119 -0
- package/form/script.js +2213 -0
- package/form/styles.css +1394 -0
- package/form/themes/default-dark.css +5 -0
- package/form/themes/default-light.css +24 -0
- package/form/themes/tufte-dark.css +29 -0
- package/form/themes/tufte-light.css +29 -0
- package/index.ts +354 -0
- package/package.json +31 -0
- package/schema.ts +236 -0
- package/server.ts +765 -0
- package/settings.ts +29 -0
package/server.ts
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { tmpdir, homedir } from "node:os";
|
|
4
|
+
import { join, dirname, basename } from "node:path";
|
|
5
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { Question, QuestionsFile } from "./schema.js";
|
|
10
|
+
|
|
11
|
+
function getGitBranch(cwd: string): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
timeout: 2000,
|
|
17
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
18
|
+
}).trim();
|
|
19
|
+
return branch || null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizePath(path: string): string {
|
|
26
|
+
const home = homedir();
|
|
27
|
+
if (path.startsWith(home)) {
|
|
28
|
+
return "~" + path.slice(home.length);
|
|
29
|
+
}
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SessionEntry {
|
|
34
|
+
id: string;
|
|
35
|
+
url: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
gitBranch: string | null;
|
|
38
|
+
title: string;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
lastSeen: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SessionsFile {
|
|
44
|
+
sessions: SessionEntry[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SESSIONS_FILE = join(homedir(), ".pi", "interview-sessions.json");
|
|
48
|
+
const RECOVERY_DIR = join(homedir(), ".pi", "interview-recovery");
|
|
49
|
+
const STALE_THRESHOLD_MS = 30000;
|
|
50
|
+
const STALE_PRUNE_MS = 60000;
|
|
51
|
+
const RECOVERY_MAX_AGE_DAYS = 7;
|
|
52
|
+
const ABANDONED_GRACE_MS = 60000;
|
|
53
|
+
const WATCHDOG_INTERVAL_MS = 5000;
|
|
54
|
+
|
|
55
|
+
function ensurePiDir(): void {
|
|
56
|
+
const piDir = join(homedir(), ".pi");
|
|
57
|
+
if (!existsSync(piDir)) {
|
|
58
|
+
mkdirSync(piDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readSessions(): SessionsFile {
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(SESSIONS_FILE)) {
|
|
65
|
+
return { sessions: [] };
|
|
66
|
+
}
|
|
67
|
+
const data = readFileSync(SESSIONS_FILE, "utf8");
|
|
68
|
+
const parsed = JSON.parse(data);
|
|
69
|
+
if (!parsed.sessions || !Array.isArray(parsed.sessions)) {
|
|
70
|
+
return { sessions: [] };
|
|
71
|
+
}
|
|
72
|
+
return parsed as SessionsFile;
|
|
73
|
+
} catch {
|
|
74
|
+
return { sessions: [] };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listSessions(): SessionEntry[] {
|
|
79
|
+
const data = readSessions();
|
|
80
|
+
const pruned = pruneStale(data.sessions);
|
|
81
|
+
if (pruned.length !== data.sessions.length) {
|
|
82
|
+
writeSessions({ sessions: pruned });
|
|
83
|
+
}
|
|
84
|
+
return pruned;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeSessions(data: SessionsFile): void {
|
|
88
|
+
ensurePiDir();
|
|
89
|
+
const tempFile = SESSIONS_FILE + ".tmp";
|
|
90
|
+
writeFileSync(tempFile, JSON.stringify(data, null, 2));
|
|
91
|
+
renameSync(tempFile, SESSIONS_FILE);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pruneStale(sessions: SessionEntry[]): SessionEntry[] {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
return sessions.filter((s) => now - s.lastSeen < STALE_PRUNE_MS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function touchSession(entry: SessionEntry): void {
|
|
100
|
+
const data = readSessions();
|
|
101
|
+
data.sessions = pruneStale(data.sessions);
|
|
102
|
+
const existing = data.sessions.find((s) => s.id === entry.id);
|
|
103
|
+
if (existing) {
|
|
104
|
+
existing.lastSeen = Date.now();
|
|
105
|
+
existing.url = entry.url;
|
|
106
|
+
existing.cwd = entry.cwd;
|
|
107
|
+
existing.gitBranch = entry.gitBranch;
|
|
108
|
+
existing.title = entry.title;
|
|
109
|
+
existing.startedAt = entry.startedAt;
|
|
110
|
+
} else {
|
|
111
|
+
data.sessions.push({ ...entry, lastSeen: Date.now() });
|
|
112
|
+
}
|
|
113
|
+
writeSessions(data);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function registerSession(entry: SessionEntry): void {
|
|
117
|
+
touchSession(entry);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unregisterSession(sessionId: string): void {
|
|
121
|
+
const data = readSessions();
|
|
122
|
+
data.sessions = data.sessions.filter((s) => s.id !== sessionId);
|
|
123
|
+
writeSessions(data);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getActiveSessions(): SessionEntry[] {
|
|
127
|
+
const pruned = listSessions();
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
return pruned.filter((s) => now - s.lastSeen < STALE_THRESHOLD_MS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function ensureRecoveryDir(): void {
|
|
133
|
+
if (!existsSync(RECOVERY_DIR)) {
|
|
134
|
+
mkdirSync(RECOVERY_DIR, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cleanupOldRecoveryFiles(): void {
|
|
139
|
+
if (!existsSync(RECOVERY_DIR)) return;
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const maxAge = RECOVERY_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
142
|
+
try {
|
|
143
|
+
const files = readdirSync(RECOVERY_DIR);
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const filePath = join(RECOVERY_DIR, file);
|
|
146
|
+
const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})_/);
|
|
147
|
+
if (dateMatch) {
|
|
148
|
+
const fileDate = new Date(dateMatch[1]).getTime();
|
|
149
|
+
if (now - fileDate > maxAge) {
|
|
150
|
+
unlinkSync(filePath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sanitizeForFilename(str: string): string {
|
|
158
|
+
return str.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 50);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function saveToRecovery(
|
|
162
|
+
questions: QuestionsFile,
|
|
163
|
+
cwd: string,
|
|
164
|
+
gitBranch: string | null,
|
|
165
|
+
sessionId: string
|
|
166
|
+
): string {
|
|
167
|
+
ensureRecoveryDir();
|
|
168
|
+
const now = new Date();
|
|
169
|
+
const date = now.toISOString().slice(0, 10);
|
|
170
|
+
const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
|
|
171
|
+
const project = sanitizeForFilename(basename(cwd) || "unknown");
|
|
172
|
+
const branch = sanitizeForFilename(gitBranch || "nogit");
|
|
173
|
+
const shortId = sessionId.slice(0, 8);
|
|
174
|
+
const filename = `${date}_${time}_${project}_${branch}_${shortId}.json`;
|
|
175
|
+
const filePath = join(RECOVERY_DIR, filename);
|
|
176
|
+
writeFileSync(filePath, JSON.stringify(questions, null, 2));
|
|
177
|
+
return filePath;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ResponseItem {
|
|
181
|
+
id: string;
|
|
182
|
+
value: string | string[];
|
|
183
|
+
attachments?: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface InterviewServerOptions {
|
|
187
|
+
questions: QuestionsFile;
|
|
188
|
+
sessionToken: string;
|
|
189
|
+
sessionId: string;
|
|
190
|
+
cwd: string;
|
|
191
|
+
timeout: number;
|
|
192
|
+
port?: number;
|
|
193
|
+
verbose?: boolean;
|
|
194
|
+
theme?: InterviewThemeConfig;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface InterviewServerCallbacks {
|
|
198
|
+
onSubmit: (responses: ResponseItem[]) => void;
|
|
199
|
+
onCancel: (reason?: "timeout" | "user" | "stale") => void;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface InterviewServerHandle {
|
|
203
|
+
server: http.Server;
|
|
204
|
+
url: string;
|
|
205
|
+
close: () => void;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type ThemeMode = "auto" | "light" | "dark";
|
|
209
|
+
|
|
210
|
+
export interface InterviewThemeConfig {
|
|
211
|
+
mode?: ThemeMode;
|
|
212
|
+
name?: string;
|
|
213
|
+
lightPath?: string;
|
|
214
|
+
darkPath?: string;
|
|
215
|
+
toggleHotkey?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const MAX_BODY_SIZE = 15 * 1024 * 1024;
|
|
219
|
+
const MAX_IMAGES = 12;
|
|
220
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
|
|
221
|
+
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
222
|
+
|
|
223
|
+
const FORM_DIR = join(dirname(fileURLToPath(import.meta.url)), "form");
|
|
224
|
+
const TEMPLATE = readFileSync(join(FORM_DIR, "index.html"), "utf-8");
|
|
225
|
+
const STYLES = readFileSync(join(FORM_DIR, "styles.css"), "utf-8");
|
|
226
|
+
const SCRIPT = readFileSync(join(FORM_DIR, "script.js"), "utf-8");
|
|
227
|
+
|
|
228
|
+
const THEMES_DIR = join(FORM_DIR, "themes");
|
|
229
|
+
const BUILTIN_THEMES = new Map<string, { light: string; dark: string }>([
|
|
230
|
+
[
|
|
231
|
+
"default",
|
|
232
|
+
{
|
|
233
|
+
light: readFileSync(join(THEMES_DIR, "default-light.css"), "utf-8"),
|
|
234
|
+
dark: readFileSync(join(THEMES_DIR, "default-dark.css"), "utf-8"),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
[
|
|
238
|
+
"tufte",
|
|
239
|
+
{
|
|
240
|
+
light: readFileSync(join(THEMES_DIR, "tufte-light.css"), "utf-8"),
|
|
241
|
+
dark: readFileSync(join(THEMES_DIR, "tufte-dark.css"), "utf-8"),
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
class BodyTooLargeError extends Error {
|
|
247
|
+
statusCode = 413;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function log(verbose: boolean | undefined, message: string) {
|
|
251
|
+
if (verbose) {
|
|
252
|
+
process.stderr.write(`[interview] ${message}\n`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function safeInlineJSON(data: unknown): string {
|
|
257
|
+
return JSON.stringify(data)
|
|
258
|
+
.replace(/</g, "\\u003c")
|
|
259
|
+
.replace(/>/g, "\\u003e")
|
|
260
|
+
.replace(/&/g, "\\u0026");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeThemeMode(mode?: string): ThemeMode | undefined {
|
|
264
|
+
if (mode === "auto" || mode === "light" || mode === "dark") return mode;
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function sendText(res: ServerResponse, status: number, text: string) {
|
|
269
|
+
res.writeHead(status, {
|
|
270
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
271
|
+
"Cache-Control": "no-store",
|
|
272
|
+
});
|
|
273
|
+
res.end(text);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function sendJson(res: ServerResponse, status: number, payload: unknown) {
|
|
277
|
+
res.writeHead(status, {
|
|
278
|
+
"Content-Type": "application/json",
|
|
279
|
+
"Cache-Control": "no-store",
|
|
280
|
+
});
|
|
281
|
+
res.end(JSON.stringify(payload));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function parseJSONBody(req: IncomingMessage): Promise<unknown> {
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
let body = "";
|
|
287
|
+
let size = 0;
|
|
288
|
+
|
|
289
|
+
req.on("data", (chunk: Buffer) => {
|
|
290
|
+
size += chunk.length;
|
|
291
|
+
if (size > MAX_BODY_SIZE) {
|
|
292
|
+
req.destroy();
|
|
293
|
+
reject(new BodyTooLargeError("Request body too large"));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
body += chunk.toString();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
req.on("end", () => {
|
|
300
|
+
try {
|
|
301
|
+
resolve(JSON.parse(body));
|
|
302
|
+
} catch {
|
|
303
|
+
reject(new Error("Invalid JSON"));
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
req.on("error", reject);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function handleImageUpload(
|
|
312
|
+
image: { id: string; filename: string; mimeType: string; data: string },
|
|
313
|
+
sessionId: string
|
|
314
|
+
): Promise<string> {
|
|
315
|
+
if (!ALLOWED_TYPES.includes(image.mimeType)) {
|
|
316
|
+
throw new Error(`Invalid image type: ${image.mimeType}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const buffer = Buffer.from(image.data, "base64");
|
|
320
|
+
if (buffer.length > MAX_IMAGE_SIZE) {
|
|
321
|
+
throw new Error("Image exceeds 5MB limit");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const sanitized = image.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
325
|
+
const basename = sanitized.split(/[/\\]/).pop() || `image_${randomUUID()}`;
|
|
326
|
+
const extMap: Record<string, string> = {
|
|
327
|
+
"image/png": ".png",
|
|
328
|
+
"image/jpeg": ".jpg",
|
|
329
|
+
"image/gif": ".gif",
|
|
330
|
+
"image/webp": ".webp",
|
|
331
|
+
};
|
|
332
|
+
const ext = extMap[image.mimeType] ?? "";
|
|
333
|
+
const filename = basename.includes(".") ? basename : `${basename}${ext}`;
|
|
334
|
+
|
|
335
|
+
const tempDir = join(tmpdir(), `pi-interview-${sessionId}`);
|
|
336
|
+
await mkdir(tempDir, { recursive: true });
|
|
337
|
+
|
|
338
|
+
const filepath = join(tempDir, filename);
|
|
339
|
+
await writeFile(filepath, buffer);
|
|
340
|
+
|
|
341
|
+
return filepath;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function validateTokenQuery(url: URL, expectedToken: string, res: ServerResponse): boolean {
|
|
345
|
+
const token = url.searchParams.get("session");
|
|
346
|
+
if (token !== expectedToken) {
|
|
347
|
+
sendText(res, 403, "Invalid session");
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validateTokenBody(body: unknown, expectedToken: string, res: ServerResponse): boolean {
|
|
354
|
+
if (!body || typeof body !== "object") {
|
|
355
|
+
sendJson(res, 400, { ok: false, error: "Invalid request body" });
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
const token = (body as { token?: string }).token;
|
|
359
|
+
if (token !== expectedToken) {
|
|
360
|
+
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function ensureQuestionId(
|
|
367
|
+
id: string,
|
|
368
|
+
questionById: Map<string, Question>
|
|
369
|
+
): { ok: true; question: Question } | { ok: false; error: string } {
|
|
370
|
+
const question = questionById.get(id);
|
|
371
|
+
if (!question) {
|
|
372
|
+
return { ok: false, error: `Unknown question id: ${id}` };
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, question };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function startInterviewServer(
|
|
378
|
+
options: InterviewServerOptions,
|
|
379
|
+
callbacks: InterviewServerCallbacks
|
|
380
|
+
): Promise<InterviewServerHandle> {
|
|
381
|
+
const { questions, sessionToken, sessionId, cwd, timeout, port, verbose } = options;
|
|
382
|
+
const questionById = new Map<string, Question>();
|
|
383
|
+
for (const question of questions.questions) {
|
|
384
|
+
questionById.set(question.id, question);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const themeConfig = options.theme ?? {};
|
|
388
|
+
const resolvedThemeName =
|
|
389
|
+
themeConfig.name && BUILTIN_THEMES.has(themeConfig.name) ? themeConfig.name : "default";
|
|
390
|
+
if (themeConfig.name && !BUILTIN_THEMES.has(themeConfig.name)) {
|
|
391
|
+
log(verbose, `Unknown theme "${themeConfig.name}", using "default"`);
|
|
392
|
+
}
|
|
393
|
+
const builtinTheme = BUILTIN_THEMES.get(resolvedThemeName) ?? BUILTIN_THEMES.get("default");
|
|
394
|
+
if (!builtinTheme) {
|
|
395
|
+
throw new Error("Missing default theme assets");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const readThemeFile = (filePath: string, fallback: string, label: string) => {
|
|
399
|
+
try {
|
|
400
|
+
return readFileSync(filePath, "utf-8");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
403
|
+
log(verbose, `Failed to load ${label} theme from "${filePath}": ${message}`);
|
|
404
|
+
return fallback;
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const themeLightCss = themeConfig.lightPath
|
|
409
|
+
? readThemeFile(themeConfig.lightPath, builtinTheme.light, "light")
|
|
410
|
+
: builtinTheme.light;
|
|
411
|
+
const themeDarkCss = themeConfig.darkPath
|
|
412
|
+
? readThemeFile(themeConfig.darkPath, builtinTheme.dark, "dark")
|
|
413
|
+
: builtinTheme.dark;
|
|
414
|
+
const themeMode = normalizeThemeMode(themeConfig.mode) ?? "dark";
|
|
415
|
+
|
|
416
|
+
const normalizedCwd = normalizePath(cwd);
|
|
417
|
+
const gitBranch = getGitBranch(cwd);
|
|
418
|
+
let sessionEntry: SessionEntry | null = null;
|
|
419
|
+
let browserConnected = false;
|
|
420
|
+
let lastHeartbeatAt = Date.now();
|
|
421
|
+
let watchdog: NodeJS.Timeout | null = null;
|
|
422
|
+
let completed = false;
|
|
423
|
+
|
|
424
|
+
const stopWatchdog = () => {
|
|
425
|
+
if (watchdog) {
|
|
426
|
+
clearInterval(watchdog);
|
|
427
|
+
watchdog = null;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const markCompleted = () => {
|
|
432
|
+
if (completed) return false;
|
|
433
|
+
completed = true;
|
|
434
|
+
stopWatchdog();
|
|
435
|
+
return true;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const touchHeartbeat = () => {
|
|
439
|
+
lastHeartbeatAt = Date.now();
|
|
440
|
+
if (!browserConnected) {
|
|
441
|
+
browserConnected = true;
|
|
442
|
+
}
|
|
443
|
+
if (sessionEntry) {
|
|
444
|
+
touchSession(sessionEntry);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const server = http.createServer(async (req, res) => {
|
|
449
|
+
try {
|
|
450
|
+
const method = req.method || "GET";
|
|
451
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
452
|
+
log(verbose, `${method} ${url.pathname}`);
|
|
453
|
+
|
|
454
|
+
if (method === "GET" && url.pathname === "/") {
|
|
455
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
456
|
+
touchHeartbeat();
|
|
457
|
+
const inlineData = safeInlineJSON({
|
|
458
|
+
questions: questions.questions,
|
|
459
|
+
title: questions.title,
|
|
460
|
+
description: questions.description,
|
|
461
|
+
sessionToken,
|
|
462
|
+
sessionId,
|
|
463
|
+
cwd: normalizedCwd,
|
|
464
|
+
gitBranch,
|
|
465
|
+
startedAt: Date.now(),
|
|
466
|
+
timeout,
|
|
467
|
+
theme: {
|
|
468
|
+
mode: themeMode,
|
|
469
|
+
toggleHotkey: themeConfig.toggleHotkey,
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
const html = TEMPLATE
|
|
473
|
+
.replace("/* __INTERVIEW_DATA_PLACEHOLDER__ */", inlineData)
|
|
474
|
+
.replace(/__SESSION_TOKEN__/g, sessionToken);
|
|
475
|
+
res.writeHead(200, {
|
|
476
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
477
|
+
"Cache-Control": "no-store",
|
|
478
|
+
});
|
|
479
|
+
res.end(html);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
484
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
485
|
+
sendJson(res, 200, { ok: true });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
490
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
491
|
+
const sessions = listSessions().map((session) => ({
|
|
492
|
+
...session,
|
|
493
|
+
status: Date.now() - session.lastSeen < STALE_THRESHOLD_MS ? "active" : "waiting",
|
|
494
|
+
}));
|
|
495
|
+
sendJson(res, 200, { ok: true, sessions });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (method === "GET" && url.pathname === "/styles.css") {
|
|
500
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
501
|
+
res.writeHead(200, {
|
|
502
|
+
"Content-Type": "text/css; charset=utf-8",
|
|
503
|
+
"Cache-Control": "no-store",
|
|
504
|
+
});
|
|
505
|
+
res.end(STYLES);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (method === "GET" && url.pathname === "/theme-light.css") {
|
|
510
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
511
|
+
res.writeHead(200, {
|
|
512
|
+
"Content-Type": "text/css; charset=utf-8",
|
|
513
|
+
"Cache-Control": "no-store",
|
|
514
|
+
});
|
|
515
|
+
res.end(themeLightCss);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (method === "GET" && url.pathname === "/theme-dark.css") {
|
|
520
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
521
|
+
res.writeHead(200, {
|
|
522
|
+
"Content-Type": "text/css; charset=utf-8",
|
|
523
|
+
"Cache-Control": "no-store",
|
|
524
|
+
});
|
|
525
|
+
res.end(themeDarkCss);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (method === "GET" && url.pathname === "/script.js") {
|
|
530
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
531
|
+
res.writeHead(200, {
|
|
532
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
533
|
+
"Cache-Control": "no-store",
|
|
534
|
+
});
|
|
535
|
+
res.end(SCRIPT);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (method === "POST" && url.pathname === "/heartbeat") {
|
|
540
|
+
const body = await parseJSONBody(req).catch(() => null);
|
|
541
|
+
if (!body) {
|
|
542
|
+
sendJson(res, 400, { ok: false, error: "Invalid body" });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
546
|
+
touchHeartbeat();
|
|
547
|
+
sendJson(res, 200, { ok: true });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (method === "POST" && url.pathname === "/cancel") {
|
|
552
|
+
const body = await parseJSONBody(req).catch((err) => {
|
|
553
|
+
if (err instanceof BodyTooLargeError) {
|
|
554
|
+
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
sendJson(res, 400, { ok: false, error: err.message });
|
|
558
|
+
return null;
|
|
559
|
+
});
|
|
560
|
+
if (!body) return;
|
|
561
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
562
|
+
if (completed) {
|
|
563
|
+
sendJson(res, 200, { ok: true });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const reason = (body as { reason?: string }).reason;
|
|
567
|
+
if (reason === "timeout" || reason === "stale") {
|
|
568
|
+
const recoveryPath = saveToRecovery(questions, cwd, gitBranch, sessionId);
|
|
569
|
+
const label = reason === "timeout" ? "timed out" : "stale";
|
|
570
|
+
log(verbose, `Interview ${label}. Saved to: ${recoveryPath}`);
|
|
571
|
+
}
|
|
572
|
+
markCompleted();
|
|
573
|
+
unregisterSession(sessionId);
|
|
574
|
+
sendJson(res, 200, { ok: true });
|
|
575
|
+
setImmediate(() => callbacks.onCancel(reason));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (method === "POST" && url.pathname === "/submit") {
|
|
580
|
+
const body = await parseJSONBody(req).catch((err) => {
|
|
581
|
+
if (err instanceof BodyTooLargeError) {
|
|
582
|
+
sendJson(res, err.statusCode, { ok: false, error: err.message });
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
sendJson(res, 400, { ok: false, error: err.message });
|
|
586
|
+
return null;
|
|
587
|
+
});
|
|
588
|
+
if (!body) return;
|
|
589
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
590
|
+
if (completed) {
|
|
591
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const payload = body as {
|
|
596
|
+
responses?: Array<{ id: string; value: string | string[]; attachments?: string[] }>;
|
|
597
|
+
images?: Array<{ id: string; filename: string; mimeType: string; data: string; isAttachment?: boolean }>;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
|
|
601
|
+
const imagesInput = Array.isArray(payload.images) ? payload.images : [];
|
|
602
|
+
|
|
603
|
+
if (imagesInput.length > MAX_IMAGES) {
|
|
604
|
+
sendJson(res, 400, { ok: false, error: `Too many images (max ${MAX_IMAGES})` });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const responses: ResponseItem[] = [];
|
|
609
|
+
for (const item of responsesInput) {
|
|
610
|
+
if (!item || typeof item.id !== "string") continue;
|
|
611
|
+
const questionCheck = ensureQuestionId(item.id, questionById);
|
|
612
|
+
if (questionCheck.ok === false) {
|
|
613
|
+
sendJson(res, 400, { ok: false, error: questionCheck.error, field: item.id });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const question = questionCheck.question;
|
|
617
|
+
|
|
618
|
+
const resp: ResponseItem = { id: item.id, value: "" };
|
|
619
|
+
|
|
620
|
+
if (question.type === "image") {
|
|
621
|
+
if (Array.isArray(item.value) && item.value.every((v) => typeof v === "string")) {
|
|
622
|
+
resp.value = item.value;
|
|
623
|
+
}
|
|
624
|
+
} else if (question.type === "multi") {
|
|
625
|
+
if (!Array.isArray(item.value) || item.value.some((v) => typeof v !== "string")) {
|
|
626
|
+
sendJson(res, 400, {
|
|
627
|
+
ok: false,
|
|
628
|
+
error: `Invalid response value for ${item.id}`,
|
|
629
|
+
field: item.id,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
resp.value = item.value;
|
|
634
|
+
} else {
|
|
635
|
+
if (typeof item.value !== "string") {
|
|
636
|
+
sendJson(res, 400, {
|
|
637
|
+
ok: false,
|
|
638
|
+
error: `Invalid response value for ${item.id}`,
|
|
639
|
+
field: item.id,
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
resp.value = item.value;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (Array.isArray(item.attachments) && item.attachments.every((a) => typeof a === "string")) {
|
|
647
|
+
resp.attachments = item.attachments;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
responses.push(resp);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
for (const image of imagesInput) {
|
|
654
|
+
if (!image || typeof image.id !== "string") continue;
|
|
655
|
+
const questionCheck = ensureQuestionId(image.id, questionById);
|
|
656
|
+
if (questionCheck.ok === false) {
|
|
657
|
+
sendJson(res, 400, { ok: false, error: questionCheck.error, field: image.id });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
typeof image.filename !== "string" ||
|
|
663
|
+
typeof image.mimeType !== "string" ||
|
|
664
|
+
typeof image.data !== "string"
|
|
665
|
+
) {
|
|
666
|
+
sendJson(res, 400, { ok: false, error: "Invalid image payload", field: image.id });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
const filepath = await handleImageUpload(image, sessionId);
|
|
672
|
+
|
|
673
|
+
const existing = responses.find((r) => r.id === image.id);
|
|
674
|
+
if (image.isAttachment) {
|
|
675
|
+
if (existing) {
|
|
676
|
+
existing.attachments = existing.attachments || [];
|
|
677
|
+
existing.attachments.push(filepath);
|
|
678
|
+
} else {
|
|
679
|
+
responses.push({ id: image.id, value: "", attachments: [filepath] });
|
|
680
|
+
}
|
|
681
|
+
} else {
|
|
682
|
+
if (existing) {
|
|
683
|
+
if (Array.isArray(existing.value)) {
|
|
684
|
+
existing.value.push(filepath);
|
|
685
|
+
} else if (existing.value === "") {
|
|
686
|
+
existing.value = filepath;
|
|
687
|
+
} else {
|
|
688
|
+
existing.value = [existing.value, filepath];
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
responses.push({ id: image.id, value: filepath });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (err) {
|
|
695
|
+
const message = err instanceof Error ? err.message : "Image upload failed";
|
|
696
|
+
sendJson(res, 400, { ok: false, error: message, field: image.id });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
markCompleted();
|
|
702
|
+
unregisterSession(sessionId);
|
|
703
|
+
sendJson(res, 200, { ok: true });
|
|
704
|
+
setImmediate(() => callbacks.onSubmit(responses));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
sendText(res, 404, "Not found");
|
|
709
|
+
} catch (err) {
|
|
710
|
+
const message = err instanceof Error ? err.message : "Server error";
|
|
711
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
return new Promise((resolve, reject) => {
|
|
716
|
+
const onError = (err: Error) => {
|
|
717
|
+
reject(new Error(`Failed to start server: ${err.message}`));
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
server.once("error", onError);
|
|
721
|
+
server.listen(port ?? 0, "127.0.0.1", () => {
|
|
722
|
+
server.off("error", onError);
|
|
723
|
+
const addr = server.address();
|
|
724
|
+
if (!addr || typeof addr === "string") {
|
|
725
|
+
reject(new Error("Failed to start server: invalid address"));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const url = `http://localhost:${addr.port}/?session=${sessionToken}`;
|
|
729
|
+
cleanupOldRecoveryFiles();
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
sessionEntry = {
|
|
732
|
+
id: sessionId,
|
|
733
|
+
url,
|
|
734
|
+
cwd: normalizedCwd,
|
|
735
|
+
gitBranch,
|
|
736
|
+
title: questions.title || "Interview",
|
|
737
|
+
startedAt: now,
|
|
738
|
+
lastSeen: now,
|
|
739
|
+
};
|
|
740
|
+
registerSession(sessionEntry);
|
|
741
|
+
if (!watchdog) {
|
|
742
|
+
watchdog = setInterval(() => {
|
|
743
|
+
if (completed || !browserConnected) return;
|
|
744
|
+
if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return;
|
|
745
|
+
if (!markCompleted()) return;
|
|
746
|
+
const recoveryPath = saveToRecovery(questions, cwd, gitBranch, sessionId);
|
|
747
|
+
log(verbose, `Interview stale. Saved to: ${recoveryPath}`);
|
|
748
|
+
unregisterSession(sessionId);
|
|
749
|
+
setImmediate(() => callbacks.onCancel("stale"));
|
|
750
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
751
|
+
}
|
|
752
|
+
resolve({
|
|
753
|
+
server,
|
|
754
|
+
url,
|
|
755
|
+
close: () => {
|
|
756
|
+
try {
|
|
757
|
+
markCompleted();
|
|
758
|
+
unregisterSession(sessionId);
|
|
759
|
+
server.close();
|
|
760
|
+
} catch {}
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
}
|