Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
02adfb5
feat(widget): add confirmed controller room closure
YueyuHoshizora Oct 16, 2026
7d8659f
perf(widget): skip unchanged name editor attribute writes
YueyuHoshizora Oct 16, 2026
fe5dbea
feat(widget): show snapshot-owned room occupancy
YueyuHoshizora Oct 16, 2026
9266062
feat(embed): show snapshot-owned room occupancy
YueyuHoshizora Oct 16, 2026
f205a68
feat(embed): show own latest-received connection quality
YueyuHoshizora Oct 16, 2026
13a692b
fix(widget): fence retired own-quality observation windows
YueyuHoshizora Oct 16, 2026
49ff0b4
feat(embed): display latest public speaking reports
YueyuHoshizora Oct 16, 2026
338ec91
fix(widget): isolate speaking receipts across stage lifecycles
YueyuHoshizora Oct 16, 2026
b902b20
feat(client): add manual own connection statistics (batch 203)
YueyuHoshizora Oct 16, 2026
26643a8
feat(embed): add manual own connection statistics (batch 204)
YueyuHoshizora Oct 16, 2026
deffd9c
fix(client): preserve observed stats consent loss (batch 205)
YueyuHoshizora Oct 16, 2026
ffef681
perf(client): skip equal playback ARIA writes (batch 206)
YueyuHoshizora Oct 16, 2026
39c9e17
fix(web): retire connection history on seat replacement (batch 207)
YueyuHoshizora Oct 16, 2026
035f519
feat(web): show directional historical connection statistics (batch 208)
YueyuHoshizora Oct 16, 2026
e70aee2
fix(web): retire stage quality on viewer departure (batch 209)
YueyuHoshizora Oct 16, 2026
c27bd93
fix(web): retire stale speaking indicators on reconnect (batch 210)
YueyuHoshizora Oct 16, 2026
1cc0036
feat(embed): add explicit offstage microphone level test (batch 211)
YueyuHoshizora Oct 16, 2026
a8e80ba
feat(widget): add explicit offstage microphone level test (batch 212)
YueyuHoshizora Oct 16, 2026
f7372c7
feat(widget): show public onstage snapshot roster (batch 213)
YueyuHoshizora Oct 16, 2026
151446e
feat(embed): add passive public onstage roster snapshot
YueyuHoshizora Oct 16, 2026
58537b0
fix(widget): fence roster receipts across predecessor retirement
YueyuHoshizora Oct 16, 2026
eaa7306
feat(sample): show public room seat snapshot and correct audience cap…
YueyuHoshizora Oct 16, 2026
4574777
fix(sample): reject stale invitation copy controls before admission
YueyuHoshizora Oct 16, 2026
02b47c3
feat(client): support typed once and abortable event subscriptions
YueyuHoshizora Oct 16, 2026
170f620
feat(widget): filter controller hand queue by participant name
YueyuHoshizora Oct 17, 2026
197a021
feat(embed): filter controller hand queue by local name search
YueyuHoshizora Oct 17, 2026
7981867
feat(web): add independent controller hand queue name filter
YueyuHoshizora Oct 17, 2026
a60db98
ci: validate Node 24 types tests and builds on pushes and pull requests
YueyuHoshizora Oct 17, 2026
c456ead
fix(web): resolve built assets under configured static mounts
YueyuHoshizora Oct 17, 2026
0e02579
test(build): verify delivered client package as an external consumer
YueyuHoshizora Oct 17, 2026
cd23ae4
test(ci): verify compiled server delivery and Linux lifecycle
YueyuHoshizora Oct 17, 2026
a05dd44
fix(scripts): reap background server when PID persistence fails
YueyuHoshizora Oct 17, 2026
04fc032
fix(windows): support first startup and verify PowerShell scripts in CI
YueyuHoshizora Oct 17, 2026
e12a02a
fix(scripts): preserve Windows run-path file collisions
YueyuHoshizora Oct 17, 2026
f62b4a2
fix(scripts): clean up Windows child after PID write failure
YueyuHoshizora Oct 17, 2026
954207d
feat(scripts): preflight Windows restart before stopping service
YueyuHoshizora Oct 17, 2026
211b19c
test(windows): expose restart fixture failure diagnostics
YueyuHoshizora Oct 17, 2026
a27591b
test(windows): select one PATH-first Node executable
YueyuHoshizora Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
stage.ps1 text eol=lf
test/fixtures/windows-stage/stage-before-run-directory.ps1 -text
test/fixtures/windows-stage/stage-before-pid-cleanup.ps1 -text
test/fixtures/windows-stage/stage-before-restart-preflight.ps1 -text
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Node.js CI

