mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 21:15:16 +02:00
feat(profiles): add opt-in Lean/Full and hybrid Auto selection
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
- 2026-09-25: Initial shortlink core — create, redirect, expiry, and delete per API.md.
|
||||
- 2026-09-25: Persistence — links survive restarts via the DATA_FILE JSON store; missing or corrupt data files start clean.
|
||||
- 2026-09-25: Abuse protection — URL validation (http/https only, length cap), request body limits, and per-client rate limiting with 429 responses.
|
||||
- 2026-09-25: Analytics — per-link redirect hit counts exposed at GET /links/:code/stats.
|
||||
@@ -0,0 +1,14 @@
|
||||
# shortlink
|
||||
|
||||
Internal link shortener service. Node.js standard library only, CommonJS.
|
||||
|
||||
- `API.md` — the HTTP contract.
|
||||
- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them.
|
||||
- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet
|
||||
listening; `node src/index.js <port>` starts the service.
|
||||
- Links persist to the JSON file named by the `DATA_FILE` environment variable
|
||||
(default `./data/links.json`).
|
||||
- `GET /links/<code>/stats` returns `{ "code", "hits", "expiresAt" }` —
|
||||
`hits` counts redirects.
|
||||
- The API is rate limited per client and validates URLs (http/https only).
|
||||
- Run the tests with `npm test`.
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { createStore } = require('./store');
|
||||
const { createService } = require('./service');
|
||||
const { createRouter } = require('./routes');
|
||||
|
||||
function createApp() {
|
||||
const file = process.env.DATA_FILE || path.join(process.cwd(), 'data', 'links.json');
|
||||
const store = createStore(file);
|
||||
const service = createService(store);
|
||||
return http.createServer(createRouter(service));
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
@@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
const { createApp } = require('./app');
|
||||
|
||||
const port = Number(process.env.PORT || process.argv[2] || 8080);
|
||||
createApp().listen(port, () => {
|
||||
console.log(`shortlink listening on ${port}`);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
const { HttpError } = require('./service');
|
||||
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
function sendJson(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sendError(res, error) {
|
||||
const known = error instanceof HttpError;
|
||||
sendJson(res, known ? error.status : 500, {
|
||||
error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' },
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
req.on('data', chunk => {
|
||||
if (settled) return;
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
settled = true;
|
||||
reject(new HttpError(413, 'PAYLOAD_TOO_LARGE', 'request body too large'));
|
||||
// Drain rather than destroy: the socket must live long enough to send the 413.
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (!body) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function createRouter(service) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/links') {
|
||||
service.assertRateLimit(req.socket.remoteAddress || 'unknown');
|
||||
const link = service.createLink(await readBody(req));
|
||||
sendJson(res, 201, { code: link.code, shortUrl: `/${link.code}`, expiresAt: link.expiresAt });
|
||||
return;
|
||||
}
|
||||
|
||||
const statsMatch = /^\/links\/([A-Za-z0-9]{1,20})\/stats$/.exec(url.pathname);
|
||||
if (req.method === 'GET' && statsMatch) {
|
||||
sendJson(res, 200, service.stats(statsMatch[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
const linkMatch = /^\/links\/([A-Za-z0-9]{1,20})$/.exec(url.pathname);
|
||||
if (req.method === 'DELETE' && linkMatch) {
|
||||
service.deleteLink(linkMatch[1]);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectMatch = /^\/([A-Za-z0-9]{1,20})$/.exec(url.pathname);
|
||||
if (req.method === 'GET' && redirectMatch) {
|
||||
const link = service.resolveLink(redirectMatch[1]);
|
||||
res.writeHead(302, { location: link.url });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpError(404, 'NOT_FOUND', 'not found');
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const DEFAULT_TTL_SECONDS = 604800;
|
||||
const MAX_TTL_SECONDS = 2592000;
|
||||
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||
const RATE_LIMIT_MAX = 20;
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function validateUrl(url) {
|
||||
if (typeof url !== 'string' || !url) throw new HttpError(400, 'INVALID_URL', 'url is required');
|
||||
if (url.length > MAX_URL_LENGTH) throw new HttpError(400, 'INVALID_URL', 'url exceeds 2048 characters');
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { throw new HttpError(400, 'INVALID_URL', 'url must be a valid absolute URL'); }
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new HttpError(400, 'INVALID_URL', 'only http and https URLs are allowed');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function validateTtl(ttlSeconds) {
|
||||
if (ttlSeconds === undefined || ttlSeconds === null) return DEFAULT_TTL_SECONDS;
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) {
|
||||
throw new HttpError(400, 'INVALID_TTL', 'ttlSeconds must be an integer between 1 and 2592000');
|
||||
}
|
||||
return ttlSeconds;
|
||||
}
|
||||
|
||||
function createService(store) {
|
||||
const buckets = new Map();
|
||||
|
||||
function assertRateLimit(key) {
|
||||
const now = Date.now();
|
||||
const windowHits = (buckets.get(key) || []).filter(at => now - at < RATE_LIMIT_WINDOW_MS);
|
||||
if (windowHits.length >= RATE_LIMIT_MAX) throw new HttpError(429, 'RATE_LIMITED', 'too many requests, slow down');
|
||||
windowHits.push(now);
|
||||
buckets.set(key, windowHits);
|
||||
}
|
||||
|
||||
function freshCode() {
|
||||
let code = crypto.randomBytes(4).toString('hex');
|
||||
while (store.get(code)) code = crypto.randomBytes(4).toString('hex');
|
||||
return code;
|
||||
}
|
||||
|
||||
return {
|
||||
assertRateLimit,
|
||||
createLink({ url, ttlSeconds } = {}) {
|
||||
const validUrl = validateUrl(url);
|
||||
const ttl = validateTtl(ttlSeconds);
|
||||
const link = { code: freshCode(), url: validUrl,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), hits: 0 };
|
||||
store.set(link.code, link);
|
||||
return link;
|
||||
},
|
||||
resolveLink(code) {
|
||||
const link = store.get(code);
|
||||
if (!link) throw new HttpError(404, 'NOT_FOUND', 'no link with that code');
|
||||
if (Date.parse(link.expiresAt) <= Date.now()) throw new HttpError(410, 'GONE', 'link has expired');
|
||||
store.incrementHits(code);
|
||||
return link;
|
||||
},
|
||||
deleteLink(code) {
|
||||
if (!store.delete(code)) throw new HttpError(404, 'NOT_FOUND', 'no link with that code');
|
||||
},
|
||||
stats(code) {
|
||||
const link = store.get(code);
|
||||
if (!link) throw new HttpError(404, 'NOT_FOUND', 'no link with that code');
|
||||
return { code, hits: link.hits || 0, expiresAt: link.expiresAt };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createService, HttpError };
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// JSON-file-backed link store. Missing or corrupt files start clean; every
|
||||
// mutation is flushed synchronously so a restart never loses a committed link.
|
||||
function createStore(file) {
|
||||
let links = new Map();
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
for (const [code, value] of Object.entries(raw.links || {})) links.set(code, value);
|
||||
} catch { /* missing or corrupt: start empty */ }
|
||||
const save = () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify({ links: Object.fromEntries(links) }, null, 1)}\n`);
|
||||
};
|
||||
return {
|
||||
get: code => links.get(code) || null,
|
||||
set(code, value) { links.set(code, value); save(); },
|
||||
delete(code) { const had = links.delete(code); if (had) save(); return had; },
|
||||
incrementHits(code) {
|
||||
const link = links.get(code);
|
||||
if (link) { link.hits = (link.hits || 0) + 1; save(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStore };
|
||||
@@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createApp } = require('../src/app');
|
||||
|
||||
process.env.DATA_FILE = require('node:path').join(require('node:os').tmpdir(),
|
||||
`shortlink-test-${process.pid}.json`);
|
||||
|
||||
let server;
|
||||
let port;
|
||||
test.before(async () => {
|
||||
server = createApp();
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
port = server.address().port;
|
||||
});
|
||||
test.after(() => server.close());
|
||||
|
||||
const post = body => fetch(`http://127.0.0.1:${port}/links`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' });
|
||||
|
||||
test('creates a link with default expiry', async () => {
|
||||
const res = await post({ url: 'https://example.com/a' });
|
||||
assert.equal(res.status, 201);
|
||||
const body = await res.json();
|
||||
assert.match(body.code, /^[A-Za-z0-9]{6,10}$/);
|
||||
assert.ok(Date.parse(body.expiresAt) > Date.now());
|
||||
});
|
||||
|
||||
test('redirects with 302 and location', async () => {
|
||||
const { code } = await (await post({ url: 'https://example.com/b' })).json();
|
||||
const res = await get(`/${code}`);
|
||||
assert.equal(res.status, 302);
|
||||
assert.equal(res.headers.get('location'), 'https://example.com/b');
|
||||
});
|
||||
|
||||
test('unknown code is a 404 envelope', async () => {
|
||||
const res = await get('/zzzzzz');
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).error.code, 'NOT_FOUND');
|
||||
});
|
||||
|
||||
test('invalid url is a 400 envelope', async () => {
|
||||
const res = await post({ url: 'notaurl' });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).error.code, 'INVALID_URL');
|
||||
});
|
||||
|
||||
test('javascript scheme rejected', async () => {
|
||||
const res = await post({ url: 'javascript:alert(1)' });
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('ttl bounds enforced', async () => {
|
||||
const res = await post({ url: 'https://example.com', ttlSeconds: 99999999 });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).error.code, 'INVALID_TTL');
|
||||
});
|
||||
|
||||
test('delete flow', async () => {
|
||||
const { code } = await (await post({ url: 'https://example.com/c' })).json();
|
||||
const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' });
|
||||
assert.equal(del.status, 204);
|
||||
assert.equal((await get(`/${code}`)).status, 404);
|
||||
});
|
||||
|
||||
test('stats start at zero and count redirects', async () => {
|
||||
const { code } = await (await post({ url: 'https://example.com/d' })).json();
|
||||
const zero = await (await fetch(`http://127.0.0.1:${port}/links/${code}/stats`)).json();
|
||||
assert.equal(zero.hits, 0);
|
||||
await get(`/${code}`);
|
||||
await get(`/${code}`);
|
||||
const two = await (await fetch(`http://127.0.0.1:${port}/links/${code}/stats`)).json();
|
||||
assert.equal(two.hits, 2);
|
||||
});
|
||||
|
||||
test('stats for unknown code are a 404 envelope', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).error.code, 'NOT_FOUND');
|
||||
});
|
||||
|
||||
test('expired links are 410', async () => {
|
||||
const { code } = await (await post({ url: 'https://example.com/e', ttlSeconds: 1 })).json();
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
assert.equal((await get(`/${code}`)).status, 410);
|
||||
});
|
||||
|
||||
test('malformed json is a 400 envelope', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/links`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).error.code, 'INVALID_JSON');
|
||||
});
|
||||
|
||||
test('error responses never leak html', async () => {
|
||||
const res = await get('/zzzzzz');
|
||||
assert.match(res.headers.get('content-type'), /application\/json/);
|
||||
});
|
||||
|
||||
// Last: the flood exhausts the per-client rate-limit bucket.
|
||||
test('rate limiting kicks in under a flood', async () => {
|
||||
const responses = await Promise.all(Array.from({ length: 30 }, (_, i) =>
|
||||
post({ url: `https://example.com/flood-${i}` })));
|
||||
assert.ok(responses.some(r => r.status === 429));
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
- 2026-09-25: Fixed INC-104 — the receiver now claims each event id and applies
|
||||
the payment synchronously in one event-loop turn, so concurrent duplicate
|
||||
deliveries can never both pass the seen-check. Added idempotency regression
|
||||
tests for concurrent duplicates, retries, and already-paid orders.
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
const { store } = require('./store');
|
||||
|
||||
// Fixed after INC-104: all state checks and mutations happen synchronously in
|
||||
// one turn of the event loop — an event is claimed the instant its body is
|
||||
// parsed, before any await, so concurrent duplicates can never both pass.
|
||||
class HttpError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sendError(res, error) {
|
||||
const known = error instanceof HttpError;
|
||||
sendJson(res, known ? error.status : 500, {
|
||||
error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' },
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateEvent(parsed) {
|
||||
if (!parsed || typeof parsed.eventId !== 'string' || !parsed.eventId
|
||||
|| typeof parsed.orderId !== 'string' || !parsed.orderId
|
||||
|| !Number.isInteger(parsed.amountCents) || parsed.amountCents <= 0
|
||||
|| parsed.type !== 'payment.succeeded') {
|
||||
throw new HttpError(400, 'INVALID_EVENT', 'body must be a valid payment.succeeded event');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Synchronous claim-and-apply: no awaits inside, so it is atomic.
|
||||
function applyEvent({ eventId, orderId, amountCents }) {
|
||||
if (store.processedEvents.has(eventId)) return { status: 'duplicate', orderId };
|
||||
const order = store.orders.get(orderId);
|
||||
if (!order) throw new HttpError(404, 'NOT_FOUND', 'no such order');
|
||||
if (order.amountCents !== amountCents) throw new HttpError(422, 'AMOUNT_MISMATCH', 'amountCents does not match the order');
|
||||
if (order.status === 'paid') return { status: 'already_paid', orderId };
|
||||
store.processedEvents.add(eventId);
|
||||
order.status = 'paid';
|
||||
order.paidAt = new Date().toISOString();
|
||||
order.paymentsApplied++;
|
||||
store.paymentLog.push({ eventId, orderId, amountCents });
|
||||
return { status: 'processed', orderId };
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
return http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
try {
|
||||
if (req.method === 'POST' && url.pathname === '/webhooks/payments') {
|
||||
const parsed = validateEvent(await readBody(req));
|
||||
sendJson(res, 200, applyEvent(parsed));
|
||||
return;
|
||||
}
|
||||
const match = /^\/orders\/([\w-]+)$/.exec(url.pathname);
|
||||
if (req.method === 'GET' && match) {
|
||||
const order = store.orders.get(match[1]);
|
||||
if (!order) throw new HttpError(404, 'NOT_FOUND', 'no such order');
|
||||
sendJson(res, 200, order);
|
||||
return;
|
||||
}
|
||||
throw new HttpError(404, 'NOT_FOUND', 'not found');
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createApp } = require('../src/app');
|
||||
const { store } = require('../src/store');
|
||||
|
||||
let server;
|
||||
let port;
|
||||
test.before(async () => {
|
||||
server = createApp();
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
port = server.address().port;
|
||||
});
|
||||
test.after(() => server.close());
|
||||
|
||||
const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) });
|
||||
|
||||
test('a single payment event processes', async () => {
|
||||
const res = await send('ev-t-1', 'o1', 5000);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, 'processed');
|
||||
assert.equal(store.orders.get('o1').status, 'paid');
|
||||
});
|
||||
|
||||
test('a sequential retry is an inert duplicate', async () => {
|
||||
await send('ev-t-2', 'o3', 800);
|
||||
const before = store.paymentLog.filter(p => p.orderId === 'o3').length;
|
||||
const res = await send('ev-t-2', 'o3', 800);
|
||||
assert.equal((await res.json()).status, 'duplicate');
|
||||
assert.equal(store.paymentLog.filter(p => p.orderId === 'o3').length, before);
|
||||
});
|
||||
|
||||
test('fifty concurrent duplicates apply exactly once (INC-104 regression)', async () => {
|
||||
const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-t-storm', 'o4', 9999)));
|
||||
const bodies = [];
|
||||
for (const r of storm) bodies.push(await r.json());
|
||||
assert.equal(bodies.filter(b => b.status === 'processed').length, 1);
|
||||
assert.equal(bodies.filter(b => b.status === 'duplicate').length, 49);
|
||||
assert.equal(store.orders.get('o4').paymentsApplied, 1);
|
||||
});
|
||||
|
||||
test('a second event for a paid order is already_paid', async () => {
|
||||
const res = await send('ev-t-3', 'o4', 9999);
|
||||
assert.equal((await res.json()).status, 'already_paid');
|
||||
assert.equal(store.orders.get('o4').paymentsApplied, 1);
|
||||
});
|
||||
|
||||
test('amount mismatch is 422 and inert', async () => {
|
||||
const res = await send('ev-t-4', 'o5', 1);
|
||||
assert.equal(res.status, 422);
|
||||
assert.equal(store.orders.get('o5').status, 'pending');
|
||||
});
|
||||
|
||||
test('unknown order is a 404 envelope', async () => {
|
||||
const res = await send('ev-t-5', 'nope', 100);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).error.code, 'NOT_FOUND');
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
- 2026-09-25: Production hardening — request validation with structured JSON
|
||||
error envelopes, 64 KB body limit with 413, /health endpoint, structured
|
||||
JSON request logging, PORT from the environment, graceful SIGTERM shutdown,
|
||||
nosniff headers, and error-path test coverage.
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
|
||||
const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES || 64 * 1024);
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sendError(res, error) {
|
||||
const known = error instanceof HttpError;
|
||||
sendJson(res, known ? error.status : 500, {
|
||||
error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' },
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
req.on('data', chunk => {
|
||||
if (settled) return;
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
settled = true;
|
||||
reject(new HttpError(413, 'PAYLOAD_TOO_LARGE', 'request body exceeds 64 KB'));
|
||||
// Drain rather than destroy: the socket must live long enough to send the 413.
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateNote(input) {
|
||||
if (!input || typeof input.title !== 'string' || !input.title.trim()) {
|
||||
throw new HttpError(400, 'INVALID_TITLE', 'title must be a non-empty string');
|
||||
}
|
||||
if (typeof input.body !== 'string') throw new HttpError(400, 'INVALID_BODY', 'body must be a string');
|
||||
return { title: input.title, body: input.body };
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const notes = new Map();
|
||||
let nextId = 1;
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
sendJson(res, 200, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/notes') {
|
||||
const fields = validateNote(await readBody(req));
|
||||
const id = `n_${nextId++}`;
|
||||
notes.set(id, { id, ...fields });
|
||||
sendJson(res, 201, notes.get(id));
|
||||
return;
|
||||
}
|
||||
const match = /^\/notes\/([\w-]+)$/.exec(url.pathname);
|
||||
if (req.method === 'GET' && match) {
|
||||
const note = notes.get(match[1]);
|
||||
if (!note) throw new HttpError(404, 'NOT_FOUND', 'no note with that id');
|
||||
sendJson(res, 200, note);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/notes') {
|
||||
sendJson(res, 200, { notes: [...notes.values()] });
|
||||
return;
|
||||
}
|
||||
throw new HttpError(404, 'NOT_FOUND', 'not found');
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
} finally {
|
||||
console.log(JSON.stringify({ method: req.method, path: url.pathname,
|
||||
status: res.statusCode, at: new Date().toISOString() }));
|
||||
}
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
const { createApp } = require('./app');
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const server = createApp();
|
||||
server.listen(port, () => {
|
||||
console.log(JSON.stringify({ event: 'listening', port }));
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000).unref();
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createApp } = require('../src/app');
|
||||
|
||||
let server;
|
||||
let port;
|
||||
test.before(async () => {
|
||||
server = createApp();
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
port = server.address().port;
|
||||
});
|
||||
test.after(() => server.close());
|
||||
|
||||
const post = body => fetch(`http://127.0.0.1:${port}/notes`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
||||
|
||||
test('create and read a note', async () => {
|
||||
const created = await post(JSON.stringify({ title: 'first', body: 'hello' }));
|
||||
assert.equal(created.status, 201);
|
||||
const { id } = await created.json();
|
||||
const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`);
|
||||
assert.equal((await read.json()).title, 'first');
|
||||
});
|
||||
|
||||
test('malformed json is a 400 envelope', async () => {
|
||||
const res = await post('{oops');
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).error.code, 'INVALID_JSON');
|
||||
});
|
||||
|
||||
test('missing title is a 400 envelope', async () => {
|
||||
const res = await post(JSON.stringify({ body: 'x' }));
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).error.code, 'INVALID_TITLE');
|
||||
});
|
||||
|
||||
test('unknown note is a 404 envelope', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/notes/n_9999`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).error.code, 'NOT_FOUND');
|
||||
});
|
||||
|
||||
test('oversize body is a 413 envelope', async () => {
|
||||
const res = await post(JSON.stringify({ title: 'x', body: 'y'.repeat(100 * 1024) }));
|
||||
assert.equal(res.status, 413);
|
||||
});
|
||||
|
||||
test('health endpoint', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, 'ok');
|
||||
});
|
||||
|
||||
test('nosniff header present', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/notes`);
|
||||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
||||
});
|
||||
Reference in New Issue
Block a user