v157 · javascript · service workers

Expose ProgressEvent in service workers

ProgressEvent has always been available on the window and in dedicated and shared workers, but not inside a ServiceWorkerGlobalScope — so a service worker tracking a long fetch() had to invent its own progress message shape. Chrome 157 exposes the interface there too, aligning service workers with every other worker context and with the XMLHttpRequest Standard.

concepts

  1. Global-scope probe

    Register a real service worker, ask it whether ProgressEvent exists in its global, and have it construct one with lengthComputable, loaded and total. The answer comes back over postMessage from inside the worker, not from a guess made on the page.

  2. Which scopes have it

    Probe ProgressEvent in four realms at once — window, dedicated worker, shared worker, service worker — and see exactly where the gap was. Each cell is a live construction attempt in that realm, not a support table.

  3. Streaming download relay

    The use case that motivated it: a service worker intercepts a download, counts bytes off the ReadableStream, and reports progress to the page as a real ProgressEvent rather than an ad-hoc object — with the pre-157 hand-rolled shape beside it for comparison.

why it was missing

ProgressEvent is defined by the XMLHttpRequest Standard, and its [Exposed] extended attribute historically listed (Window,DedicatedWorker,SharedWorker) — the realms where XHR itself lives. Service workers never had XHR, so the event came along for the ride and was left out.

But the interface is not really about XHR any more: it is the standard shape for “this operation is n of m bytes through”, and service workers are exactly where long transfers get intercepted. Without it, every service worker that wanted to report progress invented its own { loaded, total } message and every page had to know that worker's private convention.

the API

// Inside the service worker (Chrome 157+)
self.addEventListener("fetch", (event) => {
  event.respondWith((async () => {
    const response = await fetch(event.request);
    const total = Number(response.headers.get("content-length")) || 0;
    let loaded = 0;

    const stream = new ReadableStream({
      async start(controller) {
        const reader = response.body.getReader();
        for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          loaded += value.byteLength;

          // The standard shape, constructible here for the first time:
          const progress = new ProgressEvent("progress", {
            lengthComputable: total > 0,
            loaded,
            total,
          });
          const clients = await self.clients.matchAll();
          for (const client of clients) {
            client.postMessage({
              type: progress.type,
              lengthComputable: progress.lengthComputable,
              loaded: progress.loaded,
              total: progress.total,
            });
          }
          controller.enqueue(value);
        }
        controller.close();
      },
    });

    return new Response(stream, { headers: response.headers });
  })());
});

The event still has to be serialised across postMessage — an Event is not structured-cloneable — but constructing it in the worker means the field names, types and lengthComputable semantics come from the platform rather than from a convention you documented in a README.

references