Fix cross-origin staging URLs, e621 UA format and IQDB queue stalls
The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.
apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.
e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).
IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.
Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
This commit is contained in:
+14
-2
@@ -5,8 +5,20 @@ const TOKEN_KEY = "j621.token";
|
||||
|
||||
/** Absolutise a backend path; external/absolute URLs pass through. */
|
||||
export function apiUrl(path: string): string {
|
||||
if (!path.startsWith("/")) return path;
|
||||
return `${getBackendUrl()}${path}`;
|
||||
if (path.startsWith("/")) return `${getBackendUrl()}${path}`;
|
||||
// Absolute URL built by the API. In same-origin mode (the dev proxy, the
|
||||
// single deploy nginx) keep it on the page's origin: the proxy serves
|
||||
// /api too, and auth'd fetches like the IQDB staging blob would otherwise
|
||||
// need CORS. With an explicit backend URL, leave it pointing there.
|
||||
if (!getBackendUrl()) {
|
||||
try {
|
||||
const url = new URL(path);
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
|
||||
@@ -82,13 +82,21 @@ export function effectiveCredentials(
|
||||
return credentials ?? ANONYMOUS_CREDENTIALS;
|
||||
}
|
||||
|
||||
const CLIENT_VERSION = "J621/0.1";
|
||||
// e621 asks for "Application name/version (developer)". Browsers cannot set a
|
||||
// User-Agent, so the same string is sent in the `_client` parameter instead.
|
||||
// `__GIT_HASH__` is replaced by Vite at build time; guard it so the dev
|
||||
// server (which may not substitute it) still works.
|
||||
const GIT_HASH = typeof __GIT_HASH__ === "string" ? __GIT_HASH__ : "dev";
|
||||
const CLIENT_VERSION = `J621/${GIT_HASH} (JakeBreath)`;
|
||||
|
||||
// e621 allows 2 requests/second hard, 1/second sustained. Serialize every
|
||||
// request through a queue with a 1s minimum gap.
|
||||
let lastRequestAt = 0;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** A hung request would block the whole serialized queue forever. */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
function schedule<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = queue.then(task, task);
|
||||
queue = run.then(
|
||||
@@ -124,10 +132,7 @@ export function e621Request<T>(
|
||||
const base = credentials.base_url.replace(/\/+$/, "");
|
||||
const url = new URL(`${base}/${path.replace(/^\/+/, "")}`);
|
||||
// Browsers cannot set User-Agent; e621 documents `_client` for this case.
|
||||
url.searchParams.set(
|
||||
"_client",
|
||||
`${CLIENT_VERSION} (by ${credentials.username || "anonymous"} on e621)`,
|
||||
);
|
||||
url.searchParams.set("_client", CLIENT_VERSION);
|
||||
for (const [key, value] of Object.entries(options.params ?? {})) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
@@ -143,12 +148,20 @@ export function e621Request<T>(
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(new DOMException("e621 request timed out", "TimeoutError")),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const forwardAbort = () => controller.abort(options.signal?.reason);
|
||||
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body,
|
||||
signal: options.signal,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -165,6 +178,8 @@ export function e621Request<T>(
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", forwardAbort);
|
||||
useE621Metrics
|
||||
.getState()
|
||||
.record(path, performance.now() - startedAt);
|
||||
@@ -190,6 +205,9 @@ export function e621ErrorMessage(error: unknown): string {
|
||||
}
|
||||
return `e621 request failed (${error.status}).`;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "TimeoutError") {
|
||||
return "e621 did not answer in time.";
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
return "Could not reach e621. Check the base URL and your connection.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user