- typed client and useMenu/useMenuApi (SSR, menu:<code>:<locale> key, server and public bases, x-forwarded-for forwarded also on cached renders) - MenuNav and recursive MenuNavItem: WAI disclosure navigation, URL allowlist with a text fallback, rel on _blank, active link and trail, mobile toggle, Esc / focus-out / outside pointer / route change closing - menu tokens and base CSS, README - node tests (link rules, client, server-rendered NavItem) 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 menu API (/_menu/api/v1),
|
|
// answering GET /menus/<code> from testing/fixtures/menus/<code>.json and
|
|
// 404 {"error":"Menu 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 = '/_menu/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 = /^\/menus\/([A-Za-z0-9_-]+)$/.exec(url.pathname.slice(PREFIX.length))
|
|
if (!url.pathname.startsWith(PREFIX + '/') || !m) return { status: 404, body: { error: 'Menu not found' } }
|
|
try {
|
|
return { status: 200, body: JSON.parse(readFileSync(join(dir, 'menus', `${m[1]}.json`), 'utf8')) }
|
|
} catch (err) {
|
|
if (/** @type {any} */ (err).code === 'ENOENT') return { status: 404, body: { error: 'Menu 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())),
|
|
})
|
|
})
|
|
})
|
|
}
|