Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix: sendBeacon POST/PUT mismatch — critical data loss on tab close' (#93) from fix/sendbeacon-post-mismatch into main

scott 133842d7 7e944af7

+86 -34
+7
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.9.5] — 2026-03-23 11 + 12 + ### Fixed 13 + - **CRITICAL: sendBeacon saves were silently failing**: `sendBeacon` always sends POST, but the server only had a PUT endpoint for snapshots — every emergency save on tab close was silently dropped. Added POST handler so sendBeacon saves actually persist (#203) 14 + - **Stale emergency save**: `_emergencySave` now fires sendBeacon with cached state immediately (guaranteed synchronous), then ALSO attempts a fresh encode+encrypt for both sendBeacon and IDB — so the latest edits have a chance to be saved during the brief teardown window (#203) 15 + - **Unnecessary emergency saves**: added `_hasUnsavedChanges` guard to skip emergency save when nothing changed (#203) 16 + 10 17 ## [0.9.4] — 2026-03-23 11 18 12 19 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.9.4", 3 + "version": "0.9.5", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+6 -2
server/index.ts
··· 210 210 res.json({ ok: true }); 211 211 }); 212 212 213 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }>, res: Response) => { 213 + // Accept both PUT (normal save) and POST (sendBeacon — which can only POST) 214 + const snapshotHandler = (req: Request<{ id: string }>, res: Response): void => { 214 215 stmts.putSnapshot.run(req.body, req.params.id); 215 216 res.json({ ok: true }); 216 - }); 217 + }; 218 + const snapshotMiddleware = express.raw({ limit: '50mb', type: '*/*' }); 219 + app.put('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 220 + app.post('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 217 221 218 222 app.get('/api/documents/:id/snapshot', (req: Request<{ id: string }>, res: Response) => { 219 223 const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined;
+33 -25
src/lib/provider.ts
··· 326 326 /** Emergency save for page teardown: uses cached encrypted state + sendBeacon + IDB. */ 327 327 private _emergencySave(): void { 328 328 try { 329 - // Skip saving empty/trivial state if we know data existed 330 - if ((this._hadSnapshot || this._snapshotLoadFailed)) { 331 - // Use cached encrypted state if available (synchronous, no async encrypt needed) 332 - if (this._lastEncrypted) { 333 - // Server: sendBeacon is synchronous enqueue — survives page teardown 334 - if (typeof navigator !== 'undefined' && navigator.sendBeacon) { 335 - try { 336 - const blob = new Blob([new Uint8Array(this._lastEncrypted as ArrayBuffer)], { type: 'application/octet-stream' }); 337 - navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob); 338 - } catch { /* best effort */ } 339 - } 340 - // IDB: fire-and-forget (browser gives brief window) 341 - saveLocalBackup(this.roomId, this._lastEncrypted).catch(() => { /* best effort */ }); 342 - return; 329 + if (!this._hasUnsavedChanges && this._lastEncrypted) { 330 + // Nothing changed since last save — skip entirely 331 + return; 332 + } 333 + 334 + const snapshotUrl = `${this.apiUrl}/api/documents/${this.roomId}/snapshot`; 335 + 336 + // Step 1: If we have cached encrypted state, fire sendBeacon immediately. 337 + // sendBeacon enqueues synchronously — guaranteed to survive page teardown. 338 + // This may be slightly stale if edits happened since last _saveSnapshot, 339 + // but it's better than losing everything. 340 + if (this._lastEncrypted && (this._hadSnapshot || this._snapshotLoadFailed)) { 341 + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { 342 + try { 343 + const blob = new Blob([new Uint8Array(this._lastEncrypted as ArrayBuffer)], { type: 'application/octet-stream' }); 344 + navigator.sendBeacon(snapshotUrl, blob); 345 + } catch { /* best effort */ } 343 346 } 344 347 } 345 348 346 - // Fallback: encode + encrypt (async, best effort during teardown) 349 + // Step 2: Encode fresh state and attempt save (may or may not complete 350 + // during teardown — browser gives a brief window for async work). 347 351 const state = Y.encodeStateAsUpdate(this.doc); 348 352 if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) { 349 353 return; 350 354 } 351 355 352 - // Server: try sendBeacon with async encrypt 353 - if (typeof navigator !== 'undefined' && navigator.sendBeacon) { 354 - encrypt(state, this.cryptoKey).then(encrypted => { 355 - const blob = new Blob([new Uint8Array(encrypted)], { type: 'application/octet-stream' }); 356 - navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob); 357 - }).catch(() => { /* best effort */ }); 358 - } 359 - 360 - // IDB: always attempt 356 + // Fresh encrypt → sendBeacon (replaces the stale one if it completes in time) 361 357 encrypt(state, this.cryptoKey).then(encrypted => { 358 + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { 359 + try { 360 + const blob = new Blob([new Uint8Array(encrypted)], { type: 'application/octet-stream' }); 361 + navigator.sendBeacon(snapshotUrl, blob); 362 + } catch { /* best effort */ } 363 + } 364 + // IDB: also save fresh state 362 365 saveLocalBackup(this.roomId, encrypted).catch(() => { /* best effort */ }); 363 - }).catch(() => { /* best effort */ }); 366 + }).catch(() => { 367 + // Encrypt failed — fall back to saving stale cached state to IDB 368 + if (this._lastEncrypted) { 369 + saveLocalBackup(this.roomId, this._lastEncrypted).catch(() => { /* best effort */ }); 370 + } 371 + }); 364 372 } catch { /* best effort */ } 365 373 } 366 374
+5 -2
tests/integration.test.ts
··· 77 77 res.json({ ok: true }); 78 78 }); 79 79 80 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: Req, res: Res) => { 80 + const snapshotMw = express.raw({ limit: '50mb', type: '*/*' }); 81 + const snapshotFn = (req: Req, res: Res) => { 81 82 stmts.putSnapshot.run(req.body, req.params['id']); 82 83 res.json({ ok: true }); 83 - }); 84 + }; 85 + app.put('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 86 + app.post('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 84 87 85 88 app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 86 89 const row = stmts.getSnapshot.get(req.params['id']) as { snapshot: Buffer | null } | undefined;
+29 -2
tests/server.test.ts
··· 73 73 res.json({ ok: true }); 74 74 }); 75 75 76 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: Req, res: Res) => { 76 + const snapshotMw = express.raw({ limit: '50mb', type: '*/*' }); 77 + const snapshotFn = (req: Req, res: Res) => { 77 78 stmts.putSnapshot.run(req.body, req.params['id']); 78 79 res.json({ ok: true }); 79 - }); 80 + }; 81 + app.put('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 82 + app.post('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 80 83 81 84 app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 82 85 const row = stmts.getSnapshot.get(req.params['id']) as { snapshot: Buffer | null } | undefined; ··· 333 336 const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 334 337 const retrieved = new Uint8Array(await res.arrayBuffer()); 335 338 expect(Array.from(retrieved)).toEqual([10, 20, 30]); 339 + }); 340 + 341 + it('accepts POST for sendBeacon compatibility', async () => { 342 + const createRes = await fetch(`${baseUrl}/api/documents`, { 343 + method: 'POST', 344 + headers: { 'Content-Type': 'application/json' }, 345 + body: JSON.stringify({ type: 'sheet' }), 346 + }); 347 + const { id } = await createRes.json() as { id: string }; 348 + 349 + // sendBeacon sends POST with application/octet-stream 350 + const snapshot = new Uint8Array([99, 88, 77]); 351 + const postRes = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 352 + method: 'POST', 353 + headers: { 'Content-Type': 'application/octet-stream' }, 354 + body: snapshot, 355 + }); 356 + expect(postRes.status).toBe(200); 357 + 358 + // Verify it was stored 359 + const getRes = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 360 + expect(getRes.status).toBe(200); 361 + const retrieved = new Uint8Array(await getRes.arrayBuffer()); 362 + expect(Array.from(retrieved)).toEqual([99, 88, 77]); 336 363 }); 337 364 }); 338 365
+5 -2
tests/sharing.test.ts
··· 85 85 res.json({ ok: true }); 86 86 }); 87 87 88 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: import('express').Request, res: import('express').Response) => { 88 + const snapshotMw = express.raw({ limit: '50mb', type: '*/*' }); 89 + const snapshotFn = (req: import('express').Request, res: import('express').Response) => { 89 90 const row = stmts.getOne.get(req.params['id']) as DocRow | undefined; 90 91 if (!row) { res.status(404).json({ error: 'Not found' }); return; } 91 92 ··· 100 101 101 102 stmts.putSnapshot.run(req.body, req.params['id']); 102 103 res.json({ ok: true }); 103 - }); 104 + }; 105 + app.put('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 106 + app.post('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 104 107 105 108 app.get('/api/documents/:id/snapshot', (req: import('express').Request, res: import('express').Response) => { 106 109 const row = stmts.getSnapshot.get(req.params['id']) as SnapshotRow | undefined;