From 85956dbb3fc7a2ecce5b5255406623ca528f54f4 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Tue, 16 Jun 2026 21:03:46 +0200 Subject: [PATCH] keep small writes durable across page dismissal with fetch keepalive --- packages/shim/src/fs/transport.js | 17 ++++++++ packages/shim/src/fs/transport.test.js | 60 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/shim/src/fs/transport.test.js diff --git a/packages/shim/src/fs/transport.js b/packages/shim/src/fs/transport.js index 1975d9e..01d1ae1 100644 --- a/packages/shim/src/fs/transport.js +++ b/packages/shim/src/fs/transport.js @@ -19,6 +19,18 @@ function vaultId() { return window.__currentVaultId || ""; } +const KEEPALIVE_MAX_BYTES = 64 * 1024; + +// keepalive lets a request finish after the page starts unloading. +// Its body is capped at 64KB across a shared pool, so opt in only under that limit. +function withinKeepaliveCap(body) { + if (!body) { + return true; + } + + return new TextEncoder().encode(body).length <= KEEPALIVE_MAX_BYTES; +} + async function request(method, endpoint, params = {}) { const url = new URL(API_BASE + endpoint, window.location.origin); @@ -37,6 +49,11 @@ async function request(method, endpoint, params = {}) { options.body = JSON.stringify({ vault: vaultId(), ...params }); } + // A write (POST/DELETE) opts into keepalive so a page dismissal does not drop it. + if (method !== "GET" && withinKeepaliveCap(options.body)) { + options.keepalive = true; + } + const res = await fetch(url.toString(), options); if (!res.ok) { const err = await res diff --git a/packages/shim/src/fs/transport.test.js b/packages/shim/src/fs/transport.test.js new file mode 100644 index 0000000..4ea1085 --- /dev/null +++ b/packages/shim/src/fs/transport.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { transport } from "./transport.js"; + +let fetchMock; + +beforeEach(() => { + fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({}), + text: async () => "", + arrayBuffer: async () => new ArrayBuffer(0), + })); + globalThis.fetch = fetchMock; + globalThis.window = { + location: { origin: "http://localhost" }, + __currentVaultId: "v", + }; +}); + +afterEach(() => { + delete globalThis.fetch; + delete globalThis.window; +}); + +function lastInit() { + return fetchMock.mock.calls.at(-1)[1]; +} + +describe("transport keepalive gating", () => { + it("sets keepalive on a small write", async () => { + await transport.writeFile("a.md", "hello", "utf-8"); + + expect(lastInit().keepalive).toBe(true); + }); + + it("omits keepalive when the body exceeds the 64KB cap", async () => { + await transport.writeFile("a.md", "x".repeat(70 * 1024), "utf-8"); + + expect(lastInit().keepalive).toBeFalsy(); + }); + + it("counts base64 inflation against the cap for binary writes", async () => { + // 60KB of bytes inflates to ~80KB of base64, over the cap. + await transport.writeFile("a.bin", new Uint8Array(60 * 1024)); + + expect(lastInit().keepalive).toBeFalsy(); + }); + + it("sets keepalive on a bodyless delete", async () => { + await transport.unlink("a.md"); + + expect(lastInit().keepalive).toBe(true); + }); + + it("does not set keepalive on a read", async () => { + await transport.readFile("a.md", "utf8"); + + expect(lastInit().keepalive).toBeUndefined(); + }); +});