fix(web): keep in-flight Repeater sends and UTF-16 storage budget

Per-tab send generations survive editor remounts; closed tabs drop
results; previous responses stay until replacement. localStorage
eviction uses a 4MiB UTF-16 budget with retry toast on quota failure.
This commit is contained in:
ARTEX Bot committed 2026-10-26 11:16:22 +08:00
1 parent b495d58315
commit eb5d826f79
4 files changed
+246 -35

No files matched your search

@@ -1,7 +1,5 @@
"use client"; "use client";
import * as React from "react";
import { SendIcon } from "lucide-react"; import { SendIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -24,35 +22,23 @@ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
const { method, url, headersText, body, useProxy, insecure, response, history } = tab; const { method, url, headersText, body, useProxy, insecure, response, history } = tab;
const updateTab = useRepeaterStore((state) => state.updateTab); const updateTab = useRepeaterStore((state) => state.updateTab);
const recordSend = useRepeaterStore((state) => state.recordSend); const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
const setMethod = (method: string) => updateTab(tab.id, { method }); const setMethod = (method: string) => updateTab(tab.id, { method });
const setURL = (url: string) => updateTab(tab.id, { url }); const setURL = (url: string) => updateTab(tab.id, { url });
const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText }); const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText });
const setBody = (body: string) => updateTab(tab.id, { body }); const setBody = (body: string) => updateTab(tab.id, { body });
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy }); const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure }); const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
const [busy, setBusy] = React.useState(false);
const generation = React.useRef(0);
const sending = React.useRef(false);
React.useEffect(
() => () => {
generation.current += 1;
sending.current = false;
},
[],
);
async function send() { async function send() {
if (sending.current) return; const store = useRepeaterStore.getState();
if (store.inflight[tab.id] !== undefined) return;
const target = url.trim(); const target = url.trim();
if (!target) { if (!target) {
toast.error("请填写 URL"); toast.error("请填写 URL");
return; return;
} }
const requestGeneration = ++generation.current; const requestGeneration = store.beginSend(tab.id);
sending.current = true; if (requestGeneration === undefined) return;
setBusy(true);
updateTab(tab.id, { response: null });
try { try {
const result = await api.repeaterSend({ const result = await api.repeaterSend({
method, method,
@@ -62,8 +48,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
use_recording_proxy: useProxy, use_recording_proxy: useProxy,
insecure_skip_verify: insecure, insecure_skip_verify: insecure,
}); });
if (requestGeneration !== generation.current) return; store.completeSend(tab.id, requestGeneration, {
recordSend(tab.id, {
id: newId(), id: newId(),
method, method,
url: target, url: target,
@@ -75,12 +60,9 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
at: new Date().toISOString(), at: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {
if (requestGeneration === generation.current) toast.error((error as Error).message); if (store.isCurrentSend(tab.id, requestGeneration)) toast.error((error as Error).message);
} finally { } finally {
if (requestGeneration === generation.current) { store.clearInflight(tab.id, requestGeneration);
sending.current = false;
setBusy(false);
}
} }
} }
@@ -5,7 +5,9 @@ import type { RepeaterInitial } from "./repeater-http";
export const STORAGE_KEY = "artex_repeater_workspace_v1"; export const STORAGE_KEY = "artex_repeater_workspace_v1";
export const MAX_HISTORY = 20; export const MAX_HISTORY = 20;
export const MAX_RESPONSE_CHARS = 256 * 1024; export const MAX_RESPONSE_CHARS = 256 * 1024;
export const MAX_STORAGE_BYTES = 2.5 * 1024 * 1024; // Count UTF-16 storage bytes (two per code unit), reserving headroom below 5 MiB
// for the storage key and other browser preferences.
export const MAX_STORAGE_BYTES = 4 * 1024 * 1024;
export const DEFAULT_GROUP_ID = "default"; export const DEFAULT_GROUP_ID = "default";
export type RepeaterHistoryEntry = { export type RepeaterHistoryEntry = {
@@ -119,7 +121,7 @@ export function serializeWorkspace(workspace: RepeaterWorkspace, maxBytes = MAX_
})), })),
}; };
let json = JSON.stringify(saved); let json = JSON.stringify(saved);
const size = () => new TextEncoder().encode(json).length; const size = () => json.length * 2;
// Remove the oldest send globally, including its request snapshot, first. // Remove the oldest send globally, including its request snapshot, first.
const oldest = saved.tabs const oldest = saved.tabs
.flatMap((tab) => tab.history.map((entry) => ({ tab, entry }))) .flatMap((tab) => tab.history.map((entry) => ({ tab, entry })))
@@ -26,6 +26,8 @@ const {
parseHeadersText, parseHeadersText,
setHostHeader, setHostHeader,
} = require("./repeater-http.ts"); } = require("./repeater-http.ts");
const { flush, useRepeaterStore } = require("./repeater-store.ts");
const { toast } = require("sonner");
const response = (body = "ok") => ({ const response = (body = "ok") => ({
status: 200, status: 200,
@@ -134,7 +136,7 @@ test("budget removes globally oldest history first then strips responses, preser
const workspace = addTab(defaultWorkspace()); const workspace = addTab(defaultWorkspace());
workspace.tabs[0].history = [entry(3), entry(1)]; workspace.tabs[0].history = [entry(3), entry(1)];
workspace.tabs[1].history = [entry(4), entry(2)]; workspace.tabs[1].history = [entry(4), entry(2)];
const limit = Buffer.byteLength(serializeWorkspace(workspace)) - Buffer.byteLength(JSON.stringify(entry(1))) - 1; const limit = (serializeWorkspace(workspace).length - JSON.stringify(entry(1)).length - 1) * 2;
const saved = JSON.parse(serializeWorkspace(workspace, limit)); const saved = JSON.parse(serializeWorkspace(workspace, limit));
assert.deepEqual( assert.deepEqual(
saved.tabs.map((tab) => tab.history.map((item) => item.id)), saved.tabs.map((tab) => tab.history.map((item) => item.id)),
@@ -154,7 +156,189 @@ test("budget removes globally oldest history first then strips responses, preser
tab.response = response("中".repeat(MAX_RESPONSE_CHARS)); tab.response = response("中".repeat(MAX_RESPONSE_CHARS));
tab.history = [entry(1, "中".repeat(MAX_RESPONSE_CHARS))]; tab.history = [entry(1, "中".repeat(MAX_RESPONSE_CHARS))];
} }
assert.ok(Buffer.byteLength(serializeWorkspace(large)) <= MAX_STORAGE_BYTES); assert.ok(serializeWorkspace(large).length * 2 <= MAX_STORAGE_BYTES);
});
test("storage cap counts UTF-16 bytes for ASCII, Chinese and surrogate pairs", () => {
for (const text of ["x", "中", "😀"]) {
let workspace = defaultWorkspace();
for (let index = 0; index < 5; index++) workspace = addTab(workspace);
for (const tab of workspace.tabs) {
const body = text.repeat(MAX_RESPONSE_CHARS).slice(0, MAX_RESPONSE_CHARS);
tab.response = response(body);
tab.history = [entry(1, body)];
tab.body = `保留草稿 ${text}`;
}
assert.ok(JSON.stringify(workspace).length * 2 > MAX_STORAGE_BYTES);
const json = serializeWorkspace(workspace);
assert.ok(json.length * 2 <= MAX_STORAGE_BYTES);
assert.deepEqual(
JSON.parse(json).tabs.map((tab) => tab.body),
workspace.tabs.map((tab) => tab.body),
);
}
});
test("drafts exceeding the soft UTF-16 budget are never discarded", () => {
const workspace = defaultWorkspace();
workspace.tabs[0].body = "x".repeat(MAX_STORAGE_BYTES / 2);
workspace.tabs[0].response = response("previous");
workspace.tabs[0].history = [entry(1)];
const json = serializeWorkspace(workspace);
const saved = JSON.parse(json);
assert.ok(json.length * 2 > MAX_STORAGE_BYTES);
assert.equal(saved.tabs[0].body, workspace.tabs[0].body);
assert.deepEqual(saved.tabs[0].history, []);
assert.equal(saved.tabs[0].response.body, "");
});
function setupStore(t) {
const previousWindow = globalThis.window;
const writes = [];
globalThis.window = {
localStorage: {
setItem: (key, value) => writes.push({ key, value }),
},
};
useRepeaterStore.setState({ workspace: addTab(defaultWorkspace()), inflight: {} });
t.after(() => {
useRepeaterStore.setState({ workspace: null, inflight: {} });
flush(); // Cancel pending persistence without writing to another test's storage.
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
return { store: useRepeaterStore.getState(), writes };
}
test("a second send invalidates only that tab's first send and stale cleanup cannot clear it", (t) => {
const { store } = setupStore(t);
const [first, other] = store.workspace.tabs;
store.recordSend(first.id, entry(1, "previous"));
const generation1 = store.beginSend(first.id);
const otherGeneration = store.beginSend(other.id);
const generation2 = store.beginSend(first.id);
assert.notEqual(generation1, generation2);
assert.equal(store.isCurrentSend(first.id, generation1), false);
assert.equal(store.isCurrentSend(first.id, generation2), true);
assert.equal(store.isCurrentSend(other.id, otherGeneration), true);
store.completeSend(first.id, generation1, entry(2, "stale"));
store.clearInflight(first.id, generation1);
assert.equal(useRepeaterStore.getState().inflight[first.id], generation2);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous");
store.completeSend(first.id, generation2, entry(3, "newest"));
const state = useRepeaterStore.getState();
assert.equal(state.workspace.tabs[0].response.body, "newest");
assert.deepEqual(
state.workspace.tabs[0].history.map((item) => item.id),
["3", "1"],
);
assert.equal(state.inflight[first.id], undefined);
assert.equal(state.inflight[other.id], otherGeneration);
});
test("closing a tab or clearing the workspace drops pending results and history", (t) => {
const { store } = setupStore(t);
const [closed, other] = store.workspace.tabs;
const generation = store.beginSend(closed.id);
const otherGeneration = store.beginSend(other.id);
store.closeTab(closed.id);
assert.equal(store.isCurrentSend(closed.id, generation), false);
assert.equal(store.beginSend(closed.id), undefined);
store.completeSend(closed.id, generation, entry(1));
assert.equal(useRepeaterStore.getState().inflight[closed.id], undefined);
assert.equal(useRepeaterStore.getState().workspace.tabs.length, 1);
assert.deepEqual(useRepeaterStore.getState().workspace.tabs[0].history, []);
assert.equal(store.isCurrentSend(other.id, otherGeneration), true);
store.clearWorkspace();
store.completeSend(other.id, otherGeneration, entry(2));
assert.deepEqual(useRepeaterStore.getState().inflight, {});
assert.deepEqual(useRepeaterStore.getState().workspace.tabs[0].history, []);
});
test("busy state survives remount and successful completion records without an editor mounted", async (t) => {
const { store, writes } = setupStore(t);
const [tab, other] = store.workspace.tabs;
store.recordSend(tab.id, entry(1, "previous"));
const observedBusy = [];
const unmount = useRepeaterStore.subscribe((state) => observedBusy.push(state.inflight[tab.id] !== undefined));
const generation = store.beginSend(tab.id);
let resolveResponse;
const pending = new Promise((resolve) => {
resolveResponse = resolve;
}).then((result) => store.completeSend(tab.id, generation, result));
unmount();
store.setActiveTab(other.id);
store.setActiveTab(tab.id);
// A remounted editor reads the same store selector to disable duplicate sends.
assert.equal(useRepeaterStore.getState().inflight[tab.id] !== undefined, true);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous");
store.setActiveTab(other.id);
resolveResponse(entry(2, "completed offscreen"));
await pending;
const state = useRepeaterStore.getState();
assert.deepEqual(observedBusy, [true]);
assert.equal(state.inflight[tab.id], undefined);
assert.equal(state.workspace.activeTabId, other.id);
assert.equal(state.workspace.tabs[0].response.body, "completed offscreen");
assert.deepEqual(
state.workspace.tabs[0].history.map((item) => item.id),
["2", "1"],
);
assert.equal(JSON.parse(writes.at(-1).value).tabs[0].history[0].id, "2");
});
test("failed send cleanup preserves the previous response and history", (t) => {
const { store } = setupStore(t);
const tab = store.workspace.tabs[0];
store.recordSend(tab.id, entry(1, "previous"));
const generation = store.beginSend(tab.id);
store.clearInflight(tab.id, generation);
const state = useRepeaterStore.getState();
assert.equal(state.inflight[tab.id], undefined);
assert.equal(state.workspace.tabs[0].response.body, "previous");
assert.deepEqual(
state.workspace.tabs[0].history.map((item) => item.id),
["1"],
);
});
test("failed storage write retries with histories and response bodies removed, preserving live data and drafts", (t) => {
const { store } = setupStore(t);
const tab = store.workspace.tabs[0];
store.updateTab(tab.id, { body: "keep this draft" });
store.recordSend(tab.id, entry(1, "previous response"));
const attempts = [];
t.mock.method(globalThis.window.localStorage, "setItem", (_key, value) => {
attempts.push(value);
if (attempts.length === 1) throw new DOMException("full", "QuotaExceededError");
});
const error = t.mock.method(toast, "error", () => "test-toast");
flush();
assert.equal(attempts.length, 2);
assert.ok(attempts[1].length < attempts[0].length);
const saved = JSON.parse(attempts[1]).tabs[0];
assert.equal(saved.body, "keep this draft");
assert.deepEqual(saved.history, []);
assert.equal(saved.response.body, "");
assert.equal(saved.response.body_truncated, true);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous response");
assert.equal(useRepeaterStore.getState().workspace.tabs[0].history.length, 1);
assert.equal(error.mock.callCount(), 0);
});
test("two failed storage writes report save failure through a Chinese error toast", (t) => {
const { store } = setupStore(t);
const tab = store.workspace.tabs[0];
store.updateTab(tab.id, { body: "unsaved draft" });
const setItem = t.mock.method(globalThis.window.localStorage, "setItem", () => {
throw new DOMException("full", "QuotaExceededError");
});
const error = t.mock.method(toast, "error", () => "test-toast");
flush();
assert.equal(setItem.mock.callCount(), 2);
assert.equal(error.mock.callCount(), 1);
assert.match(error.mock.calls[0].arguments[0], /工作区保存失败/);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].body, "unsaved draft");
}); });
test("Host shortcut replaces case-insensitive duplicates and preserves other header lines", () => { test("Host shortcut replaces case-insensitive duplicates and preserves other header lines", () => {
+48 -5
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { toast } from "sonner";
import { create } from "zustand"; import { create } from "zustand";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import { getLocalStorageValue } from "../../lib/local-storage.client";
import type { RepeaterInitial } from "./repeater-http"; import type { RepeaterInitial } from "./repeater-http";
import { import {
addTab, addTab,
@@ -28,6 +28,11 @@ export type TabPatch = Partial<
>; >;
type Store = { type Store = {
workspace: RepeaterWorkspace | null; workspace: RepeaterWorkspace | null;
inflight: Record<string, number>;
beginSend: (id: string) => number | undefined;
isCurrentSend: (id: string, generation: number) => boolean;
clearInflight: (id: string, generation: number) => void;
completeSend: (id: string, generation: number, entry: RepeaterHistoryEntry) => void;
hydrate: () => void; hydrate: () => void;
createTab: (seed?: TabSeed, opts?: TabOptions) => string; createTab: (seed?: TabSeed, opts?: TabOptions) => string;
closeTab: (id: string) => void; closeTab: (id: string) => void;
@@ -44,11 +49,23 @@ type Store = {
}; };
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
function flush() { let nextGeneration = 0;
export function flush() {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
timer = undefined; timer = undefined;
const workspace = useRepeaterStore.getState().workspace; const workspace = useRepeaterStore.getState().workspace;
if (workspace) setLocalStorageValue(STORAGE_KEY, serializeWorkspace(workspace)); if (!workspace) return;
try {
window.localStorage.setItem(STORAGE_KEY, serializeWorkspace(workspace));
} catch {
try {
// Retry with only drafts and response metadata: evict all history, then
// response bodies. Never shrink the request drafts, even if they exceed quota.
window.localStorage.setItem(STORAGE_KEY, serializeWorkspace(workspace, 0));
} catch {
toast.error("工作区保存失败,浏览器存储空间不足或不可用,请备份请求草稿");
}
}
} }
export const useRepeaterStore = create<Store>((set, get) => { export const useRepeaterStore = create<Store>((set, get) => {
@@ -73,6 +90,26 @@ export const useRepeaterStore = create<Store>((set, get) => {
} }
return { return {
workspace: null, workspace: null,
inflight: {},
beginSend: (id) => {
if (!current().tabs.some((tab) => tab.id === id)) return;
const generation = ++nextGeneration;
set({ inflight: { ...get().inflight, [id]: generation } });
return generation;
},
isCurrentSend: (id, generation) =>
get().inflight[id] === generation && !!get().workspace?.tabs.some((tab) => tab.id === id),
clearInflight: (id, generation) => {
if (get().inflight[id] !== generation) return;
const inflight = { ...get().inflight };
delete inflight[id];
set({ inflight });
},
completeSend: (id, generation, entry) => {
if (!get().isCurrentSend(id, generation)) return;
get().recordSend(id, entry);
get().clearInflight(id, generation);
},
hydrate: () => { hydrate: () => {
if (get().workspace || typeof window === "undefined") return; if (get().workspace || typeof window === "undefined") return;
set({ workspace: deserializeWorkspace(getLocalStorageValue(STORAGE_KEY)) }); set({ workspace: deserializeWorkspace(getLocalStorageValue(STORAGE_KEY)) });
@@ -90,6 +127,8 @@ export const useRepeaterStore = create<Store>((set, get) => {
const workspace = current(); const workspace = current();
const index = workspace.tabs.findIndex((tab) => tab.id === id); const index = workspace.tabs.findIndex((tab) => tab.id === id);
if (index < 0) return; if (index < 0) return;
const generation = get().inflight[id];
if (generation !== undefined) get().clearInflight(id, generation);
const tabs = workspace.tabs.filter((tab) => tab.id !== id); const tabs = workspace.tabs.filter((tab) => tab.id !== id);
if (!tabs.length) tabs.push(emptyTab()); if (!tabs.length) tabs.push(emptyTab());
change({ change({
@@ -120,7 +159,10 @@ export const useRepeaterStore = create<Store>((set, get) => {
updateTab: editTab, updateTab: editTab,
recordSend: (id, entry) => { recordSend: (id, entry) => {
const tab = current().tabs.find((item) => item.id === id); const tab = current().tabs.find((item) => item.id === id);
if (tab) editTab(id, { response: entry.response, history: [entry, ...tab.history].slice(0, MAX_HISTORY) }); if (tab) {
editTab(id, { response: entry.response, history: [entry, ...tab.history].slice(0, MAX_HISTORY) });
flush();
}
}, },
createGroup: (name) => { createGroup: (name) => {
const workspace = current(); const workspace = current();
@@ -151,6 +193,7 @@ export const useRepeaterStore = create<Store>((set, get) => {
}); });
}, },
clearWorkspace: () => { clearWorkspace: () => {
set({ inflight: {} });
change(defaultWorkspace()); change(defaultWorkspace());
flush(); flush();
}, },