npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 add

Usage 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é avec error_code de 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 donne stream_404 (seul le heartbeat traite le 404 comme session morte). Ré-load() résout.
    • quota_exceeded — 403 quota_exceeded sur /stream (StreamQuotaError). Ne pas rouvrir de session : chaque tentative facturerait un nouveau /init-stream sans lever le quota.
    • stream_<status> — autre statut HTTP inattendu sur /stream (StreamError).
    • load_error — erreur non classée pendant load() (parse mp4box, réseau, etc.).
    • aborted — chargement volontairement annulé ou supersédé (LoadAbortedError).
    • mms_fallback — repli automatique vers le mode blob (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 :

  1. Résolution catalogue — walk de l'index tracks-db pour le cb, écrit dans album:<cb> KV (TTL 24h). Tous les (disc, track) → mid du cb sont alors instantanément disponibles cross-isolate.
  2. Métadonnées objetHEAD Scaleway pour chaque mid en parallèle, écrit dans head:<mid> KV (TTL 24h). Évite la HEAD upstream (~150ms) à chaque /init-stream.
  3. Cache edge block 0Range 0-1048575 pour chaque mid en parallèle, await caches.default.put. Cloudflare Cache API persistant 7 jours par PoP. Le premier /stream Range 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 + AVAssetResourceLoaderDelegate qui appelle /init-stream, intercepte les range requests AVPlayer et déchiffre AES-CTR via CryptoKit. Réimplémente le SDK en ~150 lignes Swift.
  • Native Android (Kotlin) : ExoPlayer + DataSource.Factory custom, déchiffrement via javax.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 via prefetchSession. Track-to-track latency is ~50ms.
  • kvLookahead = 5 — the next 5 tracks have their mid + head + edge cache pre-warmed via the new /warmup-tracks endpoint. Cross-album playlists feel as fast as in-album ones.
  • prefetchLeadSeconds = 8 — how early before audio.ended the next-track session prefetch fires.

Events (all optional):

  • onItemsChange(items) — fired after every mutation.
  • onCurrentChange(curr, prev) — fired on play(), next(), prev(), auto-advance.
  • onPrefetchState(e){itemId, ref, layer, state} where layer ∈ {session, kv} and state ∈ {pending, ready, error, invalidated}. Use for observability.
  • onError(err, ctx)ctx?.itemId correlates 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) — constructeur
  • player.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 — le HTMLAudioElement à insérer dans le DOM
  • player.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 via load())
  • prefetchSession(workerUrl, token, ref) : PrefetchedSession — pre-crée la session N+1 pour player.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.