mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
refactor(jam): move the room server to its own repository
The jam room server now lives in https://github.com/team-spotube/jam-server. Remove jam-server/ and its deploy workflow, and point code comments, test instructions, AGENTS.md and the self-hosting note (all locales) at the new repository.
This commit is contained in:
parent
45a7758eb0
commit
aaa4d66db1
40
.github/workflows/jam-server-deploy.yml
vendored
40
.github/workflows/jam-server-deploy.yml
vendored
@ -1,40 +0,0 @@
|
||||
name: Deploy Jam server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths: ["jam-server/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
# The deploy only reads the repo; the Cloudflare token comes from secrets, which
|
||||
# workflows triggered by pull requests from forks never receive.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: jam-server-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: jam-server
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: jam-server/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: Deploy
|
||||
run: npx wrangler deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
- name: Smoke test
|
||||
run: JAM_URL="${{ vars.JAM_SERVER_URL }}" npm test
|
||||
if: ${{ vars.JAM_SERVER_URL != '' }}
|
||||
@ -27,13 +27,13 @@
|
||||
- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
|
||||
|
||||
## Group Jam
|
||||
- Room server: `jam-server/` (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in `jam-server/src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes).
|
||||
- Room server: separate repository, https://github.com/team-spotube/jam-server (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in its `src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes, and land both changes together).
|
||||
- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off).
|
||||
- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See `jam-server/README.md`.
|
||||
- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in `jam-server/README.md`.
|
||||
- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See the jam-server README.
|
||||
- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in the jam-server README.
|
||||
- A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`.
|
||||
- Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`).
|
||||
- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (start `npm run dev` in `jam-server/`).
|
||||
- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (run `npm run dev` in a jam-server checkout).
|
||||
|
||||
## GraalVM native image (desktop)
|
||||
- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`.
|
||||
|
||||
@ -348,7 +348,7 @@
|
||||
<string name="settings_jam_server_url">رابط الخادم</string>
|
||||
<string name="settings_jam_server_url_invalid">أدخل رابطًا يبدأ بـ https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">تحقّق من خادم الغرف قبل بدء غرفة.</string>
|
||||
<string name="settings_jam_server_note">يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر jam-server من مستودع Spotube وأدخل رابطه.</string>
|
||||
<string name="settings_jam_server_note">يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر الخادم من github.com/team-spotube/jam-server وأدخل رابطه.</string>
|
||||
<string name="settings_jam_section_you">أنت</string>
|
||||
<string name="jam_connection_host">الخادم: %1$s</string>
|
||||
<string name="jam_connection_missing">حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها.</string>
|
||||
|
||||
@ -332,7 +332,7 @@
|
||||
<string name="settings_jam_server_url">সার্ভার URL</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// দিয়ে শুরু হওয়া একটি URL দিন</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন।</string>
|
||||
<string name="settings_jam_server_note">জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে Spotube রিপোজিটরি থেকে jam-server ডিপ্লয় করে তার URL দিন।</string>
|
||||
<string name="settings_jam_server_note">জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে github.com/team-spotube/jam-server থেকে সার্ভার ডিপ্লয় করে তার URL দিন।</string>
|
||||
<string name="settings_jam_section_you">আপনি</string>
|
||||
<string name="jam_connection_host">সার্ভার: %1$s</string>
|
||||
<string name="jam_connection_missing">রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন।</string>
|
||||
|
||||
@ -332,7 +332,7 @@
|
||||
<string name="settings_jam_server_url">Server-URL</string>
|
||||
<string name="settings_jam_server_url_invalid">Gib eine URL ein, die mit https:// beginnt.</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Prüfe den Raumserver, bevor du einen Raum startest.</string>
|
||||
<string name="settings_jam_server_note">Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle jam-server aus dem Spotube-Repository bereit und gib seine URL ein.</string>
|
||||
<string name="settings_jam_server_note">Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle den Server von github.com/team-spotube/jam-server bereit und gib seine URL ein.</string>
|
||||
<string name="settings_jam_section_you">Du</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten.</string>
|
||||
|
||||
@ -336,7 +336,7 @@
|
||||
<string name="settings_jam_server_url">URL del servidor</string>
|
||||
<string name="settings_jam_server_url_invalid">Introduce una URL que empiece por https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Comprueba el servidor de salas antes de crear una sala.</string>
|
||||
<string name="settings_jam_server_note">Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega jam-server desde el repositorio de Spotube e introduce su URL.</string>
|
||||
<string name="settings_jam_server_note">Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega el servidor de github.com/team-spotube/jam-server e introduce su URL.</string>
|
||||
<string name="settings_jam_section_you">Tú</string>
|
||||
<string name="jam_connection_host">Servidor: %1$s</string>
|
||||
<string name="jam_connection_missing">Configura un servidor de salas en Ajustes para crear una sala o unirte a una.</string>
|
||||
|
||||
@ -332,7 +332,7 @@
|
||||
<string name="settings_jam_server_url">نشانی سرور</string>
|
||||
<string name="settings_jam_server_url_invalid">نشانیای وارد کنید که با https:// شروع شود</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">پیش از شروع اتاق، سرور اتاقها را بررسی کنید.</string>
|
||||
<string name="settings_jam_server_note">جم از سرور رایگان اتاقهای Spotube استفاده میکند. برای میزبانی شخصی، jam-server را از مخزن Spotube راهاندازی کنید و نشانی آن را وارد کنید.</string>
|
||||
<string name="settings_jam_server_note">جم از سرور رایگان اتاقهای Spotube استفاده میکند. برای میزبانی شخصی، سرور را از github.com/team-spotube/jam-server راهاندازی کنید و نشانی آن را وارد کنید.</string>
|
||||
<string name="settings_jam_section_you">شما</string>
|
||||
<string name="jam_connection_host">سرور: %1$s</string>
|
||||
<string name="jam_connection_missing">برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید.</string>
|
||||
|
||||
@ -336,7 +336,7 @@
|
||||
<string name="settings_jam_server_url">URL du serveur</string>
|
||||
<string name="settings_jam_server_url_invalid">Saisissez une URL commençant par https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Vérifiez le serveur de salles avant de lancer une salle.</string>
|
||||
<string name="settings_jam_server_note">Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez jam-server depuis le dépôt Spotube et saisissez son URL.</string>
|
||||
<string name="settings_jam_server_note">Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez le serveur depuis github.com/team-spotube/jam-server et saisissez son URL.</string>
|
||||
<string name="settings_jam_section_you">Vous</string>
|
||||
<string name="jam_connection_host">Serveur : %1$s</string>
|
||||
<string name="jam_connection_missing">Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle.</string>
|
||||
|
||||
@ -331,7 +331,7 @@
|
||||
<string name="settings_jam_server_url">सर्वर URL</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// से शुरू होने वाला URL डालें</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">रूम शुरू करने से पहले रूम सर्वर जांच लें।</string>
|
||||
<string name="settings_jam_server_note">जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए Spotube रिपॉज़िटरी से jam-server डिप्लॉय करें और उसका URL डालें।</string>
|
||||
<string name="settings_jam_server_note">जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए github.com/team-spotube/jam-server से सर्वर डिप्लॉय करें और उसका URL डालें।</string>
|
||||
<string name="settings_jam_section_you">आप</string>
|
||||
<string name="jam_connection_host">सर्वर: %1$s</string>
|
||||
<string name="jam_connection_missing">रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें।</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">URL server</string>
|
||||
<string name="settings_jam_server_url_invalid">Masukkan URL yang diawali https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Periksa server ruang sebelum memulai ruang.</string>
|
||||
<string name="settings_jam_server_note">Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy jam-server dari repositori Spotube lalu masukkan URL-nya.</string>
|
||||
<string name="settings_jam_server_note">Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy server dari github.com/team-spotube/jam-server lalu masukkan URL-nya.</string>
|
||||
<string name="settings_jam_section_you">Kamu</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang.</string>
|
||||
|
||||
@ -336,7 +336,7 @@
|
||||
<string name="settings_jam_server_url">URL del server</string>
|
||||
<string name="settings_jam_server_url_invalid">Inserisci un URL che inizi con https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Controlla il server delle stanze prima di avviare una stanza.</string>
|
||||
<string name="settings_jam_server_note">Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci jam-server dal repository di Spotube e inseriscine l’URL.</string>
|
||||
<string name="settings_jam_server_note">Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci il server da github.com/team-spotube/jam-server e inseriscine l’URL.</string>
|
||||
<string name="settings_jam_section_you">Tu</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci.</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">サーバーURL</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// で始まるURLを入力してください。</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">ルームを始める前にルームサーバーを確認します。</string>
|
||||
<string name="settings_jam_server_note">ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、Spotubeリポジトリのjam-serverをデプロイし、そのURLを入力してください。</string>
|
||||
<string name="settings_jam_server_note">ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、github.com/team-spotube/jam-server のサーバーをデプロイし、そのURLを入力してください。</string>
|
||||
<string name="settings_jam_section_you">あなた</string>
|
||||
<string name="jam_connection_host">サーバー: %1$s</string>
|
||||
<string name="jam_connection_missing">ルームをホストまたは参加するには、設定でルームサーバーを指定してください。</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">서버 URL</string>
|
||||
<string name="settings_jam_server_url_invalid">https://로 시작하는 URL을 입력하세요.</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">방을 시작하기 전에 방 서버를 확인하세요.</string>
|
||||
<string name="settings_jam_server_note">잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 Spotube 저장소의 jam-server를 배포하고 URL을 입력하세요.</string>
|
||||
<string name="settings_jam_server_note">잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 github.com/team-spotube/jam-server의 서버를 배포하고 URL을 입력하세요.</string>
|
||||
<string name="settings_jam_section_you">나</string>
|
||||
<string name="jam_connection_host">서버: %1$s</string>
|
||||
<string name="jam_connection_missing">방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요.</string>
|
||||
|
||||
@ -332,7 +332,7 @@
|
||||
<string name="settings_jam_server_url">Server-URL</string>
|
||||
<string name="settings_jam_server_url_invalid">Voer een URL in die begint met https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Controleer de sessieserver voordat je een sessie start.</string>
|
||||
<string name="settings_jam_server_note">Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan jam-server uit de Spotube-repository en voer de URL in.</string>
|
||||
<string name="settings_jam_server_note">Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan de server van github.com/team-spotube/jam-server en voer de URL in.</string>
|
||||
<string name="settings_jam_section_you">Jij</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen.</string>
|
||||
|
||||
@ -340,7 +340,7 @@
|
||||
<string name="settings_jam_server_url">Adres URL serwera</string>
|
||||
<string name="settings_jam_server_url_invalid">Wpisz adres URL zaczynający się od https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Sprawdź serwer pokojów przed utworzeniem pokoju.</string>
|
||||
<string name="settings_jam_server_note">Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż jam-server z repozytorium Spotube i wpisz jego adres URL.</string>
|
||||
<string name="settings_jam_server_note">Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż serwer z github.com/team-spotube/jam-server i wpisz jego adres URL.</string>
|
||||
<string name="settings_jam_section_you">Ty</string>
|
||||
<string name="jam_connection_host">Serwer: %1$s</string>
|
||||
<string name="jam_connection_missing">Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.</string>
|
||||
|
||||
@ -336,7 +336,7 @@
|
||||
<string name="settings_jam_server_url">URL do servidor</string>
|
||||
<string name="settings_jam_server_url_invalid">Digite uma URL que comece com https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Verifique o servidor de salas antes de criar uma sala.</string>
|
||||
<string name="settings_jam_server_note">O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o jam-server do repositório do Spotube e informe a URL dele.</string>
|
||||
<string name="settings_jam_server_note">O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o servidor de github.com/team-spotube/jam-server e informe a URL dele.</string>
|
||||
<string name="settings_jam_section_you">Você</string>
|
||||
<string name="jam_connection_host">Servidor: %1$s</string>
|
||||
<string name="jam_connection_missing">Defina um servidor de salas nas Configurações para criar ou entrar em uma sala.</string>
|
||||
|
||||
@ -340,7 +340,7 @@
|
||||
<string name="settings_jam_server_url">URL сервера</string>
|
||||
<string name="settings_jam_server_url_invalid">Введите URL, начинающийся с https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Проверьте сервер комнат перед созданием комнаты.</string>
|
||||
<string name="settings_jam_server_note">Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите jam-server из репозитория Spotube и укажите его URL.</string>
|
||||
<string name="settings_jam_server_note">Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите сервер из github.com/team-spotube/jam-server и укажите его URL.</string>
|
||||
<string name="settings_jam_section_you">Вы</string>
|
||||
<string name="jam_connection_host">Сервер: %1$s</string>
|
||||
<string name="jam_connection_missing">Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней.</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">URL ของเซิร์ฟเวอร์</string>
|
||||
<string name="settings_jam_server_url_invalid">ใส่ URL ที่ขึ้นต้นด้วย https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง</string>
|
||||
<string name="settings_jam_server_note">แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้ง jam-server จากรีโพสิทอรีของ Spotube แล้วใส่ URL ของเซิร์ฟเวอร์นั้น</string>
|
||||
<string name="settings_jam_server_note">แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้งเซิร์ฟเวอร์จาก github.com/team-spotube/jam-server แล้วใส่ URL ของเซิร์ฟเวอร์นั้น</string>
|
||||
<string name="settings_jam_section_you">คุณ</string>
|
||||
<string name="jam_connection_host">เซิร์ฟเวอร์: %1$s</string>
|
||||
<string name="jam_connection_missing">ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง</string>
|
||||
|
||||
@ -332,7 +332,7 @@
|
||||
<string name="settings_jam_server_url">Sunucu URL’si</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// ile başlayan bir URL gir.</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Oda başlatmadan önce oda sunucusunu kontrol et.</string>
|
||||
<string name="settings_jam_server_note">Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için Spotube deposundaki jam-server’ı kur ve URL’sini gir.</string>
|
||||
<string name="settings_jam_server_note">Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için github.com/team-spotube/jam-server adresindeki sunucuyu kur ve URL’sini gir.</string>
|
||||
<string name="settings_jam_section_you">Sen</string>
|
||||
<string name="jam_connection_host">Sunucu: %1$s</string>
|
||||
<string name="jam_connection_missing">Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle.</string>
|
||||
|
||||
@ -340,7 +340,7 @@
|
||||
<string name="settings_jam_server_url">URL сервера</string>
|
||||
<string name="settings_jam_server_url_invalid">Введіть URL, що починається з https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Перевірте сервер кімнат перед створенням кімнати.</string>
|
||||
<string name="settings_jam_server_note">Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть jam-server із репозиторію Spotube і вкажіть його URL.</string>
|
||||
<string name="settings_jam_server_note">Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть сервер із github.com/team-spotube/jam-server і вкажіть його URL.</string>
|
||||
<string name="settings_jam_section_you">Ви</string>
|
||||
<string name="jam_connection_host">Сервер: %1$s</string>
|
||||
<string name="jam_connection_missing">Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї.</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">URL máy chủ</string>
|
||||
<string name="settings_jam_server_url_invalid">Nhập URL bắt đầu bằng https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Kiểm tra máy chủ phòng trước khi tạo phòng.</string>
|
||||
<string name="settings_jam_server_note">Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai jam-server từ kho mã Spotube rồi nhập URL của nó.</string>
|
||||
<string name="settings_jam_server_note">Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai máy chủ từ github.com/team-spotube/jam-server rồi nhập URL của nó.</string>
|
||||
<string name="settings_jam_section_you">Bạn</string>
|
||||
<string name="jam_connection_host">Máy chủ: %1$s</string>
|
||||
<string name="jam_connection_missing">Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng.</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">伺服器網址</string>
|
||||
<string name="settings_jam_server_url_invalid">請輸入以 https:// 開頭的網址</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">建立房間前先檢查房間伺服器。</string>
|
||||
<string name="settings_jam_server_note">Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 Spotube 儲存庫中的 jam-server 並輸入其網址。</string>
|
||||
<string name="settings_jam_server_note">Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 github.com/team-spotube/jam-server 中的伺服器並輸入其網址。</string>
|
||||
<string name="settings_jam_section_you">你</string>
|
||||
<string name="jam_connection_host">伺服器:%1$s</string>
|
||||
<string name="jam_connection_missing">請在設定中指定房間伺服器,以建立或加入房間。</string>
|
||||
|
||||
@ -328,7 +328,7 @@
|
||||
<string name="settings_jam_server_url">服务器网址</string>
|
||||
<string name="settings_jam_server_url_invalid">请输入以 https:// 开头的网址</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">创建房间前先检查房间服务器。</string>
|
||||
<string name="settings_jam_server_note">一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 Spotube 仓库中的 jam-server 并填写其网址。</string>
|
||||
<string name="settings_jam_server_note">一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 github.com/team-spotube/jam-server 中的服务器并填写其网址。</string>
|
||||
<string name="settings_jam_section_you">你</string>
|
||||
<string name="jam_connection_host">服务器:%1$s</string>
|
||||
<string name="jam_connection_missing">请在设置中指定房间服务器,以创建或加入房间。</string>
|
||||
|
||||
@ -314,7 +314,7 @@
|
||||
<string name="settings_jam_server_url">Server URL</string>
|
||||
<string name="settings_jam_server_url_invalid">Enter a URL starting with https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Check the room server before starting a room.</string>
|
||||
<string name="settings_jam_server_note">Jam uses Spotube’s free room server. To self-host, deploy jam-server from the Spotube repository and enter its URL.</string>
|
||||
<string name="settings_jam_server_note">Jam uses Spotube’s free room server. To self-host, deploy the server from github.com/team-spotube/jam-server and enter its URL.</string>
|
||||
<string name="settings_jam_server_test">Test connection</string>
|
||||
<string name="settings_jam_server_testing">Testing…</string>
|
||||
<string name="settings_jam_server_test_ok">Connected in %1$d ms</string>
|
||||
|
||||
@ -27,7 +27,7 @@ import spotube.composeapp.generated.resources.jam_name_format
|
||||
|
||||
/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */
|
||||
object JamNames {
|
||||
/** The server's limit (`MAX_NAME_LENGTH` in `jam-server/src/protocol.ts`). */
|
||||
/** The server's limit (`MAX_NAME_LENGTH` in the jam-server repository's `src/protocol.ts`). */
|
||||
const val MAX_LENGTH = 40
|
||||
|
||||
/**
|
||||
|
||||
@ -74,7 +74,7 @@ import spotube.composeapp.generated.resources.settings_jam_server_test_ok
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* WebSocket client for one jam room on the room server (`jam-server/`, a Cloudflare
|
||||
* WebSocket client for one jam room on the room server (https://github.com/team-spotube/jam-server, a Cloudflare
|
||||
* Durable Object per room code).
|
||||
*
|
||||
* The server keeps the host's latest queue and anchor for late joiners, routes guest
|
||||
@ -482,7 +482,7 @@ sealed interface JamRoomEvent {
|
||||
/** The server refused to let us into the room. */
|
||||
class JamRoomException(val code: String, message: String) : Exception(message)
|
||||
|
||||
// ---------- Wire frames (see jam-server/src/protocol.ts) ----------
|
||||
// ---------- Wire frames (see src/protocol.ts in https://github.com/team-spotube/jam-server) ----------
|
||||
|
||||
@Serializable
|
||||
private data class Health(val ok: Boolean = false, val v: Int = 0)
|
||||
|
||||
@ -872,7 +872,7 @@ class JamRoomService(
|
||||
|
||||
companion object {
|
||||
private const val CREATE_ATTEMPTS = 3
|
||||
/** Keeps the host under the server's budgets (see `jam-server/src/protocol.ts`). */
|
||||
/** Keeps the host under the server's budgets (see `src/protocol.ts` in the jam-server repository). */
|
||||
private const val QUEUE_MIN_GAP_MS = 1_000L
|
||||
private const val ANCHOR_MIN_GAP_MS = 250L
|
||||
private const val MAX_SUGGESTED_ITEMS = 500
|
||||
|
||||
@ -61,7 +61,7 @@ data class UserSettings(
|
||||
|
||||
// Group Jam
|
||||
val jamParticipantName: String = "",
|
||||
/** Room server (`jam-server/`); self-hosters point this at their own deployment. */
|
||||
/** Room server (https://github.com/team-spotube/jam-server); self-hosters point this at their own deployment. */
|
||||
val jamServerUrl: String = DEFAULT_JAM_SERVER_URL,
|
||||
val lastJamCode: String = "",
|
||||
/** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */
|
||||
|
||||
@ -62,7 +62,7 @@ class JamSecurityTest {
|
||||
fun clientKeysAreLongRandomAndAcceptedByTheServer() {
|
||||
val keys = List(100) { JamRoomCode.newClientKey() }
|
||||
assertEquals(keys.size, keys.toSet().size)
|
||||
// Mirrors CLIENT_KEY in jam-server/src/protocol.ts.
|
||||
// Mirrors CLIENT_KEY in the jam-server repository's src/protocol.ts.
|
||||
val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$")
|
||||
assertTrue(keys.all { serverPattern.matches(it) })
|
||||
}
|
||||
@ -82,7 +82,7 @@ class JamSecurityTest {
|
||||
assertEquals(digest, jamDeviceDigest("ABCDEF"))
|
||||
assertNotEquals(digest, jamDeviceDigest("ABCDEG"))
|
||||
assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest })
|
||||
// Mirrors DEVICE_DIGEST in jam-server/src/protocol.ts.
|
||||
// Mirrors DEVICE_DIGEST in the jam-server repository's src/protocol.ts.
|
||||
assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest))
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Runs the real client against a room server. Skipped unless `JAM_TEST_URL` is set:
|
||||
* `cd jam-server && npm run dev`, then `JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest`.
|
||||
* run `npm run dev` in a checkout of https://github.com/team-spotube/jam-server, then `JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest`.
|
||||
*/
|
||||
class JamRoomClientIntegrationTest {
|
||||
private val serverUrl: String? = System.getenv("JAM_TEST_URL")
|
||||
|
||||
3
jam-server/.gitignore
vendored
3
jam-server/.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
@ -1,117 +0,0 @@
|
||||
# Spotube Jam server
|
||||
|
||||
The room server behind Group Jam: a Cloudflare Worker with one
|
||||
[Durable Object](https://developers.cloudflare.com/durable-objects/) per room code.
|
||||
|
||||
Each room relays a few small JSON frames between the host and its guests. Nobody streams
|
||||
audio through it (every device fetches its own), so a room costs a few KB per minute.
|
||||
|
||||
Rooms are billed per incoming message (20 count as one request) and per storage write,
|
||||
so steady playback sends almost nothing:
|
||||
|
||||
- The host sends a playback anchor only when playback changes (play/pause, seek, skip,
|
||||
speed), when its player drifts from its last anchor, and once a minute as a safety net.
|
||||
Guests extrapolate anchors with the shared clock in between.
|
||||
- Anchors are kept in memory, not storage. A room that hibernated wakes for a join, and
|
||||
the host answers the join with a fresh anchor. Only queue changes and membership
|
||||
bookkeeping are written.
|
||||
- Keep-alives (`"ping"` → `"pong"`) are answered by the runtime without waking the room.
|
||||
Clock pings go out on join and every 10 minutes.
|
||||
|
||||
A four-person room sends roughly 200 messages an hour (about 10 billed requests).
|
||||
|
||||
## What a room does
|
||||
|
||||
- **Membership**: tracks who is connected, who hosts, and who is banned. If the host
|
||||
drops, it keeps hosting for 30 s (so it can reconnect, e.g. after switching networks), then the longest-connected guest
|
||||
takes over.
|
||||
- **Retained state**: keeps the host's latest queue (and, while awake, its playback
|
||||
anchor) so late joiners start in sync. An empty room keeps them for 10 minutes, then deletes itself.
|
||||
- **Routing**: guest commands (play/pause, skip, seek, suggestions) go to the host.
|
||||
Kicks and bans are enforced by the server.
|
||||
- **Clock**: answers pings with its own time. Every device syncs playback to this one
|
||||
clock (see `JamClock` in the app).
|
||||
|
||||
The wire protocol is in [`src/protocol.ts`](src/protocol.ts). The app side is
|
||||
`composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/`.
|
||||
|
||||
## Security
|
||||
|
||||
Anyone who knows a room code can join it, as with any party-code app. Everything else
|
||||
is enforced here, not in the app:
|
||||
|
||||
- **Identity**: each install keeps a random secret, and its key for a room is derived
|
||||
from that secret and the room code. Everyone else only sees an id derived from the key
|
||||
(`participantId`, different in every room). Knowing someone's id, the host's included,
|
||||
doesn't let you connect as them, so nobody can take over hosting, kick people or replace
|
||||
someone's connection.
|
||||
- **Host powers**: only the current host can set the queue and anchor or kick and ban.
|
||||
Guests can only send commands, and those go to the host, which decides what to apply.
|
||||
- **Taking over rooms**: "create" only works on a fresh code, or for the room's own host
|
||||
bringing back a room that expired while everyone was offline.
|
||||
- **Abuse and cost**: connects are rate-limited per IP (60 a minute; also slows down code
|
||||
guessing). Each socket has a message budget and a queue budget (`FRAME_BUDGET`,
|
||||
`QUEUE_BUDGET`), and a client that floods is disconnected with `rate_limited`.
|
||||
Frames over 512 KB are refused, and names are cleaned of control characters.
|
||||
- **What the app does with peer data**: queue items and suggestions from other devices
|
||||
are cleaned before use (`JamMediaItem.sanitized`): streaming tracks only, no URLs, only
|
||||
https covers. The host credits suggestions to the sender the server reports, not to
|
||||
whatever name the sender claims. The host's local files go out as an opaque id, never
|
||||
as a path.
|
||||
- **Privacy**: participants never see each other's IP addresses, since everything goes
|
||||
through the room. The room keeps no history: the queue and bans are deleted 10 minutes
|
||||
after the last person leaves.
|
||||
|
||||
Known limits:
|
||||
|
||||
- **Bans**: a ban covers the identity (the same install gets the same identity in a
|
||||
room every time) and the device. The app sends a digest of stable device traits
|
||||
(model, OS version, locale, …) hashed with the room code. The server hashes that again
|
||||
with the network (IPv4 address or IPv6 /64), so the room only stores an opaque value
|
||||
that means nothing outside it. Someone who reinstalls and rejoins from the same device
|
||||
and network is refused. People who were already in the room aren't affected, so a
|
||||
friend on the same Wi-Fi with the same phone is never caught by someone else's ban.
|
||||
Ways around it: a different device or network, or a modified client. A new arrival
|
||||
with exactly the same phone, OS version and settings on the banned person's network
|
||||
would also be refused.
|
||||
- **Room codes**: six characters, about a billion codes. That's fine against guessing
|
||||
at the rate limit, but a code someone sees on your screen lets them in.
|
||||
- **Spending**: on the paid plan, a determined attacker with many IPs can still run up
|
||||
usage. Set a usage alert in the Cloudflare dashboard. The free plan just stops at its
|
||||
daily limit (00:00 UTC reset) instead of billing.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # http://127.0.0.1:8787
|
||||
npm test # smoke test against the dev server (another terminal)
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
The app's end-to-end tests run against it too:
|
||||
|
||||
```sh
|
||||
JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest --tests 'org.spotube.spotube.core.jam.*'
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... npx wrangler deploy
|
||||
```
|
||||
|
||||
The token needs the "Edit Cloudflare Workers" permissions. Durable Objects on the
|
||||
SQLite backend work on the free plan. `.github/workflows/jam-server-deploy.yml` deploys on
|
||||
pushes to `master` that touch this directory (secrets `CLOUDFLARE_API_TOKEN` and
|
||||
`CLOUDFLARE_ACCOUNT_ID`; optional variable `JAM_SERVER_URL` runs the smoke test after).
|
||||
|
||||
## Self-host
|
||||
|
||||
Deploy to your own Cloudflare account as above, then enter the Worker's URL in
|
||||
Spotube → Settings → Jam → Server URL.
|
||||
|
||||
## Limits
|
||||
|
||||
- 32 participants per room, 512 KB per frame.
|
||||
- 60 room connections per minute per IP (`CONNECT_LIMITER` in `wrangler.jsonc`).
|
||||
1606
jam-server/package-lock.json
generated
1606
jam-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "spotube-jam-server",
|
||||
"private": true,
|
||||
"description": "Group Jam room server for Spotube (Cloudflare Worker + Durable Objects)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"types": "wrangler types",
|
||||
"typecheck": "wrangler types && tsc --noEmit",
|
||||
"test": "node test/smoke.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.156.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true,
|
||||
"workerd@1.20261023.1": true
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import { CLIENT_KEY, DEVICE_DIGEST, PROTOCOL_VERSION, ROOM_CODE, cleanName, deviceMark, participantId } from "./protocol";
|
||||
|
||||
export { JamRoom } from "./room";
|
||||
|
||||
const JSON_HEADERS = { "content-type": "application/json" };
|
||||
|
||||
/**
|
||||
* Routes:
|
||||
* - `GET /v1/health` → `{ ok, v, now }` (used by the app's "Test connection")
|
||||
* - `GET /v1/rooms/{CODE}?key=&name=&mode=create|join[&dev=]` (WebSocket) → the room's Durable Object
|
||||
*
|
||||
* Durable Objects aren't reachable from the internet, only through this Worker, so the
|
||||
* room trusts the `id` and `name` this Worker hands it.
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/v1/health") {
|
||||
return Response.json({ ok: true, v: PROTOCOL_VERSION, now: Date.now() });
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/^\/v1\/rooms\/([^/]+)$/);
|
||||
if (!match) return new Response("Not found", { status: 404 });
|
||||
|
||||
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
||||
return new Response("Expected a WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
|
||||
const code = match[1].toUpperCase();
|
||||
const key = url.searchParams.get("key") ?? "";
|
||||
const name = cleanName(url.searchParams.get("name") ?? "");
|
||||
const mode = url.searchParams.get("mode");
|
||||
const dev = url.searchParams.get("dev");
|
||||
if (
|
||||
!ROOM_CODE.test(code) ||
|
||||
!CLIENT_KEY.test(key) ||
|
||||
!name ||
|
||||
(mode !== "create" && mode !== "join") ||
|
||||
(dev !== null && !DEVICE_DIGEST.test(dev))
|
||||
) {
|
||||
return new Response(JSON.stringify({ error: "bad_request" }), { status: 400, headers: JSON_HEADERS });
|
||||
}
|
||||
|
||||
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
|
||||
const { success } = await env.CONNECT_LIMITER.limit({ key: ip });
|
||||
if (!success) return new Response("Too many connections", { status: 429 });
|
||||
|
||||
const forwarded = new URL(request.url);
|
||||
forwarded.search = new URLSearchParams({
|
||||
id: await participantId(code, key),
|
||||
name,
|
||||
mode,
|
||||
mark: (await deviceMark(code, dev, ip)) ?? "",
|
||||
}).toString();
|
||||
const room = env.JAM_ROOM.get(env.JAM_ROOM.idFromName(code));
|
||||
return room.fetch(new Request(forwarded, request));
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Wire protocol between Spotube and the jam room server (version 1).
|
||||
*
|
||||
* Every frame is a JSON object with a `t` discriminator. The server never looks
|
||||
* inside `body`: queue, anchor and command payloads are owned by the app and only
|
||||
* routed (and, for queue/anchor, retained) here.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
/** Same alphabet as the app's `JamRoomCode` (no I, O, 0, 1). */
|
||||
export const ROOM_CODE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
||||
/**
|
||||
* A participant's secret, chosen by the client (at least 128 random bits). The server
|
||||
* never shares it: everyone else only sees the id derived from it ([participantId]),
|
||||
* so knowing someone's id doesn't let you reconnect as them.
|
||||
*/
|
||||
export const CLIENT_KEY = /^[A-Za-z0-9_-]{22,64}$/;
|
||||
export const MAX_NAME_LENGTH = 40;
|
||||
export const MAX_PARTICIPANTS = 32;
|
||||
/** A long queue with cover URLs is the largest frame, a few hundred KB at most. */
|
||||
export const MAX_FRAME_BYTES = 512 * 1024;
|
||||
export const MAX_BANS = 256;
|
||||
/** Everyone who has been in the room, to tell a returning member from a new arrival. */
|
||||
export const MAX_MEMBERS = 512;
|
||||
|
||||
/** Optional per-room device digest from the client (it never sends device details). */
|
||||
export const DEVICE_DIGEST = /^[A-Za-z0-9_-]{16,64}$/;
|
||||
|
||||
/**
|
||||
* Per-socket token buckets. Every incoming message is billed, so a client that floods
|
||||
* is disconnected. Real clients stay far below: a few frames a minute, bursts of a
|
||||
* dozen when skipping quickly, and the host sends at most one queue a second.
|
||||
*/
|
||||
export const FRAME_BUDGET = { burst: 40, perSecond: 6 };
|
||||
export const QUEUE_BUDGET = { burst: 5, perSecond: 1 };
|
||||
|
||||
/**
|
||||
* A host that drops keeps the room for this long before someone else takes over (long
|
||||
* enough for a phone to switch networks). Guests keep playing in the meantime.
|
||||
*/
|
||||
export const HOST_GRACE_MS = 30_000;
|
||||
/** An empty room keeps its queue for this long, so everyone can rejoin after a blip. */
|
||||
export const EMPTY_ROOM_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Keep-alive, sent as a bare text frame. The runtime answers it without waking the room,
|
||||
* so it costs nothing; clock pings (`ping` frames) are only sent when the clock syncs.
|
||||
*/
|
||||
export const KEEPALIVE_PING = "ping";
|
||||
export const KEEPALIVE_PONG = "pong";
|
||||
|
||||
export type JoinMode = "create" | "join";
|
||||
|
||||
export type ClientFrame =
|
||||
| { t: "ping"; id: number; c: number }
|
||||
| { t: "queue"; body: unknown }
|
||||
| { t: "anchor"; body: unknown }
|
||||
| { t: "cmd"; body: unknown }
|
||||
| { t: "kick"; target: string; ban?: boolean };
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ServerFrame =
|
||||
| {
|
||||
t: "welcome";
|
||||
v: number;
|
||||
you: string;
|
||||
host: string | null;
|
||||
participants: Participant[];
|
||||
queue: unknown;
|
||||
anchor: unknown;
|
||||
}
|
||||
| { t: "pong"; id: number; c: number; s: number }
|
||||
| { t: "presence"; host: string | null; participants: Participant[] }
|
||||
| { t: "queue"; body: unknown }
|
||||
| { t: "anchor"; body: unknown }
|
||||
| { t: "cmd"; from: string; body: unknown }
|
||||
| { t: "error"; code: ErrorCode };
|
||||
|
||||
export type ErrorCode =
|
||||
| "bad_request"
|
||||
| "room_taken"
|
||||
| "room_not_found"
|
||||
| "room_full"
|
||||
| "banned"
|
||||
| "kicked"
|
||||
| "replaced"
|
||||
| "frame_too_large"
|
||||
| "rate_limited";
|
||||
|
||||
/** WebSocket close codes the app maps back to an [ErrorCode]. */
|
||||
export const CLOSE_CODES: Record<ErrorCode, number> = {
|
||||
bad_request: 4000,
|
||||
room_taken: 4001,
|
||||
room_not_found: 4004,
|
||||
room_full: 4005,
|
||||
banned: 4003,
|
||||
kicked: 4008,
|
||||
replaced: 4009,
|
||||
frame_too_large: 4013,
|
||||
rate_limited: 4029,
|
||||
};
|
||||
|
||||
/** First 96 bits of SHA-256, base64url. */
|
||||
async function shortHash(text: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest).slice(0, 12)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
/** The public id for [key] in room [code]: a different id per room, so ids can't link rooms. */
|
||||
export function participantId(code: string, key: string): Promise<string> {
|
||||
return shortHash(`${code}:${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a ban remembers about a device besides its participant id: the client's device
|
||||
* digest combined with its network, hashed again here so the room only ever stores an
|
||||
* opaque value. Null without a digest.
|
||||
*/
|
||||
export function deviceMark(code: string, digest: string | null, ip: string): Promise<string> | null {
|
||||
return digest ? shortHash(`${code}:${digest}:${networkOf(ip)}`) : null;
|
||||
}
|
||||
|
||||
/** An IPv4 address, or an IPv6 /64 (one home or phone gets a whole /64). */
|
||||
export function networkOf(ip: string): string {
|
||||
if (!ip.includes(":")) return ip;
|
||||
const [head, tail] = ip.split("::");
|
||||
const left = head ? head.split(":") : [];
|
||||
const right = tail ? tail.split(":") : [];
|
||||
const groups = tail === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
|
||||
return groups.slice(0, 4).map((g) => parseInt(g || "0", 16).toString(16)).join(":") + "::/64";
|
||||
}
|
||||
|
||||
/** Names are shown to everyone: no control or formatting characters, no padding. */
|
||||
export function cleanName(raw: string): string {
|
||||
return raw.replace(/[\p{C}]/gu, "").replace(/\s+/gu, " ").trim().slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
@ -1,383 +0,0 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import {
|
||||
CLOSE_CODES,
|
||||
KEEPALIVE_PING,
|
||||
KEEPALIVE_PONG,
|
||||
EMPTY_ROOM_TTL_MS,
|
||||
FRAME_BUDGET,
|
||||
HOST_GRACE_MS,
|
||||
MAX_BANS,
|
||||
MAX_MEMBERS,
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_PARTICIPANTS,
|
||||
PROTOCOL_VERSION,
|
||||
QUEUE_BUDGET,
|
||||
type ClientFrame,
|
||||
type ErrorCode,
|
||||
type JoinMode,
|
||||
type Participant,
|
||||
type ServerFrame,
|
||||
} from "./protocol";
|
||||
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** Takes one token; false when [bucket] is empty. */
|
||||
function spend(bucket: Bucket, budget: { burst: number; perSecond: number }, now: number): boolean {
|
||||
bucket.tokens = Math.min(budget.burst, bucket.tokens + ((now - bucket.at) / 1000) * budget.perSecond);
|
||||
bucket.at = now;
|
||||
if (bucket.tokens < 1) return false;
|
||||
bucket.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Kept on each socket so it survives hibernation. */
|
||||
interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
joined: number;
|
||||
/** See [deviceMark]; empty when the client sent no device digest. */
|
||||
mark: string;
|
||||
}
|
||||
|
||||
const enum Key {
|
||||
Host = "host",
|
||||
Queue = "queue",
|
||||
Banned = "banned",
|
||||
BannedMarks = "bannedMarks",
|
||||
Members = "members",
|
||||
HostGoneAt = "hostGoneAt",
|
||||
EmptySince = "emptySince",
|
||||
}
|
||||
|
||||
/**
|
||||
* One jam room, named by its six-letter code.
|
||||
*
|
||||
* The room is a relay with memory: it tracks who is connected and who hosts, keeps
|
||||
* the host's latest queue and playback anchor for late joiners, routes guest
|
||||
* commands to the host, and serves the clock everyone syncs playback against.
|
||||
* Sockets use the hibernation API, so an idle room costs nothing between messages.
|
||||
*
|
||||
* Cost: rooms are billed per incoming message and per storage write, so the steady
|
||||
* state is kept free of both. Keep-alives are answered by the runtime without waking
|
||||
* the room, and the anchor (sent on every playback change) lives in memory only: when
|
||||
* a hibernated room wakes for a join, the host answers the join's presence frame with
|
||||
* a fresh anchor anyway.
|
||||
*/
|
||||
export class JamRoom extends DurableObject<Env> {
|
||||
private anchor: unknown = null;
|
||||
// In memory: a room that hibernated starts everyone with a full budget again, which
|
||||
// only matters to a client that floods, and that one wakes the room right away.
|
||||
private frameBudgets = new WeakMap<WebSocket, Bucket>();
|
||||
private queueBudgets = new WeakMap<WebSocket, Bucket>();
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env);
|
||||
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(KEEPALIVE_PING, KEEPALIVE_PONG));
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id")!;
|
||||
const name = url.searchParams.get("name")!;
|
||||
const mode = url.searchParams.get("mode") as JoinMode;
|
||||
const mark = url.searchParams.get("mark") ?? "";
|
||||
|
||||
const { 0: client, 1: server } = new WebSocketPair();
|
||||
const previous = this.ctx.getWebSockets(id);
|
||||
this.ctx.acceptWebSocket(server, [id]);
|
||||
server.serializeAttachment({ id, name, joined: Date.now(), mark } satisfies Attachment);
|
||||
|
||||
// A reconnect with the same id (network change, app restart) takes over the old socket.
|
||||
for (const ws of previous) this.reject(ws, "replaced");
|
||||
|
||||
const refusal = await this.admit(id, mode, mark);
|
||||
if (refusal) {
|
||||
this.reject(server, refusal);
|
||||
} else {
|
||||
await this.ctx.storage.delete(Key.EmptySince);
|
||||
const [host, queue] = await Promise.all([
|
||||
this.ctx.storage.get<string>(Key.Host),
|
||||
this.ctx.storage.get(Key.Queue),
|
||||
]);
|
||||
this.send(server, {
|
||||
t: "welcome",
|
||||
v: PROTOCOL_VERSION,
|
||||
you: id,
|
||||
host: host ?? null,
|
||||
participants: this.participants(),
|
||||
queue: queue ?? null,
|
||||
anchor: this.anchor,
|
||||
});
|
||||
this.broadcast(await this.presence(), server);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
const size = typeof message === "string" ? message.length : message.byteLength;
|
||||
if (size > MAX_FRAME_BYTES) return this.reject(ws, "frame_too_large");
|
||||
if (typeof message !== "string") return;
|
||||
if (!this.withinBudget(this.frameBudgets, ws, FRAME_BUDGET)) return this.reject(ws, "rate_limited");
|
||||
|
||||
let frame: ClientFrame;
|
||||
try {
|
||||
frame = JSON.parse(message);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const sender = this.attachment(ws);
|
||||
|
||||
switch (frame.t) {
|
||||
case "ping":
|
||||
this.send(ws, { t: "pong", id: frame.id, c: frame.c, s: Date.now() });
|
||||
return;
|
||||
|
||||
case "queue":
|
||||
if (!(await this.isHost(sender.id))) return;
|
||||
if (!this.withinBudget(this.queueBudgets, ws, QUEUE_BUDGET)) return this.reject(ws, "rate_limited");
|
||||
await this.ctx.storage.put(Key.Queue, frame.body);
|
||||
this.broadcast({ t: "queue", body: frame.body }, ws);
|
||||
return;
|
||||
|
||||
case "anchor":
|
||||
if (!(await this.isHost(sender.id))) return;
|
||||
this.anchor = frame.body;
|
||||
this.broadcast({ t: "anchor", body: frame.body }, ws);
|
||||
return;
|
||||
|
||||
case "cmd": {
|
||||
const host = await this.ctx.storage.get<string>(Key.Host);
|
||||
const out: ServerFrame = { t: "cmd", from: sender.id, body: frame.body };
|
||||
if (sender.id === host) {
|
||||
this.broadcast(out, ws);
|
||||
} else if (host) {
|
||||
for (const target of this.ctx.getWebSockets(host)) this.send(target, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "kick": {
|
||||
if (!(await this.isHost(sender.id)) || frame.target === sender.id) return;
|
||||
if (frame.ban) await this.ban(frame.target);
|
||||
for (const target of this.ctx.getWebSockets(frame.target)) {
|
||||
this.reject(target, frame.ban ? "banned" : "kicked");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async webSocketClose(ws: WebSocket, code: number): Promise<void> {
|
||||
try {
|
||||
ws.close(code === 1005 ? 1000 : code);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
await this.onDeparture(ws);
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket): Promise<void> {
|
||||
await this.onDeparture(ws);
|
||||
}
|
||||
|
||||
/** Fires after a host's grace period, or when an empty room expires. */
|
||||
async alarm(): Promise<void> {
|
||||
const live = this.live();
|
||||
if (live.length === 0) {
|
||||
const emptySince = await this.ctx.storage.get<number>(Key.EmptySince);
|
||||
if (emptySince === undefined) return;
|
||||
const expiresAt = emptySince + EMPTY_ROOM_TTL_MS;
|
||||
if (Date.now() >= expiresAt) {
|
||||
await this.ctx.storage.deleteAll();
|
||||
this.anchor = null;
|
||||
} else {
|
||||
await this.ctx.storage.setAlarm(expiresAt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await this.ctx.storage.get<string>(Key.Host);
|
||||
if (host && live.some((ws) => this.attachment(ws).id === host)) return;
|
||||
await this.promote();
|
||||
}
|
||||
|
||||
// ---------- Membership ----------
|
||||
|
||||
/**
|
||||
* Bans [id], and the device it's on: someone who comes back with a fresh identity from
|
||||
* the same device and network is refused too, unless they were already a member.
|
||||
*/
|
||||
private async ban(id: string): Promise<void> {
|
||||
const marks = this.ctx
|
||||
.getWebSockets(id)
|
||||
.map((ws) => this.attachment(ws).mark)
|
||||
.filter((mark) => mark);
|
||||
const [banned, bannedMarks] = await Promise.all([
|
||||
this.ctx.storage.get<string[]>(Key.Banned),
|
||||
this.ctx.storage.get<string[]>(Key.BannedMarks),
|
||||
]);
|
||||
await this.ctx.storage.put({
|
||||
[Key.Banned]: [...new Set([...(banned ?? []), id])].slice(-MAX_BANS),
|
||||
[Key.BannedMarks]: [...new Set([...(bannedMarks ?? []), ...marks])].slice(-MAX_BANS),
|
||||
});
|
||||
}
|
||||
|
||||
private async admit(id: string, mode: JoinMode, mark: string): Promise<ErrorCode | null> {
|
||||
const others = this.live().filter((ws) => this.attachment(ws).id !== id);
|
||||
// An empty room past its TTL whose cleanup alarm hasn't run yet is gone.
|
||||
const emptySince = await this.ctx.storage.get<number>(Key.EmptySince);
|
||||
if (others.length === 0 && emptySince !== undefined && Date.now() - emptySince >= EMPTY_ROOM_TTL_MS) {
|
||||
await this.ctx.storage.deleteAll();
|
||||
this.anchor = null;
|
||||
}
|
||||
|
||||
const [host, banned, queue] = await Promise.all([
|
||||
this.ctx.storage.get<string>(Key.Host),
|
||||
this.ctx.storage.get<string[]>(Key.Banned),
|
||||
this.ctx.storage.get(Key.Queue),
|
||||
]);
|
||||
const exists = host !== undefined || queue !== undefined || others.length > 0;
|
||||
|
||||
if (mode === "create") {
|
||||
// Only a fresh code, or the room's own host bringing it back. Anyone else
|
||||
// "creating" an existing room (even an empty one) would take it over.
|
||||
if (others.length > 0 || (exists && host !== id)) return "room_taken";
|
||||
if (!exists) await this.ctx.storage.put({ [Key.Host]: id, [Key.Members]: [id] });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!exists) return "room_not_found";
|
||||
if (banned?.includes(id)) return "banned";
|
||||
const members = (await this.ctx.storage.get<string[]>(Key.Members)) ?? [];
|
||||
const isMember = members.includes(id);
|
||||
// A member who was in the room before a ban (a friend on the same Wi-Fi with the
|
||||
// same phone, say) is never caught by another person's device ban.
|
||||
if (mark && !isMember && (await this.ctx.storage.get<string[]>(Key.BannedMarks))?.includes(mark)) {
|
||||
return "banned";
|
||||
}
|
||||
if (others.length >= MAX_PARTICIPANTS) return "room_full";
|
||||
if (!isMember) await this.ctx.storage.put(Key.Members, [...members, id].slice(-MAX_MEMBERS));
|
||||
|
||||
// Joining a room whose host is gone (and whose grace period is over) promotes someone.
|
||||
const hostLive = host === id || others.some((ws) => this.attachment(ws).id === host);
|
||||
if (!hostLive) {
|
||||
const goneAt = await this.ctx.storage.get<number>(Key.HostGoneAt);
|
||||
if (goneAt === undefined || Date.now() - goneAt >= HOST_GRACE_MS) {
|
||||
await this.promote(id);
|
||||
}
|
||||
} else if (host === id) {
|
||||
await this.ctx.storage.delete(Key.HostGoneAt);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async onDeparture(ws: WebSocket): Promise<void> {
|
||||
const { id } = this.attachment(ws);
|
||||
const live = this.live().filter((other) => other !== ws);
|
||||
// Replaced by a newer socket of the same participant: nothing changed.
|
||||
if (live.some((other) => this.attachment(other).id === id)) return;
|
||||
|
||||
if (live.length === 0) {
|
||||
await this.ctx.storage.put(Key.EmptySince, Date.now());
|
||||
await this.ctx.storage.setAlarm(Date.now() + EMPTY_ROOM_TTL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === (await this.ctx.storage.get<string>(Key.Host))) {
|
||||
await this.ctx.storage.put(Key.HostGoneAt, Date.now());
|
||||
await this.ctx.storage.setAlarm(Date.now() + HOST_GRACE_MS);
|
||||
}
|
||||
this.broadcast(await this.presence(), ws);
|
||||
}
|
||||
|
||||
/** The longest-connected participant hosts next (or [fallback] in an empty room). */
|
||||
private async promote(fallback?: string): Promise<void> {
|
||||
const next = this.participants()[0]?.id ?? fallback;
|
||||
if (!next) return;
|
||||
await this.ctx.storage.put(Key.Host, next);
|
||||
await this.ctx.storage.delete(Key.HostGoneAt);
|
||||
this.broadcast(await this.presence());
|
||||
}
|
||||
|
||||
private async isHost(id: string): Promise<boolean> {
|
||||
return (await this.ctx.storage.get<string>(Key.Host)) === id;
|
||||
}
|
||||
|
||||
private async presence(): Promise<ServerFrame> {
|
||||
return {
|
||||
t: "presence",
|
||||
host: (await this.ctx.storage.get<string>(Key.Host)) ?? null,
|
||||
participants: this.participants(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Sockets ----------
|
||||
|
||||
private live(): WebSocket[] {
|
||||
return this.ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN);
|
||||
}
|
||||
|
||||
/** Connected participants, longest-connected first, one entry per id. */
|
||||
private participants(): Participant[] {
|
||||
const byId = new Map<string, Attachment>();
|
||||
for (const ws of this.live()) {
|
||||
const a = this.attachment(ws);
|
||||
const existing = byId.get(a.id);
|
||||
if (!existing || a.joined < existing.joined) byId.set(a.id, a);
|
||||
}
|
||||
return [...byId.values()]
|
||||
.sort((a, b) => a.joined - b.joined || a.id.localeCompare(b.id))
|
||||
.map(({ id, name }) => ({ id, name }));
|
||||
}
|
||||
|
||||
private withinBudget(
|
||||
buckets: WeakMap<WebSocket, Bucket>,
|
||||
ws: WebSocket,
|
||||
budget: { burst: number; perSecond: number },
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
let bucket = buckets.get(ws);
|
||||
if (!bucket) {
|
||||
bucket = { tokens: budget.burst, at: now };
|
||||
buckets.set(ws, bucket);
|
||||
}
|
||||
return spend(bucket, budget, now);
|
||||
}
|
||||
|
||||
private attachment(ws: WebSocket): Attachment {
|
||||
return ws.deserializeAttachment() as Attachment;
|
||||
}
|
||||
|
||||
private send(ws: WebSocket, frame: ServerFrame): void {
|
||||
try {
|
||||
ws.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
// The socket closed under us; its close handler cleans up.
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(frame: ServerFrame, except?: WebSocket): void {
|
||||
const text = JSON.stringify(frame);
|
||||
for (const ws of this.live()) {
|
||||
if (ws === except) continue;
|
||||
try {
|
||||
ws.send(text);
|
||||
} catch {
|
||||
// See send().
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reject(ws: WebSocket, code: ErrorCode): void {
|
||||
this.send(ws, { t: "error", code });
|
||||
try {
|
||||
ws.close(CLOSE_CODES[code], code);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
// End-to-end smoke test against a running server: `npm run dev`, then `npm test`.
|
||||
// Point it elsewhere with JAM_URL=https://spotube-jam.<account>.workers.dev npm test
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const BASE = process.env.JAM_URL ?? "http://127.0.0.1:8787";
|
||||
const WS_BASE = BASE.replace(/^http/, "ws");
|
||||
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
|
||||
const newKey = () => crypto.randomUUID().replaceAll("-", "");
|
||||
|
||||
/** [id] is only a label for errors; the server assigns the real id (welcome.you). */
|
||||
function connect(room, id, mode, name = id, key = newKey(), dev = null) {
|
||||
const devParam = dev ? `&dev=${dev}` : "";
|
||||
const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?key=${key}&name=${encodeURIComponent(name)}&mode=${mode}${devParam}`);
|
||||
const inbox = [];
|
||||
const waiters = [];
|
||||
ws.addEventListener("message", (e) => {
|
||||
const frame = e.data === "pong" ? { t: "keepalive" } : JSON.parse(e.data);
|
||||
const i = waiters.findIndex((w) => w.match(frame));
|
||||
if (i >= 0) waiters.splice(i, 1)[0].resolve(frame);
|
||||
else inbox.push(frame);
|
||||
});
|
||||
const closed = new Promise((resolve) => ws.addEventListener("close", (e) => resolve(e.code)));
|
||||
return {
|
||||
ws,
|
||||
key,
|
||||
closed,
|
||||
send: (frame) => ws.send(JSON.stringify(frame)),
|
||||
next(t, pred = () => true, timeoutMs = 5_000) {
|
||||
const match = (f) => f.t === t && pred(f);
|
||||
const i = inbox.findIndex(match);
|
||||
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${id}: timed out waiting for ${t}`)), timeoutMs);
|
||||
waiters.push({ match, resolve: (f) => (clearTimeout(timer), resolve(f)) });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const step = async (name, fn) => {
|
||||
await fn();
|
||||
console.log(`ok - ${name}`);
|
||||
};
|
||||
|
||||
await step("health", async () => {
|
||||
const res = await (await fetch(`${BASE}/v1/health`)).json();
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.v, 1);
|
||||
});
|
||||
|
||||
const room = code();
|
||||
const host = connect(room, "host", "create", "Host");
|
||||
let guest, hostId, guestId;
|
||||
|
||||
await step("host creates the room", async () => {
|
||||
const welcome = await host.next("welcome");
|
||||
hostId = welcome.you;
|
||||
assert.match(hostId, /^[A-Za-z0-9_-]{16}$/);
|
||||
assert.equal(welcome.host, hostId);
|
||||
host.send({ t: "queue", body: { items: [1, 2, 3] } });
|
||||
});
|
||||
|
||||
await step("guest joins and gets the retained queue", async () => {
|
||||
// The host's queue frame is processed before the guest's join is.
|
||||
host.send({ t: "ping", id: 1, c: 0 });
|
||||
await host.next("pong");
|
||||
guest = connect(room, "guest", "join", "Guest\u202e\u0000 1");
|
||||
const welcome = await guest.next("welcome");
|
||||
guestId = welcome.you;
|
||||
assert.equal(welcome.host, hostId);
|
||||
assert.deepEqual(welcome.queue, { items: [1, 2, 3] });
|
||||
const me = welcome.participants.find((p) => p.id === guestId);
|
||||
assert.equal(me.name, "Guest 1", "control and formatting characters are stripped");
|
||||
await host.next("presence", (f) => f.participants.length === 2);
|
||||
});
|
||||
|
||||
await step("clock ping", async () => {
|
||||
guest.send({ t: "ping", id: 7, c: Date.now() });
|
||||
const pong = await guest.next("pong");
|
||||
assert.equal(pong.id, 7);
|
||||
assert.equal(typeof pong.s, "number");
|
||||
});
|
||||
|
||||
await step("keep-alives are answered", async () => {
|
||||
guest.ws.send("ping");
|
||||
await guest.next("keepalive");
|
||||
});
|
||||
|
||||
await step("anchor fans out to guests", async () => {
|
||||
host.send({ t: "anchor", body: { positionMs: 1234 } });
|
||||
const anchor = await guest.next("anchor");
|
||||
assert.equal(anchor.body.positionMs, 1234);
|
||||
});
|
||||
|
||||
await step("guest anchors are ignored", async () => {
|
||||
guest.send({ t: "anchor", body: { positionMs: 1 } });
|
||||
guest.send({ t: "ping", id: 8, c: 0 });
|
||||
await guest.next("pong", (f) => f.id === 8);
|
||||
await assert.rejects(host.next("anchor", () => true, 500));
|
||||
});
|
||||
|
||||
await step("guest commands reach the host", async () => {
|
||||
guest.send({ t: "cmd", body: { type: "skipNext" } });
|
||||
const cmd = await host.next("cmd");
|
||||
assert.equal(cmd.from, guestId);
|
||||
});
|
||||
|
||||
await step("nobody can take over the host's identity", async () => {
|
||||
// The host's id is public; using it as a key yields a different id.
|
||||
const impostor = connect(room, "impostor", "join", "Host", hostId.padEnd(22, "x"));
|
||||
const welcome = await impostor.next("welcome");
|
||||
assert.notEqual(welcome.you, hostId);
|
||||
assert.equal(welcome.host, hostId);
|
||||
impostor.send({ t: "kick", target: guestId, ban: true });
|
||||
impostor.send({ t: "anchor", body: { positionMs: 666 } });
|
||||
impostor.send({ t: "ping", id: 9, c: 0 });
|
||||
await impostor.next("pong");
|
||||
await assert.rejects(guest.next("anchor", () => true, 500), "an impostor's anchor isn't relayed");
|
||||
impostor.ws.close();
|
||||
await host.next("presence", (f) => f.participants.length === 2);
|
||||
});
|
||||
|
||||
await step("a taken code can't be created again", async () => {
|
||||
const intruder = connect(room, "intruder", "create");
|
||||
const error = await intruder.next("error");
|
||||
assert.equal(error.code, "room_taken");
|
||||
assert.equal(await intruder.closed, 4001);
|
||||
});
|
||||
|
||||
await step("unknown rooms are not found", async () => {
|
||||
const lost = connect(code(), "lost", "join");
|
||||
assert.equal((await lost.next("error")).code, "room_not_found");
|
||||
});
|
||||
|
||||
await step("ban closes the socket and blocks rejoining", async () => {
|
||||
host.send({ t: "kick", target: guestId, ban: true });
|
||||
assert.equal((await guest.next("error")).code, "banned");
|
||||
assert.equal(await guest.closed, 4003);
|
||||
const again = connect(room, "guest", "join", "Guest", guest.key);
|
||||
assert.equal((await again.next("error")).code, "banned");
|
||||
});
|
||||
|
||||
await step("a ban covers the device, not just the identity", async () => {
|
||||
const party = code();
|
||||
const phone = newKey(); // The same device digest: same model, OS, locale…
|
||||
const owner = connect(party, "owner", "create", "Owner", newKey(), newKey());
|
||||
await owner.next("welcome");
|
||||
// A friend with an identical phone, already in the room before the ban.
|
||||
const friend = connect(party, "friend", "join", "Friend", newKey(), phone);
|
||||
const friendId = (await friend.next("welcome")).you;
|
||||
const troll = connect(party, "troll", "join", "Troll", newKey(), phone);
|
||||
const trollId = (await troll.next("welcome")).you;
|
||||
|
||||
owner.send({ t: "kick", target: trollId, ban: true });
|
||||
assert.equal((await troll.next("error")).code, "banned");
|
||||
|
||||
// Back with a fresh identity (reinstall) from the same device and network: refused.
|
||||
const again = connect(party, "troll-again", "join", "Troll", newKey(), phone);
|
||||
assert.equal((await again.next("error")).code, "banned");
|
||||
// The friend can still reconnect, and someone on another device can join.
|
||||
friend.ws.close();
|
||||
const friendBack = connect(party, "friend", "join", "Friend", friend.key, phone);
|
||||
assert.equal((await friendBack.next("welcome")).you, friendId);
|
||||
const stranger = connect(party, "stranger", "join", "Stranger", newKey(), newKey());
|
||||
await stranger.next("welcome");
|
||||
for (const c of [owner, friendBack, stranger]) c.ws.close();
|
||||
});
|
||||
|
||||
await step("a client that floods is cut off", async () => {
|
||||
const flooder = connect(code(), "flooder", "create");
|
||||
await flooder.next("welcome");
|
||||
for (let i = 0; i < 100; i++) flooder.send({ t: "ping", id: i, c: 0 });
|
||||
assert.equal(await flooder.closed, 4029);
|
||||
});
|
||||
|
||||
await step("host hands over after the grace period", async () => {
|
||||
const heir = connect(room, "heir", "join", "Heir");
|
||||
const heirId = (await heir.next("welcome")).you;
|
||||
host.ws.close();
|
||||
const presence = await heir.next("presence", (f) => f.host === heirId, 45_000);
|
||||
assert.deepEqual(presence.participants.map((p) => p.id), [heirId]);
|
||||
heir.ws.close();
|
||||
});
|
||||
|
||||
await step("an empty room can't be taken over, but its host can bring it back", async () => {
|
||||
const other = code();
|
||||
const owner = connect(other, "owner", "create");
|
||||
await owner.next("welcome");
|
||||
owner.ws.close();
|
||||
await owner.closed;
|
||||
const squatter = connect(other, "squatter", "create");
|
||||
assert.equal((await squatter.next("error")).code, "room_taken");
|
||||
const back = connect(other, "owner", "create", "Owner", owner.key);
|
||||
const welcome = await back.next("welcome");
|
||||
assert.equal(welcome.host, welcome.you);
|
||||
back.ws.close();
|
||||
});
|
||||
|
||||
console.log("all smoke tests passed");
|
||||
process.exit(0);
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2022"],
|
||||
"types": ["./worker-configuration.d.ts"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16269
jam-server/worker-configuration.d.ts
vendored
16269
jam-server/worker-configuration.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "spotube-jam",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-10-01",
|
||||
"observability": { "enabled": true },
|
||||
"durable_objects": {
|
||||
"bindings": [{ "name": "JAM_ROOM", "class_name": "JamRoom" }]
|
||||
},
|
||||
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JamRoom"] }],
|
||||
// Room connects per client IP: slows down code guessing and connection floods, which
|
||||
// wake (and bill) rooms. Generous because carrier-grade NAT puts many phones behind
|
||||
// one IP. Enforced per Cloudflare location, so approximate.
|
||||
"ratelimits": [
|
||||
{ "name": "CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user