1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
|
#!/usr/bin/env node
// Zero-dependency static file server for the TyperPunk web app.
// Replaces the Vite dev server so the web app has no npm runtime dependency.
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)));
const PORT = Number(process.env.PORT) || 4173;
const HOST = process.env.HOST || '0.0.0.0';
// Sent with every response.
//
// The API origin is separate during development, so connect-src has to allow
// it. In production the reverse proxy makes the API same-origin and
// API_ORIGIN can be left unset, which drops the extra source.
const API_ORIGIN = process.env.API_ORIGIN || (PORT === 4173 ? 'http://localhost:8787 ws://localhost:8787' : '');
const SECURITY_HEADERS = {
// No inline script: index.html loads every script from a file so this can
// stay strict. Style needs Google Fonts' stylesheet, and the font files
// come from gstatic. The favicon is a data: URI.
'Content-Security-Policy': [
"default-src 'self'",
// wasm-unsafe-eval is required to instantiate WebAssembly. It permits
// WASM compilation only, not eval() or inline script, and the typing
// engine is a WASM module - without it nothing types at all.
"script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data:",
`connect-src 'self'${API_ORIGIN ? ' ' + API_ORIGIN : ''}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; '),
// Stops a browser second-guessing a declared Content-Type, which is how a
// served file gets treated as script.
'X-Content-Type-Options': 'nosniff',
// frame-ancestors covers this for modern browsers; the header is for the
// ones that do not implement it.
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Nothing here uses any of them.
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=(), usb=()',
};
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
async function resolveFile(urlPath) {
let decoded;
try {
decoded = decodeURIComponent(urlPath.split('?')[0]);
} catch {
return null;
}
const safePath = normalize(join(ROOT, decoded));
if (safePath !== ROOT && !safePath.startsWith(ROOT + sep)) return null;
let target = safePath;
try {
const info = await stat(target);
if (info.isDirectory()) target = join(target, 'index.html');
} catch {
return null;
}
try {
await stat(target);
return target;
} catch {
return null;
}
}
const server = createServer(async (req, res) => {
const filePath = (await resolveFile(req.url)) || (await resolveFile('/index.html'));
if (!filePath) {
res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
res.end('Not found');
return;
}
try {
const body = await readFile(filePath);
const type = MIME_TYPES[extname(filePath)] || 'application/octet-stream';
// This replaces a dev server, and the app is under active
// development - no cache headers at all left the browser free to
// hold onto old JS/CSS indefinitely with no way to tell it changed,
// which has already caused a real "why don't I see the fix" report.
res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': type, 'Cache-Control': 'no-cache' });
res.end(body);
} catch (err) {
res.writeHead(500, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
res.end('Internal server error');
console.error(err);
}
});
server.listen(PORT, HOST, () => {
console.log(`TyperPunk web running at http://localhost:${PORT}`);
});
|