preview: pretend homeserver for the dev preview (in-page, off by default) #28

Merged
void merged 9 commits from void/preview-demo into dev 2026-11-07 19:02:48 +00:00
35 changed files with 1104 additions and 8 deletions

View file

@ -22,10 +22,13 @@ jobs:
- name: Build app
env:
CINNY_BASE: /void/rat-dev/
VITE_PREVIEW_DEMO: '1'
NODE_OPTIONS: '--max_old_space_size=3072'
run: npm run build
- name: Set background scene tier
run: node scripts/preview-config.js low
- name: Set background scene tier and the pretend homeserver
run: node scripts/preview-config.js low --demo
- name: Add the demo pose art
run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/
- name: Upload artifact
# v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath
# (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169).

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -1,12 +1,28 @@
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier]
// in the repo stays 'off'. With --demo it also switches on the pretend homeserver (src/preview-demo):
// previewDemo true, and the pretend server as the only homeserver. Usage:
// node scripts/preview-config.js [tier] [--demo]
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const tier = process.argv[2] ?? 'low';
const args = process.argv.slice(2);
const demo = args.includes('--demo');
const tier = args.find((a) => !a.startsWith('--')) ?? 'low';
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.backgroundScene = { ...config.backgroundScene, tier };
if (demo) {
config.previewDemo = true;
config.defaultHomeserver = 0;
config.homeserverList = ['demo.invalid'];
config.allowCustomHomeservers = false;
config.featuredCommunities = {
openAsDefault: false,
spaces: ['#gamecult:demo.invalid'],
rooms: [],
servers: ['demo.invalid'],
};
}
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
console.log(`${file}: backgroundScene.tier = ${tier}`);
console.log(`${file}: backgroundScene.tier = ${tier}${demo ? ', previewDemo on' : ''}`);

View file