on:
push:
branches: ['**']
pull_request:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
name: Node 24 / types, tests, build
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js 24
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
package-manager-cache: false
- name: Install locked dependencies
run: npm ci --no-audit --no-fund
- name: Check types
run: npm run typecheck
- name: Run tests
run: npm test
- name: Build all packages
run: npm run build
- name: Check built server delivery
run: npm run test:server-package
windows_scripts:
name: Windows / PowerShell startup
runs-on: windows-2025
timeout-minutes: 10
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js 24
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
package-manager-cache: false
- name: Check Windows startup scripts
shell: pwsh
run: node --test --test-timeout=120000 test/windowsStageStart.test.ts
100 changes: 100 additions & 0 deletions PLAN.md

Large diffs are not rendered by default.

85 changes: 79 additions & 6 deletions README.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
"web"
],
"scripts": {
"build": "npm run build:server && npm run build -w packages/client && npm run build -w web && npm run build:compress",
"build": "npm run build:server && npm run build -w packages/client && npm run build -w web && npm run build:compress && npm run test:client-package",
"build:server": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit -p tsconfig.json && npm run typecheck -w packages/client && npm run typecheck -w web",
"test": "node --test --test-timeout=20000 \"test/**/*.test.ts\" && npm run test -w packages/client",
"test:client-package": "node scripts/check-client-package.ts",
"start": "node dist/src/index.js",
"config:check": "node src/check-config.ts",
"dev": "node --watch src/index.ts",
"dev:web": "npm run dev -w web",
"build:compress": "node scripts/precompress.ts packages/client/dist web/dist"
"build:compress": "node scripts/precompress.ts packages/client/dist web/dist",
"test:server-package": "node scripts/check-server-package.ts"
},
"dependencies": {
"@evan/opus": "1.0.3",
Expand Down
60 changes: 55 additions & 5 deletions packages/client/README.md

Large diffs are not rendered by default.

1,781 changes: 1,567 additions & 214 deletions packages/client/src/element.ts

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function createMicMeter(stream: MediaStream): MicTest {
throw error;
}
}
export interface StageClientEventOptions { once?: boolean; signal?: AbortSignal }
export interface StageEventMap {
state: RoomStatePayload;
status: ClientStatus;
Expand Down Expand Up @@ -463,9 +464,10 @@ export class StageClient extends EventTarget {
private audioConstraints(deviceId: string): MediaTrackConstraints {
return { channelCount: 1, sampleRate: 48000, echoCancellation: true, noiseSuppression: true, autoGainControl: true, ...this.options.micConstraints, ...(deviceId ? { deviceId: { exact: deviceId } } : {}) };
}
on<K extends keyof StageEventMap>(type: K, listener: (event: CustomEvent<StageEventMap[K]>) => void): () => void {
on<K extends keyof StageEventMap>(type: K, listener: (event: CustomEvent<StageEventMap[K]>) => void, options?: StageClientEventOptions): () => void {
const handler = listener as EventListener;
this.addEventListener(type, handler);
// Keep native listener identity and false capture even with structurally wider options.
this.addEventListener(type, handler, { once: options?.once, signal: options?.signal });
return () => this.removeEventListener(type, handler);
}
private emit<K extends keyof StageEventMap>(type: K, detail: StageEventMap[K]): void {
Expand Down
3,245 changes: 3,245 additions & 0 deletions packages/client/test/element.test.ts

Large diffs are not rendered by default.

543 changes: 543 additions & 0 deletions packages/client/test/elementHandQueueSearch.test.ts

Large diffs are not rendered by default.

489 changes: 489 additions & 0 deletions packages/client/test/elementMicTest.test.ts

Large diffs are not rendered by default.

372 changes: 372 additions & 0 deletions packages/client/test/elementPublicRoster.test.ts

Large diffs are not rendered by default.

328 changes: 328 additions & 0 deletions packages/client/test/elementPublicRosterRetirement.test.ts

Large diffs are not rendered by default.

274 changes: 274 additions & 0 deletions packages/client/test/eventSubscription.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import { it, type TestContext } from 'node:test';
import type { ServerMessage } from '../../../shared/protocol.ts';
import { StageClient, type StageClientEventOptions, type StageEventMap } from '../src/index.ts';

type HandEvent = CustomEvent<StageEventMap['hand']>;
class Socket {
static OPEN = 1;
static latest: Socket;
readyState = Socket.OPEN;
onmessage: ((event: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
constructor() { Socket.latest = this; }
send() { throw new Error('Event subscription tests must not send requests'); }
deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); }
close() { this.readyState = 3; this.onclose?.(); }
}
async function fixture(t: TestContext) {
const originals = new Map(['window', 'WebSocket'].map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperty(globalThis, 'window', { configurable: true, value: new EventTarget() });
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: Socket });
const client = new StageClient({ url: 'ws://localhost/events', audioElement: { pause() {} } as unknown as HTMLAudioElement });
t.after(async () => {
try { await client.disconnect(); }
finally {
for (const [key, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
const connected = client.connect(), socket = Socket.latest;
socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'event-test',
limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } });
await connected;
const hand = (participantId = 'participant', raised = true) => socket.deliver({ type: raised ? 'hand:raise' : 'hand:withdraw', participantId });
return { client, hand };
}

it('keeps two-argument typed subscriptions, actual SDK event identity, receiver and idempotent unsubscribe', async t => {
const { client, hand } = await fixture(t);
const native: Event[] = [], received: HandEvent[] = [];
const off = client.on('hand', function (this: StageClient, event) {
assert.ok(event instanceof CustomEvent);
assert.equal(this, client);
assert.equal(event.target, client);
assert.equal(event.currentTarget, client);
received.push(event);
});
client.addEventListener('hand', event => native.push(event));
hand('first'); hand('second', false); off(); off(); hand('third');
assert.deepEqual(received.map(event => event.detail), [
{ participantId: 'first', raised: true }, { participantId: 'second', raised: false },
]);
assert.equal(received[0], native[0]); assert.equal(received[1], native[1]);
assert.equal(native.length, 3);
});

it('removes once subscriptions before reentrant SDK delivery', async t => {
const { client, hand } = await fixture(t);
const seen: string[] = [];
const off = client.on('hand', event => {
seen.push(event.detail.participantId);
if (seen.length === 1) hand('nested');
}, { once: true });
hand('outer'); hand('later'); off(); off();
assert.deepEqual(seen, ['outer']);
});

for (const options of [undefined, {}, { once: false }] satisfies (StageClientEventOptions | undefined)[]) {
it(`retains ordinary repeated delivery with ${JSON.stringify(options) ?? 'omitted options'}`, async t => {
const { client, hand } = await fixture(t);
let calls = 0;
const off = client.on('hand', () => { calls++; }, options);
hand(); hand(); off(); hand();
assert.equal(calls, 2);
});
}

it('honors active abort and isolates other callbacks', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController();
let scoped = 0, independent = 0;
const off = client.on('hand', () => { scoped++; }, { signal: controller.signal });
client.on('hand', () => { independent++; });
hand(); controller.abort(); hand(); off(); off(); controller.abort(); hand();
assert.deepEqual([scoped, independent], [1, 3]);
});

it('never registers a callback for an already aborted signal', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController(); controller.abort();
let calls = 0;
const off = client.on('hand', () => { calls++; }, { once: true, signal: controller.signal });
hand(); off(); off(); hand();
assert.equal(calls, 0);
});

it('aborts all distinct listeners sharing a signal', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController();
const seen: string[] = [];
client.on('hand', () => seen.push('first'), { signal: controller.signal });
client.on('hand', () => seen.push('second'), { signal: controller.signal });
hand(); controller.abort(); hand();
assert.deepEqual(seen, ['first', 'second']);
});

it('suppresses a later listener when its signal is aborted during dispatch', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController();
const seen: string[] = [];
client.on('hand', () => { seen.push('first'); controller.abort(); });
client.on('hand', () => seen.push('aborted'), { signal: controller.signal });
client.on('hand', () => seen.push('last'));
hand(); hand();
assert.deepEqual(seen, ['first', 'last', 'first', 'last']);
});

it('allows self-abort during a callback and prevents nested delivery to that callback', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController();
let calls = 0;
client.on('hand', () => {
calls++;
controller.abort();
if (calls === 1) hand('nested');
}, { signal: controller.signal });
hand(); hand();
assert.equal(calls, 1);
});

