// src/server.mjs import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; // The web client's three files, by the path a browser asks for them. On the // platform, the deploy serves them from client/ ahead of these routes. A // local run has nothing in front of it, so this server answers them itself. const CLIENT_FILES = Object.freeze({ '/': ['index.html', 'text/html; charset=utf-8'], '/index.html': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/styles.css': ['styles.css', 'text/css; charset=utf-8'], }); const readBody = async (request) => { // One decoder for the whole body: a character whose bytes arrive in two chunks is read whole. request.setEncoding('utf8'); let text = ''; for await (const chunk of request) text += chunk; return text; }; // The server over its three parts: db, the data layer; userIdOf, which reads // the signed-in user's id from a request, or null; and ask, which answers a // question under the AI allowance, or names why the model gave no answer. export function createAppServer({ db, userIdOf, ask }) { return createServer(async (request, response) => { // The deployed runtime's abort signal: end the response when it fires. request.signal?.addEventListener('abort', () => response.destroy(), { once: true }); const send = (status, body) => { if (response.destroyed) return; response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(body)); }; try { // A target that does not parse as a URL names no route, so it is answered 404 below. let pathname = null; try { pathname = new URL(request.url, 'http://localhost').pathname; } catch {} if (request.method === 'GET' && pathname === '/health') { return (await db.reachable()) ? send(200, { ok: true }) : send(503, { ok: false }); } if (request.method === 'GET' && Object.hasOwn(CLIENT_FILES, pathname)) { const [file, type] = CLIENT_FILES[pathname]; try { const body = await readFile(new URL(`../client/${file}`, import.meta.url)); if (!response.destroyed) response.writeHead(200, { 'content-type': type }).end(body); return; } catch (error) { if (error?.code === 'ENOENT') return send(404, { error: 'not_found' }); throw error; } } // Every route under /api/ answers the signed-in user alone. if (pathname === '/api/notes' || pathname === '/api/ask') { const user = await userIdOf(request); if (user === null) return send(401, { error: 'sign_in_required' }); if (request.method === 'GET' && pathname === '/api/notes') return send(200, await db.listNotes(user)); if (request.method === 'POST' && pathname === '/api/notes') { const posted = JSON.parse(await readBody(request)); return send(201, await db.addNote(user, String(posted?.title ?? ''))); } if (request.method === 'POST' && pathname === '/api/ask') { let question = ''; try { question = String(JSON.parse(await readBody(request))?.question ?? '').trim(); } catch {} if (question === '') return send(400, { error: 'invalid_question' }); // 503 names why the model gave no answer: allowance_drawn, // allowance_unavailable, or upstream_quota (src/ai.mjs). const asked = await ask(question); return 'answer' in asked ? send(200, { answer: asked.answer }) : send(503, { error: asked.unanswered }); } } send(404, { error: 'not_found' }); } catch (error) { const refused = error instanceof SyntaxError ? 'invalid_json' : error?.code === '23514' ? 'invalid_note' : null; if (refused === null) console.error(error); send(refused === null ? 500 : 400, { error: refused ?? 'internal' }); } }); }