Files
julian.kiedaischandClaude Opus 5 26ed1495fc chore(tools): the live tools take their target, they do not assume it
The dashboard's fifth guard learned to see a host name and found three in
tools/live: a default host in lib.ts and two health checks written out.
A measuring tool that aims at somebody else's server by default is a trap,
so FILIUS_HOST has no fallback and the run says what to set; the two checks
use BASE, which is derived from it.

BASE had to be imported in m3.ts — without that this would have been a
NameError instead of the intended refusal.

README.md keeps the real names: the project is this school's and the prose
describes its deployment; what has to stand on its own is the code.

The allow file also excuses the simulator's own data. Filius simulates a
network, so its scenarios, tests and documentation carry 65 693 addresses
(measured) — all of invented machines in invented networks, which is the
point of the program. Deliberately broad: a list of scenario files would be
one nobody keeps correct.

typecheck green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-11-13 19:09:03 +00:00

178 lines
7.2 KiB
TypeScript

// Measuring on test-filius: projects through the service, people through
// tickets, the shared document through /sync, rooms through /sim. The
// secrets come from ~/.filius-test.env and are never printed.
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import * as Y from 'yjs';
import { openProjectArchive } from '@mdg-filius/fls';
import { writeProject } from '@mdg-filius/doc';
import { signTicket } from '../../packages/server/test/tickets.js';
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import WebSocket from 'ws';
const env = Object.fromEntries(
readFileSync(`${homedir()}/.filius-test.env`, 'utf8')
.split('\n')
.filter((l) => l.includes('='))
.map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]),
);
// No deployment's host as a fallback: this repository is handed out, and
// a measuring tool that aims at somebody else's server by default is a
// trap. Unset, the run says what to set.
export const HOST = process.env.FILIUS_HOST ?? '';
if (!HOST) throw new Error('set FILIUS_HOST to the instance to measure');
export const BASE = `https://${HOST}`;
const SECRET = env.FILIUS_TICKET_SECRET!;
const KEY = env.FILIUS_SERVICE_KEY!;
export const made: string[] = [];
/**
* How many seconds before now the tickets are signed (0: as the dashboard
* signs them). F before TICKET_AHEAD_SECONDS refused a ticket from its
* future, and test-filius' clock was behind this machine's (886 ms, then
* 8 ms with NTP); against such an F, 2 helps.
*/
const SKEW_SECONDS = Number(process.env.SKEW_SECONDS ?? 0);
export async function svc(method: string, path: string, body?: unknown): Promise<{ status: number; json: any }> {
const r = await fetch(`${BASE}${path}`, { method, headers: { 'x-filius-service-key': KEY, 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { status: r.status, json: await r.json().catch(() => null) };
}
export async function newProject(): Promise<string> {
const id = randomUUID();
const r = await svc('POST', '/svc/projects', { project: id });
if (r.status !== 201) throw new Error(`project not made: ${r.status}`);
made.push(id);
return id;
}
export async function cleanUp(): Promise<void> {
for (const id of made.splice(0)) await svc('DELETE', `/svc/projects/${id}`);
}
/** The address a person's browser opens: a fresh ticket, then the editor on the project. */
export function signInAddress(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): string {
const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS);
return `${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(`/?project=${project}`)}`;
}
export async function signIn(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): Promise<string> {
const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS);
const r = await fetch(`${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=/`, { redirect: 'manual' });
if (r.status !== 302) throw new Error(`not signed in: ${r.status}`);
return r.headers.get('set-cookie')!.split(';')[0]!;
}
export function readExample(file: string) {
return openProjectArchive(new Uint8Array(readFileSync(new URL(`../../corpus/${file}`, import.meta.url))), () => randomUUID());
}
export interface Shared {
doc: Y.Doc;
provider: any;
close(): void;
}
export function joinDoc(project: string, cookie: string): Promise<Shared> {
const doc = new Y.Doc();
class Browser extends WebSocket {
constructor(url: string, protocols?: string | string[]) {
super(url, protocols, { headers: { cookie, origin: BASE } });
this.on('error', () => {});
}
}
const socket = new HocuspocusProviderWebsocket({ url: `wss://${HOST}/sync`, WebSocketPolyfill: Browser });
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('not synced in 15 s')), 15_000);
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: project,
document: doc,
token: 'cookie',
onSynced: () => {
clearTimeout(timer);
resolve({
doc,
provider,
close: () => {
provider.destroy();
socket.destroy();
},
});
},
onAuthenticationFailed: ({ reason }: any) => reject(new Error(`refused ${reason}`)),
});
provider.attach();
});
}
export async function whenStored(shared: Shared, ms = 15_000): Promise<void> {
for (let waited = 0; shared.provider.unsyncedChanges > 0; waited += 20) {
if (waited > ms) throw new Error('not stored');
await new Promise((r) => setTimeout(r, 20));
}
}
/** A project of F with an example written into it, as one person's editor would. */
export async function seededProject(file: string) {
const project = await newProject();
const example = readExample(file);
const shared = await joinDoc(project, await signIn(project));
writeProject(shared.doc, example);
await whenStored(shared);
shared.close();
return { project, example };
}
export interface Room {
send(m: object): void;
messages: any[];
waitFor(test: (m: any) => boolean, ms?: number): Promise<any>;
onMessage: ((m: any) => void) | undefined;
close(): void;
closed: Promise<string>;
}
export function openRoom(project: string, cookie: string): Promise<Room> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(`wss://${HOST}/sim?project=${project}`, { headers: { cookie, origin: BASE } });
const messages: any[] = [];
const waiters: { test: (m: any) => boolean; resolve: (m: any) => void }[] = [];
let closedWith: (r: string) => void = () => {};
const room: Room = {
send: (m) => socket.send(JSON.stringify({ v: 1, ...m })),
messages,
onMessage: undefined,
waitFor: (test, ms = 10_000) =>
new Promise((resolve, reject) => {
const found = messages.find(test);
if (found) return resolve(found);
const timer = setTimeout(() => reject(new Error('waited in vain')), ms);
waiters.push({
test,
resolve: (m) => {
clearTimeout(timer);
resolve(m);
},
});
}),
close: () => socket.close(),
closed: new Promise((r) => (closedWith = r)),
};
socket.on('message', (data: Buffer) => {
const m = JSON.parse(data.toString());
room.onMessage?.(m);
if (m.kind !== 'activity') messages.push(m);
for (const w of [...waiters])
if (w.test(m)) {
waiters.splice(waiters.indexOf(w), 1);
w.resolve(m);
}
});
socket.on('open', () => resolve(room));
socket.on('error', (e: Error) => reject(e));
socket.on('close', (code: number, reason: Buffer) => closedWith(`${code} ${reason}`));
});
}