Tests, tooling and the served scripts are TypeScript (#392)

The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).

Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.

The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.

boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.

Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 02:23:22 +00:00
parent b509ea4b35
commit f8408a3d26
45 changed files with 692 additions and 531 deletions

View File

@@ -1,21 +1,36 @@
// The API reference's content: each service's endpoints, grouped, and the types they take and
// give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A
// change to a route or a field there wants its line here; gen.mjs turns this into pages.
// change to a route or a field there wants its line here; gen.mts turns this into pages.
// Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links
// to its page.
export type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
/// A field, a path parameter or an enum's variant: its name, its Rust type, and what it is.
export type Field = [name: string, type: string, doc: string];
/// An endpoint. `body`, `query` and `returns` are Rust types, or words for an answer with no body.
export type Item = {
method: Method, path: string, slug: string, summary: string,
access?: string, doc?: string, body?: string, query?: string, returns: string,
pathParams?: Field[], errors?: [status: string, why: string][], example?: string,
};
/// A module of endpoints, as docs.rs shows a crate's modules.
export type Group = { id: string, name: string, title: string, summary: string, items: Item[] };
export type ApiType = { name: string, doc?: string, fields: Field[] };
/// One service's reference.
export type Api = { name: string, label?: string, root: string, changelog: string, intro: string, groups: Group[], types: ApiType[] };
const U = 'http://<your server>:8099';
const D = 'https://directory.ipxreader.app';
const ipxCurl = (m, p, body) =>
const ipxCurl = (m: Method, p: string, body?: string) =>
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`;
const dirCurl = (m, p, body) =>
const dirCurl = (m: Method, p: string, body?: string) =>
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer <token>"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`;
const NOT_SUBSCRIBED = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.'];
const NOT_ADMIN = ['403 Forbidden', 'You are not an admin.'];
const NO_DIRECTORY = ['503 Service Unavailable', 'No directory is set up on this server.'];
const NOT_SUBSCRIBED: [string, string] = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.'];
const NOT_ADMIN: [string, string] = ['403 Forbidden', 'You are not an admin.'];
const NO_DIRECTORY: [string, string] = ['503 Service Unavailable', 'No directory is set up on this server.'];
export const ipx = {
export const ipx: Api = {
name: 'ipx',
root: '/docs/api/ipx',
changelog: 'crates/ipx/CHANGELOG.md',
@@ -607,7 +622,7 @@ export const ipx = {
],
};
export const directory = {
export const directory: Api = {
name: 'ipx_directory',
label: 'ipx-directory',
root: '/docs/api/directory',

View File

@@ -11,4 +11,4 @@ cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
cp -r "$here/shots" "$here/dist/"
# The docs: the guides and the API references, generated (#223).
node "$here/gen.mjs"
node "$here/gen.mts"

View File

@@ -4,7 +4,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const read = name => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
const read = (name: string) => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
const front = `<h1>Docs</h1>
<p class="lede">How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.</p>

View File

@@ -1,35 +1,35 @@
// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each
// service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its
// The docs pages, into site/dist/docs: the guides in docs.mts, and an API reference for each
// service from api.mts, laid out as docs.rs lays out a crate (#223): a root page listing its
// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type.
// No script: the site's Content-Security-Policy allows none of its own.
//
// node site/gen.mjs (site/build.sh runs it)
// node site/gen.mts (site/build.sh runs it)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ipx, directory } from './api.mjs';
import { guides } from './docs.mjs';
import { ipx, directory, type Api, type ApiType, type Field, type Group, type Item, type Method } from './api.mts';
import { guides } from './docs.mts';
const here = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(here, 'dist');
const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main';
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: unknown) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/// The version a reference documents: its service's last release, from its changelog, not the
/// -dev version between releases.
function released(changelog) {
function released(changelog: string) {
const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m);
return m ? m[1] : '';
return m?.[1] ?? '';
}
function write(rel, html) {
function write(rel: string, html: string) {
const file = path.join(dist, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, html);
}
function shell({ title, description, sidebar, body, current }) {
function shell({ title, description, sidebar, body }: { title: string, description: string, sidebar: string, body: string, current: string }) {
return `<!doctype html>
<html lang="en">
<head>
@@ -65,10 +65,10 @@ function shell({ title, description, sidebar, body, current }) {
/* ---------------- the guides ---------------- */
const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']];
const REFS: [Api, string][] = [[ipx, 'ipx'], [directory, 'ipx-directory']];
function guideSidebar(cur) {
const li = (href, name, on) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
function guideSidebar(cur: string) {
const li = (href: string, name: string, on: boolean) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
return `<nav aria-label="Docs">
<h2 class="side-h">Guides</h2>
<ul>${guides.map(g => li(g.href, g.name, g.href === cur)).join('')}</ul>
@@ -83,43 +83,46 @@ for (const g of guides) {
/* ---------------- the references ---------------- */
const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
const METHOD_CLASS: Record<Method, string> = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
function reference(api) {
function reference(api: Api) {
const label = api.label || api.name;
const version = released(api.changelog);
const typeNames = new Set(api.types.map(t => t.name));
const typeHref = n => `${api.root}/types/${n}`;
const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`;
const fnName = it => it.slug.replace(/-/g, '_');
const typeHref = (n: string) => `${api.root}/types/${n}`;
const itemHref = (g: Group, it: Item) => `${api.root}/${g.id}/${it.slug}`;
const fnName = (it: Item) => it.slug.replace(/-/g, '_');
// A Rust type, its names that are types of this API linked.
const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
const access = it => it.access || 'signed in';
const ty = (s: string) => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
const isType = (s: string) => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
const access = (it: Item) => it.access || 'signed in';
// Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up.
const usedBy = new Map();
// An endpoint, with its module, or a type that has it as a field (its module null).
const usedBy = new Map<string, ([Group, Item] | [null, ApiType])[]>();
for (const g of api.groups) for (const it of g.items) {
for (const s of [it.body, it.query, it.returns].filter(Boolean)) {
for (const s of [it.body, it.query, it.returns].filter((s): s is string => !!s)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) {
if (!usedBy.has(n)) usedBy.set(n, []);
if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]);
const uses = usedBy.get(n) ?? [];
usedBy.set(n, uses);
if (!uses.some(([, x]) => x === it)) uses.push([g, it]);
}
}
}
// Types inside types: a type used only as a field is reached from the type that holds it.
for (const t of api.types) for (const [, ft] of t.fields) {
for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) {
if (!usedBy.has(n)) usedBy.set(n, []);
if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]);
const uses = usedBy.get(n) ?? [];
usedBy.set(n, uses);
if (!uses.some(([, x]) => x === t)) uses.push([null, t]);
}
}
const crumbs = (...parts) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
const crumbs = (...parts: [href: string, name: string][]) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
const top = `<a class="crate" href="${api.root}/"><img src="/logo.svg" alt="" width="40" height="40"><span><b>${esc(label)}</b>${version ? `<span class="ver">${version}</span>` : ''}</span></a>`;
const back = `<p class="side-back"><a href="/docs/">All docs</a></p>`;
function sidebar({ group, item, type } = {}) {
function sidebar({ group, item, type }: { group?: Group, item?: Item, type?: ApiType } = {}) {
let s = top;
if (group) {
s += `<h2 class="side-h">Module <a href="${api.root}/${group.id}/">${group.name}</a></h2>
@@ -130,7 +133,7 @@ function reference(api) {
return `<nav aria-label="${esc(label)} reference">${s}</nav>${back}`;
}
const page = (rel, title, description, side, body) =>
const page = (rel: string, title: string, description: string, side: string, body: string) =>
write(rel, shell({ title, description, sidebar: side, body, current: 'docs' }));
// The root: what the crate page shows on docs.rs, its modules and types.
@@ -156,8 +159,8 @@ ${api.intro}
it.body && ` body ${ty(it.body)}`,
` -&gt; ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`,
].filter(Boolean).join('\n');
const fieldsOf = n => api.types.find(t => t.name === n)?.fields || [];
const rows = list => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
const fieldsOf = (n: string) => api.types.find(t => t.name === n)?.fields || [];
const rows = (list: Field[]) => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}
<h1 class="item"><span class="kind">Endpoint</span> ${esc(api.name)}::${g.name}::<span class="name fn">${fnName(it)}</span><a class="srclink" href="${SRC}/${api.name === 'ipx' ? 'crates/ipx/src/web.rs' : 'crates/directory/src/lib.rs'}">Source</a></h1>
<pre class="decl"><code>${decl}</code></pre>
@@ -201,10 +204,10 @@ ${t.doc ? `<p class="summary">${t.doc}</p>` : ''}`;
page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body);
}
// Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a
// Every link to a type must name one there is: a misspelt type in api.mts is otherwise a
// name in plain text, which looks right and leads nowhere.
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter((s): s is string => !!s)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) ?? []) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
}
}