← back to New Engine
Add search + vendor filter, sort, since-date filter, and density slider
6df1adf145bc4bd88bf8086de546bed321b7abb1 · 2026-07-28 14:48:17 -0700 · Steve Abrams
- server: fetchItems() with safe filters (sq-escaped q/vendor, whitelisted sort/since) + /api/facets vendor list
- front-end: persisted controls (localStorage), density switches list<->multi-col image grid
- injection-guarded, verified across all filters
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 6df1adf145bc4bd88bf8086de546bed321b7abb1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 14:48:17 2026 -0700
Add search + vendor filter, sort, since-date filter, and density slider
- server: fetchItems() with safe filters (sq-escaped q/vendor, whitelisted sort/since) + /api/facets vendor list
- front-end: persisted controls (localStorage), density switches list<->multi-col image grid
- injection-guarded, verified across all filters
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/index.html | 186 ++++++++++++++++++++++++++++++++++++++++++------------
server.js | 79 +++++++++++++++++++----
2 files changed, 215 insertions(+), 50 deletions(-)
diff --git a/public/index.html b/public/index.html
index 3854a3b..a797f00 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,14 +7,14 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
-:root{--gold:#c8a96a;--gold-lit:#e6c98f;--ink:#e8e6e0;--muted:#8f8c84;--green:#5fbf6a}
+:root{--gold:#c8a96a;--gold-lit:#e6c98f;--ink:#e8e6e0;--muted:#8f8c84;--green:#5fbf6a;--cols:1}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Jost',sans-serif;color:var(--ink);
background:radial-gradient(120% 80% at 50% -10%,#22201d,#131211 45%,#070605);min-height:100vh;padding:22px 14px 60px;-webkit-font-smoothing:antialiased}
-.wrap{max-width:1080px;margin:0 auto}
+.wrap{max-width:1180px;margin:0 auto}
header{display:flex;align-items:center;justify-content:space-between;gap:14px;
background:linear-gradient(180deg,#1b1a17,#0d0c0b);border:1px solid #2b2925;border-radius:16px;
- padding:18px 24px;box-shadow:inset 0 2px 0 rgba(255,255,255,.04),0 20px 50px rgba(0,0,0,.7);position:sticky;top:10px;z-index:5}
+ padding:18px 24px;box-shadow:inset 0 2px 0 rgba(255,255,255,.04),0 20px 50px rgba(0,0,0,.7);position:sticky;top:10px;z-index:6}
.brand{display:flex;align-items:center;gap:12px}
.brand .h1{font-family:'Cormorant Garamond',serif;font-size:26px;font-weight:600;letter-spacing:.5px;
background:linear-gradient(180deg,#f4efe4 0%,#c8a96a 55%,#8a6f3e);-webkit-background-clip:text;background-clip:text;color:var(--gold-lit)}
@@ -25,8 +25,24 @@ header{display:flex;align-items:center;justify-content:space-between;gap:14px;
.stats{text-align:right;font-family:'Share Tech Mono',monospace}
.stats .big{font-size:26px;color:var(--gold-lit);text-shadow:0 0 12px rgba(200,169,106,.4);line-height:1}
.stats .lbl{font-size:9px;letter-spacing:3px;color:var(--muted)}
-.subline{text-align:center;font-size:10px;letter-spacing:4px;color:#7d7a72;margin:14px 0 16px}
-.list{display:flex;flex-direction:column;gap:9px}
+/* controls */
+.controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:14px 0 8px;
+ background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:14px;padding:12px 16px;position:sticky;top:96px;z-index:5}
+.controls input[type=search],.controls select{font-family:'Jost',sans-serif;font-size:13px;color:var(--ink);
+ background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;outline:none}
+.controls input[type=search]{flex:1 1 220px;min-width:160px}
+.controls input[type=search]:focus,.controls select:focus{border-color:var(--gold)}
+.controls select{cursor:pointer}
+.fld{display:flex;align-items:center;gap:6px}
+.fld label{font-size:9px;letter-spacing:2px;color:var(--muted);text-transform:uppercase}
+.dens{display:flex;align-items:center;gap:8px}
+.dens input[type=range]{width:96px;accent-color:var(--gold);cursor:pointer}
+.btn{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1px;color:var(--muted);
+ background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;cursor:pointer}
+.btn:hover{color:var(--gold-lit);border-color:var(--gold)}
+.subline{text-align:center;font-size:10px;letter-spacing:4px;color:#7d7a72;margin:6px 0 16px}
+/* list / grid */
+.list{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:9px}
.row{display:grid;grid-template-columns:30px 56px 1fr 200px;gap:13px;align-items:center;
background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:12px;
padding:11px 14px;box-shadow:0 5px 14px rgba(0,0,0,.45);transition:box-shadow .15s}
@@ -48,9 +64,22 @@ header{display:flex;align-items:center;justify-content:space-between;gap:14px;
.pal i span{font-family:'Share Tech Mono',monospace;font-size:7px;color:rgba(255,255,255,.9);text-shadow:0 1px 2px #000;opacity:0;padding-bottom:1px}
.pal:hover i span{opacity:1}
.gidl{font-family:'Share Tech Mono',monospace;font-size:9px;color:#5f5c54;letter-spacing:1px}
-.hint{text-align:center;color:#5f5c54;font-size:11px;margin-top:20px;letter-spacing:2px}
a.open{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--gold-lit);text-decoration:none;letter-spacing:1px}
a.open:hover{text-shadow:0 0 8px rgba(200,169,106,.6)}
+/* compact card (density >= 2) */
+.card{background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:12px;overflow:hidden;
+ box-shadow:0 5px 14px rgba(0,0,0,.45);transition:box-shadow .15s}
+.card:hover{box-shadow:0 8px 20px rgba(0,0,0,.7),0 0 0 1px rgba(200,169,106,.3)}
+.card.fresh{animation:land .8s ease}
+.card a.ph{display:block;aspect-ratio:1/1;background:#201f1c}
+.card img{width:100%;height:100%;object-fit:cover;display:block}
+.card .cbody{padding:8px 10px 10px}
+.card .ct{font-family:'Cormorant Garamond',serif;font-size:14px;line-height:1.12;max-height:2.3em;overflow:hidden}
+.card .ct a{color:var(--ink);text-decoration:none}
+.card .ct a:hover{color:var(--gold-lit)}
+.card .cmeta{font-size:9px;letter-spacing:.5px;color:var(--muted);text-transform:uppercase;margin:3px 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.card .pal{height:16px;border-radius:5px}
+.hint{text-align:center;color:#5f5c54;font-size:11px;margin-top:20px;letter-spacing:2px}
</style>
</head>
<body>
@@ -62,50 +91,129 @@ a.open:hover{text-shadow:0 0 8px rgba(200,169,106,.6)}
<div class="sub-h">Designer Wallcoverings</div>
<div class="live"><span class="dot"></span><span id="clock">— live catalog feed —</span></div></div>
</div>
- <div class="stats"><div class="big" id="total">0</div><div class="lbl">ACTIVE SKUS</div></div>
+ <div class="stats"><div class="big" id="total">0</div><div class="lbl" id="totlbl">MATCHES</div></div>
</header>
- <div class="subline">N E W E S T P R O D U C T S · N E W E S T F I R S T · A U T O - R E F R E S H</div>
+
+ <div class="controls">
+ <input type="search" id="q" placeholder="Search title, vendor, or SKU…" autocomplete="off">
+ <div class="fld"><label>Vendor</label>
+ <select id="vendor"><option value="">All</option></select></div>
+ <div class="fld"><label>Sort</label>
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="vendor">Vendor A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="title">Title A→Z</option>
+ </select></div>
+ <div class="fld"><label>Since</label>
+ <select id="since">
+ <option value="all">All time</option>
+ <option value="today">Today</option>
+ <option value="7d">7 days</option>
+ <option value="30d">30 days</option>
+ </select></div>
+ <div class="dens"><label style="font-size:9px;letter-spacing:2px;color:var(--muted)">DENSITY</label>
+ <input type="range" id="dens" min="1" max="6" step="1" value="1"></div>
+ <button class="btn" id="reset">RESET</button>
+ </div>
+
+ <div class="subline" id="subline">N E W E S T F I R S T · L I V E · A U T O - R E F R E S H</div>
<div class="list" id="list"></div>
<div class="hint" id="hint">connecting…</div>
</div>
<script>
-let seen=new Set(), first=true;
+const $ = id => document.getElementById(id);
+const LS = 'newengine.v1';
+const state = Object.assign({ q:'', vendor:'', sort:'newest', since:'all', cols:1 },
+ JSON.parse(localStorage.getItem(LS) || '{}'));
+let seen = new Set(), first = true;
+function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
-// route product images through the same-origin proxy so a dead CDN asset degrades to a
-// transparent placeholder instead of a browser 404
function proxied(url){ return url ? '/img?u='+encodeURIComponent(url) : ''; }
+function palHTML(p, big){ const a=p.palette||[];
+ return a.length ? a.map(c=>`<i style="flex:${c.pct||1};background:${esc(c.hex)}">${big?`<span>${esc(c.hex)}</span>`:''}</i>`).join('') : '<i style="flex:1;background:#201f1c"></i>'; }
+function href(p){ return p.handle ? `https://designer-laboratory-sandbox.myshopify.com/products/${encodeURIComponent(p.handle)}` : null; }
+
+// hydrate controls from persisted state
+$('q').value=state.q; $('vendor').value=state.vendor; $('sort').value=state.sort;
+$('since').value=state.since; $('dens').value=state.cols;
+document.documentElement.style.setProperty('--cols', state.cols);
+
+function qs(){ const p=new URLSearchParams({limit:'60', sort:state.sort, since:state.since});
+ if(state.q) p.set('q',state.q); if(state.vendor) p.set('vendor',state.vendor); return p.toString(); }
+
+function renderRow(p,i,isNew){
+ const h=href(p), title=h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title);
+ const img=proxied(p.image), thumb=img?`<img class="thumb" src="${img}" loading="lazy" alt="">`:`<div class="thumb"></div>`;
+ const chips=(p.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join('');
+ const el=document.createElement('div'); el.className='row'+(isNew?' fresh':'');
+ el.innerHTML=`<div class="idx ${i<3?'top':''}">${i+1}</div>
+ ${h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${thumb}</a>`:thumb}
+ <div class="main"><div class="t">${title}</div>
+ <div class="meta">${esc(p.vendor)||'—'} · ${esc(p.sku)||'no sku'} · ${(p.palette||[]).length} colors</div>
+ <div class="chips">${chips}</div></div>
+ <div class="right"><div class="pal">${palHTML(p,true)}</div>
+ <div class="gidl">#${esc(p.gid)}</div>
+ ${h?`<a class="open" href="${h}" target="_blank" rel="noopener noreferrer">OPEN PAGE ▸</a>`:''}</div>`;
+ return el;
+}
+function renderCard(p,isNew){
+ const h=href(p), img=proxied(p.image);
+ const ph=img?`<img src="${img}" loading="lazy" alt="">`:'';
+ const el=document.createElement('div'); el.className='card'+(isNew?' fresh':'');
+ el.innerHTML=`${h?`<a class="ph" href="${h}" target="_blank" rel="noopener noreferrer">${ph}</a>`:`<div class="ph">${ph}</div>`}
+ <div class="cbody">
+ <div class="ct">${h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title)}</div>
+ <div class="cmeta">${esc(p.vendor)||'—'} · ${esc(p.sku)||''}</div>
+ <div class="pal">${palHTML(p,false)}</div>
+ </div>`;
+ return el;
+}
+function paint(records){
+ const list=$('list'); list.innerHTML='';
+ const compact = state.cols>1;
+ records.forEach((p,i)=>{
+ const isNew=!seen.has(p.gid)&&!first;
+ list.appendChild(compact?renderCard(p,isNew):renderRow(p,i,isNew));
+ });
+ seen=new Set(records.map(p=>p.gid)); first=false;
+}
+let lastRecords=[];
async function tick(){
try{
- const r=await fetch('/api/new-items?limit=60',{cache:'no-store'});
+ const r=await fetch('/api/new-items?'+qs(),{cache:'no-store'});
const d=await r.json();
- document.getElementById('total').textContent=(d.total||0).toLocaleString();
- document.getElementById('clock').textContent='updated '+new Date().toLocaleTimeString();
- document.getElementById('hint').textContent=d.records.length?'▲ newest at top · live feed':'waiting for items to land…';
- const list=document.getElementById('list'); list.innerHTML='';
- d.records.forEach((p,i)=>{
- const isNew=!seen.has(p.gid)&&!first;
- const pal=(p.palette&&p.palette.length)?p.palette.map(c=>`<i style="flex:${c.pct||1};background:${esc(c.hex)}"><span>${esc(c.hex)}</span></i>`).join(''):'<i style="flex:1;background:#201f1c"></i>';
- const href=p.handle?`https://designer-laboratory-sandbox.myshopify.com/products/${encodeURIComponent(p.handle)}`:null;
- const title=href?`<a href="${href}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title);
- const chips=(p.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join('');
- const img=proxied(p.image);
- const thumb=img?`<img class="thumb" src="${img}" loading="lazy" alt="">`:`<div class="thumb"></div>`;
- const row=document.createElement('div');
- row.className='row'+(isNew?' fresh':'');
- row.innerHTML=`<div class="idx ${i<3?'top':''}">${i+1}</div>
- ${href?`<a href="${href}" target="_blank" rel="noopener noreferrer">${thumb}</a>`:thumb}
- <div class="main"><div class="t">${title}</div>
- <div class="meta">${esc(p.vendor)||'—'} · ${esc(p.sku)||'no sku'} · ${(p.palette||[]).length} colors</div>
- <div class="chips">${chips}</div></div>
- <div class="right"><div class="pal">${pal}</div>
- <div class="gidl">#${esc(p.gid)}</div>
- ${href?`<a class="open" href="${href}" target="_blank" rel="noopener noreferrer">OPEN PAGE ▸</a>`:''}</div>`;
- list.appendChild(row);
- });
- seen=new Set(d.records.map(p=>p.gid)); first=false;
- }catch(e){document.getElementById('hint').textContent='feed error: '+e.message;}
+ lastRecords=d.records||[];
+ $('total').textContent=(d.total||0).toLocaleString();
+ $('totlbl').textContent=(state.q||state.vendor||state.since!=='all')?'MATCHES':'ACTIVE SKUS';
+ $('clock').textContent='updated '+new Date().toLocaleTimeString();
+ $('hint').textContent=lastRecords.length?`▲ ${state.sort==='newest'?'newest at top':'sorted by '+state.sort} · ${lastRecords.length} shown`:'no matches — loosen the filters';
+ paint(lastRecords);
+ }catch(e){ $('hint').textContent='feed error: '+e.message; }
}
-tick(); setInterval(tick,5000);
+// density is a client-only relayout — no refetch
+function applyDensity(){ document.documentElement.style.setProperty('--cols', state.cols); first=true; paint(lastRecords); }
+
+// wire controls
+let deb; $('q').addEventListener('input',e=>{ state.q=e.target.value.trim(); save();
+ clearTimeout(deb); deb=setTimeout(()=>{ first=true; tick(); },350); });
+['vendor','sort','since'].forEach(id=>$(id).addEventListener('change',e=>{
+ state[id]=e.target.value; save(); first=true; tick(); }));
+$('dens').addEventListener('input',e=>{ state.cols=+e.target.value; save(); applyDensity(); });
+$('reset').addEventListener('click',()=>{
+ Object.assign(state,{q:'',vendor:'',sort:'newest',since:'all'});
+ $('q').value=''; $('vendor').value=''; $('sort').value='newest'; $('since').value='all';
+ save(); first=true; tick(); });
+
+// load vendor facets once
+fetch('/api/facets',{cache:'no-store'}).then(r=>r.json()).then(d=>{
+ const sel=$('vendor'); (d.vendors||[]).forEach(v=>{
+ const o=document.createElement('option'); o.value=v.vendor; o.textContent=`${v.vendor} (${v.n})`; sel.appendChild(o); });
+ sel.value=state.vendor; // restore selection now that options exist
+}).catch(()=>{});
+
+tick(); setInterval(tick,6000);
</script>
</body>
</html>
diff --git a/server.js b/server.js
index 7b33dcd..776cc98 100644
--- a/server.js
+++ b/server.js
@@ -12,14 +12,45 @@ const PGDB = process.env.PGDATABASE || 'dw_unified';
const PGHOST = process.env.PGHOST || '/tmp';
const MIME = { '.html': 'text/html', '.json': 'application/json', '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
-// Read-only live pull of the newest ACTIVE, imaged products. `limit` is clamped to an
-// integer before interpolation (the only dynamic value in the SQL) so there's no
-// injection surface. Returns { records:[…newest-first…], total } where total is the
-// TRUE count of eligible products so the header never lies about the catalog size.
+// SQL string-literal escape: double single-quotes. PG runs with standard_conforming_strings
+// on by default, so backslashes are literal and doubling quotes fully neutralizes injection.
+const sq = s => "'" + String(s).replace(/'/g, "''") + "'";
+
+// Read-only live pull of the newest ACTIVE, imaged products, with optional filters.
+// Only these dynamic values ever touch the SQL, and each is neutralized:
+// • limit → clamped integer
+// • q / vendor → sq()-escaped string literals
+// • sort / since → whitelisted (raw user value is NEVER interpolated)
+// Returns { records:[…], total } where total is the count matching the SAME filters.
// color_enrichment is LEFT-joined — a product without a palette still shows.
-function fetchNewest(limit, cb) {
- const n = Math.max(1, Math.min(parseInt(limit, 10) || 60, 200));
- const where = `p.status = 'ACTIVE' and p.created_at_shopify is not null and p.image_url is not null`;
+function fetchItems(params, cb) {
+ const n = Math.max(1, Math.min(parseInt(params.get('limit'), 10) || 60, 200));
+ const q = (params.get('q') || '').trim().slice(0, 80);
+ const vendor = (params.get('vendor') || '').trim().slice(0, 120);
+ const sortKey = params.get('sort') || 'newest';
+ const since = params.get('since') || 'all';
+
+ const conds = [`p.status = 'ACTIVE'`, `p.created_at_shopify is not null`, `p.image_url is not null`];
+ if (q) conds.push(`(p.title ilike ${sq('%' + q + '%')} or p.vendor ilike ${sq('%' + q + '%')}
+ or coalesce(p.dw_sku,p.mfr_sku,p.variant_sku,'') ilike ${sq('%' + q + '%')})`);
+ if (vendor) conds.push(`p.vendor = ${sq(vendor)}`);
+ const sinceMap = {
+ today: `p.created_at_shopify >= date_trunc('day', now())`,
+ '7d': `p.created_at_shopify >= now() - interval '7 days'`,
+ '30d': `p.created_at_shopify >= now() - interval '30 days'`,
+ };
+ if (sinceMap[since]) conds.push(sinceMap[since]);
+ const where = conds.join(' and ');
+
+ const orderMap = {
+ newest: 'p.created_at_shopify desc',
+ vendor: `p.vendor asc nulls last, p.created_at_shopify desc`,
+ sku: `coalesce(nullif(p.dw_sku,''),nullif(p.mfr_sku,''),nullif(p.variant_sku,'')) asc nulls last`,
+ title: 'lower(p.title) asc nulls last',
+ color: `coalesce(nullif(e.color_family,''),nullif(e.dominant_color,''),'zzzz') asc, p.created_at_shopify desc`,
+ };
+ const orderBy = orderMap[sortKey] || orderMap.newest;
+
const sql = `select json_build_object(
'records', (select coalesce(json_agg(row_to_json(t)),'[]') from (
select regexp_replace(p.shopify_id,'.*/','') as gid, p.title, p.vendor, p.handle, p.image_url as image,
@@ -34,7 +65,7 @@ function fetchNewest(limit, cb) {
from shopify_products p
left join shopify_color_enrichment e on e.shopify_id = p.shopify_id
where ${where}
- order by p.created_at_shopify desc
+ order by ${orderBy}
limit ${n}) t),
'total', (select count(*) from shopify_products p where ${where})
)`;
@@ -46,6 +77,22 @@ function fetchNewest(limit, cb) {
});
}
+// Distinct vendors (with counts) among the eligible set — powers the vendor filter dropdown.
+function fetchFacets(cb) {
+ const sql = `select json_build_object(
+ 'vendors', (select coalesce(json_agg(row_to_json(t)),'[]') from (
+ select vendor, count(*) as n
+ from shopify_products
+ where status='ACTIVE' and image_url is not null and vendor is not null and vendor <> ''
+ group by vendor order by count(*) desc, vendor asc limit 250) t))`;
+ execFile('psql', ['-h', PGHOST, '-d', PGDB, '-tAc', sql], { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
+ if (err) return cb(err);
+ const out = (stdout || '').trim();
+ if (!out) return cb(new Error('empty'));
+ cb(null, out);
+ });
+}
+
// HTTP Basic Auth gate. Credentials come from BASIC_AUTH ("user:pass"), default admin:DW2024!.
// /healthz is intentionally left OPEN so the deploy smoke-test + uptime probes work unauthed.
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
@@ -67,10 +114,20 @@ http.createServer((req, res) => {
return res.end('auth required');
}
- // Live newest feed. On any DB failure, fall back to the frozen snapshot (wrapped to
- // match the {records,total} contract) so the front end always gets valid data.
+ // Vendor facets for the filter dropdown.
+ if (p === '/api/facets') {
+ fetchFacets((err, json) => {
+ if (!err && json) { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' }); return res.end(json); }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end('{"vendors":[]}');
+ });
+ return;
+ }
+
+ // Live newest feed (with filters). On any DB failure, fall back to the frozen snapshot
+ // (wrapped to match the {records,total} contract) so the front end always gets valid data.
if (p === '/api/new-items') {
- fetchNewest(u.searchParams.get('limit'), (err, json) => {
+ fetchItems(u.searchParams, (err, json) => {
if (!err && json) { res.writeHead(200, { 'Content-Type': 'application/json', 'X-Data-Source': 'live', 'Cache-Control': 'no-store' }); return res.end(json); }
fs.readFile(path.join(ROOT, 'data.json'), (e, buf) => {
if (e) { res.writeHead(502); return res.end('no live db and no snapshot'); }
← da6a927 Save prod nginx vhost (IP-specific listen 45.61.58.125:80/44
·
back to New Engine
·
deploy.conf: HEALTH_URL -> public https domain (surfaces on 40f95bb →