@cyberscaling/secure-audio-stream-client
v0.4.2
Published
JavaScript SDK for the Cyberscaling secure audio streaming worker. AES-CTR transit decryption + MSE / ManagedMediaSource playback.
Downloads
538
Maintainers
Readme
@cyberscaling/secure-audio-stream-client
JavaScript SDK pour le lecteur audio chiffré musicme / Cyberscaling. Charge un morceau identifié par (cb, disc, track), ouvre une session sur le worker de streaming, télécharge les chunks chiffrés en Range, déchiffre AES-CTR au vol, alimente une MediaSource (ou ManagedMediaSource sur iOS Safari 17.1+) via mp4box.js. Expose un <audio> HTML standard.
Installation
bun add @cyberscaling/secure-audio-stream-client
# ou: pnpm add / npm install / yarn addUsage minimal
import { SecureAudioPlayer } from '@cyberscaling/secure-audio-stream-client'
const player = new SecureAudioPlayer({
workerUrl: 'https://stream.musicme.cc',
getToken: async () => {
const r = await fetch('/api/player-token', { method: 'POST', credentials: 'include' })
const { token } = await r.json()
return token
},
mode: 'mse', // 'blob' uniquement pour debug / fallback explicite
})
document.querySelector('#player-container')!.append(player.audio)
await player.load({ cb: 5400863209100, disc: 1, track: 1 })
await player.play()Usage React (player réutilisable)
Depuis 0.4.0, load() est rappelable sur un player déjà utilisé (teardown implicite du morceau précédent) — un seul SecureAudioPlayer par composant suffit pour jouer plusieurs pistes successives. (Avant 0.4.0, un load() sur un player déjà chargé se comportait de façon non garantie — il fallait recréer un player par piste.)
import { useEffect, useRef } from 'react'
import { SecureAudioPlayer } from '@cyberscaling/secure-audio-stream-client'
function Player() {
const containerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<SecureAudioPlayer | null>(null)
useEffect(() => {
const p = new SecureAudioPlayer({
workerUrl: 'https://stream.musicme.cc',
getToken: async () => {
const r = await fetch('/api/player-token', { method: 'POST', credentials: 'include' })
const { token } = await r.json()
return token
},
onError: console.error,
})
containerRef.current?.append(p.audio)
playerRef.current = p
return () => p.destroy() // libère la session, l'audio, les listeners
}, [])
async function playTrack(cb: number, disc: number, track: number) {
const p = playerRef.current
if (!p) return
await p.load({ cb, disc, track }) // ré-utilise le même player — requiert ≥0.4.0
await p.play()
}
return <div ref={containerRef} />
}Plateformes & modes de lecture
Le partenaire passe toujours mode: 'mse'. Le SDK choisit le backend au runtime selon les capacités du navigateur :
| Plateforme | Backend résolu | Comportement |
|---|---|---|
| iOS Safari 17.1+, macOS Safari 17.1+ | ManagedMediaSource (MMS) | Streaming progressif, économe en cellulaire. Pause/reprise du fetch loop sur endstreaming/startstreaming. |
| Chrome / Firefox / Edge / Safari desktop pré-17.1 | MediaSource (MSE classique) | Streaming progressif standard, comportement inchangé. |
| iOS Safari pré-17.1 | blob (fallback auto) | Téléchargement + déchiffrement intégral avant lecture. onError reçoit un avertissement non-fatal mms_fallback: no_media_source. |
| Browser sans MediaSource ni ManagedMediaSource | blob (fallback auto) | Idem ci-dessus. |
Aucune modif du code partenaire pour activer iOS. Le SDK auto-détecte ManagedMediaSource et choisit le bon chemin.
Erreurs
Toute erreur surfacée par le SDK est une SecureAudioError (ou une sous-classe), avec deux champs stables :
code— identifiant machine, aligné avecerror_codede la télémétrie :session_expired— session expirée/évincée (410) ou fingerprint mismatch (403 non-quota) sur/key·/stream. Un 404 y donnestream_404(seul le heartbeat traite le 404 comme session morte). Ré-load()résout.quota_exceeded— 403quota_exceededsur/stream(StreamQuotaError). Ne pas rouvrir de session : chaque tentative facturerait un nouveau/init-streamsans lever le quota.stream_<status>— autre statut HTTP inattendu sur/stream(StreamError).load_error— erreur non classée pendantload()(parse mp4box, réseau, etc.).aborted— chargement volontairement annulé ou supersédé (LoadAbortedError).mms_fallback— repli automatique vers le modeblob(FallbackWarning, voir ci-dessous).
severity—'fatal'(échec réel) ou'warning'(avertissement non bloquant).
Signal unique pour l'expiration de session
Si onSessionExpired est fourni, une SessionExpiredError (code: 'session_expired') est acheminée uniquement vers ce callback — elle ne repasse plus par onError. Sans onSessionExpired, elle continue d'arriver via onError comme avant. N'attends pas les deux pour la même erreur.
Annulation volontaire d'un chargement
Un load()/loadPrefetched() interrompu par destroy() ou par un nouveau
chargement rejette une LoadAbortedError (code: 'aborted',
severity: 'warning'). Cette annulation attendue ne passe pas par onError et
ne déclenche pas onStreamEnded({ reason: 'error' }) ; le chargement remplacé
reçoit uniquement son signal onStreamEnded({ reason: 'aborted' }).
FallbackWarning : le son continue de jouer
FallbackWarning (severity: 'warning', code: 'mms_fallback') est délivrée via onError pour compat arrière — teste severity === 'warning' (ou err instanceof FallbackWarning) avant d'afficher une UI d'erreur fatale : la lecture continue normalement via le repli blob.
import { FallbackWarning } from '@cyberscaling/secure-audio-stream-client'
onError: (err) => {
if (err instanceof FallbackWarning || (err as { severity?: string }).severity === 'warning') {
console.info('[player] repli non-fatal', err.message)
return
}
showFatalErrorUi(err)
},Nouveaux callbacks : onSessionOpened, onStreamEnded
onSessionOpened est déclenché juste après un /init-stream facturé, avant /key — donc signalé même si /key échoue ensuite. Pas déclenché par loadPrefetched() (déjà signalé au moment du prefetchSession, avec origin: 'prefetch').
new SecureAudioPlayer({
workerUrl, getToken, mode: 'mse',
onSessionOpened: ({ sessionId, ref, expiresAt, origin }) => {
console.info('[player] session facturée', sessionId, origin)
},
})onStreamEnded signale la fin de flux, au plus une fois par load() : 'complete' (fichier livré intégralement), 'error' (échec fatal), ou 'aborted' (destroy() ou nouveau load() en cours de route).
new SecureAudioPlayer({
workerUrl, getToken, mode: 'mse',
onStreamEnded: ({ reason }) => console.info('[player] flux terminé :', reason),
})Prefetch & cache warm-up
Le play→canplay froid coûte typiquement 1.5-2s (cold DO, cold KV catalogue, cold edge cache). Deux helpers réduisent radicalement cette latence dans le scénario réel partenaire — un user qui ouvre une page album puis joue un morceau.
prefetchAlbum(workerUrl, token, cb)
À appeler dès le mount d'une page album, en fire-and-forget. Le SDK orchestre une série de calls /warmup-album côté worker — chaque call traite jusqu'à 8 tracks en pools parallèles :
- Résolution catalogue — walk de l'index
tracks-dbpour lecb, écrit dansalbum:<cb>KV (TTL 24h). Tous les(disc, track) → midducbsont alors instantanément disponibles cross-isolate. - Métadonnées objet —
HEADScaleway pour chaquemiden parallèle, écrit danshead:<mid>KV (TTL 24h). Évite la HEAD upstream (~150ms) à chaque/init-stream. - Cache edge block 0 —
Range 0-1048575pour chaquemiden parallèle,await caches.default.put. Cloudflare Cache API persistant 7 jours par PoP. Le premier/streamRange du listener final est alors un HIT (~80ms au lieu de ~400-700ms).
import { prefetchAlbum } from '@cyberscaling/secure-audio-stream-client'
// Appel typique sur ouverture d'une page album
useEffect(() => {
void prefetchAlbum(workerUrl, token, cb).catch(() => {}) // non-fatal
}, [cb])Pourquoi le SDK chunke: la limite Cloudflare Workers de 50 subrequests par invocation (Bundled plan) est atteinte dès qu'un album dépasse ~10 tracks (chaque track ≈ 4 subrequests : KV head get/put + Scaleway HEAD + Scaleway GET). prefetchAlbum issue donc le 1er batch synchrone (qui retourne tracks = compte total), puis fanout les batches restants en parallèle. Chaque batch HTTP = une invocation Worker séparée avec son propre budget de 50. Albums de toute taille marchent de manière transparente.
Retourne AlbumWarmupReport agrégé ({tracks, head_cached, edge_filled, edge_errors, batches, phases_ms}).
prefetchSession(workerUrl, token, ref)
Pre-crée la session N+1 pendant que N joue. Renvoie un PrefetchedSession activable via player.loadPrefetched(...) → switch instantané sur fin de track (auto-advance gapless).
import { prefetchSession } from '@cyberscaling/secure-audio-stream-client'
// Pendant la lecture de la track N, dès que `currentTime > duration - 5s` :
const next = await prefetchSession(workerUrl, token, { cb, disc: 1, track: N + 1 })
// Sur audio.ended :
await player.loadPrefetched(next)Combiné avec prefetchAlbum (déjà appelé sur mount), prefetchSession se contente d'un /init-stream warm — ~50ms.
prefetchTracks(workerUrl, token, refs)
Pré-chauffe une liste de refs arbitraires — cas radio / file multi-albums / "à écouter plus tard" — sans dépendre d'un cb unique comme prefetchAlbum. Chunke transparemment par lots de 8 refs côté client (même contrainte de 50 subrequests/invocation), via l'endpoint /warmup-tracks.
import { prefetchTracks } from '@cyberscaling/secure-audio-stream-client'
const report = await prefetchTracks(workerUrl, token, [
{ cb: 5400863209100, disc: 1, track: 1 },
{ cb: 3663729427441, disc: 1, track: 7 },
// … jusqu'à N, chunké par 8 côté client
])
// report: TracksWarmupReport — { refs_total, refs_warmed, head_cached, edge_filled, edge_errors, not_found, batches, phases_ms }Playlist l'utilise automatiquement pour son kvLookahead — appelle-le directement seulement pour chauffer une liste que tu ne joues pas (encore).
Caches serveur (transparents)
Aucune action client requise, à connaître pour le debug :
| Cache | Clé | Couverture | TTL | Effet |
|---|---|---|---|---|
| album:<cb> (KV) | code-barres | cross-isolate | 24h | Skip b-tree walk (~300ms cold) |
| head:<mid> (KV) | mid | cross-isolate | 24h | Skip Scaleway HEAD (~150ms cold) |
| scaleway/full/<mid> (Cache API) | mid | per-PoP | 7 jours | Fichiers ≤10 MiB cached whole — toute Range = HIT |
| scaleway/blk/<mid>/<n> (Cache API) | mid+blockIdx | per-PoP | 7 jours | Fichiers >10 MiB cached en blocks 1 MiB |
| SessionPoolDO warmth alarm | 4 pool instances | global | self-rearm 15s | putSession reste warm (~6-10ms) malgré inactivité |
| lookupCache (module-scoped) | (cb,disc,track) | per-isolate | 5min | Skip KV/R2 sur replay même isolate |
X-Cache: HIT|MISS|PARTIAL est exposé sur les responses /stream pour observer le edge cache. Le header Server-Timing détaille les phases serveur de chaque endpoint (/init-stream, /key, /stream, /warmup-album).
Télémétrie
Active la collecte de métriques côté worker (dataset play_performance) :
new SecureAudioPlayer({
workerUrl: 'https://stream.musicme.cc',
getToken,
mode: 'mse',
metrics: { enabled: true, sampleRate: 1.0 },
onMetrics: (report) => {
// report.mode = 'mse' | 'mms' | 'blob' (le backend réellement utilisé)
// report.outcome = 'canplay' | 'error' | 'aborted'
// report.phases_ms.* = breakdown latence (get_token, init_session, fetch_key, mse_setup, mp4box_ready, canplay, total)
console.info('[player] metrics', report)
},
})report.mode permet de slicer les latences par plateforme côté Analytics Engine.
Heartbeat & facturation
Le SDK ping /heartbeat toutes les 10s avec currentTime cumulé. Quand la page
passe à l'état hidden, visibilitychange envoie une progression supplémentaire
complete:false afin de couvrir le throttling des timers en arrière-plan. Un
heartbeat terminal complete:true est envoyé sur ended, destroy(),
pagehide, ou juste avant un nouveau load().
complete:true est un marqueur terminal de mesure, pas une fermeture de
session et pas la preuve que la piste a été écoutée jusqu'au bout. Le worker
dérive play_full vs play_extract server-side, à partir d'un seuil fixe de
30 secondes de duration_ms cumulé : le premier franchissement produit
play_full ; un heartbeat terminal sous ce seuil produit play_extract, qui
peut être promu plus tard si la même session reprend et franchit 30 secondes.
Chaque heartbeat valide prolonge le TTL ; la session reste disponible jusqu'à
son expiration naturelle après le dernier heartbeat. Un destroy() sans son
joué envoie donc seulement un terminal avec duration_ms: 0.
Le heartbeat s'arrête automatiquement dès que le worker répond 410/404 (session déjà morte/évincée) — inutile de le gérer côté intégrateur.
Plateformes non-web
Le SDK est web-only — il dépend de MediaSource/ManagedMediaSource, fetch, et crypto.subtle du navigateur. Pour les apps mobiles natives ou React Native, voir le guide d'intégration partenaire (docs/integration-guide.md du repo musicme-onboarding-mcp), section "Plateformes non-web". En résumé :
- React Native : recommandé via
react-native-webview. Deux patterns possibles : (1) WebView qui charge ta webapp partenaire en distant (UI = ta page web), ou (2) WebView invisible (1×1px) qui héberge un bundle SDK minimal, bridgée par message-passing avec une UI 100% React Native — démo complète àdemos/react-native/. Alternative lourde : bridge natif réimplémentant le flow init/key/stream + déchiffrement (cf.system-design/09-partner-integration-guide.md§6.5.3-4). - Native iOS (Swift) :
AVPlayer+AVAssetResourceLoaderDelegatequi appelle/init-stream, intercepte les range requests AVPlayer et déchiffre AES-CTR viaCryptoKit. Réimplémente le SDK en ~150 lignes Swift. - Native Android (Kotlin) : ExoPlayer +
DataSource.Factorycustom, déchiffrement viajavax.crypto.Cipher. Mêmes endpoints HTTP que le SDK web.
Playlist (auto-advance + dynamic queue)
Costs to know before integrating: sessionLookahead defaults to 2 — the next 2 tracks each get a billed /init-stream session kept warm, on top of the current track. prefetchLeadSeconds defaults to 8 (seconds before audio.ended when the N+1 session prefetch fires) — now actually honored. There is no session-release endpoint: a prefetched-but-unused session (fast skip, queue swap) stays billed until it expires naturally. If that cost matters for your integration, guard against queue churn on your side — e.g. debounce rapid insert/remove/setItems calls that recompute the lookahead window.
import { Playlist } from '@cyberscaling/secure-audio-stream-client'
const playlist = new Playlist({
workerUrl: 'https://stream.musicme.cc',
getToken: async () => (await fetch('/api/player-token', { method: 'POST', credentials: 'include' }).then(r => r.json())).token,
items: [
{ cb: 5400863209100, disc: 1, track: 1 },
{ cb: 5400863209100, disc: 1, track: 2 },
{ cb: 3663729427441, disc: 1, track: 7 },
],
onCurrentChange: (curr, prev) => console.info('[playlist] now playing', curr?.ref),
})
document.querySelector('#player')!.append(playlist.audio)
await playlist.play()
// Mutate live:
playlist.insert({ cb: 5400863209100, disc: 1, track: 5 }, /* position */ 1)
playlist.move(playlist.items[2].id, 0)
playlist.remove(playlist.items[1].id)Each item gets a stable id (auto-generated on insert if you pass a bare TrackRef). Mutations are synchronous; the Playlist re-computes its lookahead window on every change and adapts the prefetch state transparently.
Lookahead policy (defaults — override via constructor opts):
sessionLookahead = 2— the next 2 tracks have their session + key pre-created viaprefetchSession. Track-to-track latency is ~50ms.kvLookahead = 5— the next 5 tracks have their mid + head + edge cache pre-warmed via the new/warmup-tracksendpoint. Cross-album playlists feel as fast as in-album ones.prefetchLeadSeconds = 8— how early beforeaudio.endedthe next-track session prefetch fires.
Events (all optional):
onItemsChange(items)— fired after every mutation.onCurrentChange(curr, prev)— fired onplay(),next(),prev(), auto-advance.onPrefetchState(e)—{itemId, ref, layer, state}wherelayer ∈ {session, kv}andstate ∈ {pending, ready, error, invalidated}. Use for observability.onError(err, ctx)—ctx?.itemIdcorrelates the failure to the offending track.
Backwards compatibility: SecureAudioPlayer, prefetchSession, prefetchAlbum, prefetchTracks remain exported and unchanged. Playlist is purely additive.
Gapless, not crossfade
Playlist drives a single <audio> element (one SecureAudioPlayer, rebuilt on every advance) — two tracks can never sound simultaneously. Auto-advance is gapless (thanks to session prefetch), but it is not an audio crossfade.
For a real crossfade (two tracks overlapping), drive two SecureAudioPlayers (or two Playlists) yourself — this is supported, just outside Playlist's scope. currentPlayer gives you access to the outgoing track's player during the transition:
const outgoing = playlist.currentPlayer // current track's player, before advancing
// drive fade-out on outgoing.audio.volume while a second SecureAudioPlayer /
// Playlist ramps up the incoming track in parallel.Migration — de 0.2.x vers 0.3.x
Aucun breaking change. Deux nouveautés à intégrer si tu veux profiter du gain UX :
1) Warmup album à l'ouverture d'une page album
Si ton intégration actuelle attend que l'utilisateur clique pour lancer la lecture, tu peux pré-chauffer côté serveur dès qu'il atterrit sur la page album. Le premier canplay passe de ~1.5 s à ~400 ms.
import { prefetchAlbum } from '@cyberscaling/secure-audio-stream-client'
// Au mount de la page album, fire-and-forget :
void prefetchAlbum(workerUrl, token, cb).catch(console.warn)Le helper chunke transparemment côté client si l'album a plus de 8 tracks (limite CF subrequest). Aucune action côté backend partenaire.
2) Remplacer la gestion manuelle "next track" par Playlist
Avant (0.2.x) tu écrivais :
const player = new SecureAudioPlayer({ workerUrl, getToken, audioElement: audio })
player.audio.addEventListener('ended', () => {
const next = myQueue.shift()
if (next) void player.load(next)
})
await player.load(myQueue[0])Avec 0.3.x — pré-fetch + auto-advance gapless + mutations live offertes :
import { Playlist } from '@cyberscaling/secure-audio-stream-client'
const playlist = new Playlist({
workerUrl,
getToken,
audioElement: audio, // optionnel ; sinon Playlist en crée un
items: myQueue, // [{cb, disc, track}, ...] ou [{ref, meta}, ...]
sessionLookahead: 2, // défauts ; ajuste si besoin
kvLookahead: 5,
onCurrentChange: (curr, prev) => updateUi(curr),
})
await playlist.play()Le prefetchSession du prochain track est lancé pendant la lecture du courant, donc audio.ended → loadPrefetched() est ~50 ms au lieu de ~1.5 s. La queue peut muter pendant la lecture sans interruption.
3) Pattern UX "playlist persistante + lectures ponctuelles"
Ce que la démo demos/webapp fait : conserver la playlist utilisateur indépendante des lectures ad-hoc (un album → "Play all", un clic sur un titre seul). Idée : un seul <audio> global + une seule instance Playlist, mais une logique applicative qui distingue "sauvegardée" (LS-backed, modifiée seulement par +) et "éphémère" (album entier, ou track standalone, qui ne touche pas la sauvegardée).
// Dans ton store applicatif :
playTrack(ref, meta) {
// ne touche pas savedQueue
playlist.setItems([{ ref, meta }])
void playlist.play()
}
enqueue(ref, meta) {
savedQueue.push({ id: genId(), ref, meta })
if (mode === 'queue') playlist.insert({ ref, meta }, savedQueue.length - 1)
persistToLocalStorage()
}
playFromSavedQueue() {
mode = 'queue'
playlist.setItems(savedQueue.map(it => ({ ref: it.ref, meta: it.meta })))
void playlist.play()
}Code complet à recopier : demos/webapp/public/playlist-store.ts (singleton, < 200 lignes, sans framework).
4) Pré-warmup pour playlists cross-album
Si ta playlist contient des refs venant de plusieurs albums (radio, mix, recommandations), Playlist chauffe automatiquement les 5 prochaines via /warmup-tracks. Aucune intégration supplémentaire. Si tu veux warmer une liste sans la jouer (ex. page "à écouter plus tard") :
import { prefetchTracks } from '@cyberscaling/secure-audio-stream-client'
void prefetchTracks(workerUrl, token, [
{ cb: 5400863209100, disc: 1, track: 1 },
{ cb: 3663729427441, disc: 1, track: 7 },
// … jusqu'à N — le helper chunke par 8 côté client
]).catch(console.warn)API
Voir src/index.ts pour la surface complète :
SecureAudioPlayer(options)— constructeurplayer.load(ref)— ouvre une session pour(cb, disc, track)player.loadPrefetched(prefetched)— active une session pré-créée (gapless)player.play()/player.pause()/player.seek(time)— wrappers<audio>player.audio— leHTMLAudioElementà insérer dans le DOMplayer.destroy()— annule les chargements, démonte l'audio et les listeners, puis envoie le heartbeat terminal ; la session facturée n'est pas libérée et expire naturellement côté worker (player réutilisable ensuite viaload())prefetchSession(workerUrl, token, ref) : PrefetchedSession— pre-crée la session N+1 pourplayer.loadPrefetched(gapless auto-advance)prefetchAlbum(workerUrl, token, cb) : AlbumWarmupReport— fire-and-forget sur mount d'album, warm les caches album KV + head KV + edge block 0 de toutes les tracks
Tests
bun run test # vitest, helpers purs (detectMediaSourceCtor, WaitGate)
bun run typecheck # tsc --noEmit
bun run build # tsup → dist/ (lib publish artifacts: ESM + CJS + .d.ts)
bun run build:demo # vite build → ../dist-demo/ (demo SPA, dev only)Les chemins DOM-heavy (loadMse, MMS event wiring) sont validés manuellement sur la matrice browser documentée dans docs/superpowers/plans/2026-05-10-ios-mms-streaming.md du repo.
Changelog
Voir CHANGELOG.md — livré dans le package npm.
Publication (mainteneurs)
Procédure de release (npm org, token, workflow tag-triggered) déplacée dans RELEASING.md.
License
MIT — voir LICENSE.
