183 lines
6.4 KiB
TypeScript
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();
|
|
}
|