- typed client and useFaq/useFaqApi (SSR, faq:<slug>:<locale> key, server and public bases, x-forwarded-for forwarded also on cached renders) - FaqBlock, FaqList and FaqItem: native details, exclusive and openFirst, server-sanitized description and answers, nothing or #fallback on 404, error or no items - FAQ tokens and base CSS with the .faq-html rules, README - node client tests and a playground SSR smoke against a fixture API
68 lines
2.5 KiB
JavaScript
68 lines
2.5 KiB
JavaScript
// A small node:http stand-in for the anonymous FAQ API (/_faq/api/v1),
|
|
// answering GET /faqs/<slug> from testing/fixtures/faqs/<slug>.json and
|
|
// 404 {"error":"FAQ not found"} otherwise. It records every request so a
|
|
// smoke run can assert header forwarding.
|
|
//
|
|
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
|
// node_modules, and consuming apps may import this file from there.
|
|
|
|
import { createServer } from 'node:http'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
const PREFIX = '/_faq/api/v1'
|
|
|
|
/**
|
|
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
|
|
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
|
|
*/
|
|
|
|
/**
|
|
* Starts the fixture API on 127.0.0.1.
|
|
* @param {{ dir: string, port?: number }} opts
|
|
* @returns {Promise<FixtureApi>}
|
|
*/
|
|
export function startFixtureApi({ dir, port = 0 }) {
|
|
/** @type {FixtureRequest[]} */
|
|
const requests = []
|
|
|
|
/** @param {import('node:http').IncomingMessage} req */
|
|
const route = (req) => {
|
|
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return { status: 405, body: { error: 'Method not allowed' } }
|
|
const m = /^\/faqs\/([a-z0-9][a-z0-9-]*)$/.exec(url.pathname.slice(PREFIX.length))
|
|
if (!url.pathname.startsWith(PREFIX + '/') || !m) return { status: 404, body: { error: 'FAQ not found' } }
|
|
try {
|
|
return { status: 200, body: JSON.parse(readFileSync(join(dir, 'faqs', `${m[1]}.json`), 'utf8')) }
|
|
} catch (err) {
|
|
if (/** @type {any} */ (err).code === 'ENOENT') return { status: 404, body: { error: 'FAQ not found' } }
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
const xff = req.headers['x-forwarded-for']
|
|
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: Array.isArray(xff) ? xff.join(', ') : (xff ?? null) })
|
|
let out
|
|
try {
|
|
out = route(req)
|
|
} catch (err) {
|
|
out = { status: 500, body: { error: String(err) } }
|
|
}
|
|
res.writeHead(out.status, { 'content-type': 'application/json' })
|
|
res.end(req.method === 'HEAD' ? undefined : JSON.stringify(out.body))
|
|
})
|
|
|
|
return new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(port, '127.0.0.1', () => {
|
|
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
|
|
resolve({
|
|
url: `http://127.0.0.1:${addr.port}`,
|
|
requests,
|
|
close: () => new Promise((done) => server.close(() => done())),
|
|
})
|
|
})
|
|
})
|
|
}
|