it('supports unsubscribe before delivery with both once and signal', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController();
let calls = 0;
const off = client.on('hand', () => { calls++; }, { once: true, signal: controller.signal });
off(); off(); hand(); controller.abort(); hand();
assert.equal(calls, 0);
});

it('allows once and signal together and harmless cleanup after one-shot delivery', async t => {
const { client, hand } = await fixture(t);
const controller = new AbortController();
let calls = 0;
const off = client.on('hand', () => { calls++; }, { once: true, signal: controller.signal });
hand(); hand(); off(); controller.abort(); off(); hand();
assert.equal(calls, 1);
});

it('preserves native deduplication and either unsubscribe removes the shared callback', async t => {
const { client, hand } = await fixture(t);
let calls = 0;
const listener = (_event: HandEvent) => { calls++; };
const firstOff = client.on('hand', listener);
const secondOff = client.on('hand', listener, { once: false });
hand(); assert.equal(calls, 1);
secondOff(); hand(); assert.equal(calls, 1);
firstOff(); hand(); assert.equal(calls, 1);
});

for (const firstOnce of [false, true]) {
it(`does not change native once behavior on duplicate registration when first once is ${firstOnce}`, async t => {
const { client, hand } = await fixture(t);
let calls = 0;
const listener = (_event: HandEvent) => { calls++; };
client.on('hand', listener, { once: firstOnce });
client.on('hand', listener, { once: !firstOnce });
hand(); hand();
assert.equal(calls, firstOnce ? 1 : 2);
});
}

