srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/serve.mjs
blob: 8f5644e055c24439d4eddf996593557def5eaba9 (plain) (blame)
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}`);
});