server.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import {
  2. createServer,
  3. type IncomingMessage,
  4. type ServerResponse,
  5. } from 'node:http';
  6. import { URL } from 'node:url';
  7. import type { InterviewAnswer, InterviewState } from './types';
  8. import { renderInterviewPage } from './ui';
  9. function getSubmissionStatus(error: unknown): number {
  10. if (error instanceof SyntaxError) {
  11. return 400;
  12. }
  13. const message = error instanceof Error ? error.message : '';
  14. if (message === 'Interview not found') {
  15. return 404;
  16. }
  17. if (message.includes('busy')) {
  18. return 409;
  19. }
  20. if (
  21. message.includes('waiting for a valid agent update') ||
  22. message.includes('There are no active interview questions') ||
  23. message.includes('Answer every active interview question') ||
  24. message.includes('Answers do not match') ||
  25. message.includes('Request body too large') ||
  26. message.includes('Invalid answers payload') ||
  27. message.includes('no longer active')
  28. ) {
  29. return 400;
  30. }
  31. return 500;
  32. }
  33. function parseAnswersPayload(value: unknown): { answers: InterviewAnswer[] } {
  34. if (!value || typeof value !== 'object') {
  35. throw new Error('Invalid answers payload.');
  36. }
  37. const answersRaw = (value as { answers?: unknown }).answers;
  38. if (!Array.isArray(answersRaw)) {
  39. throw new Error('Invalid answers payload.');
  40. }
  41. return {
  42. answers: answersRaw.map((answer) => {
  43. if (!answer || typeof answer !== 'object') {
  44. throw new Error('Invalid answers payload.');
  45. }
  46. const record = answer as { questionId?: unknown; answer?: unknown };
  47. if (
  48. typeof record.questionId !== 'string' ||
  49. typeof record.answer !== 'string'
  50. ) {
  51. throw new Error('Invalid answers payload.');
  52. }
  53. return {
  54. questionId: record.questionId.trim(),
  55. answer: record.answer.trim(),
  56. };
  57. }),
  58. };
  59. }
  60. async function readJsonBody(request: IncomingMessage): Promise<unknown> {
  61. const chunks: Buffer[] = [];
  62. let size = 0;
  63. for await (const chunk of request) {
  64. const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
  65. size += buffer.length;
  66. if (size > 64 * 1024) {
  67. throw new Error('Request body too large');
  68. }
  69. chunks.push(buffer);
  70. }
  71. const raw = Buffer.concat(chunks).toString('utf8').trim();
  72. return raw ? JSON.parse(raw) : {};
  73. }
  74. function sendJson(
  75. response: ServerResponse,
  76. status: number,
  77. value: unknown,
  78. ): void {
  79. response.statusCode = status;
  80. response.setHeader('content-type', 'application/json; charset=utf-8');
  81. response.end(`${JSON.stringify(value)}\n`);
  82. }
  83. function sendHtml(response: ServerResponse, html: string): void {
  84. response.statusCode = 200;
  85. response.setHeader('content-type', 'text/html; charset=utf-8');
  86. response.end(html);
  87. }
  88. export function createInterviewServer(deps: {
  89. getState: (interviewId: string) => Promise<InterviewState>;
  90. submitAnswers: (
  91. interviewId: string,
  92. answers: InterviewAnswer[],
  93. ) => Promise<void>;
  94. }): {
  95. ensureStarted: () => Promise<string>;
  96. } {
  97. let baseUrl: string | null = null;
  98. let startPromise: Promise<string> | null = null;
  99. async function handle(
  100. request: IncomingMessage,
  101. response: ServerResponse,
  102. ): Promise<void> {
  103. const url = new URL(request.url ?? '/', 'http://127.0.0.1');
  104. const pathname = url.pathname;
  105. if (request.method === 'GET' && pathname.startsWith('/interview/')) {
  106. sendHtml(
  107. response,
  108. renderInterviewPage(pathname.split('/').pop() ?? 'unknown'),
  109. );
  110. return;
  111. }
  112. const stateMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/state$/);
  113. if (request.method === 'GET' && stateMatch) {
  114. try {
  115. const state = await deps.getState(stateMatch[1]);
  116. sendJson(response, 200, state);
  117. } catch (error) {
  118. sendJson(response, 404, {
  119. error: error instanceof Error ? error.message : 'Interview not found',
  120. });
  121. }
  122. return;
  123. }
  124. const answersMatch = pathname.match(
  125. /^\/api\/interviews\/([^/]+)\/answers$/,
  126. );
  127. if (request.method === 'POST' && answersMatch) {
  128. try {
  129. const body = parseAnswersPayload(await readJsonBody(request));
  130. await deps.submitAnswers(answersMatch[1], body.answers);
  131. sendJson(response, 200, {
  132. ok: true,
  133. message: 'Answers submitted to the OpenCode session.',
  134. });
  135. } catch (error) {
  136. const message =
  137. error instanceof Error ? error.message : 'Failed to submit answers.';
  138. const status = getSubmissionStatus(error);
  139. sendJson(response, status, {
  140. ok: false,
  141. message,
  142. });
  143. }
  144. return;
  145. }
  146. sendJson(response, 404, { error: 'Not found' });
  147. }
  148. async function ensureStarted(): Promise<string> {
  149. if (baseUrl) {
  150. return baseUrl;
  151. }
  152. if (startPromise) {
  153. return startPromise;
  154. }
  155. startPromise = new Promise((resolve, reject) => {
  156. const server = createServer((request, response) => {
  157. handle(request, response).catch((error) => {
  158. sendJson(response, 500, {
  159. error:
  160. error instanceof Error ? error.message : 'Internal server error',
  161. });
  162. });
  163. });
  164. server.on('error', (error) => {
  165. startPromise = null;
  166. reject(error);
  167. });
  168. server.listen(0, '127.0.0.1', () => {
  169. const address = server.address();
  170. if (!address || typeof address === 'string') {
  171. startPromise = null;
  172. reject(new Error('Failed to start interview server'));
  173. return;
  174. }
  175. baseUrl = `http://127.0.0.1:${address.port}`;
  176. resolve(baseUrl);
  177. });
  178. });
  179. return startPromise;
  180. }
  181. return {
  182. ensureStarted,
  183. };
  184. }