webtorrent-sw.js is a single combined script that ships with the SpawnDev.WebTorrent NuGet package. It plays two roles depending on which context it runs in:
- In the page (window) context: registers itself as a service worker, waits for Cross-Origin-Isolation, and loads
blazor.webassembly.js. - In the service-worker (self) context: intercepts
/webtorrent/{infoHash}/{fileIdx}requests, adds COOP/COEP headers (forSharedArrayBuffer), and serves torrent file ranges back viaReadableStream.
Same file, two roles. The runtime detects which context loaded it.
Two unrelated browser-platform requirements collide on one library:
- Cross-Origin Isolation (
SharedArrayBuffer/ WebGPU prerequisites). Requires the page to be served withCross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corp. Most static hosts (GitHub Pages included) won't add those headers. A service worker can — by intercepting every fetch and stamping the headers in itself. - Streaming a torrent into a
<video>/<audio>element with seeking. Browser media elements demand HTTPRange:request support. The torrent client lives in the page context with the pieces in OPFS — the service worker intercepts/webtorrent/{hash}/{fileIdx}requests, sends the byte range over aMessageChannelto the page, and streams back a synthetic206 Partial Contentresponse.
Same file does both because the registration cost is identical and Blazor needs to know the SW is active before it can use SharedArrayBuffer-dependent features.
In your Blazor WASM app's wwwroot/index.html, replace the default Blazor script tag:
<!-- Replace this: -->
<script src="_framework/blazor.webassembly.js"></script>
<!-- With this: -->
<script src="webtorrent-sw.js"></script>The script picks up the original Blazor loader in its page-context branch. No other html / Program.cs changes required.
The file deploys to your app root automatically — SpawnDev.WebTorrent.csproj declares StaticWebAssetBasePath="/", which puts the wwwroot bits at / instead of /_content/SpawnDev.WebTorrent/. SW scope is the entire app.
Register the stream handler alongside the client. Both are IAsyncBackgroundService:
builder.Services.AddSingleton<ServiceWorkerStreamHandler>();
builder.Services.AddSingleton<WebTorrentClient>();The client picks up the handler automatically (constructor DI). When you Add a torrent, the handler's OnRequest is wired so subsequent SW range requests find the right pieces.
- Detects
serviceWorker in navigator. If absent, loads Blazor directly — no COI or streaming, but the app still works for non-streaming uses. - Registers itself (
navigator.serviceWorker.register). - Checks
window.crossOriginIsolated:- Already isolated + SW controlling: load Blazor immediately.
- Isolated but SW not yet controlling: wait for
serviceWorker.ready, reload once, load Blazor on the post-reload page (the reload makes the SW intercept Blazor's framework fetches for COEP). - Not isolated yet: wait for the SW to activate, reload to pick up the COOP/COEP headers it adds.
- The reload counter (
sessionStorage["coi-sw-reload"]) prevents infinite reload loops if something is broken upstream — caps at 1 retry.
Verbose logging is gated behind a top-of-file verbose flag — flip to true for [COI] ... traces during integration.
The SW listens on fetch and handles three kinds of requests:
fetch('/webtorrent-sw-check').then(r => r.json())
// → { active: true, version: '...', timestamp: 1234567890 }Useful for the page to confirm "yes, the SW is wired, streaming will work" before calling Torrent.Files[0].StreamURL. Returns 503 with { active: false } if the SW is somehow not in control.
The SW does not hold the pieces — those live in OPFS in the page context. Instead it runs a request/response protocol over MessageChannel:
- SW receives the fetch event, parses
(infoHash, fileIdx, Range)from the URL + headers. - SW iterates
clients.matchAll(), picks the first window client. - SW posts
{ type: 'webtorrent-request', infoHash, fileIdx, range }to the client via the channel'sport2. - The page-side
ServiceWorkerStreamHandlerreceives the message, looks up the torrent byWireInfoHashHex, opens aTorrentReadStreamover the file at the requested range, and posts chunks back throughport1. - SW assembles chunks into a
ReadableStream, builds aResponsewith206 Partial Content+Content-Range, and resolves the original fetch with it.
The streaming is lazy — pieces download as the consumer reads. This is what makes <video src="..."> seeking work without the whole file being downloaded first.
The SW intercepts every other fetch, lets it go to the network, then clones the response and adds:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
This is what makes SharedArrayBuffer available to your Blazor app even on static hosts that don't set these headers themselves.
Once the SW is active, every Torrent.Files[i] exposes:
| Member | Returns | Description |
|---|---|---|
file.StreamURL |
string |
/webtorrent/{infoHash}/{fileIdx} — drop into <video src=...> / <audio src=...> directly. |
file.StreamTo(elem) |
Task |
Sets elem.SrcObject / elem.Src for a typed HTMLMediaElement. |
file.CreateReadStream(start, end) |
Stream |
.NET Stream over the file range. |
file.ReadAsync(offset, length) |
Task<byte[]> |
One-shot range read. |
StreamURL is the most-used path — it's what makes a torrent-backed <video> "just work" in the browser.
The SW file's bytes are the cache key. Any byte change → browser fetches the new version on next page load → the new SW activates after old clients close. SpawnDev.WebTorrent's NuGet ships the same SW bytes for the lifetime of a major+minor version, so consumer apps don't pick up surprise SW updates mid-session.
If you're adapting webtorrent-sw.js for your own app, change the version comment at the top — that triggers byte-level diff and guarantees the new SW takes over.
| Symptom | Likely cause | Check |
|---|---|---|
<video> shows broken icon, network tab shows 404 on /webtorrent/.../0 |
SW not active or not controlling | fetch('/webtorrent-sw-check') returns { active: false } |
Blazor loads but SharedArrayBuffer is undefined |
COOP/COEP not landed | Open DevTools → Application → Service Workers; verify the SW is activated AND controlling. Hard reload (Ctrl+Shift+R) once. |
| Reload loop on first visit | coi-sw-reload counter at 1+ |
Script auto-caps at 1 retry; second time it falls through to direct Blazor load. Check for SW registration errors in console. |
| Range requests work but seeking is slow | Pieces aren't downloaded yet | Expected — SpawnDev.WebTorrent prioritizes the requested range, but pieces still arrive at swarm speed. |
- Source:
SpawnDev.WebTorrent/wwwroot/webtorrent-sw.js - Page-side handler:
SpawnDev.WebTorrent/ServiceWorkerStreamHandler.cs - Stream URL builder:
Torrent.File.StreamURLinSpawnDev.WebTorrent/Torrent.cs - The
webtorrent-sw.jsfile's contract is intentionally identical toservice-worker-fs.jsin SpawnDev.BlazorJS.WebDesktop — sameMessageChannelprotocol, different stream provider on the page side.