Files
TrueNews/scripts/server.mjs
T

66 lines
3.4 KiB
JavaScript

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)':''}`));