227 lines
5.7 KiB
JavaScript
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`);
|
|
});
|