Files
makelore/electron/services/static-release-server.ts

183 lines
6.4 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import { createServer, type ServerResponse } from 'node:http';
import type { Socket } from 'node:net';
import { extname } from 'node:path';
const MIME_TYPES: Readonly<Record<string, string>> = {
'.avif': 'image/avif',
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.mjs': 'text/javascript; charset=utf-8',
'.otf': 'font/otf',
'.ogg': 'audio/ogg',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.wav': 'audio/wav',
};
const MAX_FILES = 2_000;
const MAX_BYTES = 50 * 1024 * 1024;
declare const staticArtifactSnapshotBrand: unique symbol;
export interface StaticArtifactSnapshot {
readonly [staticArtifactSnapshotBrand]: true;
}
export interface StaticArtifactFile {
readonly path: string;
readonly bytes: Buffer;
}
export interface StaticReleaseServer {
readonly entryUrl: string;
close(): Promise<void>;
}
const snapshotFiles = new WeakMap<StaticArtifactSnapshot, ReadonlyMap<string, Buffer>>();
export function createStaticArtifactSnapshot(
files: readonly StaticArtifactFile[],
): StaticArtifactSnapshot {
if (!Array.isArray(files) || files.length === 0 || files.length > MAX_FILES) {
throw new Error('Static artifact file set is invalid.');
}
const owned = new Map<string, Buffer>();
const destinationKeys = new Set<string>();
let totalBytes = 0;
for (const file of files) {
if (!file || !isSafeArtifactPath(file.path) || !Buffer.isBuffer(file.bytes)) {
throw new Error('Static artifact file is invalid.');
}
const destinationKey = file.path.toLowerCase();
if (destinationKeys.has(destinationKey)) {
throw new Error('Static artifact paths must be unique.');
}
destinationKeys.add(destinationKey);
const bytes = Buffer.from(file.bytes);
totalBytes += bytes.length;
if (totalBytes > MAX_BYTES) throw new Error('Static artifact is too large.');
owned.set(file.path, bytes);
}
const entry = owned.get('index.html');
if (!entry?.length) throw new Error('Static artifact entry is missing.');
const handle = Object.create(null) as StaticArtifactSnapshot;
Object.defineProperty(handle, 'toJSON', {
value: () => { throw new Error('Static artifact snapshots are Main-owned and cannot be serialized.'); },
});
Object.freeze(handle);
snapshotFiles.set(handle, owned);
return handle;
}
export function staticArtifactSnapshotFiles(
snapshot: StaticArtifactSnapshot,
): readonly StaticArtifactFile[] {
const files = snapshotFiles.get(snapshot);
if (!files) throw new Error('Static artifact snapshot must be Main-owned.');
return Array.from(files, ([path, bytes]) => ({ path, bytes: Buffer.from(bytes) }));
}
export async function startStaticReleaseServer(
snapshot: StaticArtifactSnapshot,
): Promise<StaticReleaseServer> {
const files = snapshotFiles.get(snapshot);
if (!files) throw new Error('Static artifact snapshot must be Main-owned.');
const nonce = randomBytes(24).toString('hex');
const sockets = new Set<Socket>();
const server = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.setHeader('Allow', 'GET, HEAD');
sendEmpty(response, 405);
return;
}
try {
const rawUrl = request.url ?? '';
if (rawUrl.includes('\0') || rawUrl.includes('\\')) throw new Error('unsafe');
const url = new URL(rawUrl, 'http://127.0.0.1');
const prefix = `/${nonce}/`;
if (!url.pathname.startsWith(prefix)) throw new Error('outside');
const rawPath = url.pathname.slice(prefix.length);
if (!rawPath || rawPath.endsWith('/')) throw new Error('directory');
let decoded: string;
try {
decoded = decodeURIComponent(rawPath);
} catch {
throw new Error('encoding');
}
if (!isSafeArtifactPath(decoded)) throw new Error('unsafe');
const bytes = files.get(decoded);
if (!bytes) throw new Error('missing');
response.statusCode = 200;
response.setHeader('Content-Type', MIME_TYPES[extname(decoded).toLowerCase()] ?? 'application/octet-stream');
response.setHeader('Content-Length', String(bytes.length));
response.end(request.method === 'HEAD' ? undefined : bytes);
} catch {
sendEmpty(response, 404);
}
});
server.on('connection', (socket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
try {
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject);
resolveListen();
});
});
} catch (error) {
for (const socket of sockets) socket.destroy();
throw error;
}
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Static artifact server did not bind TCP.');
let closed: Promise<void> | null = null;
return {
entryUrl: `http://127.0.0.1:${address.port}/${nonce}/index.html`,
close: () => {
closed ??= new Promise<void>((resolveClose) => {
server.close(() => resolveClose());
server.closeAllConnections?.();
for (const socket of sockets) socket.destroy();
setTimeout(resolveClose, 1_000).unref?.();
});
return closed;
},
};
}
function isSafeArtifactPath(path: string): boolean {
if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false;
const parts = path.split('/');
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return false;
return !parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));
}
function sendEmpty(response: ServerResponse, status: number): void {
if (response.headersSent) return;
response.statusCode = status;
response.setHeader('Content-Length', '0');
response.end();
}