// A small node:http stand-in for the SEO plugin's public API // (/_seo/api/v1) and the Go binary's root files: // // GET /_seo/api/v1/resolve?url=… the answer for that exact url from // /seo/resolve.json, then // /resolve.json, else not_found // GET /_seo/api/v1/site /seo/site.json (404 without the // file or with { site: false }: an // older backend) // POST /_seo/api/v1/notfound 204 with `Authorization: Bearer `, // 401 with another token, 404 without one // GET /_seo/api/v1/verification/ /verification/ or // /seo/verification/, else 404 // GET /robots.txt, /llms.txt, /seo/root/, else 404 // /sitemap.xml // GET /_sitemap/files/ /seo/root/, else 404; // sitemap-fail-*.xml answers 500 // GET /_sitemap/indexnow/.txt /seo/indexnow/.txt, else 404 // // It records every request (with the parsed JSON body of a POST) so a smoke // run can assert header forwarding, resolve counts and the 404 reports. // `reload()` reads the fixture files again (purge-check changes them). // // 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 { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' const PREFIX = '/_seo/api/v1' /** The bearer token the fixture accepts on POST /notfound. */ export const FIXTURE_NOTFOUND_TOKEN = 'fixture-seo-notfound-token-0123456789' /** * @typedef {{ method: string, url: string, xff: string | null, authorization: string | null, body: unknown }} FixtureRequest * @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise }} FixtureApi */ /** @param {string} file @returns {any} */ function readJson(file) { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null } /** @param {string} name */ function contentType(name) { if (name.endsWith('.xml')) return 'application/xml; charset=utf-8' if (name.endsWith('.html')) return 'text/html; charset=utf-8' return 'text/plain; charset=utf-8' } /** * Starts the fixture API on 127.0.0.1. * @param {{ dir: string, port?: number, token?: string, site?: boolean }} opts * @returns {Promise} */ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true }) { /** @type {FixtureRequest[]} */ const requests = [] /** @type {Record} */ let answers = {} /** @type {unknown} */ let siteAnswer = null const load = () => { answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) } siteAnswer = site ? readJson(join(dir, 'seo', 'site.json')) : null } load() /** @param {string} file @param {string} name */ const fileAnswer = (file, name) => { if (/^sitemap-fail-/.test(name)) return { status: 500, text: 'backend failure', type: 'text/plain' } if (!existsSync(file)) return { status: 404, body: { error: 'not_found' } } return { status: 200, text: readFileSync(file, 'utf8'), type: contentType(name), robots: name.startsWith('sitemap-') || name === 'llms.txt' ? 'noindex' : undefined } } /** * @param {import('node:http').IncomingMessage} req * @returns {{ status: number, body?: unknown, text?: string, type?: string, robots?: string }} */ const route = (req) => { const url = new URL(req.url ?? '/', 'http://fixture.local') const root = /^\/(robots\.txt|llms\.txt|sitemap\.xml)$/.exec(url.pathname) if (root && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', root[1]), root[1]) const files = /^\/_sitemap\/files\/(sitemap-[a-z0-9-]+\.xml)$/.exec(url.pathname) if (files && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', files[1]), files[1]) const key = /^\/_sitemap\/indexnow\/([A-Za-z0-9-]{8,128}\.txt)$/.exec(url.pathname) if (key && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'indexnow', key[1]), key[1]) if (!url.pathname.startsWith(PREFIX + '/')) return { status: 404, body: { error: 'not_found' } } const path = url.pathname.slice(PREFIX.length) if (path === '/resolve' && req.method === 'GET') { const target = url.searchParams.get('url') ?? '' if (!target.startsWith('/')) return { status: 422, body: { error: 'bad_url' } } const hit = answers[target] if (hit) return { status: 200, body: hit } return { status: 200, body: { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null } } } if (path === '/site' && req.method === 'GET') { return siteAnswer ? { status: 200, body: siteAnswer } : { status: 404, body: { error: 'not_found' } } } if (path === '/notfound' && req.method === 'POST') { if (!token) return { status: 404, body: { error: 'not_found' } } return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } } } const v = /^\/verification\/([A-Za-z0-9._-]+)$/.exec(path) if (v && req.method === 'GET') { for (const file of [join(dir, 'verification', v[1]), join(dir, 'seo', 'verification', v[1])]) { if (existsSync(file)) return { status: 200, text: readFileSync(file, 'utf8'), type: 'text/html; charset=utf-8' } } return { status: 404, body: { error: 'not_found' } } } return { status: 404, body: { error: 'not_found' } } } const server = createServer((req, res) => { const chunks = /** @type {Buffer[]} */ ([]) req.on('data', (c) => chunks.push(c)) req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8') let body = null try { body = raw ? JSON.parse(raw) : null } catch { body = raw } const header = (/** @type {string} */ name) => { const v = req.headers[name] return Array.isArray(v) ? v.join(', ') : (v ?? null) } requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: header('x-forwarded-for'), authorization: header('authorization'), body }) let out try { out = route(req) } catch (err) { out = { status: 500, body: { error: String(err) } } } if (out.text !== undefined) { res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain', ...(out.robots ? { 'x-robots-tag': out.robots } : {}) }) res.end(out.text) return } res.writeHead(out.status, out.body === undefined ? {} : { 'content-type': 'application/json' }) res.end(out.body === undefined ? undefined : JSON.stringify(out.body)) }) }) /** Resolve calls so far, for one target url or (without one) all of them. @param {string} [target] */ const resolveCount = (target) => requests.filter((r) => { if (r.method !== 'GET') return false const u = new URL(r.url, 'http://fixture.local') return u.pathname === `${PREFIX}/resolve` && (target === undefined || u.searchParams.get('url') === target) }).length 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, resolveCount, reload: load, close: () => new Promise((done) => server.close(() => done())), }) }) }) }