preflight-mime-mjs.mjs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. const sha=bytes=>crypto.createHash('sha256').update(bytes.toString('utf8').replaceAll('\r\n','\n')).digest('hex');
  6. const jsType=value=>/^(?:application|text)\/javascript$/i.test(String(value).split(';')[0].trim());
  7. const remedy='将 mjs 加入 nginx 的 JavaScript MIME 映射,确认配置后执行 nginx -t && nginx -s reload,再重新预检。';
  8. /** Read-only gate: a correct file hash cannot compensate for an unusable module Content-Type. */
  9. export async function preflightMjsMime({origin,targetDir,mimeFile='/etc/nginx/mime.types',files,fetchImpl=fetch}){
  10. const result={ok:false,readOnly:true,checkedAt:new Date().toISOString(),checks:[],modules:[],businessWrites:0};
  11. const check=(name,ok,extra={})=>result.checks.push({name,ok,...extra});
  12. try{
  13. const base=new URL(origin);
  14. if(!['http:','https:'].includes(base.protocol)||base.username||base.password||base.pathname!=='/'||base.search||base.hash)throw Error('INVALID_ORIGIN');
  15. if(!path.isAbsolute(targetDir)||await fs.realpath(targetDir)!==path.resolve(targetDir))throw Error('INVALID_TARGET_DIRECTORY');
  16. result.origin=base.origin;result.targetDir=targetDir;
  17. const text=(await fs.readFile(mimeFile,'utf8')).replace(/#[^\r\n]*/g,'');
  18. const mappings=text.split(';').map(s=>s.replace(/[{}]/g,' ').trim().split(/\s+/)).filter(words=>words.includes('mjs'));
  19. check('nginx-mjs-javascript-mapping',mappings.length===1&&mappings[0].some(jsType),{mimeFile});
  20. if(!result.checks[0].ok)throw Error('MJS_JAVASCRIPT_MAPPING_MISSING');
  21. const modules=files.filter(file=>file.path.endsWith('.mjs'));
  22. for(const file of modules){
  23. if(file.path.startsWith('/')||file.path.includes('\\')||file.path.split('/').some(p=>p==='..'||!p))throw Error('INVALID_MODULE_PATH');
  24. const local=path.resolve(targetDir,file.path);
  25. let bytes;
  26. try{bytes=await fs.readFile(local);}catch(e){if(e.code==='ENOENT'){result.modules.push({path:file.path,checked:false,reason:'not-yet-deployed'});continue;}throw e;}
  27. const url=new URL(file.path,base);url.searchParams.set('mjsPreflight',crypto.randomUUID());
  28. const response=await fetchImpl(url,{headers:{'cache-control':'no-cache'},redirect:'error',signal:AbortSignal.timeout(30000)});
  29. const contentType=response.headers.get('content-type')||'',body=Buffer.from(await response.arrayBuffer());
  30. const row={path:file.path,checked:true,status:response.status,contentType,bytesMatchTarget:sha(bytes)===sha(body),ok:response.status===200&&jsType(contentType)&&sha(bytes)===sha(body)};
  31. result.modules.push(row);check('public-mjs:'+file.path,row.ok);
  32. }
  33. result.publicModulesChecked=result.modules.filter(m=>m.checked).length;
  34. result.publicVerificationDeferred=result.modules.some(m=>!m.checked);
  35. result.ok=result.checks.every(c=>c.ok);
  36. if(!result.ok)result.errorCode='PUBLIC_MJS_NOT_LOADABLE';
  37. }catch(e){result.errorCode=/^[A-Z_]+$/.test(e.message)?e.message:'MJS_PREFLIGHT_UNAVAILABLE';}
  38. if(!result.ok)result.remedy=remedy;
  39. return result;
  40. }
  41. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  42. try{
  43. const options={origin:'https://kangshifu.fmode.cn'};const args=process.argv.slice(2);
  44. for(let i=0;i<args.length;i++){
  45. const key={'--origin':'origin','--target-dir':'targetDir','--mime-file':'mimeFile','--manifest':'manifest'}[args[i]];
  46. if(!key||!args[i+1]||args[i+1].startsWith('--'))throw Error('USAGE');options[key]=args[++i];
  47. }
  48. if(!options.targetDir)throw Error('USAGE');
  49. const manifestPath=options.manifest||'deploy/board-local-20261111.json';
  50. if(!/^deploy\/[a-zA-Z0-9_.-]+\.json$/.test(manifestPath))throw Error('USAGE');
  51. const manifest=JSON.parse(await fs.readFile(new URL('../'+manifestPath,import.meta.url)));
  52. const result=await preflightMjsMime({...options,files:manifest.files});console.log(JSON.stringify(result,null,2));if(!result.ok)process.exitCode=1;
  53. }catch{console.error(JSON.stringify({ok:false,readOnly:true,errorCode:'MJS_PREFLIGHT_STOPPED',remedy}));process.exitCode=1;}
  54. }