197 lines
5.2 KiB
JavaScript
197 lines
5.2 KiB
JavaScript
import http from 'node:http'
|
|
|
|
const port = Number(process.env.PORT || 7171)
|
|
const backendBaseUrl = (process.env.BACKEND_BASE_URL || 'http://safetwitch-backend:7000').replace(
|
|
/\/+$/,
|
|
''
|
|
)
|
|
|
|
const jsonHeaders = {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Access-Control-Allow-Origin': '*'
|
|
}
|
|
|
|
const emptyResult = () => ({
|
|
channels: [],
|
|
categories: [],
|
|
relatedChannels: [],
|
|
channelsWithTag: []
|
|
})
|
|
|
|
const hasAnyResults = (data) =>
|
|
!!data &&
|
|
(data.channels?.length ||
|
|
data.categories?.length ||
|
|
data.relatedChannels?.length ||
|
|
data.channelsWithTag?.length)
|
|
|
|
const normalize = (value) =>
|
|
String(value || '')
|
|
.toLowerCase()
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.replace(/\biv\b/g, '4')
|
|
.replace(/\biii\b/g, '3')
|
|
.replace(/\bii\b/g, '2')
|
|
.replace(/\bi\b/g, '1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
|
|
const withRomanNumerals = (value) =>
|
|
String(value || '')
|
|
.replace(/\b4\b/gi, 'IV')
|
|
.replace(/\b3\b/gi, 'III')
|
|
.replace(/\b2\b/gi, 'II')
|
|
.replace(/\b1\b/gi, 'I')
|
|
|
|
const withArabicNumerals = (value) =>
|
|
String(value || '')
|
|
.replace(/\biv\b/gi, '4')
|
|
.replace(/\biii\b/gi, '3')
|
|
.replace(/\bii\b/gi, '2')
|
|
.replace(/\bi\b/gi, '1')
|
|
|
|
const mapDiscoverCategory = (category) => ({
|
|
name: category?.name || '',
|
|
displayName: category?.displayName || category?.name || '',
|
|
viewers: Number(category?.viewers || 0),
|
|
tags: Array.isArray(category?.tags) ? category.tags : [],
|
|
createdAt: category?.createdAt,
|
|
image: category?.image || ''
|
|
})
|
|
|
|
const fetchBackend = async (path) => {
|
|
const response = await fetch(`${backendBaseUrl}${path}`, {
|
|
redirect: 'follow',
|
|
headers: {
|
|
Accept: 'application/json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Backend request failed (${response.status}) for ${path}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
const buildFallback = async (query) => {
|
|
const result = emptyResult()
|
|
|
|
try {
|
|
const channelResp = await fetchBackend(`/api/users/${encodeURIComponent(query)}`)
|
|
if (channelResp?.status === 'ok' && channelResp.data) {
|
|
result.channels.push(channelResp.data)
|
|
}
|
|
} catch {
|
|
// Ignore fallback channel lookup failures.
|
|
}
|
|
|
|
try {
|
|
const discoverResp = await fetchBackend('/api/discover')
|
|
const categories = Array.isArray(discoverResp?.data) ? discoverResp.data : []
|
|
const normalizedQuery = normalize(query)
|
|
|
|
result.categories = categories
|
|
.filter((category) => {
|
|
const displayName = normalize(category?.displayName)
|
|
const name = normalize(category?.name)
|
|
return (
|
|
displayName.includes(normalizedQuery) ||
|
|
normalizedQuery.includes(displayName) ||
|
|
name.includes(normalizedQuery) ||
|
|
normalizedQuery.includes(name)
|
|
)
|
|
})
|
|
.slice(0, 24)
|
|
} catch {
|
|
// Ignore fallback category lookup failures.
|
|
}
|
|
|
|
const categoryCandidates = [...new Set([query, withRomanNumerals(query), withArabicNumerals(query)])]
|
|
for (const candidate of categoryCandidates) {
|
|
const trimmed = candidate.trim()
|
|
if (!trimmed) continue
|
|
|
|
try {
|
|
const categoryResp = await fetchBackend(`/api/discover/${encodeURIComponent(trimmed)}`)
|
|
if (categoryResp?.status !== 'ok' || !categoryResp?.data) {
|
|
continue
|
|
}
|
|
|
|
const mapped = mapDiscoverCategory(categoryResp.data)
|
|
if (!mapped.name) {
|
|
continue
|
|
}
|
|
|
|
const alreadyIncluded = result.categories.some(
|
|
(existing) => normalize(existing.name) === normalize(mapped.name)
|
|
)
|
|
if (!alreadyIncluded) {
|
|
result.categories.unshift(mapped)
|
|
}
|
|
} catch {
|
|
// Ignore exact category lookup failures.
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const handleSearch = async (query) => {
|
|
if (!query) {
|
|
return { status: 'ok', data: emptyResult() }
|
|
}
|
|
|
|
try {
|
|
const backendSearch = await fetchBackend(`/api/search/?query=${encodeURIComponent(query)}`)
|
|
if (backendSearch?.status === 'ok' && hasAnyResults(backendSearch.data)) {
|
|
return backendSearch
|
|
}
|
|
} catch {
|
|
// Fallback path below handles backend search failures.
|
|
}
|
|
|
|
const fallbackData = await buildFallback(query)
|
|
return { status: 'ok', data: fallbackData }
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
if (!req.url) {
|
|
res.writeHead(400, jsonHeaders)
|
|
res.end(JSON.stringify({ status: 'error', message: 'Missing request URL' }))
|
|
return
|
|
}
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, {
|
|
...jsonHeaders,
|
|
'Access-Control-Allow-Methods': 'GET,OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type,Accept-Language'
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const url = new URL(req.url, 'http://localhost')
|
|
const query = (url.searchParams.get('query') || '').trim()
|
|
|
|
if (!url.pathname.startsWith('/api/search')) {
|
|
res.writeHead(404, jsonHeaders)
|
|
res.end(JSON.stringify({ status: 'error', message: 'Not found' }))
|
|
return
|
|
}
|
|
|
|
try {
|
|
const payload = await handleSearch(query)
|
|
res.writeHead(200, jsonHeaders)
|
|
res.end(JSON.stringify(payload))
|
|
} catch (error) {
|
|
res.writeHead(500, jsonHeaders)
|
|
res.end(JSON.stringify({ status: 'error', message: String(error) }))
|
|
}
|
|
})
|
|
|
|
server.listen(port, '0.0.0.0')
|