for (const abortSecond of [false, true]) {
it(`matches native duplicate-signal semantics when aborting the ${abortSecond ? 'second' : 'first'} signal`, async t => {
const { client, hand } = await fixture(t);
const native = new EventTarget();
const first = new AbortController(), second = new AbortController();
const actual: string[] = [], expected: string[] = [];
const listener = (event: HandEvent) => actual.push(event.detail.participantId);
const nativeListener = (event: Event) => expected.push((event as HandEvent).detail.participantId);
client.on('hand', listener, { signal: first.signal });
client.on('hand', listener, { once: true, signal: second.signal });
native.addEventListener('hand', nativeListener, { signal: first.signal });
native.addEventListener('hand', nativeListener, { once: true, signal: second.signal });
const emit = (participantId: string) => {
hand(participantId);
native.dispatchEvent(new CustomEvent('hand', { detail: { participantId, raised: true } }));
assert.deepEqual(actual, expected);
};
emit('before'); (abortSecond ? second : first).abort(); emit('after');
first.abort(); second.abort(); emit('both');
});
}

it('keeps native and typed registrations interoperable in both detach directions', async t => {
const { client, hand } = await fixture(t);
let calls = 0;
const listener = (_event: HandEvent) => { calls++; };
client.addEventListener('hand', listener as EventListener);
const off = client.on('hand', listener, { once: true });
hand(); hand(); assert.equal(calls, 2);
off(); hand(); assert.equal(calls, 2);
client.on('hand', listener, { signal: new AbortController().signal });
client.removeEventListener('hand', listener as EventListener);
hand(); assert.equal(calls, 2);
});

it('passes the original callback and only projected once/signal values to native registration', async t => {
const { client, hand } = await fixture(t);
const add = t.mock.method(client, 'addEventListener');
const remove = t.mock.method(client, 'removeEventListener');
const controller = new AbortController();
const wider = { once: false, signal: controller.signal, capture: true, passive: true };
let calls = 0;
const listener = (_event: HandEvent) => { calls++; };
const off = client.on('hand', listener, wider);
const registration = add.mock.calls[0]!;
assert.equal(registration.arguments[0], 'hand');
assert.equal(registration.arguments[1], listener);
assert.notEqual(registration.arguments[2], wider);
assert.deepEqual(registration.arguments[2], { once: false, signal: controller.signal });
hand(); off(); hand();
assert.equal(calls, 1);
assert.deepEqual(remove.mock.calls[0]!.arguments, ['hand', listener]);
});

it('does not read unsupported wider option getters or mutate the options object', async t => {
const { client, hand } = await fixture(t);
const options = Object.freeze({ once: true,
get capture(): boolean { throw new Error('capture must not be read'); },
get passive(): boolean { throw new Error('passive must not be read'); },
});
let calls = 0;
const off = client.on('hand', () => { calls++; }, options);
hand(); hand(); off(); assert.equal(calls, 1);
});

it('keeps true-capture native registration separate while ignoring wider capture on the helper', async t => {
const { client, hand } = await fixture(t);
let calls = 0;
const listener = (_event: HandEvent) => { calls++; };
client.addEventListener('hand', listener as EventListener, { capture: true });
const wider = { once: false, capture: true };
const off = client.on('hand', listener, wider);
hand(); assert.equal(calls, 2);
off(); hand(); assert.equal(calls, 3);
client.removeEventListener('hand', listener as EventListener, { capture: true });
hand(); assert.equal(calls, 3);
});

it('retains callback and event-type isolation and supports unsubscribe during dispatch', async t => {
const { client, hand } = await fixture(t);
const seen: string[] = [];
let offLater: () => void;
const shared = () => seen.push('shared');
client.on('status', shared);
const offFirst = client.on('hand', () => { seen.push('first'); offLater(); });
offLater = client.on('hand', shared, { once: true });
hand(); offFirst(); hand();
client.dispatchEvent(new CustomEvent('status', { detail: 'waiting' }));
assert.deepEqual(seen, ['first', 'shared']);
});

it('retains native identity when an old unsubscribe removes a later registration', async t => {
const { client, hand } = await fixture(t);
let calls = 0;
const listener = (_event: HandEvent) => { calls++; };
const oldOff = client.on('hand', listener, { once: true });
hand();
client.on('hand', listener, { signal: new AbortController().signal });
oldOff(); oldOff(); hand();
assert.equal(calls, 1);
});
Loading