Add browser security headers, implement upload temp directory management, and enhance SSE handling.
CI / check (22) (push) Failing after 15m52s
CI / check (24) (push) Failing after 15m49s

This commit is contained in:
2026-10-07 00:54:58 +02:00
parent 0ead7fa30e
commit 10ec76d437
7 changed files with 101 additions and 186 deletions
+20 -178
View File
@@ -1,180 +1,22 @@
/** JSON requests return error records; streaming requests reject unless onError handles them. */
import {createSseParser, parseSseData, SseParseError} from "./sse.js";
export class ApiError extends Error {
constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.details = details;
}
toJSON() {
return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId};
}
}
function requestScope({signal, timeoutMs = 30000} = {}) {
const controller = new AbortController();
const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"}));
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, {once: true});
const timer = timeoutMs > 0 ? setTimeout(() => {
controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"}));
}, timeoutMs) : null;
return {
signal: controller.signal,
close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
};
}
// Also bound body reads, even after fetch has resolved its response headers.
function abortable(operation, signal) {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
signal.addEventListener("abort", abort, {once: true});
try {
Promise.resolve(operation()).then(resolve, reject)
.finally(() => signal.removeEventListener("abort", abort));
} catch (error) {
signal.removeEventListener("abort", abort);
reject(error);
}
});
}
function responseError(response, payload = {}, fallback = "", code = "") {
const details = payload && typeof payload === "object" ? payload : {};
const error = details.error;
return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {
...details,
status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status,
code: details.code || error?.code || code || `HTTP_${response.status}`,
requestId: details.requestId || response.headers.get("X-Request-ID") || null
});
}
async function readJson(response, signal) {
const text = await abortable(() => response.text(), signal);
let payload;
if (text.trim()) {
try { payload = JSON.parse(text); }
catch {
if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE");
}
}
if (!response.ok) throw responseError(response, payload);
return payload ?? {};
}
function normalizeError(error, scope, response) {
const failure = scope.signal.aborted ? scope.signal.reason : error;
if (failure instanceof ApiError) {
if (!failure.status && response) failure.status = response.status;
failure.requestId ||= response?.headers.get("X-Request-ID") || null;
return failure;
}
return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {
status: response?.status || 0,
code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR",
requestId: response?.headers.get("X-Request-ID") || null
});
}
async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) {
const scope = requestScope(options);
let response;
try {
response = await abortable(() => fetch(url, {
method,
credentials: "same-origin",
headers,
body,
signal: scope.signal
}), scope.signal);
if (!asBlob || !response.ok) return await readJson(response, scope.signal);
return {blob: await abortable(() => response.blob(), scope.signal)};
} catch (error) {
return normalizeError(error, scope, response).toJSON();
} finally {
scope.close();
}
}
export function request(url, data, method = "POST", options = {}) {
return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: data == null ? undefined : JSON.stringify(data)}, options);
}
// Let fetch set the multipart boundary; share deadlines, cancellation and errors.
export const multipart = (url, form, options = {}) => bufferedRequest(url,
{method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options});
export const download = (url, options = {}) => bufferedRequest(url,
{method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true);
export const get = (url, options) => request(url, null, "GET", options);
export const post = (url, data, options) => request(url, data, "POST", options);
export const put = (url, data, options) => request(url, data, "PUT", options);
export const del = (url, options) => request(url, null, "DELETE", options);
/**
* SSE text events carry {chunk}; corrections carry {target: "reply"|"thinking", text}.
* A legacy AbortSignal is also accepted as the fourth argument. Set timeoutMs: 0 to disable the deadline.
*/
export async function stream(url, data, handlers = {}, options = {}) {
if (options && typeof options.addEventListener === "function") options = {signal: options};
const scope = requestScope({timeoutMs: 660000, ...options});
let response, reader;
try {
response = await abortable(() => fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"},
body: JSON.stringify(data || {}),
signal: scope.signal
}), scope.signal);
if (!response.ok) await readJson(response, scope.signal);
if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE");
reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let terminal = false;
while (!terminal) {
const {value, done} = await abortable(() => reader.read(), scope.signal);
if (done) break;
buffer += decoder.decode(value, {stream: true});
const parts = buffer.split(/\r?\n\r?\n/);
buffer = parts.pop() || "";
for (const part of parts) {
const lines = part.split(/\r?\n/);
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
const dataLines = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart());
if (!event || !dataLines.length) continue;
const payload = JSON.parse(dataLines.join("\n"));
if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
const textFields = {status: "status", done: "reply", error: "error", replace: "text"};
if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
if (event === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new Error("Invalid response stream.");
if (event === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new Error("Invalid response stream.");
if (event === "replace" && !["reply", "thinking"].includes(payload.target)) throw new Error("Invalid replacement stream.");
if (event === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR");
const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[event];
if (handler) handlers[handler]?.(payload);
if (event === "done") { terminal = true; break; }
}
}
if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED");
} catch (error) {
const failure = normalizeError(error, scope, response);
if (handlers.onError) handlers.onError(failure.toJSON());
else throw failure;
} finally {
scope.close();
if (reader) {
// Cancelling a custom or stalled source must not prevent the request from settling.
void reader.cancel().catch(() => {});
reader.releaseLock();
}
}
constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.requestId = requestId; this.details = details; }
toJSON() { return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; }
}
function requestScope({signal, timeoutMs = 30000} = {}) { const controller = new AbortController(); const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, {once: true}); const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})), timeoutMs) : null; return {signal: controller.signal, close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }}; }
function abortable(operation, signal) { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); }; signal.addEventListener("abort", abort, {once: true}); Promise.resolve().then(operation).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); }
function responseError(response, payload = {}, fallback = "", code = "") { const details = payload && typeof payload === "object" ? payload : {}; const error = details.error; return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {...details, status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, code: details.code || error?.code || code || `HTTP_${response.status}`, requestId: details.requestId || response.headers.get("X-Request-ID") || null}); }
async function readJson(response, signal) { const text = await abortable(() => response.text(), signal); let payload; if (text.trim()) { try { payload = JSON.parse(text); } catch { if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); } } if (!response.ok) throw responseError(response, payload); return payload ?? {}; }
function normalizeError(error, scope, response) { const failure = scope.signal.aborted ? scope.signal.reason : error; if (failure instanceof ApiError) { if (!failure.status && response) failure.status = response.status; failure.requestId ||= response?.headers.get("X-Request-ID") || null; return failure; } return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {status: response?.status || 0, code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", requestId: response?.headers.get("X-Request-ID") || null}); }
const transient = e => e?.status === 408 || e?.status === 425 || e?.status === 429 || e?.status >= 500 || e?.code === "NETWORK_ERROR" || e?.code === "TIMEOUT";
const pause = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, {once: true}); });
function filenameFromDisposition(value) { const encoded = value?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const plain = value?.match(/filename="?([^";]+)"?/i)?.[1]; try { return decodeURIComponent(encoded || plain || ""); } catch { return plain || null; } }
async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) { const scope = requestScope(options); const maxRetries = method === "GET" ? Math.max(0, options.retries ?? 2) : 0; try { for (let attempt = 0; ; attempt += 1) { let response; try { response = await abortable(() => fetch(url, {method, credentials: "same-origin", headers, body, signal: scope.signal}), scope.signal); if (!asBlob || !response.ok) return await readJson(response, scope.signal); const blob = await abortable(() => response.blob(), scope.signal); return {blob, filename: filenameFromDisposition(response.headers.get("Content-Disposition")), contentType: response.headers.get("Content-Type") || blob.type || "application/octet-stream", size: Number(response.headers.get("Content-Length")) || blob.size}; } catch (error) { const failure = normalizeError(error, scope, response); if (attempt < maxRetries && transient(failure) && !scope.signal.aborted) { await pause(Math.min(8000, 250 * 2 ** attempt), scope.signal); continue; } return failure.toJSON(); } } } finally { scope.close(); } }
export function request(url, data, method = "POST", options = {}) { return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"}, body: data == null ? undefined : JSON.stringify(data)}, options); }
export async function requestResult(url, data, method = "POST", options = {}) { const value = await request(url, data, method, options); return value?.error ? {ok: false, data: null, error: new ApiError(value.error, value)} : {ok: true, data: value, error: null}; }
export async function requestOrThrow(url, data, method = "POST", options = {}) { const result = await requestResult(url, data, method, options); if (!result.ok) throw result.error; return result.data; }
export const multipart = (url, form, options = {}) => bufferedRequest(url, {method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options});
export const download = (url, options = {}) => bufferedRequest(url, {method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true);
export const get = (url, options) => request(url, null, "GET", options); export const post = (url, data, options) => request(url, data, "POST", options); export const put = (url, data, options) => request(url, data, "PUT", options); export const del = (url, options) => request(url, null, "DELETE", options);
export async function stream(url, data, handlers = {}, options = {}) { if (options && typeof options.addEventListener === "function") options = {signal: options}; const scope = requestScope({timeoutMs: 660000, ...options}); let response; let reader; let terminal = false; try { response = await abortable(() => fetch(url, {method: "POST", credentials: "same-origin", headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"}, body: JSON.stringify(data || {}), signal: scope.signal}), scope.signal); if (!response.ok) await readJson(response, scope.signal); if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE"); reader = response.body.getReader(); const parser = createSseParser({onComment: handlers.onComment, onRetry: handlers.onRetry}); const decoder = new TextDecoder(); const consume = events => { for (const event of events) { let payload; try { payload = parseSseData(event); } catch (error) { throw new SseParseError(error.message, {cause: error, event}); } if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new SseParseError("Invalid response stream.", {event}); const name = event.event; const fields = {status: "status", done: "reply", error: "error", replace: "text"}; if (fields[name] && typeof payload[fields[name]] !== "string") throw new SseParseError(`Invalid ${name} event payload.`, {event}); if (name === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new SseParseError("Invalid chunk event payload.", {event}); if (name === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new SseParseError("Invalid thinking event payload.", {event}); if (name === "replace" && !["reply", "thinking"].includes(payload.target)) throw new SseParseError("Invalid replacement stream.", {event}); if (name === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR"); const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[name]; if (handler) handlers[handler]?.(payload); else if (name !== "message") handlers.onUnknown?.(event, payload); if (name === "done") { terminal = true; break; } } }; while (!terminal) { const {value, done} = await abortable(() => reader.read(), scope.signal); if (done) break; consume(parser.push(decoder.decode(value, {stream: true}))); } consume(parser.finish()); if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED"); } catch (error) { const failure = normalizeError(error, scope, response); const reconnectAttempt = options._reconnectAttempt || 0; if (options.reconnect === true && reconnectAttempt < (options.maxReconnects ?? 3) && transient(failure) && !scope.signal.aborted) { await pause(Math.min(8000, options.reconnectDelayMs ?? 500 * 2 ** reconnectAttempt), scope.signal); return stream(url, data, handlers, {...options, _reconnectAttempt: reconnectAttempt + 1}); } if (handlers.onError) handlers.onError(failure.toJSON()); else throw failure; } finally { scope.close(); if (reader) { void reader.cancel().catch(() => {}); reader.releaseLock(); } } }
+50
View File
@@ -0,0 +1,50 @@
/** Reusable Server-Sent Events parser for browser and test environments. */
export class SseParseError extends Error {
constructor(message, details = {}) {
super(message);
this.name = "SseParseError";
Object.assign(this, details);
}
}
function parseEvent(block, {onComment, onRetry} = {}) {
if (!block) return null;
let event = "message";
const data = [];
let id;
let retry;
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(":")) { onComment?.(line.slice(1).trimStart()); continue; }
const separator = line.indexOf(":");
const field = separator < 0 ? line : line.slice(0, separator);
const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
if (field === "event") event = value;
else if (field === "data") data.push(value);
else if (field === "id" && !value.includes("\u0000")) id = value;
else if (field === "retry" && /^\d+$/.test(value)) { retry = Number(value); onRetry?.(retry); }
}
if (!data.length) return null;
return {event, data: data.join("\n"), ...(id !== undefined ? {id} : {}), ...(retry !== undefined ? {retry} : {})};
}
export function createSseParser(options = {}) {
let buffer = "";
return {
push(text) {
buffer += text;
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() || "";
return blocks.map(block => parseEvent(block, options)).filter(Boolean);
},
finish() {
const event = parseEvent(buffer, options);
buffer = "";
return event ? [event] : [];
}
};
}
export function parseSseData(event) {
try { return JSON.parse(event.data); }
catch (error) { throw new SseParseError("Malformed SSE JSON payload.", {cause: error, event}); }
}