1
0
mirror of https://github.com/morris/vanilla-todo.git synced 2025-01-18 05:08:18 +01:00
vanilla-todo/dev/server.mjs
2023-11-19 14:48:31 +01:00

154 lines
3.4 KiB
JavaScript

/* eslint-env node */
/* eslint-disable no-console */
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as http from 'http';
import mime from 'mime';
import * as path from 'path';
import WebSocket, { WebSocketServer } from 'ws';
// Options
const argv = process.argv.slice(2);
const webroot = path.resolve(argv.shift());
const port = parseInt(process.env.PORT, 10) || 8080;
// File transforms
const clientJS = fs.readFileSync(
import.meta.resolve('./client.js').replace(/^file:\/\//, ''),
);
function transformFileContents(file, contents) {
const ext = path.extname(file);
if (ext === '.html') {
return contents
.toString()
.replace(
'</title>',
`</title> <script type="module">${clientJS}</script>`,
);
}
return contents;
}
// Static file resolution
const fileCache = new Map();
async function readFileCached(file) {
const cached = fileCache.get(file);
if (cached) return cached;
const promise = readFile(file);
fileCache.set(file, promise);
return promise;
}
async function readFile(file) {
const stat = await fs.promises.lstat(file);
if (stat.isDirectory()) {
file = path.join(file, 'index.html');
}
const contents = transformFileContents(
file,
await fs.promises.readFile(file),
);
const contentType = mime.getType(file) ?? 'application/octet-stream';
const version = crypto.createHash('sha1').update(contents).digest('base64');
return { contents, contentType, version };
}
function invalidateFile(file) {
fileCache.delete(file);
}
// HTTP server
const server = http.createServer(async (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('content-type', 'text/plain');
res.writeHead(405);
res.end('405 Method not allowed');
return;
}
const url = new URL(req.url, `http://localhost:${port}`);
try {
let file = path.join(webroot, path.resolve('.', url.pathname));
let { contents, contentType, version } = await readFileCached(file);
if (req.headers['if-none-match'] === version) {
res.writeHead(304);
res.end();
return;
}
res.setHeader('content-type', contentType);
res.setHeader('etag', version);
res.writeHead(200);
if (req.method === 'HEAD') {
res.end();
} else {
res.end(contents);
}
} catch (err) {
if (err.code === 'ENOENT') {
res.setHeader('content-type', 'text/plain');
res.writeHead(404);
res.end('404 Not found');
} else {
console.error(err);
res.setHeader('content-type', 'text/plain');
res.writeHead(500);
res.end(`500 Internal server error: ${err.message}`);
}
}
});
server.on('listening', () => {
console.log(`Serving ${webroot} on port ${server.address().port}`);
});
server.listen(port);
// WebSocket server
const wsClients = new Set();
const wsServer = new WebSocketServer({ server });
wsServer.on('connection', (client) => {
wsClients.add(client);
});
function broadcast(message) {
for (const wsClient of wsClients) {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(message));
} else {
wsClients.delete(wsClient);
wsClient.terminate();
}
}
}
// File watcher
const fileWatcher = fs.watch(webroot, { recursive: true });
fileWatcher.on('change', (_, filename) => {
invalidateFile(path.join(webroot, filename));
broadcast({ type: 'modified', url: filename });
});