Files

182 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
// 赛博小晚桌宠 · 本地服务
// 零依赖:仅使用 Node 内置模块,所有流量只走 127.0.0.1 回环地址。
import { createServer } from 'node:http';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runtimeHome } from './runtime-paths.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(here, 'public');
const assetsDir = path.join(here, 'assets');
const runtimeDir = runtimeHome();
const runtimeFile = path.join(runtimeDir, 'pet.json');
const manifest = JSON.parse(await readFile(path.join(here, 'pets.json'), 'utf8'));
const stateNames = Object.keys(manifest.states);
/** @type {Map<import('node:http').ServerResponse, boolean>} */
const sseClients = new Map();
const state = { name: manifest.defaultState, note: '', since: Date.now() };
function setState(name, note = '') {
if (!stateNames.includes(name)) return false;
state.name = name;
state.note = String(note || '').slice(0, 120);
state.since = Date.now();
broadcast(JSON.stringify({ type: 'state', ...state }));
return true;
}
function broadcast(payload) {
for (const [res] of sseClients) {
try {
res.write(`data: ${payload}\n\n`);
} catch {
sseClients.delete(res);
}
}
}
const mime = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
};
function sendJson(res, code, payload) {
const body = JSON.stringify(payload);
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
res.end(body);
}
async function readBody(req, limit = 8 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new Error('payload too large');
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}
function serveFile(res, absolutePath) {
const ext = path.extname(absolutePath).toLowerCase();
const stream = createReadStream(absolutePath);
stream.once('open', () => {
res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream' });
stream.pipe(res);
});
stream.once('error', (error) => {
if (res.headersSent) {
res.destroy(error);
return;
}
if (error && (error.code === 'ENOENT' || error.code === 'EISDIR')) {
sendJson(res, 404, { error: 'not found' });
return;
}
sendJson(res, 500, { error: 'failed to read file' });
});
}
function hasTrustedOrigin(req) {
const origin = req.headers.origin;
if (!origin) return true;
return origin === `http://${req.headers.host}`;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
return serveFile(res, path.join(publicDir, 'index.html'));
}
if (req.method === 'GET' && url.pathname === '/pets.json') {
return sendJson(res, 200, manifest);
}
if (req.method === 'GET' && url.pathname === '/api/meta') {
return sendJson(res, 200, { ...manifest, version: 1 });
}
if (req.method === 'GET' && url.pathname === '/api/state') {
return sendJson(res, 200, state);
}
if (req.method === 'POST' && url.pathname === '/api/state') {
if (!hasTrustedOrigin(req)) {
return sendJson(res, 403, { error: 'forbidden origin' });
}
const body = JSON.parse((await readBody(req)) || '{}');
const ok = setState(body.state, body.note);
if (!ok) {
return sendJson(res, 400, { error: 'unknown state', states: stateNames });
}
return sendJson(res, 200, state);
}
if (req.method === 'GET' && url.pathname === '/api/events') {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
});
res.write(`data: ${JSON.stringify({ type: 'state', ...state })}\n\n`);
sseClients.set(res, true);
req.on('close', () => sseClients.delete(res));
return;
}
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
const relative = path.normalize(decodeURIComponent(url.pathname.slice('/assets/'.length)));
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return sendJson(res, 403, { error: 'forbidden' });
}
const absolute = path.join(assetsDir, relative);
if (!absolute.startsWith(assetsDir)) return sendJson(res, 403, { error: 'forbidden' });
return serveFile(res, absolute);
}
sendJson(res, 404, { error: 'not found' });
} catch (error) {
sendJson(res, 500, { error: String((error && error.message) || error) });
}
});
export async function start({ port = 0 } = {}) {
await mkdir(runtimeDir, { recursive: true });
await new Promise((resolve) => {
server.listen(port, '127.0.0.1', resolve);
});
const address = server.address();
const actualPort = typeof address === 'object' ? address.port : port;
await writeFile(runtimeFile, `${JSON.stringify({ port: actualPort, pid: process.pid, startedAt: new Date().toISOString() })}\n`);
server.on('close', () => {
readFile(runtimeFile, 'utf8')
.then((text) => {
const info = JSON.parse(text);
if (info.pid === process.pid) {
return writeFile(runtimeFile, '');
}
return undefined;
})
.catch(() => {});
});
return { port: actualPort, url: `http://127.0.0.1:${actualPort}/` };
}