import http from 'node:http'; import fs from 'node:fs/promises'; import path from 'node:path'; import { watch } from 'node:fs'; import { loadEnvFile } from 'node:process'; import { build, root, readArticles } from './site.mjs'; import { PushService, createPushHandler } from './push.mjs'; try {loadEnvFile(path.join(root,'.env'));} catch(error) {if(error.code!=='ENOENT')throw error;} const port=Number(process.env.PORT || 3000); const siteUrl=new URL(process.env.SITE_URL || `http://localhost:${port}`).origin; if(!/^https?:/.test(siteUrl))throw new Error('SITE_URL must be an HTTP(S) URL.'); let articles=await build(); let push; if(process.env.PUSH_ENABLED!=='false') { const subject=process.env.VAPID_SUBJECT || (siteUrl.startsWith('https:')?siteUrl:'https://localhost'); push=await new PushService({directory:path.resolve(root,process.env.PUSH_DATA_DIR || '.data/push'),subject}).init(articles); } const handlePush=createPushHandler(push,{siteUrl}); let rebuilding=Promise.resolve(); let timer; let snapshot=JSON.stringify(articles); function refresh() { rebuilding=rebuilding.then(async()=>{ articles=await build();snapshot=JSON.stringify(articles); if(push)await push.sync(articles); }).catch(error=>console.error('Rebuild failed:',error.message)); return rebuilding; } for(const folder of ['pages','public'])watch(path.join(root,folder),{recursive:true},()=>{clearTimeout(timer);timer=setTimeout(refresh,200);}); // Polling also detects changes if filesystem events are missed. Delivery happens // only after a successful build, so a notification never precedes its page. let ticking=false; setInterval(async()=>{ if(ticking)return;ticking=true; try { await rebuilding; if(JSON.stringify(await readArticles())!==snapshot)await refresh(); if(push)await push.flush(); }catch(error){console.error('Notification worker:',error.message);} finally{ticking=false;} },5000).unref(); const types={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.svg':'image/svg+xml','.jpg':'image/jpeg','.png':'image/png','.webp':'image/webp','.webmanifest':'application/manifest+json'}; const server=http.createServer(async(req,res)=>{ try { const pathname=decodeURIComponent(new URL(req.url,'http://localhost').pathname); if(await handlePush(req,res,pathname))return; if(!['GET','HEAD'].includes(req.method)){res.writeHead(405);res.end();return;} await rebuilding; const base=path.join(root,'dist'); let target=path.resolve(base,'.'+pathname); if(!target.startsWith(base+path.sep)&&target!==base){res.writeHead(403);res.end();return;} if((await fs.stat(target)).isDirectory()){ if(!pathname.endsWith('/')){res.writeHead(301,{Location:pathname+'/'});res.end();return;} target=path.join(target,'index.html'); } const content=await fs.readFile(target); const headers={'Content-Type':types[path.extname(target)] || 'application/octet-stream'}; if(pathname==='/sw.js')headers['Cache-Control']='no-cache'; res.writeHead(200,headers);res.end(req.method==='HEAD'?undefined:content); }catch{ res.writeHead(404,{'Content-Type':'text/html; charset=utf-8'}); res.end(req.method==='HEAD'?undefined:await fs.readFile(path.join(root,'dist/404.html')).catch(()=>'Page not found')); } }); server.listen(port,'0.0.0.0',()=>console.log(`TrueNews → ${siteUrl}${push?' (push notifications enabled)':''}`));