electric-sql / electric-sql/electric
experimentalLiveSse crashes with ReferenceError: document is not defined in Web Worker
- Dominant language
- TypeScript
- Stars
- 10.4k
- Forks
- 375
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 18
Description
# `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:
```ts
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 ()
at fetchEventSource (fetch.ts:67:12)
at ShapeStream. (client.ts:784:13)
at Generator.next ()
at fetch.ts:154:1
at new Promise ()
at __async (fetch.ts:154:1)
at ShapeStream.requestShapeSSE_fn (client.ts:779:21)
at ShapeStream. (client.ts:747:14)
```
## Root cause
`@microsoft/fetch-event-source@2.0.1` (`lib/esm/fetch.js`) unconditionally accesses `document` and `window`:
```js
// 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.
```diff
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.
Contributor guide
Assessment
This issue has not been assessed yet.