Skip to content

experimentalLiveSse crashes with ReferenceError: document is not defined in Web Worker #4132

@cledoux95

Description

@cledoux95

experimentalLiveSse throws ReferenceError: document is not defined in a Web Worker

Summary

Enabling experimentalLiveSse: true on ShapeStreamOptions crashes every shape stream immediately when the client runs inside a Web Worker (e.g. PGliteWorker). The SSE path depends on @microsoft/fetch-event-source@^2.0.1, which unconditionally touches document and window. Both are undefined in a Worker context.

Repro

  • @electric-sql/client@^1.0.1
  • @electric-sql/pglite@0.4.4
  • @electric-sql/pglite-sync@0.5.4
  • Electric server 1.5.1
  • Client runs inside PGliteWorker (spawned via new Worker(new URL('./worker', import.meta.url), { type: 'module' })).

Minimal shape config:

await pglite.sync.syncShapeToTable({
  initialInsertMethod: 'insert',
  shape: {
    url: new URL('/v1/shape', baseUrl).toString(),
    params: { table: tableName },
    experimentalLiveSse: true,
  },
  primaryKey: ['id'],
  table: tableName,
  shapeKey: tableName,
});

Error

Thrown for every shape the moment the SSE path is taken:

[shape] Error syncing event_messages_synced: ReferenceError: document is not defined
    at fetch.ts:83:13
    at new Promise (<anonymous>)
    at fetchEventSource (fetch.ts:67:12)
    at ShapeStream.<anonymous> (client.ts:784:13)
    at Generator.next (<anonymous>)
    at fetch.ts:154:1
    at new Promise (<anonymous>)
    at __async (fetch.ts:154:1)
    at ShapeStream.requestShapeSSE_fn (client.ts:779:21)
    at ShapeStream.<anonymous> (client.ts:747:14)

Root cause

@microsoft/fetch-event-source@2.0.1 (lib/esm/fetch.js) unconditionally accesses document and window:

// lib/esm/fetch.js
function onVisibilityChange() {
    curRequestController.abort();
    if (!document.hidden) {          // L26
        create();
    }
}
if (!openWhenHidden) {
    document.addEventListener('visibilitychange', onVisibilityChange);  // L31
}
// ...
function dispose() {
    document.removeEventListener('visibilitychange', onVisibilityChange);  // L36
    window.clearTimeout(retryTimer);                                       // L37
    // ...
}
const fetch = inputFetch ?? window.fetch;                                  // L44
// ...
retryTimer = window.setTimeout(create, interval);                          // L74

document doesn't exist in a Web Worker, so the very first property access throws and the stream never opens. This affects any consumer that wants SSE live-updates from a worker — notably the recommended PGliteWorker architecture in the pglite-sync docs.

Proposed fix

Two possible directions:

Option A — guard the DOM accesses in fetch-event-source (minimal)

Wrap the document / window accesses with a typeof check and fall back to globalThis where appropriate. Workers don't have a visibility API, so the listener is simply skipped — which is the correct behavior: the "re-open when tab becomes visible" heuristic only makes sense for a Document-backed browsing context.

 let curRequestController;
+const hasDocument = typeof document !== 'undefined';
 function onVisibilityChange() {
     curRequestController.abort();
-    if (!document.hidden) {
+    if (hasDocument && !document.hidden) {
         create();
     }
 }
-if (!openWhenHidden) {
+if (!openWhenHidden && hasDocument) {
     document.addEventListener('visibilitychange', onVisibilityChange);
 }
 // ...
 function dispose() {
-    document.removeEventListener('visibilitychange', onVisibilityChange);
-    window.clearTimeout(retryTimer);
+    if (hasDocument) {
+        document.removeEventListener('visibilitychange', onVisibilityChange);
+    }
+    (typeof window !== 'undefined' ? window : globalThis).clearTimeout(retryTimer);
     curRequestController.abort();
 }
 // ...
-const fetch = inputFetch ?? window.fetch;
+const fetch = inputFetch ?? (typeof window !== 'undefined' ? window : globalThis).fetch;
 // ...
-retryTimer = window.setTimeout(create, interval);
+retryTimer = (typeof window !== 'undefined' ? window : globalThis).setTimeout(create, interval);

This is the patch I'm running locally via yarn patch — verified working: SSE engages, shape requests show content-type: text/event-stream, long-poll cycling stops. Happy to submit this to @microsoft/fetch-event-source upstream if preferred.

Option B — swap dependency or vendor a worker-safe SSE parser (in Electric itself)

If coordinating with Microsoft isn't practical, Electric could:

  • Vendor the tiny set of SSE framing logic inline (~100 lines).
  • Switch to a worker-safe fork such as the one distributed with Vercel's ai SDK.
  • Feature-detect document in Electric's own code path before invoking fetchEventSource, falling back to the long-poll request shape when running in a worker (so experimentalLiveSse: true degrades gracefully instead of crashing).

Why this matters

pglite-sync's recommended architecture puts the sync client in a Worker. Today that's flatly incompatible with experimentalLiveSse, which is the only option that reclaims the long-poll round-trip floor on live updates. Either fix unblocks SSE for the most common Electric+PGlite deployment shape.

Happy to open a PR against whichever path you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions