keep small writes durable across page dismissal with fetch keepalive

This commit is contained in:
Nystik
2026-06-16 21:03:46 +02:00
parent 97bcf4fde5
commit 85956dbb3f
2 changed files with 77 additions and 0 deletions

View File

@@ -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

View File

@@ -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();
});
});