import crypto from 'node:crypto'; import http from 'node:http'; const port = Number(process.env.INQUIRY_API_PORT || 5174); const maxBodyBytes = Number(process.env.INQUIRY_MAX_BODY_BYTES || 100_000); const notificationUrl = String(process.env.INQUIRY_NOTIFICATION_URL || '').trim(); const notificationAuthHeader = String(process.env.INQUIRY_NOTIFICATION_AUTH_HEADER || '').trim(); const allowedOrigin = String(process.env.INQUIRY_ALLOWED_ORIGIN || '*').trim() || '*'; const server = http.createServer(async (request, response) => { const url = new URL(request.url || '/', `http://${request.headers.host || '127.0.0.1'}`); if (request.method === 'OPTIONS') { send(response, 204, null); return; } try { if (url.pathname === '/api/health' && request.method === 'GET') { send(response, 200, { ok: true, service: 'travel-inquiry-api', uptime: Math.round(process.uptime()), notification: { configured: Boolean(notificationUrl), provider: notificationUrl ? 'webhook' : 'none', }, }); return; } if (url.pathname === '/api/inquiries' && request.method === 'POST') { const inquiry = normalizeInquiry(await readBody(request)); await notifyInquiry(inquiry); send(response, 202, { id: inquiry.id, receivedAt: inquiry.receivedAt, status: 'accepted', notification: 'delivered', }); return; } send(response, 404, { error: 'Not found' }); } catch (error) { const status = error instanceof HttpError ? error.status : 500; if (status >= 500) console.error(error); send(response, status, { error: error instanceof Error ? error.message : 'Server error' }); } }); server.listen(port, '127.0.0.1', () => { console.log(`Travel inquiry API running at http://127.0.0.1:${port}`); console.log(`Notification webhook: ${notificationUrl ? 'configured' : 'not configured'}`); }); process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); async function shutdown() { server.close(); process.exit(0); } async function notifyInquiry(inquiry) { if (!notificationUrl) { throw new HttpError(503, 'Inquiry notification is not configured'); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); const headers = { 'Content-Type': 'application/json', Accept: 'application/json', }; if (notificationAuthHeader) headers.Authorization = notificationAuthHeader; try { const response = await fetch(notificationUrl, { method: 'POST', headers, body: JSON.stringify({ type: 'inquiry.submitted', occurredAt: inquiry.receivedAt, data: inquiry, }), signal: controller.signal, }); if (!response.ok) { throw new HttpError(502, 'Inquiry notification service rejected the request'); } } catch (error) { if (error instanceof HttpError) throw error; throw new HttpError(502, 'Inquiry notification service is unavailable'); } finally { clearTimeout(timeout); } } function normalizeInquiry(body) { if (!isObject(body)) { throw new HttpError(400, 'Inquiry must be an object'); } const name = field(body.name, 160); const contact = field(body.contact, 240); if (!name || !contact) { throw new HttpError(400, 'Inquiry name and contact are required'); } return { id: field(body.id, 120) || makeId('inq'), receivedAt: new Date().toISOString(), name, contact, interest: field(body.interest, 500), travelMonth: field(body.travelMonth, 120), guests: field(body.guests, 120), message: field(body.message, 6_000), locale: body.locale === 'en' ? 'en' : 'zh', }; } function field(value, maxLength) { return String(value || '').trim().slice(0, maxLength); } function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } async function readBody(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > maxBodyBytes) { throw new HttpError(413, 'Request body is too large'); } chunks.push(chunk); } const body = Buffer.concat(chunks).toString('utf8').trim(); if (!body) return {}; try { return JSON.parse(body); } catch { throw new HttpError(400, 'Request body must be valid JSON'); } } function send(response, status, body) { response.writeHead(status, { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8', }); response.end(body === null ? '' : JSON.stringify(body)); } function makeId(prefix) { return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`; } class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }