CI / build (push) Successful in 2m0s
Vite/React + shadcn/ui frontend with Superhuman-style keyboard nav (j/k/o/v/e/s/u, feed switching, localStorage caching for instant loads) backed by an Express API that polls the HN Firebase API into Postgres and serves cached feeds/comments. Instrumented with OpenTelemetry (traces/metrics/logs via OTLP/HTTP).
135 lines
4.5 KiB
JavaScript
135 lines
4.5 KiB
JavaScript
require('dotenv').config({ quiet: true })
|
|
require('./otel') // must load before express/pg are used, for auto-instrumentation
|
|
|
|
const path = require('path')
|
|
const express = require('express')
|
|
const cors = require('cors')
|
|
const { pool } = require('./db')
|
|
const { log } = require('./otel')
|
|
const { FEEDS, syncAll, fetchCommentsTree } = require('./hn')
|
|
|
|
const PORT = Number(process.env.PORT || 3012)
|
|
const SYNC_INTERVAL_MS = 5 * 60 * 1000
|
|
|
|
const app = express()
|
|
app.use(cors())
|
|
app.use(express.json())
|
|
|
|
app.get('/api/health', async (_req, res) => {
|
|
try {
|
|
await pool.query('SELECT 1')
|
|
res.json({ ok: true })
|
|
} catch (err) {
|
|
res.status(503).json({ ok: false, error: String(err) })
|
|
}
|
|
})
|
|
|
|
app.get('/api/feed/:feed', async (req, res) => {
|
|
const { feed } = req.params
|
|
if (!FEEDS[feed]) return res.status(404).json({ error: 'unknown feed' })
|
|
|
|
const limit = Math.min(Number(req.query.limit) || 100, 100)
|
|
const includeArchived = req.query.includeArchived === 'true'
|
|
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT s.id, s.type, s.title, s.url, s.domain, s.by_user, s.score, s.descendants,
|
|
s.story_text, s.hn_created_at, fi.rank,
|
|
COALESCE(st.read, false) AS read,
|
|
COALESCE(st.starred, false) AS starred,
|
|
COALESCE(st.archived, false) AS archived
|
|
FROM feed_items fi
|
|
JOIN stories s ON s.id = fi.story_id
|
|
LEFT JOIN story_state st ON st.story_id = s.id
|
|
WHERE fi.feed = $1 ${includeArchived ? '' : 'AND COALESCE(st.archived, false) = false'}
|
|
ORDER BY fi.rank ASC
|
|
LIMIT $2`,
|
|
[feed, limit]
|
|
)
|
|
res.json({ feed, items: rows })
|
|
} catch (err) {
|
|
log('ERROR', 'feed query failed', { feed, error: String(err) })
|
|
res.status(500).json({ error: 'internal error' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/story/:id', async (req, res) => {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT s.*, COALESCE(st.read, false) AS read,
|
|
COALESCE(st.starred, false) AS starred,
|
|
COALESCE(st.archived, false) AS archived
|
|
FROM stories s
|
|
LEFT JOIN story_state st ON st.story_id = s.id
|
|
WHERE s.id = $1`,
|
|
[id]
|
|
)
|
|
if (!rows.length) return res.status(404).json({ error: 'not found' })
|
|
res.json(rows[0])
|
|
} catch (err) {
|
|
log('ERROR', 'story query failed', { id, error: String(err) })
|
|
res.status(500).json({ error: 'internal error' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/story/:id/comments', async (req, res) => {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const comments = await fetchCommentsTree(id)
|
|
res.json({ id, comments })
|
|
} catch (err) {
|
|
log('ERROR', 'comments fetch failed', { id, error: String(err) })
|
|
res.status(502).json({ error: 'failed to fetch comments' })
|
|
}
|
|
})
|
|
|
|
app.post('/api/story/:id/state', async (req, res) => {
|
|
const id = Number(req.params.id)
|
|
const { read, starred, archived } = req.body || {}
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO story_state (story_id, read, starred, archived, read_at, updated_at)
|
|
VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false),
|
|
CASE WHEN $2 THEN now() ELSE NULL END, now())
|
|
ON CONFLICT (story_id) DO UPDATE SET
|
|
read = COALESCE($2, story_state.read),
|
|
starred = COALESCE($3, story_state.starred),
|
|
archived = COALESCE($4, story_state.archived),
|
|
read_at = CASE WHEN COALESCE($2, story_state.read) AND NOT story_state.read
|
|
THEN now() ELSE story_state.read_at END,
|
|
updated_at = now()
|
|
RETURNING *`,
|
|
[id, read, starred, archived]
|
|
)
|
|
res.json(rows[0])
|
|
} catch (err) {
|
|
log('ERROR', 'state update failed', { id, error: String(err) })
|
|
res.status(500).json({ error: 'internal error' })
|
|
}
|
|
})
|
|
|
|
app.post('/api/sync', async (_req, res) => {
|
|
syncAll().catch((err) => log('ERROR', 'manual sync failed', { error: String(err) }))
|
|
res.json({ ok: true, started: true })
|
|
})
|
|
|
|
// Serve the built frontend in production.
|
|
const distDir = path.join(__dirname, '..', 'dist')
|
|
app.use(express.static(distDir))
|
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
|
res.sendFile(path.join(distDir, 'index.html'))
|
|
})
|
|
|
|
app.listen(PORT, () => {
|
|
log('INFO', 'hn-reader-api listening', { port: PORT })
|
|
})
|
|
|
|
syncAll()
|
|
.then(() => log('INFO', 'initial sync complete'))
|
|
.catch((err) => log('ERROR', 'initial sync failed', { error: String(err) }))
|
|
|
|
setInterval(() => {
|
|
syncAll().catch((err) => log('ERROR', 'periodic sync failed', { error: String(err) }))
|
|
}, SYNC_INTERVAL_MS)
|