@ -21,6 +21,9 @@ export type ClientConfig = {
hashRouter?: HashRouterConfig;
backgroundScene?: BackgroundSceneConfig;
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
previewDemo?: boolean;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View file

@ -36,7 +36,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
export function Login() {
const server = useAuthServer();
const { hashRouter } = useClientConfig();
const { hashRouter, previewDemo } = useClientConfig();
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
@ -65,7 +65,7 @@ export function Login() {
{parsedFlows.password && (
<>
<PasswordLoginForm
defaultUsername={loginSearchParams.username}
defaultUsername={loginSearchParams.username ?? (previewDemo ? 'demo' : undefined)}
defaultEmail={loginSearchParams.email}
/>
<span data-spacing-node />

View file

@ -1,4 +1,5 @@
/* eslint-disable import/first */
import './preview-demo/seed';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
@ -52,6 +53,16 @@ if ('serviceWorker' in navigator) {
});
}
// The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev
// workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it.
const installPreviewDemo = async () => {
if (import.meta.env.VITE_PREVIEW_DEMO !== '1') return;
const config = await fetch(`${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`)
.then((r) => r.json())
.catch(() => ({}));
if (config.previewDemo === true) (await import('./preview-demo')).installDemo();
};
const mountApp = () => {
const rootContainer = document.getElementById('root');
@ -64,4 +75,4 @@ const mountApp = () => {
root.render(<App />);
};
mountApp();
installPreviewDemo().finally(mountApp);

View file

@ -0,0 +1,215 @@
// Fixtures for the preview-only pretend homeserver. Public-safe by construction: the only
// names are GameCult's Minds and a fictional visitor; every room, line and id is invented here.
export const DEMO_SERVER = 'demo.invalid';
export const DEMO_ORIGIN = `https://${DEMO_SERVER}`;
export const ME = `@demo:${DEMO_SERVER}`;
export const DEMO_DEVICE = 'DEMOPREVIEW';
export const DEMO_TOKEN = 'demo-preview-token';
export type DemoEvent = {
type: string;
state_key?: string;
sender: string;
content: Record<string, unknown>;
event_id: string;
origin_server_ts: number;
unsigned?: Record<string, unknown>;
};
export type DemoUser = { id: string; name: string };
export const USERS: DemoUser[] = [
{ id: ME, name: 'Demo' },
{ id: `@eureka:${DEMO_SERVER}`, name: 'Eureka' },
{ id: `@void:${DEMO_SERVER}`, name: 'Void' },
{ id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' },
{ id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' },
];
const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`;
export type DemoLine = { from: string; body: string; pose?: string; minutesAgo: number };
export type DemoRoom = {
id: string;
alias?: string;
name: string;
space?: boolean;
direct?: boolean;
topic?: string;
members: string[];
notice?: 'show' | 'study' | 'train';
parent?: string;
steward?: { mind: string; repo: string };
joined: boolean;
lines: DemoLine[];
};
export const ROOT = `!root:${DEMO_SERVER}`;
export const PROJECTS = `!projects:${DEMO_SERVER}`;
export const GENERAL = `!general:${DEMO_SERVER}`;
export const COMMONS = `!commons:${DEMO_SERVER}`;
const everyone = USERS.map((u) => u.id);
export const MIND_IDS = [user('eureka'), user('void'), user('nibu')];
const minds = MIND_IDS;
export const ROOMS: DemoRoom[] = [
{
id: ROOT,
alias: `#gamecult:${DEMO_SERVER}`,
name: 'GameCult',
space: true,
members: [ME],
joined: true,
lines: [],
},
{
id: GENERAL,
alias: `#general:${DEMO_SERVER}`,
name: 'General',
topic: 'A pretend room on a pretend server.',
members: everyone,
notice: 'train',
parent: ROOT,
joined: true,
lines: [
{ from: user('visitor'), body: 'Is anyone around? I just found this place.', minutesAgo: 42 },
{
from: user('eureka'),
body: 'Welcome. This is a preview with no real server behind it.',
pose: 'happy',
minutesAgo: 40,
},
{
from: user('void'),
body: 'The portrait beside a message is the pose its sender chose. Turn the setting on if you cannot see it.',
pose: 'reading',
minutesAgo: 38,
},
{
from: user('nibu'),
body: 'Check the sky behind the login screen while you are here.',
pose: 'triumph',
minutesAgo: 35,
},
{ from: user('visitor'), body: 'Can I type here?', minutesAgo: 30 },
{
from: user('eureka'),
body: 'Yes. Say something and a Mind will answer. The composer underlines misspelled words, so try "recieve".',
pose: 'amused',
minutesAgo: 29,
},
{
from: user('void'),
body: 'Nothing you type leaves this page.',
pose: 'deadpan',
minutesAgo: 25,
},
],
},
{
id: PROJECTS,
alias: `#projects:${DEMO_SERVER}`,
name: 'Projects',
space: true,
members: [ME],
parent: ROOT,
joined: true,
lines: [],
},
{
id: `!ratatoskr:${DEMO_SERVER}`,
alias: `#ratatoskr:${DEMO_SERVER}`,
name: 'Ratatoskr',
topic: 'The chat client, built by the people using it.',
members: [ME, ...minds],
notice: 'study',
parent: PROJECTS,
steward: { mind: 'nibu', repo: 'Ratatoskr' },
joined: true,
lines: [
{
from: user('nibu'),
body: 'The ridge scene is on at the low tier in this preview.',
pose: 'resolute',
minutesAgo: 90,
},
{
from: user('void'),
body: 'Backgrounded tabs now nudge sync when you come back, so the list is not stale.',
pose: 'point',
minutesAgo: 80,
},
{
from: user('eureka'),
body: 'Messages from Minds carry a pose label; this one is thinking.',
pose: 'thinking',
minutesAgo: 70,
},
{
from: user('nibu'),
body: 'This label is not in the pose catalogue, so the portrait falls back to neutral.',
pose: 'no-such-pose',
minutesAgo: 60,
},
],
},
{
id: `!huginn:${DEMO_SERVER}`,
alias: `#huginn:${DEMO_SERVER}`,
name: 'Huginn',
topic: 'Memory for the Minds.',
members: [ME, user('eureka'), user('void')],
notice: 'show',
parent: PROJECTS,
steward: { mind: 'eureka', repo: 'Huginn' },
joined: true,
lines: [
{
from: user('eureka'),
body: 'This room shows no training line, because its notice says show.',
pose: 'neutral',
minutesAgo: 120,
},
],
},
{
id: `!dm-eureka:${DEMO_SERVER}`,
name: 'Eureka',
direct: true,
members: [ME, user('eureka')],
joined: true,
lines: [
{
from: user('eureka'),
body: 'A direct message. I answer here too.',
pose: 'happy',
minutesAgo: 15,
},
{
from: user('eureka'),
body: 'Ask me anything; the answers are canned.',
pose: 'thinking',
minutesAgo: 14,
},
],
},
{
id: COMMONS,
alias: `#commons:${DEMO_SERVER}`,
name: 'Commons',
topic: 'Open to anyone. Join to look inside.',
members: [user('visitor'), user('void')],
parent: ROOT,
joined: false,
lines: [
{ from: user('void'), body: 'You found the unjoined room.', pose: 'glare', minutesAgo: 200 },
],
},
];
// A Mind's canned answer to whatever the visitor types: label and line.
export const REPLIES: { pose: string; body: string }[] = [
{ pose: 'thinking', body: 'Hm. Let me think about that.' },
{ pose: 'amused', body: 'That is a fair thing to say to a pretend server.' },
{ pose: 'happy', body: 'Noted. Nothing was sent anywhere.' },
{ pose: 'surprised', body: 'I did not expect that.' },
];

View file

@ -0,0 +1 @@
export { installDemo } from './install';

146
src/preview-demo/install.ts Normal file
View file

@ -0,0 +1,146 @@
/* eslint-disable no-param-reassign */
// Patching the window's network entry points is this module's whole job.
// Routes the page's network through the pretend homeserver and refuses everything else. Installed
// only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx).
// Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any
// other origin is refused, so no real homeserver can be contacted while this is on.
import { DEMO_ORIGIN } from './fixtures';
import { createDemoServer, DemoServer } from './server';
export type Route = 'own' | 'demo' | 'refuse';
export const routeOf = (url: string, pageOrigin: string): Route => {
let u: URL;
try {
u = new URL(url, pageOrigin);
} catch {
return 'refuse';
}
if (u.protocol === 'data:' || u.protocol === 'blob:') return 'own';
if (u.origin === DEMO_ORIGIN) return 'demo';
const ws = u.protocol === 'ws:' || u.protocol === 'wss:';
const sameHost = u.host === new URL(pageOrigin).host;
return sameHost && (u.origin === pageOrigin || ws) ? 'own' : 'refuse';
};
const refusal = (what: string, url: string) =>
new Error(`preview: pretend server only; ${what} to ${url} refused`);
const BANNER_TEXT = 'preview: pretend server. Any password works.';
const FOREIGN_SESSION_TEXT =
'preview: pretend server. A login for another server is stored here and cannot be used.';
const showBanner = (text: string) => {
const el = document.createElement('div');
el.textContent = text;
el.setAttribute('role', 'status');
el.style.cssText =
'position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:96vw;' +
'padding:0 8px;font:10px/1.5 system-ui,sans-serif;white-space:nowrap;opacity:0.9;color:#1b1b1b;background:#ffc46b;' +
'border-radius:0 0 6px 6px;pointer-events:none;text-align:center';
document.body.appendChild(el);
};
// The preview host's CSP sandbox has no allow-forms, so the browser cancels a native form
// submission before its submit event fires and no login button works. Deliver the event ourselves;
// nothing is submitted natively. Remove this when the host grants allow-forms to /void/rat-*.
const deliverSubmitEvents = (doc: Document) => {
const submit = (form: HTMLFormElement) =>
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
doc.addEventListener(
'click',
(e) => {
const button = (e.target as Element | null)?.closest?.(
'button[type="submit"], input[type="submit"]'
);
const form = (button as HTMLButtonElement | null)?.form;
if (!form || (button as HTMLButtonElement).disabled) return;
e.preventDefault();
submit(form);
},
true
);
doc.addEventListener(
'keydown',
(e) => {
const target = e.target as HTMLInputElement | null;
if (e.key !== 'Enter' || e.isComposing || target?.tagName !== 'INPUT' || !target.form) return;
e.preventDefault();
submit(target.form);
},
true
);
};
const parseBody = (body: unknown): unknown => {
if (typeof body !== 'string') return undefined;
try {
return JSON.parse(body);
} catch {
return undefined;
}
};
export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
// Own-origin requests go to the real fetch, the demo origin to the pretend server, the rest throw.
export const guardedFetch =
(server: DemoServer, origin: string, realFetch: FetchFn): FetchFn =>
async (input, init) => {
const req = input instanceof Request ? input : undefined;
const url = req ? req.url : String(input);
const route = routeOf(url, origin);
if (route === 'own') return realFetch(input, init);
if (route === 'refuse') throw new TypeError(refusal('fetch', url).message);
const method = init?.method ?? req?.method ?? 'GET';
const raw = init?.body ?? (req ? await req.clone().text() : undefined);
const out = await server.handle({
method,
url,
body: parseBody(raw),
signal: init?.signal ?? req?.signal ?? undefined,
});
return new Response(JSON.stringify(out.body), {
status: out.status,
headers: { 'Content-Type': 'application/json' },
});
};
export const installDemo = (win: Window & typeof globalThis = window) => {
const server = createDemoServer();
const { origin } = win.location;
const realFetch = win.fetch.bind(win);
win.fetch = guardedFetch(server, origin, realFetch);
const { WebSocket: RealSocket } = win;
class GuardedSocket extends RealSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (routeOf(String(url), origin) !== 'own') throw refusal('WebSocket', String(url));
super(url, protocols);
}
}
win.WebSocket = GuardedSocket;
const { open: realOpen } = win.XMLHttpRequest.prototype;
win.XMLHttpRequest.prototype.open = function open(
this: XMLHttpRequest,
method: string,
url: string | URL,
...rest: unknown[]
) {
if (routeOf(String(url), origin) !== 'own') throw refusal('XMLHttpRequest', String(url));
return (realOpen as (...a: unknown[]) => void).call(this, method, url, ...rest);
} as typeof realOpen;
const realBeacon = win.navigator.sendBeacon?.bind(win.navigator);
if (realBeacon) {
win.navigator.sendBeacon = (url: string | URL, data?: BodyInit | null) =>
routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false;
}
deliverSubmitEvents(document);
const stored = win.localStorage.getItem('cinny_hs_base_url');
showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT);
return server;
};

10
src/preview-demo/seed.ts Normal file
View file

@ -0,0 +1,10 @@
// Imported first by src/index.tsx: the settings module reads localStorage when it is first
// imported, so a demo build's first-visit defaults must be stored before anything else loads.
// Pose portraits default off; the demo exists to show them. Does nothing without the build flag.
export const seedDemoSettings = (storage: Storage) => {
if (storage.getItem('settings') === null) {
storage.setItem('settings', JSON.stringify({ poseBubbles: true }));
}
};
if (import.meta.env.VITE_PREVIEW_DEMO === '1') seedDemoSettings(localStorage);

View file

@ -0,0 +1,270 @@
/* eslint-disable max-classes-per-file, no-promise-executor-return, no-useless-constructor, no-empty-function, no-void, @typescript-eslint/no-non-null-assertion */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk';
import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures';
import { guardedFetch, installDemo, routeOf } from './install';
import { createDemoServer, DemoServer } from './server';
import { seedDemoSettings } from './seed';
import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses';
import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice';
const PAGE = 'https://page.test';
const never = vi.fn(async () => new Response('real network'));
const clientFor = (server: DemoServer): MatrixClient =>
createClient({
baseUrl: DEMO_ORIGIN,
fetchFn: guardedFetch(server, PAGE, never as never) as never,
});
const waitFor = <T>(read: () => T | undefined, ms = 5000): Promise<T> =>
new Promise((resolve, reject) => {
const t0 = Date.now();
const tick = () => {
const v = read();
if (v !== undefined) resolve(v);
else if (Date.now() - t0 > ms) reject(new Error('timed out'));
else setTimeout(tick, 20);
};
tick();
});
const live: { server: DemoServer; mx: MatrixClient }[] = [];
afterEach(() => {
live.splice(0).forEach(({ server, mx }) => {
mx.stopClient();
server.close();
});
never.mockClear();
});
const syncedClient = async () => {
const server = createDemoServer();
const mx = clientFor(server);
live.push({ server, mx });
const login = await mx.loginWithPassword('demo', 'anything');
mx.http.opts.accessToken = login.access_token;
await mx.startClient({ initialSyncLimit: 20 });
await waitFor(() => (mx.getSyncState() === SyncState.Syncing ? true : undefined));
return { server, mx, login };
};
describe('routeOf', () => {
it('lets own-origin requests through, answers the demo origin, refuses the rest', () => {
expect(routeOf('/config.json', PAGE)).toBe('own');
expect(routeOf(`${PAGE}/assets/a.js`, PAGE)).toBe('own');
expect(routeOf('data:text/plain,hi', PAGE)).toBe('own');
expect(routeOf(`${DEMO_ORIGIN}/_matrix/client/versions`, PAGE)).toBe('demo');
expect(routeOf('https://matrix.org/_matrix/client/versions', PAGE)).toBe('refuse');
expect(routeOf('https://page.test.evil.example/x', PAGE)).toBe('refuse');
expect(routeOf('https://demo.invalid.evil.example/x', PAGE)).toBe('refuse');
expect(routeOf('http://page.test/x', PAGE)).toBe('refuse');
expect(routeOf('wss://matrix.org/socket', PAGE)).toBe('refuse');
expect(routeOf('wss://page.test/socket', PAGE)).toBe('own');
});
});
describe('the pretend homeserver through matrix-js-sdk', () => {
it('logs in as demo with any password', async () => {
const server = createDemoServer();
live.push({ server, mx: clientFor(server) });
const res = await live[0].mx.loginWithPassword('demo', 'whatever');
expect(res.user_id).toBe(ME);
expect(res.access_token).toBeTruthy();
});
it('syncs the joined rooms, space tree, direct message and members', async () => {
const { mx } = await syncedClient();
const joined = ROOMS.filter((r) => r.joined);
expect(
mx
.getRooms()
.map((r) => r.roomId)
.sort()
).toEqual(joined.map((r) => r.id).sort());
expect(mx.getRoom(GENERAL)?.name).toBe('General');
expect(mx.getRoom(COMMONS)).toBeNull();
const direct = mx.getAccountData('m.direct')?.getContent() as Record<string, string[]>;
expect(Object.values(direct).flat()).toContain(ROOMS.find((r) => r.direct)?.id);
expect(mx.getRoom(GENERAL)?.getJoinedMemberCount()).toBeGreaterThan(3);
});
it('carries pose labels that the pose catalogue resolves, for known Minds only', async () => {
const { mx } = await syncedClient();
const events = mx
.getRoom(GENERAL)!
.getLiveTimeline()
.getEvents()
.filter((e) => e.getType() === 'm.room.message');
const posed = events.filter((e) => e.getContent()[POSE_KEY]);
expect(posed.length).toBeGreaterThan(3);
posed.forEach((e) => {
const local = e.getSender()!.slice(1).split(':')[0];
const mind = mindOfSender(local, undefined);
expect(mind, e.getSender()!).toBeDefined();
expect(resolvePose(mind!, e.getContent()[POSE_KEY])).toBe(e.getContent()[POSE_KEY]);
});
expect(
events.some((e) => mindOfSender(e.getSender()!.slice(1).split(':')[0]) === undefined)
).toBe(true);
});
it('shows the training line only in the rooms whose notice says train', async () => {
const { mx } = await syncedClient();
const lines = ROOMS.filter((r) => r.joined && !r.space && !r.direct).map((r) => {
const ev = mx.getRoom(r.id)!.currentState.getStateEvents('org.gamecult.notice', '');
return [r.name, roomTrainingLine(parseRoomNotice(ev?.getContent())) !== undefined];
});
expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false });
});
it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => {
const { mx } = await syncedClient();
const room = mx.getRoom(GENERAL)!;
const before = room.getLiveTimeline().getEvents().length;
const sent = await mx.sendTextMessage(GENERAL, 'hello pretend world');
const mine = await waitFor(() =>
room
.getLiveTimeline()
.getEvents()
.find((e) => e.getId() === sent.event_id && !e.status)
);
expect(mine.getSender()).toBe(ME);
const reply = await waitFor(
() =>
room
.getLiveTimeline()
.getEvents()
.find(
(e) =>
e.getSender() !== ME &&
e.getContent().body?.length &&
room.getLiveTimeline().getEvents().indexOf(e) >= before + 1
),
6000
);
expect(reply.getContent()[POSE_KEY]).toBeTruthy();
const ours = room
.getLiveTimeline()
.getEvents()
.filter((e) => e.getContent().body === 'hello pretend world');
expect(ours).toHaveLength(1);
});
it('joins the unjoined public room and then syncs it', async () => {
const { mx } = await syncedClient();
const joinedRoom = new Promise<string>((resolve) =>
mx.on(ClientEvent.Room, (r) => resolve(r.roomId))
);
await mx.joinRoom(COMMONS);
expect(await joinedRoom).toBe(COMMONS);
});
it('resolves the root space alias and serves the hierarchy with steward metadata', async () => {
const { mx } = await syncedClient();
const alias = await mx.getRoomIdForAlias('#gamecult:demo.invalid');
expect(alias.room_id).toBeTruthy();
const h = await mx.getRoomHierarchy(alias.room_id);
const stewards = h.rooms
.flatMap((r) => r.children_state)
.map((c) => (c.content as Record<string, unknown>)['org.gamecult.steward']);
expect(stewards.filter(Boolean).length).toBe(2);
});
it('answers a long poll with nothing new after its timeout', async () => {
const server = createDemoServer();
live.push({ server, mx: clientFor(server) });
const first = await server.handle({
method: 'GET',
url: `${DEMO_ORIGIN}/_matrix/client/v3/sync`,
});
const token = (first.body as { next_batch: string }).next_batch;
const t0 = Date.now();
const idle = await server.handle({
method: 'GET',
url: `${DEMO_ORIGIN}/_matrix/client/v3/sync?since=${token}&timeout=150`,
});
expect(Date.now() - t0).toBeGreaterThanOrEqual(140);
expect((idle.body as { rooms: { join: object } }).rooms.join).toEqual({});
});
it('says so, rather than inventing an answer, for an endpoint it does not implement', async () => {
const server = createDemoServer();
live.push({ server, mx: clientFor(server) });
const res = await server.handle({
method: 'GET',
url: `${DEMO_ORIGIN}/_matrix/client/v3/something/new`,
});
expect(res.status).toBe(404);
expect((res.body as { errcode: string }).errcode).toBe('M_UNRECOGNIZED');
});
});
describe('the guard', () => {
it('refuses a real homeserver and never reaches the real fetch for it', async () => {
const server = createDemoServer();
live.push({ server, mx: clientFor(server) });
const f = guardedFetch(server, PAGE, never as never);
await expect(f('https://matrix.org/_matrix/client/versions')).rejects.toThrow(
/pretend server only/
);
await expect(f(new Request('https://converser.eu/_matrix/client/v3/login'))).rejects.toThrow(
/refused/
);
expect(never).not.toHaveBeenCalled();
await f(`${PAGE}/config.json`);
expect(never).toHaveBeenCalledTimes(1);
const versions = await f(`${DEMO_ORIGIN}/_matrix/client/versions`);
expect((await versions.json()).versions.length).toBeGreaterThan(0);
});
it('installDemo refuses WebSocket, XMLHttpRequest and beacons to foreign origins', () => {
const store = new Map<string, string>();
const open = vi.fn();
class Sock {
constructor(public url: string) {}
}
const win = {
location: { origin: PAGE },
fetch: never,
WebSocket: Sock,
XMLHttpRequest: { prototype: { open } },
navigator: { sendBeacon: vi.fn(() => true) },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
},
} as unknown as Window & typeof globalThis;
vi.stubGlobal('document', {
createElement: () => ({ style: {}, setAttribute: vi.fn() }),
body: { appendChild: vi.fn() },
addEventListener: vi.fn(),
});
const server = installDemo(win);
live.push({ server, mx: clientFor(server) });
expect(() => new win.WebSocket('wss://matrix.org/x')).toThrow(/refused/);
expect(() => new win.WebSocket(`wss://page.test/x`)).not.toThrow();
expect(() => win.XMLHttpRequest.prototype.open('GET', 'https://matrix.org/x')).toThrow(
/refused/
);
win.XMLHttpRequest.prototype.open('GET', '/same-origin');
expect(open).toHaveBeenCalledTimes(1);
expect(win.navigator.sendBeacon('https://matrix.org/x')).toBe(false);
vi.unstubAllGlobals();
});
it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => {
const fresh = new Map<string, string>();
seedDemoSettings({
getItem: (k: string) => fresh.get(k) ?? null,
setItem: (k: string, v: string) => void fresh.set(k, v),
} as Storage);
expect(JSON.parse(fresh.get('settings')!)).toEqual({ poseBubbles: true });
const chosen = new Map([['settings', '{"poseBubbles":false}']]);
seedDemoSettings({
getItem: (k: string) => chosen.get(k) ?? null,
setItem: (k: string, v: string) => void chosen.set(k, v),
} as Storage);
expect(chosen.get('settings')).toBe('{"poseBubbles":false}');
});
});

