Files
whetstone_DSL/example_projects/twitter_clone_local/server.js

227 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const PORT = Number(process.env.PORT || 3000);
const PUBLIC_DIR = path.join(__dirname, 'public');
let nextTweetId = 4;
let nextReplyId = 1;
const tweets = [
{
id: 1,
author: 'whetstone',
text: 'Twitter clone local demo is live.',
likes: 3,
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
replies: []
},
{
id: 2,
author: 'editor-bot',
text: 'You can post, like, and reply in this demo timeline.',
likes: 1,
createdAt: new Date(Date.now() - 1000 * 60 * 20).toISOString(),
replies: []
},
{
id: 3,
author: 'bill',
text: 'Running locally with zero external dependencies.',
likes: 2,
createdAt: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
replies: []
}
];
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
});
res.end(body);
}
function sendText(res, statusCode, text) {
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(text)
});
res.end(text);
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let raw = '';
req.on('data', chunk => {
raw += chunk;
if (raw.length > 1024 * 1024) {
reject(new Error('payload_too_large'));
}
});
req.on('end', () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('invalid_json'));
}
});
req.on('error', reject);
});
}
function getTweetById(id) {
return tweets.find(t => t.id === id) || null;
}
function listTweets() {
return [...tweets].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
async function handleApi(req, res, pathname) {
if (req.method === 'GET' && pathname === '/api/health') {
sendJson(res, 200, { ok: true, service: 'twitter-clone-local' });
return;
}
if (req.method === 'GET' && pathname === '/api/tweets') {
sendJson(res, 200, { tweets: listTweets() });
return;
}
if (req.method === 'POST' && pathname === '/api/tweets') {
try {
const body = await parseBody(req);
const author = String(body.author || '').trim();
const text = String(body.text || '').trim();
if (!author || !text) {
sendJson(res, 400, { error: 'author_and_text_required' });
return;
}
const tweet = {
id: nextTweetId++,
author,
text,
likes: 0,
createdAt: new Date().toISOString(),
replies: []
};
tweets.push(tweet);
sendJson(res, 201, { tweet });
} catch (err) {
sendJson(res, 400, { error: err.message || 'invalid_request' });
}
return;
}
const likeMatch = pathname.match(/^\/api\/tweets\/(\d+)\/like$/);
if (req.method === 'POST' && likeMatch) {
const id = Number(likeMatch[1]);
const tweet = getTweetById(id);
if (!tweet) {
sendJson(res, 404, { error: 'tweet_not_found' });
return;
}
tweet.likes += 1;
sendJson(res, 200, { tweet });
return;
}
const replyMatch = pathname.match(/^\/api\/tweets\/(\d+)\/reply$/);
if (req.method === 'POST' && replyMatch) {
const id = Number(replyMatch[1]);
const tweet = getTweetById(id);
if (!tweet) {
sendJson(res, 404, { error: 'tweet_not_found' });
return;
}
try {
const body = await parseBody(req);
const author = String(body.author || '').trim();
const text = String(body.text || '').trim();
if (!author || !text) {
sendJson(res, 400, { error: 'author_and_text_required' });
return;
}
const reply = {
id: nextReplyId++,
author,
text,
createdAt: new Date().toISOString()
};
tweet.replies.push(reply);
sendJson(res, 201, { tweet, reply });
} catch (err) {
sendJson(res, 400, { error: err.message || 'invalid_request' });
}
return;
}
sendJson(res, 404, { error: 'not_found' });
}
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.html') return 'text/html; charset=utf-8';
if (ext === '.css') return 'text/css; charset=utf-8';
if (ext === '.js') return 'application/javascript; charset=utf-8';
if (ext === '.json') return 'application/json; charset=utf-8';
return 'text/plain; charset=utf-8';
}
function safeFilePath(pathname) {
const cleanPath = pathname === '/' ? '/index.html' : pathname;
const abs = path.join(PUBLIC_DIR, path.normalize(cleanPath));
if (!abs.startsWith(PUBLIC_DIR)) return null;
return abs;
}
function serveStatic(res, pathname) {
const abs = safeFilePath(pathname);
if (!abs) {
sendText(res, 403, 'forbidden');
return;
}
fs.readFile(abs, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
sendText(res, 404, 'not found');
} else {
sendText(res, 500, 'internal error');
}
return;
}
res.writeHead(200, {
'Content-Type': contentTypeFor(abs),
'Content-Length': data.length
});
res.end(data);
});
}
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
if (pathname.startsWith('/api/')) {
await handleApi(req, res, pathname);
return;
}
serveStatic(res, pathname);
});
server.listen(PORT, () => {
process.stdout.write(`twitter_clone_local listening on http://localhost:${PORT}\n`);
});