在 Makelore 构建并预检静态发布产物
This commit is contained in:
182
electron/services/static-release-server.ts
Normal file
182
electron/services/static-release-server.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user