preview: pretend homeserver for the dev preview (in-page, off by default) #28
7
.github/workflows/rat-dev-preview.yml
vendored
|
|
@ -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).
|
||||
|
|
|
|||
BIN
preview-demo/poses/eureka/amused.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
preview-demo/poses/eureka/annoyed.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
preview-demo/poses/eureka/happy.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
preview-demo/poses/eureka/neutral.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
preview-demo/poses/eureka/surprised.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
preview-demo/poses/eureka/thinking.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
preview-demo/poses/nibu/amused.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/annoyed.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/happy.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/neutral.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/resolute.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/surprised.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/thinking.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/nibu/triumph.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
preview-demo/poses/void/amused.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
preview-demo/poses/void/annoyed.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
preview-demo/poses/void/deadpan.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
preview-demo/poses/void/glare.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
preview-demo/poses/void/happy.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
preview-demo/poses/void/neutral.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
preview-demo/poses/void/point.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
preview-demo/poses/void/reading.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
preview-demo/poses/void/surprised.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
preview-demo/poses/void/thinking.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -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' : ''}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
215
src/preview-demo/fixtures.ts
Normal 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.' },
|
||||
];
|
||||
1
src/preview-demo/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { installDemo } from './install';
|
||||
146
src/preview-demo/install.ts
Normal 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
|
|
@ -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);
|
||||
270
src/preview-demo/server.test.ts
Normal 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
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||