Fix canvas transfer timeouts and move images out of snapshots
This commit is contained in:
+24
-4
@@ -6,7 +6,12 @@ export type Profile = {
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
export class ApiError extends Error {
|
||||
constructor(code: string, readonly status: number, readonly details?: unknown, readonly retryAfter?: string | null) { super(code); this.name = "ApiError"; }
|
||||
readonly status: number;
|
||||
readonly details?: unknown;
|
||||
readonly retryAfter?: string | null;
|
||||
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
|
||||
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
@@ -16,10 +21,20 @@ export async function api<T>(
|
||||
...options,
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
let body: unknown;
|
||||
if (res.status === 204) body = null;
|
||||
else {
|
||||
try { body = await res.json(); }
|
||||
catch (error) {
|
||||
if (options.signal?.aborted) throw options.signal.reason;
|
||||
const malformed = error instanceof SyntaxError;
|
||||
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
|
||||
}
|
||||
}
|
||||
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
|
||||
if (!res.ok) {
|
||||
const code = body?.error?.code || body?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, body?.details, res.headers.get("retry-after"));
|
||||
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -55,6 +70,11 @@ export const errorText = (error: unknown) => {
|
||||
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
|
||||
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
|
||||
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
|
||||
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
|
||||
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
|
||||
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
|
||||
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
|
||||
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
|
||||
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
export function inlineImageCount(document: CanvasDocument) {
|
||||
return document.nodes.filter((node) => ["image", "plain-image"].includes(node.data.kind) && node.data.src?.startsWith("data:image/")).length;
|
||||
}
|
||||
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal): Promise<CanvasAsset> {
|
||||
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
||||
const form = new FormData(); form.append("image", file, name);
|
||||
const key = crypto.randomUUID();
|
||||
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
||||
let asset: CanvasAsset;
|
||||
try { asset = await send(); } catch (error) {
|
||||
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
|
||||
asset = await send(); // The same bytes/key retry a possibly committed upload.
|
||||
}
|
||||
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
|
||||
let url: URL;
|
||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
|
||||
return asset;
|
||||
}
|
||||
export function inlineImageBlob(source: string) {
|
||||
const match = /^data:image\/(png|jpeg|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(source);
|
||||
if (!match || match[2].length > Math.ceil(ASSET_LIMIT / 3) * 4) throw new Error("invalid_asset");
|
||||
const bytes = Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0));
|
||||
return { file: new Blob([bytes], { type: "image/" + match[1] }), extension: match[1] };
|
||||
}
|
||||
/** Explicit one-time action: preserve image bytes and all node/edge identities. */
|
||||
export async function prepareManagedImages(document: CanvasDocument, request: Request, workspace: string, signal?: AbortSignal) {
|
||||
const assets = new Map<string, CanvasAsset>();
|
||||
const nodes = [];
|
||||
for (const node of document.nodes) {
|
||||
const source = node.data.src;
|
||||
if (!["image", "plain-image"].includes(node.data.kind) || !source?.startsWith("data:image/")) { nodes.push(node); continue; }
|
||||
let asset = assets.get(source);
|
||||
if (!asset) { const { file, extension } = inlineImageBlob(source); asset = await uploadCanvasImage(request, workspace, file, `image-${node.id}.${extension}`, signal); assets.set(source, asset); }
|
||||
nodes.push({ ...node, data: { ...node.data, src: asset.url, ...(document.schema_version === 2 ? { assetId: asset.id } : {}) } });
|
||||
}
|
||||
return { ...document, nodes };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Separate the response-header deadline from a large snapshot's transfer.
|
||||
* A client disconnect still aborts the upstream; bodies are never cached. */
|
||||
export type CanvasTransferTrace = { started_at: string; finished_at: string; headers_ms: number; first_chunk_ms?: number; transfer_ms: number; total_ms: number; bytes: number; chunks: number; outcome: "complete" | "cancelled" | "error"; error?: string };
|
||||
export async function fetchCanvasResponse(url: string, options: RequestInit, limits = { headersMs: 15000, bodyMs: 90000, idleMs: 30000 }, request = fetch, report?: (trace: CanvasTransferTrace) => void) {
|
||||
const started = performance.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const abort = new AbortController();
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
|
||||
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
|
||||
let timer = setTimeout(timeout, limits.headersMs);
|
||||
let upstream: Response;
|
||||
try { upstream = await request(url, { ...options, signal }); }
|
||||
catch (error) {
|
||||
clearTimeout(timer);
|
||||
const elapsed = Math.round((performance.now() - started) * 10) / 10;
|
||||
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: elapsed, transfer_ms: 0, total_ms: elapsed, bytes: 0, chunks: 0, outcome: "error", ...(error instanceof Error ? { error: error.name } : {}) });
|
||||
throw error;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
if (!upstream.body) return upstream;
|
||||
const headersAt = performance.now();
|
||||
const reader = upstream.body.getReader();
|
||||
let bytes = 0, chunks = 0, firstChunk: number | undefined;
|
||||
const record = (outcome: CanvasTransferTrace["outcome"], error?: unknown) => {
|
||||
const now = performance.now(), rounded = (value: number) => Math.round(value * 10) / 10;
|
||||
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: rounded(headersAt - started), ...(firstChunk === undefined ? {} : { first_chunk_ms: rounded(firstChunk - headersAt) }), transfer_ms: rounded(now - headersAt), total_ms: rounded(now - started), bytes, chunks, outcome, ...(error instanceof Error ? { error: error.name } : {}) });
|
||||
};
|
||||
let idle: ReturnType<typeof setTimeout> | undefined;
|
||||
let finished = false;
|
||||
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
|
||||
let interrupt: (reason: unknown) => void;
|
||||
const interrupted = () => interrupt(signal.reason);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
interrupt = (reason) => { if (finished) return; cleanup(); record("error", reason); controller.error(reason); void reader.cancel(reason).catch(() => {}); };
|
||||
signal.addEventListener("abort", interrupted, { once: true });
|
||||
timer = setTimeout(timeout, limits.bodyMs);
|
||||
if (signal.aborted) interrupted();
|
||||
},
|
||||
async pull(controller) {
|
||||
if (finished) return;
|
||||
idle = setTimeout(timeout, limits.idleMs);
|
||||
try {
|
||||
const next = await reader.read(); clearTimeout(idle);
|
||||
if (finished) return;
|
||||
if (next.done) { cleanup(); record("complete"); controller.close(); } else { firstChunk ??= performance.now(); bytes += next.value.byteLength; chunks++; controller.enqueue(next.value); }
|
||||
} catch (error) { if (!finished) { cleanup(); record("error", error); controller.error(error); } }
|
||||
},
|
||||
async cancel(reason) { if (!finished) { cleanup(); record("cancelled", reason); } abort.abort(reason); await reader.cancel(reason).catch(() => {}); },
|
||||
});
|
||||
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
|
||||
}
|
||||
+28
-8
@@ -11,8 +11,25 @@ export function canvasPayload(document: CanvasDocument) {
|
||||
const clean = decodeCanvas(JSON.stringify(document));
|
||||
return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) };
|
||||
}
|
||||
export function canvasFingerprint(document: CanvasDocument) {
|
||||
return JSON.stringify(canvasPayload(document), (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
export function canvasFingerprint(document: CanvasDocument, includeViewport = true) {
|
||||
const { viewport, ...content } = canvasPayload(document);
|
||||
return JSON.stringify(includeViewport ? { ...content, viewport } : content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
}
|
||||
export function canvasChanges(before: CanvasDocument, after: CanvasDocument) {
|
||||
const a = canvasPayload(before), b = canvasPayload(after);
|
||||
const changed = (left: unknown, right: unknown) => JSON.stringify(left) !== JSON.stringify(right);
|
||||
return {
|
||||
viewport: changed(a.viewport, b.viewport),
|
||||
nodes: b.nodes.flatMap((node) => {
|
||||
const prior = a.nodes.find((item) => item.id === node.id);
|
||||
if (!prior) return [{ id: node.id, fields: ["added"] }];
|
||||
const fields = ["position", "width", "height"].filter((key) => changed(prior[key as keyof typeof prior], node[key as keyof typeof node]));
|
||||
for (const key of new Set([...Object.keys(prior.data), ...Object.keys(node.data)])) if (changed((prior.data as Record<string, unknown>)[key], (node.data as Record<string, unknown>)[key])) fields.push("data." + key);
|
||||
return fields.length ? [{ id: node.id, fields }] : [];
|
||||
}),
|
||||
removed_nodes: a.nodes.filter((node) => !b.nodes.some((item) => item.id === node.id)).map((node) => node.id),
|
||||
edges: changed(a.edges, b.edges),
|
||||
};
|
||||
}
|
||||
export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
|
||||
if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot");
|
||||
@@ -23,6 +40,7 @@ export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
|
||||
export class CanvasSync {
|
||||
version: number;
|
||||
saved: string;
|
||||
private savedContent: string;
|
||||
error = "";
|
||||
document: CanvasDocument;
|
||||
private pending: CanvasDocument | null = null;
|
||||
@@ -32,9 +50,10 @@ export class CanvasSync {
|
||||
private notify: (state: SyncState) => void;
|
||||
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
|
||||
this.request = request; this.path = path; this.version = initial.version;
|
||||
this.saved = canvasFingerprint(initial.document); this.notify = notify;
|
||||
this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify;
|
||||
this.document = initial.document;
|
||||
}
|
||||
needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; }
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
this.pending = document;
|
||||
if (this.error) return Promise.resolve();
|
||||
@@ -46,7 +65,7 @@ export class CanvasSync {
|
||||
adopt(snapshot: CanvasSnapshot) {
|
||||
if (this.running) throw new Error("canvas_busy");
|
||||
this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document;
|
||||
this.saved = canvasFingerprint(snapshot.document); this.notify({ status: "saved", version: this.version });
|
||||
this.saved = canvasFingerprint(snapshot.document); this.savedContent = canvasFingerprint(snapshot.document, false); this.notify({ status: "saved", version: this.version });
|
||||
}
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
@@ -54,12 +73,13 @@ export class CanvasSync {
|
||||
this.notify({ status: "saving", version: this.version });
|
||||
try {
|
||||
const fingerprint = canvasFingerprint(document);
|
||||
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
if (!this.needsSave(document)) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
if (process.env.NODE_ENV === "development") console.info("[canvas-dirty]", JSON.stringify({ resource: this.path, ...canvasChanges(this.document, document) }));
|
||||
const body = JSON.stringify({ document: canvasPayload(document) });
|
||||
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
|
||||
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
|
||||
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
|
||||
this.version = result.version; this.saved = fingerprint;
|
||||
this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
|
||||
this.document = document;
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
} catch (error) {
|
||||
@@ -77,13 +97,13 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
if (record) {
|
||||
const value = JSON.parse(record);
|
||||
const doc = decodeCanvas(JSON.stringify(value.document));
|
||||
if (!snapshot || (value.dirty && canvasFingerprint(doc) !== canvasFingerprint(snapshot.document))) return doc;
|
||||
if (!snapshot || (value.dirty && canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false))) return doc;
|
||||
return null;
|
||||
}
|
||||
const legacy = storage.getItem(key);
|
||||
if (!legacy) return null;
|
||||
const doc = decodeCanvas(legacy);
|
||||
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
|
||||
return !snapshot || canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false) ? doc : null;
|
||||
}
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
|
||||
@@ -82,10 +82,10 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
export function managedImageSource(src?: string) {
|
||||
export function managedImageSource(src?: string, preview = false) {
|
||||
if (!src) return src;
|
||||
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1];
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : "");
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
return src;
|
||||
}
|
||||
|
||||
+5
-1
@@ -77,7 +77,11 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
data: { ...node.data, ...(node.data.kind === "frame" && node.data.subgraph && patch.width !== undefined && patch.height !== undefined ? { presentation: { ...node.data.presentation, [node.data.presentation?.collapsed ? "collapsedSize" : "expandedSize"]: { width: patch.width, height: patch.height } } } : {}), ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
}) } };
|
||||
}
|
||||
case "viewport": return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
case "viewport": {
|
||||
const before = state.present.viewport;
|
||||
if (Math.abs(before.x - action.viewport.x) < 1e-6 && Math.abs(before.y - action.viewport.y) < 1e-6 && Math.abs(before.zoom - action.viewport.zoom) < 1e-9) return state;
|
||||
return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
}
|
||||
case "undo": {
|
||||
const last = state.past.at(-1);
|
||||
return last ? { past: state.past.slice(0, -1), present: { ...last, viewport: state.present.viewport }, future: [state.present, ...state.future] } : state;
|
||||
|
||||
Reference in New Issue
Block a user