server.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import {
  2. createServer,
  3. type IncomingMessage,
  4. type Server,
  5. type ServerResponse,
  6. } from 'node:http';
  7. import { URL } from 'node:url';
  8. import { extractResumeSlug, readJsonBody, sendHtml, sendJson } from './helpers';
  9. import type {
  10. InterviewAnswer,
  11. InterviewFileItem,
  12. InterviewListItem,
  13. InterviewState,
  14. } from './types';
  15. import { renderDashboardPage, renderInterviewPage } from './ui';
  16. function getSubmissionStatus(error: unknown): number {
  17. if (error instanceof SyntaxError) {
  18. return 400;
  19. }
  20. const message = error instanceof Error ? error.message : '';
  21. if (message === 'Interview not found') {
  22. return 404;
  23. }
  24. if (message.includes('busy')) {
  25. return 409;
  26. }
  27. if (
  28. message.includes('waiting for a valid agent update') ||
  29. message.includes('There are no active interview questions') ||
  30. message.includes('Answer every active interview question') ||
  31. message.includes('Answers do not match') ||
  32. message.includes('Request body too large') ||
  33. message.includes('Invalid answers payload') ||
  34. message.includes('no longer active')
  35. ) {
  36. return 400;
  37. }
  38. return 500;
  39. }
  40. function parseAnswersPayload(value: unknown): { answers: InterviewAnswer[] } {
  41. if (!value || typeof value !== 'object') {
  42. throw new Error('Invalid answers payload.');
  43. }
  44. const answersRaw = (value as { answers?: unknown }).answers;
  45. if (!Array.isArray(answersRaw)) {
  46. throw new Error('Invalid answers payload.');
  47. }
  48. return {
  49. answers: answersRaw.map((answer) => {
  50. if (!answer || typeof answer !== 'object') {
  51. throw new Error('Invalid answers payload.');
  52. }
  53. const record = answer as { questionId?: unknown; answer?: unknown };
  54. if (
  55. typeof record.questionId !== 'string' ||
  56. typeof record.answer !== 'string'
  57. ) {
  58. throw new Error('Invalid answers payload.');
  59. }
  60. return {
  61. questionId: record.questionId.trim(),
  62. answer: record.answer.trim(),
  63. };
  64. }),
  65. };
  66. }
  67. export function createInterviewServer(deps: {
  68. getState: (interviewId: string) => Promise<InterviewState>;
  69. listInterviewFiles: () => Promise<InterviewFileItem[]>;
  70. listInterviews: () => InterviewListItem[];
  71. submitAnswers: (
  72. interviewId: string,
  73. answers: InterviewAnswer[],
  74. ) => Promise<void>;
  75. submitBlockComment: (
  76. interviewId: string,
  77. section: string,
  78. comment: string,
  79. ) => Promise<void>;
  80. submitChat: (interviewId: string, message: string) => Promise<void>;
  81. handleNudgeAction: (
  82. interviewId: string,
  83. action: 'more-questions' | 'confirm-complete',
  84. ) => Promise<void>;
  85. outputFolder: string;
  86. port: number;
  87. }): {
  88. ensureStarted: () => Promise<string>;
  89. close: () => void;
  90. } {
  91. let baseUrl: string | null = null;
  92. let startPromise: Promise<string> | null = null;
  93. let activeServer: Server | null = null;
  94. async function loadDashboardData() {
  95. const interviews = deps.listInterviews().map((item) => {
  96. const resumeSlug = extractResumeSlug(item.id);
  97. return {
  98. ...item,
  99. url: `/interview/${item.id}`,
  100. mode: 'active',
  101. resumeSlug,
  102. };
  103. });
  104. const files = await deps.listInterviewFiles();
  105. return { interviews, files };
  106. }
  107. async function handle(
  108. request: IncomingMessage,
  109. response: ServerResponse,
  110. ): Promise<void> {
  111. let url: URL;
  112. try {
  113. url = new URL(request.url ?? '/', 'http://127.0.0.1');
  114. } catch {
  115. sendJson(response, 400, { error: 'Invalid request URL' });
  116. return;
  117. }
  118. const pathname = url.pathname;
  119. // Dashboard: root page listing all interviews
  120. if (request.method === 'GET' && pathname === '/') {
  121. try {
  122. const { interviews, files } = await loadDashboardData();
  123. sendHtml(
  124. response,
  125. renderDashboardPage(interviews, files, deps.outputFolder),
  126. );
  127. } catch {
  128. sendJson(response, 500, { error: 'Failed to load interviews' });
  129. }
  130. return;
  131. }
  132. // API: list all interviews as JSON
  133. if (request.method === 'GET' && pathname === '/api/interviews') {
  134. try {
  135. const { interviews, files } = await loadDashboardData();
  136. sendJson(response, 200, { active: interviews, files });
  137. } catch {
  138. sendJson(response, 500, { error: 'Failed to load interviews' });
  139. }
  140. return;
  141. }
  142. if (request.method === 'GET' && pathname.startsWith('/interview/')) {
  143. const rawId = decodeURIComponent(pathname.split('/').pop() ?? 'unknown');
  144. sendHtml(response, renderInterviewPage(rawId, extractResumeSlug(rawId)));
  145. return;
  146. }
  147. const stateMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/state$/);
  148. if (request.method === 'GET' && stateMatch) {
  149. try {
  150. const state = await deps.getState(decodeURIComponent(stateMatch[1]));
  151. sendJson(response, 200, state);
  152. } catch (error) {
  153. const message =
  154. error instanceof Error ? error.message : 'Interview not found';
  155. const status = message === 'Interview not found' ? 404 : 500;
  156. sendJson(response, status, { error: message });
  157. }
  158. return;
  159. }
  160. // CSRF note: This endpoint intentionally sends no CORS headers.
  161. // The browser's same-origin policy blocks cross-origin POST with
  162. // Content-Type: application/json (it triggers a preflight, which
  163. // 404s here). Do NOT add Access-Control-Allow-Origin without also
  164. // adding an Origin check or CSRF token.
  165. const answersMatch = pathname.match(
  166. /^\/api\/interviews\/([^/]+)\/answers$/,
  167. );
  168. if (request.method === 'POST' && answersMatch) {
  169. try {
  170. const body = parseAnswersPayload(await readJsonBody(request));
  171. await deps.submitAnswers(
  172. decodeURIComponent(answersMatch[1]),
  173. body.answers,
  174. );
  175. sendJson(response, 200, {
  176. ok: true,
  177. message: 'Answers submitted to the OpenCode session.',
  178. });
  179. } catch (error) {
  180. const message =
  181. error instanceof Error ? error.message : 'Failed to submit answers.';
  182. const status = getSubmissionStatus(error);
  183. sendJson(response, status, {
  184. ok: false,
  185. message,
  186. });
  187. }
  188. return;
  189. }
  190. const blockCommentMatch = pathname.match(
  191. /^\/api\/interviews\/([^/]+)\/block-comment$/,
  192. );
  193. if (request.method === 'POST' && blockCommentMatch) {
  194. try {
  195. const body = (await readJsonBody(request)) as {
  196. section?: string;
  197. comment?: string;
  198. };
  199. if (
  200. typeof body.section !== 'string' ||
  201. typeof body.comment !== 'string'
  202. ) {
  203. sendJson(response, 400, {
  204. error: 'section and comment must be strings',
  205. });
  206. return;
  207. }
  208. await deps.submitBlockComment(
  209. decodeURIComponent(blockCommentMatch[1]),
  210. body.section,
  211. body.comment,
  212. );
  213. sendJson(response, 200, {
  214. ok: true,
  215. message: 'Block feedback forwarded.',
  216. });
  217. } catch (error) {
  218. const message =
  219. error instanceof Error
  220. ? error.message
  221. : 'Failed to submit block comment.';
  222. const status = getSubmissionStatus(error);
  223. sendJson(response, status, { ok: false, message });
  224. }
  225. return;
  226. }
  227. // ── Chat: freeform message to agent ─────────────────────────────
  228. const chatMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/chat$/);
  229. if (request.method === 'POST' && chatMatch) {
  230. try {
  231. const body = (await readJsonBody(request)) as {
  232. message?: string;
  233. };
  234. if (typeof body.message !== 'string' || !body.message.trim()) {
  235. sendJson(response, 400, {
  236. error: 'message must be a non-empty string',
  237. });
  238. return;
  239. }
  240. await deps.submitChat(
  241. decodeURIComponent(chatMatch[1]),
  242. body.message.trim(),
  243. );
  244. sendJson(response, 200, {
  245. ok: true,
  246. message: 'Chat message forwarded to agent.',
  247. });
  248. } catch (error) {
  249. const message =
  250. error instanceof Error
  251. ? error.message
  252. : 'Failed to submit chat message.';
  253. const status = getSubmissionStatus(error);
  254. sendJson(response, status, { ok: false, message });
  255. }
  256. return;
  257. }
  258. // Nudge: ask more questions or confirm complete
  259. const nudgeMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/nudge$/);
  260. if (request.method === 'POST' && nudgeMatch) {
  261. try {
  262. const body = (await readJsonBody(request)) as {
  263. action?: string;
  264. };
  265. if (
  266. body.action !== 'more-questions' &&
  267. body.action !== 'confirm-complete'
  268. ) {
  269. sendJson(response, 400, {
  270. error: 'action must be "more-questions" or "confirm-complete"',
  271. });
  272. return;
  273. }
  274. await deps.handleNudgeAction(
  275. decodeURIComponent(nudgeMatch[1]),
  276. body.action,
  277. );
  278. sendJson(response, 200, { ok: true, message: 'Nudge sent.' });
  279. } catch (error) {
  280. const message =
  281. error instanceof Error ? error.message : 'Failed to nudge.';
  282. const status = message === 'Interview not found' ? 404 : 500;
  283. sendJson(response, status, { ok: false, message });
  284. }
  285. return;
  286. }
  287. sendJson(response, 404, { error: 'Not found' });
  288. }
  289. async function ensureStarted(): Promise<string> {
  290. if (baseUrl) {
  291. return baseUrl;
  292. }
  293. if (startPromise) {
  294. return startPromise;
  295. }
  296. startPromise = new Promise((resolve, reject) => {
  297. const server = createServer((request, response) => {
  298. handle(request, response).catch((error) => {
  299. sendJson(response, 500, {
  300. error:
  301. error instanceof Error ? error.message : 'Internal server error',
  302. });
  303. });
  304. });
  305. server.requestTimeout = 30_000;
  306. server.headersTimeout = 10_000;
  307. activeServer = server;
  308. server.on('error', (error: NodeJS.ErrnoException) => {
  309. server.close();
  310. activeServer = null;
  311. startPromise = null;
  312. if (error.code === 'EADDRINUSE') {
  313. reject(
  314. new Error(
  315. `Interview server port ${deps.port} is already in use. Choose a different port or set port to 0 for an OS-assigned port.`,
  316. ),
  317. );
  318. } else {
  319. reject(error);
  320. }
  321. });
  322. server.listen(deps.port, '127.0.0.1', () => {
  323. const address = server.address();
  324. if (!address || typeof address === 'string') {
  325. startPromise = null;
  326. reject(new Error('Failed to start interview server'));
  327. return;
  328. }
  329. baseUrl = `http://127.0.0.1:${address.port}`;
  330. resolve(baseUrl);
  331. });
  332. });
  333. return startPromise;
  334. }
  335. return {
  336. ensureStarted,
  337. close: () => {
  338. if (activeServer) {
  339. activeServer.closeAllConnections();
  340. activeServer.close();
  341. activeServer = null;
  342. }
  343. baseUrl = null;
  344. startPromise = null;
  345. },
  346. };
  347. }