mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
b495d58315
commit
eb5d826f79
4 files changed
+246
-35
No files matched your search
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -24,35 +22,23 @@ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
||||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
const { method, url, headersText, body, useProxy, insecure, response, history } = tab;
|
||||
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 setURL = (url: string) => updateTab(tab.id, { url });
|
||||
const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText });
|
||||
const setBody = (body: string) => updateTab(tab.id, { body });
|
||||
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
|
||||
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() {
|
||||
if (sending.current) return;
|
||||
const store = useRepeaterStore.getState();
|
||||
if (store.inflight[tab.id] !== undefined) return;
|
||||
const target = url.trim();
|
||||
if (!target) {
|
||||
toast.error("请填写 URL");
|
||||
return;
|
||||
}
|
||||
const requestGeneration = ++generation.current;
|
||||
sending.current = true;
|
||||
setBusy(true);
|
||||
updateTab(tab.id, { response: null });
|
||||
const requestGeneration = store.beginSend(tab.id);
|
||||
if (requestGeneration === undefined) return;
|
||||
try {
|
||||
const result = await api.repeaterSend({
|
||||
method,
|
||||
@@ -62,8 +48,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
use_recording_proxy: useProxy,
|
||||
insecure_skip_verify: insecure,
|
||||
});
|
||||
if (requestGeneration !== generation.current) return;
|
||||
recordSend(tab.id, {
|
||||
store.completeSend(tab.id, requestGeneration, {
|
||||
id: newId(),
|
||||
method,
|
||||
url: target,
|
||||
@@ -75,12 +60,9 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} 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 {
|
||||
if (requestGeneration === generation.current) {
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
store.clearInflight(tab.id, requestGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { RepeaterInitial } from "./repeater-http";
|
||||
export const STORAGE_KEY = "artex_repeater_workspace_v1";
|
||||
export const MAX_HISTORY = 20;
|
||||
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 type RepeaterHistoryEntry = {
|
||||
@@ -119,7 +121,7 @@ export function serializeWorkspace(workspace: RepeaterWorkspace, maxBytes = MAX_
|
||||
})),
|
||||
};
|
||||
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.
|
||||
const oldest = saved.tabs
|
||||
.flatMap((tab) => tab.history.map((entry) => ({ tab, entry })))
|
||||
|
||||
@@ -26,6 +26,8 @@ const {
|
||||
parseHeadersText,
|
||||
setHostHeader,
|
||||
} = require("./repeater-http.ts");
|
||||
const { flush, useRepeaterStore } = require("./repeater-store.ts");
|
||||
const { toast } = require("sonner");
|
||||
|
||||
const response = (body = "ok") => ({
|
||||
status: 200,
|
||||
@@ -134,7 +136,7 @@ test("budget removes globally oldest history first then strips responses, preser
|
||||
const workspace = addTab(defaultWorkspace());
|
||||
workspace.tabs[0].history = [entry(3), entry(1)];
|
||||
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));
|
||||
assert.deepEqual(
|
||||
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.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", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
addTab,
|
||||
@@ -28,6 +28,11 @@ export type TabPatch = Partial<
|
||||
>;
|
||||
type Store = {
|
||||
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;
|
||||
createTab: (seed?: TabSeed, opts?: TabOptions) => string;
|
||||
closeTab: (id: string) => void;
|
||||
@@ -44,11 +49,23 @@ type Store = {
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
function flush() {
|
||||
let nextGeneration = 0;
|
||||
export function flush() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
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) => {
|
||||
@@ -73,6 +90,26 @@ export const useRepeaterStore = create<Store>((set, get) => {
|
||||
}
|
||||
return {
|
||||
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: () => {
|
||||
if (get().workspace || typeof window === "undefined") return;
|
||||
set({ workspace: deserializeWorkspace(getLocalStorageValue(STORAGE_KEY)) });
|
||||
@@ -90,6 +127,8 @@ export const useRepeaterStore = create<Store>((set, get) => {
|
||||
const workspace = current();
|
||||
const index = workspace.tabs.findIndex((tab) => tab.id === id);
|
||||
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);
|
||||
if (!tabs.length) tabs.push(emptyTab());
|
||||
change({
|
||||
@@ -120,7 +159,10 @@ export const useRepeaterStore = create<Store>((set, get) => {
|
||||
updateTab: editTab,
|
||||
recordSend: (id, entry) => {
|
||||
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) => {
|
||||
const workspace = current();
|
||||
@@ -151,6 +193,7 @@ export const useRepeaterStore = create<Store>((set, get) => {
|
||||
});
|
||||
},
|
||||
clearWorkspace: () => {
|
||||
set({ inflight: {} });
|
||||
change(defaultWorkspace());
|
||||
flush();
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user