fix: repair PR verification and docs cleanup

This commit is contained in:
Timo
2026-07-01 23:46:23 +02:00
parent 463f871958
commit dab7368537
13 changed files with 144 additions and 1059 deletions
-1
View File
@@ -44,7 +44,6 @@ jobs:
- name: Run tests
run: npm test
continue-on-error: true
- name: Run verification suite
run: npm run verify
+1 -1
View File
@@ -26,7 +26,7 @@ All notable changes to the KoalaSync browser extension and relay server.
### Changed
- **Playback sync now follows the room's control setting** — When Host Control is enabled, only the host can drive room-wide playback changes; guests can still watch in sync without accidentally changing playback for everyone.
- **Architecture: Modularized background.js** — Refactored 2410-line file into 11 focused modules for better maintainability.
- **Documentation: Consolidated Host Control Mode** — Unified documentation in host-control-mode.md with references to internal implementation details.
- **Documentation: Consolidated Host Control Mode** — Unified documentation in host-control-mode.md.
- **README: Added protocol documentation** — Updated documentation links to include PROTOCOL.md reference.
- **ARCHITECTURE: Added protocol reference** — Updated architecture documentation with link to PROTOCOL.md.
-1
View File
@@ -284,4 +284,3 @@ function getHostSyncTarget() {
- [Protocol Specification](PROTOCOL.md) — Technical event details
- [Architecture](ARCHITECTURE.md) — System overview
- [Internal Documentation](internal/host-control-mode-plan.md) — Original implementation plan (German)
@@ -1,118 +0,0 @@
# Co-Host (Multi-Controller) — Implementation Plan
Branch base: `feature/host-control-mode` (builds directly on it).
Goal: let the room owner grant **playback control to several peers** (co-hosts), not
just one — e.g. 4 of N people in a room may drive play/pause/seek, the rest are guests.
This is the second server-gated feature the `capabilities` hook was designed for
(`CAPABILITIES.CO_HOST = 'co-host'`, already stubbed in `shared/constants.js`).
---
## 1. Roles
| Role | Can drive (play/pause/seek/force-sync/episode-lobby) | Can promote/demote + toggle mode |
|------|------|------|
| **Owner** (room creator, = today's "host") | yes (always a controller) | **yes** |
| **Controller** (co-host) | yes (in `host-only` mode) | no |
| **Guest** | only in `everyone` mode | no |
The single-host feature is just the special case `controllers = { owner }`.
## 2. Data model
### Server (`room` object)
- `ownerPeerId` — the creator / manager. Keep `hostPeerId` as an **alias** (= ownerPeerId)
so older clients keep working.
- `controllers: Set<peerId>` — peers allowed to drive. **Always contains ownerPeerId.**
- `controlMode: 'everyone' | 'host-only'` — unchanged wire values (`'host-only'` now means
"restricted to controllers", not "single host").
- `MAX_CONTROLLERS` cap (e.g. 10) to bound the set + payload.
### Shared constants
- `CAPABILITIES.CO_HOST = 'co-host'` (un-stub it) → add to `SERVER_CAPABILITIES`.
- New events:
- `SET_PEER_ROLE` (client→server): `{ peerId, controller: boolean }` — owner promotes/demotes.
- Extend `CONTROL_MODE` (server→client) payload: `{ controlMode, ownerPeerId, hostPeerId, controllers: [peerId...] }`.
- `ROOM_DATA` gains `ownerPeerId` + `controllers`.
## 3. Gate generalization (the core change)
Today the gate compares against a single `hostPeerId`. Generalize to set membership:
- **Server relay gate** (`server/index.js`): `controlMode === 'host-only' && !room.controllers.has(mapping.peerId)` → drop. (Was `mapping.peerId !== room.hostPeerId`.)
- **Background gates** (sender + receiver): replace `amHost()` / `senderId !== hostPeerId`
with controller-set membership: `controllers.includes(myPeerId)` / `senderId ∈ controllers`.
- **Helpers:** split `amHost()` into `amOwner()` (manage rights) and `amController()`
(drive rights). The desync/snap-back path keys on `!amController()` instead of `!amHost()`.
`SET_PEER_ROLE` handler (server): validate sender is owner, target is a current peer in the
room, enforce `MAX_CONTROLLERS`, always keep owner in the set, then broadcast `CONTROL_MODE`
with the new `controllers`.
## 4. Client + UI
- **Owner** sees the peer list with a per-peer **"Controller" toggle** (promote/demote) plus
the existing mode toggle.
- **Controllers** see a "Controller" badge and are NOT locked out of the remote-control buttons.
- **Guests** see "Guest" + the host-only notice (unchanged).
- The promote UI + co-host badges render only when the relay advertises the `co-host`
capability (feature detection, same pattern as `hostControlSupported`).
- i18n: new keys (`ROLE_CONTROLLER`, `BTN_PROMOTE`, `BTN_DEMOTE`, …) across all locales.
## 5. Backwards compatibility
- **New client + old server** (host-control only, no `co-host` capability): no co-host UI;
behaves as today's single-host. ✓
- **Old client + new server**: ignores `controllers` / `SET_PEER_ROLE`. An old client that
the owner promotes still **gates itself** (its sender-gate only knows `!amHost`), so it
can't drive — it degrades to a guest. Co-host requires a client that understands
`controllers`. Document this; not a crash. ✓
- No `PROTOCOL_VERSION` bump needed — purely additive, same as host-control.
## 6. Edge cases
- **Controller leaves** → `removePeerFromRoom` also does `room.controllers.delete(peerId)`.
- **Owner leaves** → fallback: promote the earliest remaining **controller** to owner (prefer
a controller over a random peer); if none, earliest peer; keep the rest of the set. Reuse
the `peerJoinLocks` guard so a reconnect/second-tab doesn't demote (same fix as host).
- **Promote a peer not in the room** → server rejects (target must be a live peer).
- **Promote beyond `MAX_CONTROLLERS`** → server rejects, re-syncs the owner's UI.
- **`everyone` mode** → the `controllers` set is still maintained (so flipping to `host-only`
keeps the chosen co-hosts), it just isn't enforced while in `everyone`.
- **peerId spoofing** → unchanged accepted limitation (see `docs/KNOWN_LIMITATIONS.md`);
co-host doesn't widen it materially (still bounded to a temporary room).
## 7. Scale: the "4 of 510 people" part — read this
The role change above is moderate. **Putting 510 people in one room is a separate, larger
problem** and should be its own track:
- `MAX_PEERS_PER_ROOM` is **25** today. 510 needs a large raise + load testing.
- **The real bottleneck at scale is heartbeat fan-out, not control events.** Every peer
heartbeats and the relay broadcasts each to all peers → O(N²) per interval. At 510 that's
~510×509 / 15s ≈ **17k msg/s just for heartbeats** — the scaling wall.
- **Co-host actually *helps* the control-event side:** in `host-only` mode only the few
controllers emit play/pause/seek, so event *sources* drop from N to K (e.g. 4). Restricting
who can drive is synergistic with big rooms.
- Large rooms therefore need (independent of co-host):
- **Heartbeat fan-out reduction** — e.g. only relay controller/owner heartbeats to everyone,
relay guest heartbeats only to the owner/controllers (for the UI), or server-side
aggregation into periodic snapshots instead of per-peer relay.
- **`ROOM_DATA` payload trimming** — a 510-entry peer list is large; send counts + controller
details, lazy-load the full roster.
- Possibly the **socket.io Redis adapter** for horizontal scaling, and broadcast tuning.
## 8. Effort estimate
- **Co-host roles** (server gate generalization + `SET_PEER_ROLE` + owner-leave fallback +
client gates + promote UI + i18n), at the current ≤25-peer scale: **~34 dev days** (same
shape as host-control itself — mostly generalizing host→controller-set).
- **Large-room scaling (510)**: a **separate ~12 week** track (heartbeat redesign + payload
trimming + cap raise + load testing), independent of co-host. Recommend shipping co-host at
the current cap first, then scaling rooms as its own project.
## 9. Suggested sequencing
1. `CAPABILITIES.CO_HOST` + `controllers`/`ownerPeerId` in room state + `ROOM_DATA` (additive).
2. Server `SET_PEER_ROLE` + gate generalization + owner-leave fallback + WS tests.
3. Background: controller-set membership in both gates + `amOwner`/`amController`.
4. Popup: promote/demote toggles (owner) + Controller badge + i18n.
5. (Separate track) large-room scaling.
@@ -1,283 +0,0 @@
# Host Control Mode — Branch Overview, Goals & Edge Cases
> **This is the canonical entry-point doc for branch `feature/host-control-mode`.**
> If you're an agent or contributor picking this up: read this file first, then the
> implementation plan in [`host-control-mode-plan.md`](./host-control-mode-plan.md).
> Temporary working doc — delete or fold into permanent docs before merging to `main`.
>
> Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
---
## 0. Implementation status
All five layers are implemented & pushed (server + background + content + popup + i18n).
Automated checks green: ESLint, WS integration tests (incl. host-only gate, toggle
reject, host-leave fallback), content video-finder, locale consistency (15 langs),
full release verification.
**Still needs real-device testing** — the EC test matrix in §7 (YouTube/Netflix/
Twitch/Disney+/Jellyfin): involuntary-pause classification (EC-1/EC-5/EC-8), snap-back
reliability and fight-loops (EC-4), and the desync/resync flow across players. The
intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by
reading the code, not yet by watching them behave on each site.
Deferred by decision (see §8): host grace period on disconnect (EC-10).
### Capability detection (forward-compat hook)
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
enables host-control UI/behavior only when the flag is present, so the feature
degrades cleanly on an older relay (absent → off) and old clients ignore the
field. This is the extensible hook for the planned **co-host** feature (owner
promotes guests to additional controllers): it will add a `'co-host'` capability
+ events without a protocol bump or breaking older relays/clients. Add new flags
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
### Pre-test self-audit (fixed)
- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC
click was gated server-side but the button stuck on "Playing"/disabled with no
feedback. Now the remote controls are locked (disabled + tooltip) for guests, with
backstop guards in the handlers. (popup.js)
- **Desync dialog could break under strict CSP** — it used `innerHTML` with inline
`style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt
with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS
can't restyle/hide it. (content.js)
- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
heuristic in `hcmIsLive()`. (content.js)
### Pre-test self-audit
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
no longer a gated guest; single pending poll (no stacking).
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
since background only sends it to gated guests.
- ~~**Dialog/badge text is English-only**~~ FIXED — background resolves the strings
via GET_HCM_STRINGS (it has the i18n loader); content fetches them on init with
English fallback. 6 new keys (HCM_DIALOG_*/HCM_BADGE_*) across all 15 locales.
---
## 1. What this branch is for
Origin: a GitHub feature request. When watching with larger groups, anyone can pause
or seek and disrupt everyone else. The requester wants the room creator to optionally
restrict control to a single **host**, the way Teleparty works — guests who try to
pause get asked whether they want to pause *only their own* player (and desync), and
otherwise get snapped back to the room's position.
**Goal:** Add an optional per-room **Host Control Mode**. A room can be switched
between:
- **`everyone`** (default, current behavior): anyone can play/pause/seek for the room.
- **`host-only`**: only the host drives the room. A guest's deliberate play/pause/seek
is not broadcast; instead they're snapped back to the room position — unless they
explicitly choose to desync (go solo) with a "Resync" escape hatch.
## 2. Trust model (read this before over-engineering)
This is **client-side trust, by design**. It's a watch party, not a security boundary.
The point is preventing *accidental* and *casual* disruption, not stopping someone
determined to patch their own extension. We do **not** add auth, tokens, or
cryptographic host identity. `peerId` is unauthenticated and that's fine here.
(We still gate server-side as the robust chokepoint — see plan — but that's about
killing spam reliably, not about defeating a hostile client.)
## 3. Scope / non-goals
In scope:
- Host designation (first joiner = host), mode toggle, host-only gating of all
room-moving events, guest snap-back, deliberate-desync flow + resync, host UI.
Explicit non-goals (for this branch):
- Authenticated / spoof-proof host identity.
- Persistent host across server restarts (room state is in-memory).
- Syncing around personalized ad breaks.
- Host transfer UI (auto-fallback to `everyone` when host leaves; manual transfer
is a possible later add).
## 4. Architecture summary
Three-layer gate for room-moving events from a non-host in host-only mode
(`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`):
1. **Server** — doesn't relay them (robust chokepoint, kills spam regardless of client).
2. **Sender (guest)** — doesn't emit; shows confirm dialog / disables host-only buttons.
3. **Receiver** — drops any that slip through (covers old/buggy/modified clients).
Snap-back reuses the existing `_setSuppress` mechanism (content.js:442) so applying
the room state programmatically doesn't echo back as a new event. Target position is
extrapolated from the host's last known state (±1s). Full detail + code hooks in the
plan doc.
---
## 5. The central challenge
Everything hard about this feature reduces to **one question** (see EC-9):
> How do we reliably tell a **deliberate** guest pause/seek from an **involuntary**
> player/browser event (buffering, ads, tab throttling, source swaps, DRM hiccups)?
If we get this wrong, guests get spammed with desync dialogs and snap-back loops for
things they never did. The host/role plumbing is the easy part; this classifier is the
real work. **Design the intent-classifier before writing the gate.**
---
## 6. Edge cases
### EC-1 🔴 Buffering / loading fires a `pause` event
content.js listens to `play`/`pause`/`seeked`/`loadeddata` only (content.js:1000-1003),
not `waiting`/`stalled`. Pure HTML5 buffering fires `waiting` → harmless. But custom
players (Netflix/YouTube/Twitch/JW) often call `video.pause()` during buffering/ads →
real `pause` → guest gate would mis-classify as deliberate. Sub-cases: (a) initial load
sits paused, no event, fine; (b) mid-stream stall, player-dependent; (c) seek-induced
re-buffering may outlast the suppress window and leak. Mitigation: `isBuffering` flag
from `waiting`/`playing`, or grace window; in host-only the guest's own state is
irrelevant so just ignore involuntary pauses and let catch-up logic (content.js:489)
re-sync them.
### EC-2 🟢 Force-Sync / Episode-Lobby abuse by guests
Guest could seek + spam Force-Sync to drag everyone, or spam Episode-Lobby to pause
everyone. Decision: host-only blocks guest *initiation* of `FORCE_SYNC_*` and
`EPISODE_LOBBY`; guests may only respond (`FORCE_SYNC_ACK`, `EPISODE_READY`). Guests'
legitimate path is the personal "Resync" button.
### EC-3 🟢 Host leaves the room
Fall back to `controlMode = 'everyone'`, broadcast `CONTROL_MODE`. Never a stuck locked
room. (Auto-promote next peer deferred.)
### EC-4 🔴 Snap-back fight loop (pause/play/skip back/pause/play)
Mashing controls or a janky player → each snap-back may emit events → ping-pong.
Mitigation: cooldown (~600ms) after snap-back; ensure snap-back runs fully under
suppress. Also: if target is unreachable (seek past buffered range), retry K times then
give up — no infinite loop.
### EC-5 🔴 Ad breaks (YouTube/Twitch/…)
Mid-roll ads pause/swap the media element, differ per peer → desync is unavoidable and
must NOT spam the dialog. Probably covered by EC-1 buffering grace; flag for explicit
testing.
### EC-6 🟡 Snap-back target accuracy
No continuous room clock; extrapolate from host's `currentTime` + `lastHeartbeat`
(±1s, worse if stale). The follow-up host correction must also be suppressed so it
doesn't read as guest input.
### EC-7 🟢 Old / buggy / modified guest client
Covered by receiver-side + server-side gates.
### EC-8 🔴 Tab throttling / background tab
Backgrounding throttles timers and may pause media. There's existing
`visibilityGraceUntil` handling for seeks (content.js:892). Confirm a
background-induced pause isn't treated as deliberate in host-only; reuse the grace flag.
### EC-9 🔴 What counts as "deliberate" — the central unresolved question
Collapses EC-1/EC-5/EC-8. Candidate signals: `readyState`/`networkState`/`video.seeking`
at event time; recent `waiting`; recent user gesture (`navigator.userActivation`,
keydown/click); visibility/focus. Build one shared **intent-classifier** helper in
content.js that all host-only gating flows through.
### EC-10 🔴 Host brief disconnect / reconnect (network blip)
Host's wifi drops for 3s and reconnects. With "host leaves → fallback to everyone",
a blip would silently unlock the room and demote the host (peerId persists in
chrome.storage so they rejoin with the same id, but the server already cleared
`hostPeerId`). Mitigation idea: short **host grace period** (e.g. keep `hostPeerId`
reserved for ~30s after disconnect; if the same peerId rejoins, restore host + mode).
Needs the server reaper (server:644) and `removePeerFromRoom` (server:168) to cooperate.
### EC-11 🔴 New guest joins mid-session in host-only mode
On join they must (a) immediately sync to the host's current position without the host
doing anything, and (b) see they're a guest in the UI. ROOM_DATA already carries peers;
add `hostPeerId`/`controlMode` so a fresh client knows its role instantly. Verify the
existing "newcomer syncs without waiting" path (content.js:542) still fires.
### EC-12 🔴 Desync semantics — what does "solo" actually mean?
When a guest chooses "pause only me", do they (a) fully ignore all subsequent host
events until they Resync, or (b) keep receiving but not auto-applying? Define clearly.
Proposed: full solo — ignore host play/pause/seek while desynced; Resync re-attaches and
snaps to current host position. Also: what state does Resync land them in if the host is
currently paused vs playing?
### EC-13 🔴 Race: host flips to host-only exactly as a guest pauses
Event ordering between `SET_CONTROL_MODE`/`CONTROL_MODE` and an in-flight guest `PAUSE`.
The `seq` ordering helps, but define the tie-break. Likely: server is authoritative —
once it has `host-only`, it drops the guest event regardless of client-side timing.
### EC-14 🟡 Volume / mute / audio-options must NOT be gated
Those are per-peer, not room control. The gate must target only play/pause/seek +
forcesync/episode — not `PEER_STATUS` volume/mute fields. Easy to over-block; add a test.
### EC-15 🔴 Live streams (Twitch live, live DVR)
"Room timestamp" is fuzzy on live edge; seeking semantics differ. Decide whether
host-only even makes sense for live, or degrade gracefully. Low priority but log it.
### EC-16 🟡 Host's own involuntary events still drive the room
If the host buffers and the player auto-pauses, that pause is "allowed" and pauses
everyone. That's existing behavior, but in host-only it means the host's buffering
stalls the whole room. Acceptable? Probably yes (host is authoritative), but note it.
### EC-17 🟡 Server restart drops room state
`hostPeerId`/`controlMode` are in-memory. After a server restart, whoever rejoins first
becomes the new host and mode resets to `everyone`. Acceptable for now (non-goal), but
document so it's not a surprise.
### EC-18 🟡 Dialog dismissed without choosing
Guest clicks away / presses Esc on the desync prompt. Default = treat as "No" → snap
back. Make sure an un-answered dialog can't leave them in limbo (paused + not desynced +
no dialog).
### EC-19 🟡 Multiple video elements / element swap (SPA, ad → content)
Players that swap the `<video>` element mid-session: re-attach handlers (content.js
already re-binds on `loadeddata`) and make sure host-only gating follows the new element.
### EC-20 🟡 Peer list shape (object vs legacy string)
Throughout background.js peers may be objects or bare peerId strings
(`typeof p === 'object' ? p.peerId : p`). All new `hostPeerId` comparisons must handle
both forms, or we get a host that's never recognized.
### EC-21 🟡 Mode toggle spam / rate limiting
Host hammering the toggle → many `SET_CONTROL_MODE`. Covered by existing
`checkEventRate` (server), but debounce in the UI and ignore no-op transitions.
---
## 7. Test matrix (fill in during dev)
| Player | Buffer→`pause`? | Ad behavior | Snap-back works? | Element swap? | Notes |
|-------------------|-----------------|-------------|------------------|---------------|-------|
| Generic HTML5 | | | | | |
| YouTube | | | | | |
| Netflix | | | | | |
| Twitch (VOD) | | | | | |
| Twitch (live) | | | | | |
| Disney+ / DRM | | | | | |
| Jellyfin / Emby | | | | | |
## 8. Decisions (audited)
- [x] **Intent-classifier (EC-9):** A `pause`/`seek` is **involuntary** if ANY of:
`readyState < 3`, `video.seeking`, a `waiting` fired < ~1500ms ago (`isBuffering`
flag), inside `visibilityGraceUntil`, OR no own-tracked user gesture
(`Date.now() - lastUserGestureAt < 1000`, via capturing keydown/pointerdown — do
NOT use sticky `navigator.userActivation.hasBeenActive`). Bias: only *clearly*
involuntary is ignored; everything else = deliberate. **Note:** in host-only the
guest never broadcasts anyway, so this only decides dialog-vs-silent — a UX call,
not a room-integrity call. Start simple, tune later.
- [x] **Host grace on disconnect (EC-10): NOT in v1.** Immediate fallback to
`everyone` (EC-3). A grace window risks a multi-second hard-lock if the host never
returns. Revisit as polish once the core flow works.
- [x] **Desync semantics (EC-12): full solo.** Ignore host play/pause/seek while
desynced; Resync snaps to host position + adopts host play/pause state. Desync
auto-clears on new media/episode. Requires a persistent, obvious "You are desynced"
UI.
- [x] **Snap-back cooldown (EC-4): until-settled, not fixed.** Suppress re-trigger
until `readyState>=3 && playing && |Δt|<tol`, hard-cap ~1500ms. Retry target up to
3×, then give up (no infinite loop).
- [x] **Live streams (EC-15): degrade.** Disable the gate when
`video.duration === Infinity`. Caveat: live-DVR may report finite duration and slip
through — acceptable for v1.
@@ -1,83 +0,0 @@
# Host Control Mode — Beta Testing Guide
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
> The feature needs the **server** half too — the official relay
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
> server first.
## 1. Run the beta relay (Docker)
The branch publishes the server image to GHCR under non-production tags
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
never touched.
```bash
# Log in (the package is private → PAT with read:packages)
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
# you must remove and re-run (or use compose / Watchtower).
docker pull ghcr.io/shik3i/koalasync:beta
docker rm -f koala-beta 2>/dev/null || true
docker run -d --name koala-beta -p 3000:3000 \
-e SERVER_SALT='choose-your-own-salt' \
ghcr.io/shik3i/koalasync:beta
```
To always run the newest beta automatically, point **Watchtower** at the
container — it does the pull → recreate whenever `:beta` moves.
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
is needed. Set `SERVER_SALT` (used for room-password hashing).
## 2. Connect the extension to it
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
(see background.js "Upgraded to wss:// for remote host"). So a bare
`ws://your-server:3000` will fail without TLS. Two options:
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
```bash
ssh -L 3000:localhost:3000 your-beta-server
```
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
HTTPS) → `wss://beta.yourdomain`.
Then create/join a room.
## 3. ⚠️ Use two *new* clients
Test with **two browser profiles both running this branch build** (load unpacked
from `extension/`, or install the built zip). A stock release client as a guest
will be correctly gated by the server but has none of the content-side code, so
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
bug, but it looks like one during testing.
## 4. Verification checklist
| # | Step | Expect |
|---|------|--------|
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
| 9 | Host disables host-only | Card hides for guest; controls unlock |
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
| 12 | Switch the popup language | Dialog/badge text is localized |
## 5. Capability detection
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
only enables the feature when that flag is present, so:
- against this beta server → feature on;
- against the old official server → feature cleanly hidden (no errors).
This is the extensible hook for the planned **co-host** feature (owner promotes
guests to additional controllers) — it'll add a `'co-host'` capability + events
without breaking older relays/clients.
-122
View File
@@ -1,122 +0,0 @@
# Host Control Mode — Implementierungsplan
Branch: `feature/host-control-mode`
Issue: GitHub feature request (wasserrutschentester) — nur Host darf den Raum steuern; Gäste werden zurückgesnappt oder gehen bewusst in Desync.
## Ziel
Ein Raum kann zwischen zwei Modi umgeschaltet werden:
- **`everyone`** (Default, heutiges Verhalten): jeder kann play/pause/seek für alle auslösen.
- **`host-only`**: nur der Host steuert den Raum. Pause/Seek eines Gasts wird **nicht** gebroadcastet; stattdessen snappt die eigene Extension den Gast zurück auf den Raum-Zustand — es sei denn, der Gast entscheidet sich bewusst für Desync.
Trust-Modell: client-seitig durchgesetzt. Kein Token, keine Auth. Es geht um versehentliches Stören, nicht um Angriffsschutz.
---
## Datenmodell
### Server (`server/index.js`, Room-Objekt ~Z.331)
Room bekommt zwei neue Felder:
```js
room = {
...,
hostPeerId: peerId, // gesetzt beim Anlegen = erster Joiner
controlMode: 'everyone', // 'everyone' | 'host-only'
}
```
- In `ROOM_DATA` (~Z.418) mitschicken: `hostPeerId`, `controlMode`.
- Neues Event `SET_CONTROL_MODE` (siehe unten): nur akzeptieren, wenn `senderPeerId === room.hostPeerId`. Server setzt `room.controlMode`, broadcastet die Änderung an alle.
- **Host-Migration:** in `removePeerFromRoom` (~Z.168) — wenn der gehende Peer `hostPeerId` war: entweder neuen Host bestimmen (nächster Peer) **oder** `controlMode` auf `everyone` zurückfallen lassen. → Entscheidung: **Fallback auf `everyone`** (simpel, nie verwaister gesperrter Raum). Optional später: Host-Transfer-Button.
### Shared Constants (`shared/constants.js`)
Neue Events im `EVENTS`-Objekt:
```js
SET_CONTROL_MODE: "set_control_mode", // Client->Server: Host ändert Modus
CONTROL_MODE: "control_mode", // Server->Client: Modus geändert { controlMode, hostPeerId }
```
⚠️ Danach `node scripts/build-extension.cjs` laufen lassen (Single Source of Truth propagieren). Ggf. `PROTOCOL_VERSION` bumpen — **nein, nur wenn alte Clients brechen würden**. Da alles additiv ist und alte Clients die neuen Felder/Events einfach ignorieren, ist KEIN Protokoll-Bump nötig. (Alter Client in host-only-Raum kennt den Modus nicht und sendet weiter → Host-Extensions ignorieren fremde Events nicht... → siehe Edge Case 7. Evtl. doch Bump erwägen.)
### Extension State (`background.js`)
```js
let controlMode = 'everyone';
let hostPeerId = null;
// abgeleitet: const amHost = () => hostPeerId === peerId;
```
Aus `ROOM_DATA` / `CONTROL_MODE` befüllen, in `chrome.storage.session` persistieren (wie `currentRoom`).
---
## Implementierung nach Schichten
### 1. Server (`server/index.js`)
- [ ] Room-Objekt um `hostPeerId` + `controlMode` erweitern (~Z.331).
- [ ] `ROOM_DATA`-Payload erweitern (~Z.418).
- [ ] Handler `SET_CONTROL_MODE`: validieren (Host-Check + Wert in {everyone, host-only}), setzen, `CONTROL_MODE` an Raum broadcasten.
- [ ] Host-Migration in `removePeerFromRoom`: Fallback auf `everyone` + neues `CONTROL_MODE` broadcasten, wenn Host geht.
- [ ] `SET_CONTROL_MODE` in die `relayEvents`-Liste? **Nein** — eigener Handler, da Sonderlogik + Host-Check. (relayEvents broadcastet blind.)
### 2. Shared / Build
- [ ] `EVENTS.SET_CONTROL_MODE`, `EVENTS.CONTROL_MODE` ergänzen.
- [ ] `node scripts/build-extension.cjs`.
### 3. background.js (Gast-Logik = Kern)
- [ ] `controlMode` / `hostPeerId` aus `ROOM_DATA` (~Z.875) und neuem `CONTROL_MODE`-Case übernehmen + persistieren + an Popup/Content pushen.
- [ ] **Emit-Gate** im SEND-Pfad (~Z.1786): bei `host-only && !amHost()` und action ∈ {play, pause, seek}:
- NICHT `emit`en.
- Stattdessen Content-Script anweisen: "snap back" ODER Desync-Confirm anzeigen.
- [ ] **Snap-Back-Zielzeit berechnen:** aus Host-Peer-State (`playbackState`, `currentTime`, `lastHeartbeat`) extrapolieren:
`targetTime = host.currentTime + (host.playbackState==='playing' ? (now - host.lastHeartbeat)/1000 : 0)`.
Genauigkeit ~±1s, für Watchparty ok. (Force-Sync-Maschinerie als Referenz für Ziel-Zeit-Koordination.)
- [ ] Nachricht an content.js: `{ type: 'HOST_BLOCK', action, targetTime, hostPlaybackState }`.
### 4. content.js (Player-Reaktion + Dialog)
- [ ] Handler für `HOST_BLOCK`: Confirm-Dialog im Player-Overlay rendern:
"Pause only your own player and desync from the group? [Yes] [No]".
- **No** (Default): Player via bestehende `_setSuppress`-Mechanik ([content.js:442](../extension/content.js:442)) wieder in Raum-Zustand zwingen (play + seek auf targetTime). Suppress verhindert Re-Broadcast.
- **Yes:** lokal pausiert lassen, `isDesynced = true` setzen, dezenten "Desynced — Resync"-Button zeigen.
- [ ] "Resync"-Button → snappt zurück auf aktuelle Raum-Zeit, `isDesynced = false`.
- [ ] **Loop-Schutz:** nach einer Snap-Back-Aktion kurzes Cooldown-Fenster (z.B. 600ms), in dem weitere lokale pause/seek-Events nicht erneut den Dialog triggern (verhindert pause→play→pause-Pingpong).
### 5. popup (Host-UI)
- [ ] Host-Toggle "Only I can control" (nur sichtbar wenn `amHost()`), sendet `SET_CONTROL_MODE`.
- [ ] Rollen-Badge: "Host" / "Guest" + aktueller Modus.
- [ ] Gast-Hinweis wenn host-only aktiv: "The host controls playback".
- [ ] i18n-Keys in `extension/_locales` / `locales` für ~15 Sprachen.
---
## Edge Cases (Test-Checkliste)
1. **Pause nicht verhinderbar, nur revidierbar** → kurzer Flicker (~½s) beim Gast ist erwartet/akzeptabel.
2. **Snap-Back-Zielzeit** aus Heartbeat extrapoliert, ±1s. Bei stark veraltetem Host-State (kein Heartbeat) → letzten bekannten Wert nehmen.
3. **Kampf-Loop pause/play/skip back/pause/play** → Cooldown-Fenster nach Snap-Back. Testen mit aggressivem Mashing.
4. **Desync-Escape:** Gast kann bewusst pausieren (Klo/Telefon) → "Yes" → solo, dann Resync.
5. **Host verlässt Raum** → Fallback auf `everyone`, alle bekommen `CONTROL_MODE`-Update. Testen: Host schließt Tab / Disconnect / Netzabbruch.
6. **host-only + Episode-Auto-Sync / Force-Sync** (server/index.js:503-516): **Gast darf NICHT initiieren.** Force-Sync trägt eine `targetTime` und zwingt ALLE darauf (background.js:1261) — ein Gast könnte seeken → Force-Sync spammen und damit host-only komplett aushebeln. Episode-Lobby pausiert ebenfalls alle. → Im host-only-Modus dürfen `FORCE_SYNC_PREPARE`/`FORCE_SYNC_EXECUTE` und `EPISODE_LOBBY` nur vom Host **initiiert** werden. Gäste dürfen weiterhin nur **reagieren**: `FORCE_SYNC_ACK`, `EPISODE_READY`. Gäste brauchen Force-Sync nicht — ihr legitimer Fall ist der "Resync"-Button (snappt nur sie selbst, nicht alle).
7. **Alter Client (ohne Feature) in host-only-Raum** → kennt Modus nicht, sendet weiter pause/seek → andere Extensions wenden es an. Mitigation: Empfänger-seitiges Gate (host-only-Clients ignorieren play/pause/seek von Nicht-Host) ODER `MIN_VERSION`/Protokoll-Bump. → **Empfehlung: zusätzlich Empfänger-seitig filtern** (robuster als nur Sender-Gate).
8. **Seek getrennt von Pause** → host-only blockt auch Gast-Seeks, nicht nur Pausen.
9. **Mehrere Tabs / Multi-Peer mit gleicher peerId** (Dedup, server:381) → Host-Identität bleibt an peerId hängen, ok.
10. **DAU-Verwirrung** "warum kann ich nicht mehr pausieren?" → klare UI-Botschaft + der Desync-Dialog erklärt sich selbst.
## Architektur-Entscheidung zu Edge Case 7 (wichtig)
Wir setzen das Gate **doppelt** und über **alle raum-verschiebenden Events**, nicht nur play/pause/seek:
Geblockte Initiierungen für Nicht-Host im host-only-Modus:
`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`.
Weiterhin erlaubt für Gäste (reine Reaktion, verschiebt niemanden): `FORCE_SYNC_ACK`, `EPISODE_READY`, `PEER_STATUS`, `PING`/`PONG`.
- **Sender-seitig** (Gast sendet erst gar nicht) → saubere UX, Confirm-Dialog bei play/pause/seek; Force-Sync-/Episode-Lobby-Buttons im Gast-UI deaktiviert/ausgeblendet.
- **Empfänger-seitig** (in `handleServerEvent`, background.js:969 + Force-Sync-/Episode-Cases): wenn `host-only` und `data.senderId !== hostPeerId` → Event verwerfen (nicht an Content routen, keine State-Mutation).
So sind auch alte/buggy/manipulierte Clients abgedeckt, ohne harten Protokoll-Bump.
Optional zusätzlich **server-seitig** in den `relayEvents` (server/index.js:445): im host-only-Modus Initiierungs-Events von Nicht-Host gar nicht erst relayen. Spart Traffic + deckt alles zentral ab. Empfehlenswert, da der Server `hostPeerId`/`controlMode` ohnehin kennt.
---
## Reihenfolge der Umsetzung (kleine, testbare Schritte)
1. Constants + Build (Events da, nichts kaputt).
2. Server: hostPeerId/controlMode + ROOM_DATA + SET_CONTROL_MODE + Migration.
3. background.js: State übernehmen + Empfänger-seitiges Gate (Edge 7) — testbar ohne UI.
4. background.js: Sender-seitiges Gate + Snap-Back-Zielzeit.
5. content.js: Snap-Back-Apply + Confirm-Dialog + Loop-Cooldown.
6. popup: Host-Toggle + Badge + i18n.
7. Durchtesten der Edge-Case-Liste auf YT / Netflix / generischem HTML5-Player.
+1 -1
View File
@@ -1,6 +1,6 @@
export default [
{
ignores: ["dist/**", "node_modules/**", "scratch/**"]
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**"]
},
{
languageOptions: {
+121 -427
View File
@@ -8,8 +8,8 @@
"name": "koalasync",
"version": "2.5.1",
"devDependencies": {
"@vitest/coverage-v8": "^4.1.9",
"archiver": "^7.0.1",
"c8": "^11.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
@@ -18,6 +18,56 @@
"vitest": "^4.1.9"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
@@ -28,29 +78,6 @@
"node": ">=18"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1224,16 +1251,6 @@
"node": ">=12"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1395,9 +1412,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1415,9 +1429,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1435,9 +1446,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1455,9 +1463,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1475,9 +1480,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1495,9 +1497,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1634,13 +1633,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1648,6 +1640,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
@@ -1780,6 +1804,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1888,6 +1913,18 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -1923,6 +1960,7 @@
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -2087,40 +2125,6 @@
"node": ">=8.0.0"
}
},
"node_modules/c8": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
"integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "20 || >=22"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2131,100 +2135,6 @@
"node": ">=18"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2543,6 +2453,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -2578,16 +2489,6 @@
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2607,6 +2508,7 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -2986,16 +2888,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -3218,6 +3110,13 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -3465,9 +3364,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3489,9 +3385,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3513,9 +3406,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3537,9 +3427,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3635,6 +3522,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -3868,6 +3767,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3981,16 +3881,6 @@
"node": ">=10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rolldown": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
@@ -4369,105 +4259,6 @@
"streamx": "^2.12.5"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -4570,27 +4361,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/vite": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -4669,6 +4446,7 @@
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
@@ -4894,90 +4672,6 @@
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1 -1
View File
@@ -12,8 +12,8 @@
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.9",
"archiver": "^7.0.1",
"c8": "^11.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
+2 -1
View File
@@ -29,6 +29,7 @@ import {
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
startRateLimitCleanup,
stopRateLimitCleanup,
clearRateLimitMaps
@@ -949,7 +950,7 @@ export async function stopServerForTests() {
healthResponseCache.clear();
io.removeAllListeners();
io.disconnectSockets(true);
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
if (!httpServer.listening) return;
await new Promise((resolve, reject) => {
httpServer.close((err) => err ? reject(err) : resolve());
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
checkLeaveRoomRate,
LEAVE_ROOM_RATE_LIMIT,
+8 -10
View File
@@ -4,23 +4,21 @@ export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['**/*.test.js', '**/*.test.mjs'],
exclude: ['**/scripts/test-*.*(mjs|cjs|js)'],
include: [
'server/**/*.test.js',
'server/**/*.test.mjs',
'shared/**/*.test.js',
'shared/**/*.test.mjs'
],
coverage: {
provider: 'c8',
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['server/**/*.js', 'shared/**/*.js'],
exclude: [
'**/node_modules/**',
'**/scripts/**',
'**/extension/**'
],
thresholds: {
functions: 30,
lines: 30,
branches: 25,
statements: 30
}
]
}
}
});