← back to Rentv
fix(rentv-v1): guard non-array feed data in /api/search + /api/pulse + feeds (live crash)
7a8f755948daa8dc1dfe2cfa2253c6fc8f1b6a2a · 2026-07-24 08:31:32 -0700 · Steve Abrams
pm2 err log (rotated 2026-07-24_00-00) showed 4x TypeError: (readJSON(...)||[]).forEach
is not a function at server.js:425 — /api/search crashed whenever a cron mid-write left
news.json/deals.json/videos.json with a truthy-but-non-array .items/.deals ('x || []'
does not catch a non-array). Added asArr() = Array.isArray(x)?x:[] and applied it at every
consumption site (search x5, videos merge, pulse, feed.xml/feed.json/deals.xml) so a malformed
feed degrades to empty instead of a 500 + pm2 restart-loop. Repro'd the throw + verified the
fix; /api/search?q=office now 200s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 7a8f755948daa8dc1dfe2cfa2253c6fc8f1b6a2a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 24 08:31:32 2026 -0700
fix(rentv-v1): guard non-array feed data in /api/search + /api/pulse + feeds (live crash)
pm2 err log (rotated 2026-07-24_00-00) showed 4x TypeError: (readJSON(...)||[]).forEach
is not a function at server.js:425 — /api/search crashed whenever a cron mid-write left
news.json/deals.json/videos.json with a truthy-but-non-array .items/.deals ('x || []'
does not catch a non-array). Added asArr() = Array.isArray(x)?x:[] and applied it at every
consumption site (search x5, videos merge, pulse, feed.xml/feed.json/deals.xml) so a malformed
feed degrades to empty instead of a 500 + pm2 restart-loop. Repro'd the throw + verified the
fix; /api/search?q=office now 200s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
server.js | 27 ++++++++++++++++-----------
1 file changed, 16 insertions(+), 11 deletions(-)
diff --git a/server.js b/server.js
index 914ee7eb..03d81f2e 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,11 @@ const PORT = process.env.PORT || 9704;
const DATA = path.join(__dirname, 'data');
const PUB = path.join(__dirname, 'public');
const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
+// A cron mid-write (pull-news/pull-deals) can leave a data file whose .items/.deals is
+// transiently a non-array; `x || []` does NOT catch a truthy-non-array, so `.forEach`/.filter
+// throws and crashes the request (observed on /api/search line ~425, TypeError in the pm2 err log).
+// asArr() forces an array at every consumption site so a malformed feed degrades to empty, never a 500.
+const asArr = (x) => (Array.isArray(x) ? x : []);
// ── Basic Auth gate (unified + client logins). Health stays open for smoke-tests. ──
const CREDS = ['admin:DW2024!', 'Boomer:rentfree911', 'Daniel:Dandeana1992', 'Scott:Russia1980']
@@ -46,8 +51,8 @@ app.get('/api/videos', (_q, r) => {
const orig = readJSON('originals.json', { cat: null, items: [] });
const items = Array.isArray(orig.items) ? orig.items : [];
if (items.length && orig.cat) {
- base.items = [...items, ...(base.items || []).filter(x => !String(x.id).startsWith('orig-'))];
- base.cats = [orig.cat, ...(base.cats || []).filter(c => c !== orig.cat)];
+ base.items = [...items, ...asArr(base.items).filter(x => !String(x.id).startsWith('orig-'))];
+ base.cats = [orig.cat, ...asArr(base.cats).filter(c => c !== orig.cat)];
base.count = base.items.length;
}
r.json(base);
@@ -422,29 +427,29 @@ app.get('/api/search', (req, res) => {
const push = (type, row) => { (out[type] = out[type] || []).push(row); };
// News
- ((readJSON('news.json', {}).items) || []).forEach((n) => {
+ asArr(readJSON('news.json', {}).items).forEach((n) => {
const blob = [n.title, n.cat].join(' ');
if (hit(blob)) push('News', { title: n.title, sub: n.cat || 'News', href: '/article.html?id=' + encodeURIComponent(n.id), image: n.image || '', s: score(blob) + 1 });
});
// Deals
- ((readJSON('deals.json', {}).deals) || []).forEach((d) => {
+ asArr(readJSON('deals.json', {}).deals).forEach((d) => {
const blob = [d.title, d.city, d.state, d.property_type, d.txn_type, d.amount_label].join(' ');
if (hit(blob)) push('Deals', { title: d.title, sub: [d.property_type, [d.city, d.state].filter(Boolean).join(', '), d.amount_label].filter(Boolean).join(' · '), href: '/deals', image: d.image || '', s: score(blob) });
});
// Videos
- ((readJSON('videos.json', {}).items) || []).forEach((v) => {
+ asArr(readJSON('videos.json', {}).items).forEach((v) => {
const blob = [v.title, v.desc, v.cat].join(' ');
if (hit(blob)) push('Videos', { title: v.title, sub: v.cat || 'Video', href: v.view_url || v.embed || '#', image: v.thumb || '', s: score(blob) });
});
// Editorial posts
- (readJSON('posts.json', []) || []).forEach((p) => {
+ asArr(readJSON('posts.json', [])).forEach((p) => {
if (p.status && p.status !== 'published' && p.status !== 'live') return;
const blob = [p.title, p.dek, p.author, p.cat].join(' ');
if (hit(blob)) push('The REview', { title: p.title, sub: [p.cat, p.author].filter(Boolean).join(' · '), href: '/post.html?id=' + encodeURIComponent(p.id), image: p.image || '', s: score(blob) });
});
// LA Commercial firms
const la = readJSON('la-commercial.json', {}) || {};
- (la.firms || []).forEach((f) => {
+ asArr(la.firms).forEach((f) => {
const name = f.name || f.firm || f;
const blob = [name, f.focus, f.city].join(' ');
if (hit(blob)) push('LA Commercial', { title: name, sub: [f.focus, f.city].filter(Boolean).join(' · ') || 'Firm', href: '/la-commercial', image: '', s: score(blob) });
@@ -462,7 +467,7 @@ app.get('/api/search', (req, res) => {
// ── CRE Market Pulse — analytics layer over the tracked deals feed. ──
app.get('/api/pulse', (_req, res) => {
const file = readJSON('deals.json', {});
- const deals = file.deals || [];
+ const deals = asArr(file.deals);
res.set('Cache-Control', 'public, max-age=300');
const withAmt = deals.filter((d) => typeof d.amount === 'number' && d.amount > 0);
const fmt = (n) => (n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' : n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? '$' + Math.round(n / 1e3) + 'K' : '$' + Math.round(n));
@@ -496,7 +501,7 @@ const baseUrl = (req) => `${req.headers['x-forwarded-proto'] || req.protocol}://
app.get('/feed.xml', (req, res) => {
const B = baseUrl(req);
- const items = (readJSON('news.json', {}).items || []).slice(0, 40);
+ const items = asArr(readJSON('news.json', {}).items).slice(0, 40);
const body = items.map((n) => {
const link = `${B}/article.html?id=${encodeURIComponent(n.id)}`;
return ` <item>\n <title>${xmlEsc(n.title)}</title>\n <link>${xmlEsc(link)}</link>\n <guid isPermaLink="false">rentv-${xmlEsc(n.id)}</guid>\n ${n.cat ? `<category>${xmlEsc(n.cat)}</category>` : ''}\n </item>`;
@@ -506,7 +511,7 @@ app.get('/feed.xml', (req, res) => {
app.get('/feed.json', (req, res) => {
const B = baseUrl(req);
- const items = (readJSON('news.json', {}).items || []).slice(0, 40).map((n) => ({
+ const items = asArr(readJSON('news.json', {}).items).slice(0, 40).map((n) => ({
id: `rentv-${n.id}`,
title: n.title,
url: `${B}/article.html?id=${encodeURIComponent(n.id)}`,
@@ -524,7 +529,7 @@ app.get('/feed.json', (req, res) => {
app.get('/deals.xml', (req, res) => {
const B = baseUrl(req);
- const items = (readJSON('deals.json', {}).deals || []).slice(0, 40);
+ const items = asArr(readJSON('deals.json', {}).deals).slice(0, 40);
const body = items.map((d) => {
const desc = [d.property_type, [d.city, d.state].filter(Boolean).join(', '), d.amount_label].filter(Boolean).join(' · ');
return ` <item>\n <title>${xmlEsc(d.title)}</title>\n <link>${xmlEsc(B + '/deals')}</link>\n <guid isPermaLink="false">rentv-deal-${xmlEsc(d.id)}</guid>\n <description>${xmlEsc(desc)}</description>\n ${d.property_type ? `<category>${xmlEsc(d.property_type)}</category>` : ''}\n </item>`;
← 9ebf0cf9 auto-save: 2026-07-24T08:26:42 (3 files) — data/markets.json
·
back to Rentv
·
fix(rentv-v1): stop review.html linking out to rentvreview.c fcf02505 →