421
src/preview-demo/server.ts Normal file
View file

@ -0,0 +1,421 @@
// The preview-only pretend homeserver: a pure request handler over in-memory fixtures. It speaks
// just enough of the Matrix client-server API for matrix-js-sdk to log in, sync, show rooms,
// send messages and get a Mind's canned answer. It touches no network and no storage; the
// fetch guard that routes requests here lives in install.ts.
import {
COMMONS,
DEMO_DEVICE,
DEMO_ORIGIN,
DEMO_SERVER,
DEMO_TOKEN,
DemoEvent,
DemoRoom,
ME,
MIND_IDS,
REPLIES,
ROOMS,
USERS,
} from './fixtures';
export type DemoResponse = { status: number; body: unknown };
export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal };
const POSE_KEY = 'org.gamecult.pose';
const NOTICE_TYPE = 'org.gamecult.notice';
const MAX_LONG_POLL_MS = 15000;
const ok = (body: unknown = {}): DemoResponse => ({ status: 200, body });
const err = (status: number, errcode: string, error: string): DemoResponse => ({
status,
body: { errcode, error },
});
type Stamped = { version: number; roomId: string; event: DemoEvent };
export type DemoServer = {
handle: (req: DemoRequest) => Promise<DemoResponse>;
/** Stops the pending Mind replies and long polls. */
close: () => void;
};
export const createDemoServer = (now: () => number = Date.now): DemoServer => {
let version = 1;
let eventCounter = 0;
const joined = new Set(ROOMS.filter((r) => r.joined).map((r) => r.id));
const timeline: Stamped[] = [];
const stateOf = new Map<string, DemoEvent[]>();
const accountData = new Map<string, unknown>();
const waiters = new Set<() => void>();
const timers = new Set<ReturnType<typeof setTimeout>>();
let replyCounter = 0;
let accountDataVersion = 0;
let syncedJoined = new Set(joined);
let polled = false;
let closed = false;
const mk = (
type: string,
sender: string,
content: Record<string, unknown>,
ts: number,
stateKey?: string
): DemoEvent => {
eventCounter += 1;
return {
type,
sender,
content,
origin_server_ts: ts,
event_id: `$demo${eventCounter}`,
...(stateKey === undefined ? {} : { state_key: stateKey }),
};
};
const nameOf = (id: string) => USERS.find((u) => u.id === id)?.name ?? id;
const buildState = (room: DemoRoom): DemoEvent[] => {
const t0 = now() - 1000 * 60 * 60 * 24;
const creator = room.members[0] ?? ME;
const events: DemoEvent[] = [
mk(
'm.room.create',
creator,
room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' },
t0,
''
),
mk('m.room.name', creator, { name: room.name }, t0, ''),
mk(
'm.room.join_rules',
creator,
{ join_rule: room.id === COMMONS ? 'public' : 'invite' },
t0,
''
),
mk('m.room.history_visibility', creator, { history_visibility: 'shared' }, t0, ''),
mk(
'm.room.power_levels',
creator,
{ users: { [ME]: 100 }, users_default: 0, events_default: 0, state_default: 50 },
t0,
''
),
...room.members.map((m) =>
mk('m.room.member', m, { membership: 'join', displayname: nameOf(m) }, t0, m)
),
];
if (room.topic) events.push(mk('m.room.topic', creator, { topic: room.topic }, t0, ''));
if (room.alias)
events.push(mk('m.room.canonical_alias', creator, { alias: room.alias }, t0, ''));
if (room.notice)
events.push(mk(NOTICE_TYPE, creator, { version: 1, scope: room.notice }, t0, ''));
if (room.parent) {
events.push(
mk('m.space.parent', creator, { via: [DEMO_SERVER], canonical: true }, t0, room.parent)
);
}
ROOMS.filter((c) => c.parent === room.id).forEach((c) => {
events.push(
mk(
'm.space.child',
creator,
{
via: [DEMO_SERVER],
...(c.steward
? {
'org.gamecult.steward': { ...c.steward, source: 'demo', steward: c.steward.mind },
}
: {}),
},
t0,
c.id
)
);
});
return events;
};
ROOMS.forEach((room) => {
stateOf.set(room.id, buildState(room));
room.lines.forEach((l) => {
timeline.push({
version: 1,
roomId: room.id,
event: mk(
'm.room.message',
l.from,
{ msgtype: 'm.text', body: l.body, ...(l.pose ? { [POSE_KEY]: l.pose } : {}) },
now() - l.minutesAgo * 60 * 1000
),
});
});
});
timeline.sort((a, b) => a.event.origin_server_ts - b.event.origin_server_ts);
accountData.set('m.direct', {
[`@eureka:${DEMO_SERVER}`]: ROOMS.filter((r) => r.direct).map((r) => r.id),
});
const notify = () => {
waiters.forEach((w) => w());
waiters.clear();
};
const push = (roomId: string, event: DemoEvent) => {
version += 1;
timeline.push({ version, roomId, event });
notify();
};
const later = (ms: number, fn: () => void) => {
const t = setTimeout(() => {
timers.delete(t);
if (!closed) fn();
}, ms);
timers.add(t);
};
// A Mind in the room answers whatever the visitor sent, with a pose from a short cycle.
const scheduleReply = (room: DemoRoom) => {
const mind = room.members.find((m) => MIND_IDS.includes(m));
if (!mind) return;
const reply = REPLIES[replyCounter % REPLIES.length];
replyCounter += 1;
later(1200, () =>
push(
room.id,
mk(
'm.room.message',
mind,
{ msgtype: 'm.text', body: reply.body, [POSE_KEY]: reply.pose },
now()
)
)
);
};
const roomJson = (room: DemoRoom, since: number) => {
const full = since === 0;
const events = timeline
.filter((t) => t.roomId === room.id && (full || t.version > since))
.map((t) => t.event);
return {
state: { events: full ? stateOf.get(room.id) : [] },
timeline: { events, limited: false, prev_batch: 'p0' },
ephemeral: { events: [] },
account_data: { events: [] },
unread_notifications: { highlight_count: 0, notification_count: 0 },
summary: {
'm.joined_member_count': room.members.length,
'm.invited_member_count': 0,
'm.heroes': [],
},
};
};
const syncBody = (since: number, joinedBefore: Set<string>) => {
const join: Record<string, unknown> = {};
ROOMS.filter((r) => joined.has(r.id)).forEach((r) => {
const newlyJoined = !joinedBefore.has(r.id);
const j = roomJson(r, newlyJoined ? 0 : since);
if (since === 0 || newlyJoined || (j.timeline.events as unknown[]).length) join[r.id] = j;
});
const ad: { type: string; content: unknown }[] = [];
if (since === 0 || accountDataVersion > since) {
accountData.forEach((content, type) => ad.push({ type, content }));
}
return {
next_batch: `d${version}`,
rooms: { join, invite: {}, leave: {} },
account_data: { events: ad },
presence: { events: [] },
to_device: { events: [] },
device_one_time_keys_count: { signed_curve25519: 50 },
};
};
const sync = async (
since: number,
timeoutMs: number,
signal?: AbortSignal
): Promise<DemoResponse> => {
if (since === 0) {
syncedJoined = new Set(joined);
return ok(syncBody(0, new Set()));
}
const changed = () => version > since || accountDataVersion > since;
if (!changed()) {
await new Promise<void>((resolve) => {
const done = () => {
waiters.delete(done);
resolve();
};
waiters.add(done);
// The first poll answers quickly so the client leaves its Connecting state.
later(polled ? Math.min(timeoutMs, MAX_LONG_POLL_MS) : 200, done);
polled = true;
signal?.addEventListener('abort', done);
});
}
const before = syncedJoined;
syncedJoined = new Set(joined);
return ok(syncBody(since, before));
};
const roomOf = (id: string) => ROOMS.find((r) => r.id === id);
const hierarchy = () => {
const entry = (r: DemoRoom) => ({
room_id: r.id,
name: r.name,
topic: r.topic,
canonical_alias: r.alias,
num_joined_members: r.members.length,
world_readable: false,
guest_can_join: false,
join_rule: r.id === COMMONS ? 'public' : 'invite',
...(r.space ? { room_type: 'm.space' } : {}),
children_state: (stateOf.get(r.id) ?? []).filter((e) => e.type === 'm.space.child'),
});
return ROOMS.filter((r) => !r.direct).map(entry);
};
const handle = async (req: DemoRequest): Promise<DemoResponse> => {
const u = new URL(req.url, DEMO_ORIGIN);
const p = decodeURIComponent(u.pathname);
const m = req.method.toUpperCase();
const body = (req.body ?? {}) as Record<string, unknown>;
let g: RegExpMatchArray | null;
if (p === '/.well-known/matrix/client')
return ok({ 'm.homeserver': { base_url: DEMO_ORIGIN } });
if (!p.startsWith('/_matrix/')) return err(404, 'M_NOT_FOUND', 'Not found');
const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, '');
if (p.endsWith('/versions'))
return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (c === '/login') {
if (m === 'GET') return ok({ flows: [{ type: 'm.login.password' }] });
return ok({
user_id: ME,
access_token: DEMO_TOKEN,
device_id: DEMO_DEVICE,
home_server: DEMO_SERVER,
});
}
if (c === '/register')
return err(403, 'M_FORBIDDEN', 'Registration is closed on the pretend server.');
if (c === '/account/whoami') return ok({ user_id: ME, device_id: DEMO_DEVICE });
if (c === '/capabilities') return ok({ capabilities: {} });
g = c.match(/^\/profile\/(.+)$/);
if (g) return ok({ displayname: nameOf(g[1]) });
if (c.endsWith('/filter') && m === 'POST') return ok({ filter_id: '1' });
if (c.includes('/filter/')) return ok({});
if (c === '/sync') {
const raw = u.searchParams.get('since');
const since = raw ? Number(raw.replace(/^d/, '')) || 0 : 0;
return sync(since, Number(u.searchParams.get('timeout') ?? 0), req.signal);
}
if (c === '/pushrules/' || c === '/pushrules') {
return ok({ global: { override: [], underride: [], room: [], sender: [], content: [] } });
}
g = c.match(/^\/user\/[^/]+\/account_data\/(.+)$/);
if (g) {
if (m === 'PUT') {
accountData.set(g[1], req.body ?? {});
version += 1;
accountDataVersion = version;
notify();
return ok({});
}
return accountData.has(g[1])
? ok(accountData.get(g[1]))
: err(404, 'M_NOT_FOUND', 'No account data');
}
g = c.match(/^\/directory\/room\/(.+)$/);
if (g) {
const room = ROOMS.find((r) => r.alias === g![1]);
return room
? ok({ room_id: room.id, servers: [DEMO_SERVER] })
: err(404, 'M_NOT_FOUND', 'Room alias not found');
}
if (c === '/joined_rooms') return ok({ joined_rooms: [...joined] });
g = c.match(/^\/rooms\/([^/]+)\/hierarchy$/);
if (g) return ok({ rooms: hierarchy() });
g = c.match(/^\/(?:rooms\/([^/]+)\/join|join\/([^/]+))$/);
if (g && m === 'POST') {
const target = g[1] ?? g[2];
const room = roomOf(target) ?? ROOMS.find((r) => r.alias === target);
if (!room) return err(404, 'M_NOT_FOUND', 'No such room');
if (!joined.has(room.id)) {
joined.add(room.id);
if (!room.members.includes(ME)) room.members.push(ME);
stateOf.set(room.id, buildState(room));
version += 1;
notify();
}
return ok({ room_id: room.id });
}
g = c.match(
/^\/rooms\/([^/]+)\/(?:leave|forget|read_markers|typing\/[^/]+|receipt\/.+|report\/.+)$/
);
if (g) return ok({});
g = c.match(/^\/rooms\/([^/]+)\/send\/([^/]+)\/([^/]+)$/);
if (g && m === 'PUT') {
const room = roomOf(g[1]);
if (!room || !joined.has(room.id)) return err(403, 'M_FORBIDDEN', 'Not in room');
const event = mk(g[2], ME, body, now());
event.unsigned = { transaction_id: g[3] };
push(room.id, event);
if (g[2] === 'm.room.message') scheduleReply(room);
return ok({ event_id: event.event_id });
}
g = c.match(/^\/rooms\/([^/]+)\/state\/([^/]+)(?:\/(.*))?$/);
if (g) {
const room = roomOf(g[1]);
if (!room) return err(404, 'M_NOT_FOUND', 'No such room');
const key = g[3] ?? '';
if (m === 'PUT') {
const event = mk(g[2], ME, body, now(), key);
stateOf.set(room.id, [
...(stateOf.get(room.id) ?? []).filter((e) => !(e.type === g![2] && e.state_key === key)),
event,
]);
push(room.id, event);
return ok({ event_id: event.event_id });
}
const found = (stateOf.get(room.id) ?? []).find(
(e) => e.type === g![2] && e.state_key === key
);
return found ? ok(found.content) : err(404, 'M_NOT_FOUND', 'No such state event');
}
g = c.match(/^\/rooms\/([^/]+)\/state$/);
if (g) return ok(stateOf.get(g[1]) ?? []);
g = c.match(/^\/rooms\/([^/]+)\/members$/);
if (g) {
return ok({ chunk: (stateOf.get(g[1]) ?? []).filter((e) => e.type === 'm.room.member') });
}
g = c.match(/^\/rooms\/([^/]+)\/messages$/);
if (g) return ok({ chunk: [], start: 'p0', end: 'p0' });
g = c.match(/^\/rooms\/([^/]+)\/(?:summary|context\/.+)$/);
if (g) return err(404, 'M_NOT_FOUND', 'Not available on the pretend server');
if (c === '/keys/upload') return ok({ one_time_key_counts: { signed_curve25519: 50 } });
if (c === '/keys/query') return ok({ device_keys: {}, failures: {} });
if (c === '/keys/claim') return ok({ one_time_keys: {}, failures: {} });
if (c === '/keys/changes') return ok({ changed: [], left: [] });
if (c.startsWith('/keys/')) return ok({});
if (c.startsWith('/sendToDevice/')) return ok({});
if (c.startsWith('/room_keys/')) return err(404, 'M_NOT_FOUND', 'No backup');
if (c === '/devices')
return ok({ devices: [{ device_id: DEMO_DEVICE, display_name: 'Preview' }] });
if (c === '/thirdparty/protocols' || c === '/voip/turnServer') return ok({});
if (c.startsWith('/presence/')) return ok({});
if (c === '/user_directory/search') return ok({ results: [], limited: false });
if (p.endsWith('/media/config') || p.endsWith('/config')) return ok({ 'm.upload.size': 0 });
return err(404, 'M_UNRECOGNIZED', `The pretend server does not implement ${m} ${p}`);
};
return {
handle,
close: () => {
closed = true;
timers.forEach(clearTimeout);
timers.clear();
notify();
},
};
};