#!/usr/bin/env node const base = process.argv[2]; if (!base) { console.error('usage: node alt/compare.mjs '); process.exit(2); } const alt = (process.env.ALT_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); const video = process.env.VIDEO_ID || 'dQw4w9WgXcQ'; const generation = process.env.MEDIA_GEN || '1'; const query = process.env.SEARCH_QUERY || 'worship'; const paths = [ ['search', `/api/search?q=${encodeURIComponent(query)}`], ['streams', `/api/streams?v=${encodeURIComponent(video)}`], ['download-prepare', `/api/download/${encodeURIComponent(video)}/prepare`], ['media-range', `/api/media/${encodeURIComponent(video)}?g=${encodeURIComponent(generation)}`], ]; function jsonPaths(value, prefix = '$', out = []) { if (Array.isArray(value)) { out.push(`${prefix}[]`); if (value.length) jsonPaths(value[0], `${prefix}[]`, out); } else if (value && typeof value === 'object') for (const key of Object.keys(value).sort()) { out.push(`${prefix}.${key}`); jsonPaths(value[key], `${prefix}.${key}`, out); } return out.sort(); } async function request(url, range = false) { const headers = range ? { Range: 'bytes=0-1023' } : {}; const response = await fetch(url, { headers, signal: AbortSignal.timeout(Number(process.env.COMPARE_TIMEOUT_MS || 30000)) }); const type = response.headers.get('content-type') || ''; const body = type.includes('json') ? await response.json() : await response.arrayBuffer(); return { status: response.status, headers: Object.fromEntries(['content-type','cache-control','accept-ranges','content-range','etag'].map(k => [k,response.headers.get(k)])), body }; } let failed = false; for (const [name, path] of paths) { try { const [a,b] = await Promise.all([request(base.replace(/\/$/,'')+path,name==='media-range'),request(alt+path,name==='media-range')]); const isJson = typeof a.body === 'object' && !ArrayBuffer.isView(a.body) && !(a.body instanceof ArrayBuffer); const result = { name, path, bun: {status:a.status,headers:a.headers}, swoole:{status:b.status,headers:b.headers} }; if (isJson) { result.bun.shape=jsonPaths(a.body); result.swoole.shape=jsonPaths(b.body); result.shapeMatches=JSON.stringify(result.bun.shape)===JSON.stringify(result.swoole.shape); } const headerKeys=['content-type','cache-control','accept-ranges','content-range','etag']; const norm=(key,value)=>value===null?null:(key==='content-type'?value.toLowerCase().replace(/;\s*charset=utf-8/i,'; charset=utf-8'):value.trim()); result.headerMatches=headerKeys.filter((key)=>norm(key,a.headers[key])===norm(key,b.headers[key])); result.headerDiffs=headerKeys.filter((key)=>norm(key,a.headers[key])!==norm(key,b.headers[key])).map((key)=>({key,bun:a.headers[key] ?? null,swoole:b.headers[key] ?? null})); if(name==='media-range') result.rangeBytes={bun:a.body.byteLength,swoole:b.body.byteLength}; result.statusMatches=a.status===b.status; console.log(JSON.stringify(result)); if(!result.statusMatches || (result.shapeMatches===false) || result.headerDiffs.length)failed=true; } catch(error) { failed=true; console.error(JSON.stringify({name,path,error:String(error)})); } } if(failed) process.exitCode=1;