infinite-canvas-local-proxy 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.js +18 -3
- package/package.json +1 -1
- package/script-runner.js +96 -29
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ http://127.0.0.1:23210/https://api.openai.com/v1/models
|
|
|
56
56
|
- 默认数据目录为用户主目录下的 `.canvas-proxy`(Windows 为 `%USERPROFILE%\.canvas-proxy`),包括 `tasks.sqlite`、SQLite 日志文件和 `results/` 响应文件。备份时需停止代理后复制整个目录。
|
|
57
57
|
- API Key、请求头和请求体仅在代理内存中用于上游请求;任务请求体、脚本上下文及 API Key 不写入数据库或任务日志。若上游响应或脚本错误中发现已识别的凭证原文,该结果不会保存或会在错误中脱敏。
|
|
58
58
|
- 图片/视频脚本请求支持 JSON、文本、URL 编码和 multipart/form-data;multipart 文件在本地任务创建请求中临时 Base64 编码,代理端重建为 multipart 后发送。请求体总大小上限 80 MiB;独立二进制请求体不支持。
|
|
59
|
-
- 任务数量、并发数和单个生成脚本的请求次数均不设上限;任务提交后会立即启动。生成任务仍受单任务
|
|
59
|
+
- 任务数量、并发数和单个生成脚本的请求次数均不设上限;任务提交后会立即启动。生成任务仍受单任务 60 分钟等待时限、单次上游请求 30 分钟超时、80 MiB 请求体和 100 MiB 响应大小限制。成功/失败记录保留 24 小时。
|
|
60
60
|
- 可通过环境变量 `CANVAS_PROXY_DATA_DIR`、`CANVAS_PROXY_TASK_REQUEST_LIMIT_BYTES`、`CANVAS_PROXY_TASK_RESPONSE_LIMIT_BYTES`、`CANVAS_PROXY_TASK_TIMEOUT_MS` 和 `CANVAS_PROXY_TASK_RETENTION_MS` 调整目录与限制。
|
|
61
61
|
- 代理进程重启时,原来排队或运行中的任务标记为“结果未知”,不会自动再次提交上游请求,以避免重复计费。已完成结果仍可从数据库记录和响应文件恢复,直至过期。
|
|
62
62
|
- 代理只报告 queued/running/polling/完成/失败等本地状态,不提供或伪造供应商生成百分比。代理必须保持运行,才能在浏览器关闭后继续执行代理持有的请求或自定义脚本轮询。
|
package/index.js
CHANGED
|
@@ -24,7 +24,8 @@ const CORS_HEADERS = {
|
|
|
24
24
|
const SKIP_REQUEST_HEADERS = new Set(["host", "connection", "content-length", "accept-encoding", "origin", "referer", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site"]);
|
|
25
25
|
/** fetch() already decoded and re-framed the body, so the upstream framing headers no longer apply. */
|
|
26
26
|
const SKIP_RESPONSE_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
|
|
27
|
-
|
|
27
|
+
/** 异步生成任务的总生命周期:长视频、多轮素材处理都要能跑完,与 script-runner 的脚本超时对齐。 */
|
|
28
|
+
const TASK_TIMEOUT_MS = 60 * 60 * 1000;
|
|
28
29
|
const TASK_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
29
30
|
const TASK_REQUEST_LIMIT = 80 * 1024 * 1024;
|
|
30
31
|
const TASK_RESPONSE_LIMIT = 100 * 1024 * 1024;
|
|
@@ -318,7 +319,11 @@ export function createProxyServer(options = {}) {
|
|
|
318
319
|
store.setStatus(taskId, "running", startedAt);
|
|
319
320
|
const controller = new AbortController();
|
|
320
321
|
const credentials = new Set(taskRequest.credentials);
|
|
321
|
-
|
|
322
|
+
let timedOut = false;
|
|
323
|
+
const timeout = setTimeout(() => {
|
|
324
|
+
timedOut = true;
|
|
325
|
+
controller.abort();
|
|
326
|
+
}, taskTimeoutMs);
|
|
322
327
|
try {
|
|
323
328
|
const result = await runGenerationScriptTask(taskRequest.task, {
|
|
324
329
|
signal: controller.signal,
|
|
@@ -343,7 +348,17 @@ export function createProxyServer(options = {}) {
|
|
|
343
348
|
store.setSucceeded(taskId, Date.now(), 200, "application/json; charset=utf-8", path.basename(responsePath));
|
|
344
349
|
console.log(`${new Date().toLocaleTimeString()} task ${taskId.slice(0, 8)} -> completed ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
|
|
345
350
|
} catch (error) {
|
|
346
|
-
const
|
|
351
|
+
const rawMessage = String(
|
|
352
|
+
error && typeof error.message === "string" ? error.message : typeof error === "string" ? error : "Generation script failed.",
|
|
353
|
+
).replace(/^Error:\s*/, "");
|
|
354
|
+
// 超时被 abort 时原始错误只是一句 Aborted,改成能看出是任务时长耗尽
|
|
355
|
+
const baseMessage = timedOut ? `Generation task exceeded the ${taskTimeoutMs >= 60000 ? `${Math.round(taskTimeoutMs / 60000)} minute` : `${Math.round(taskTimeoutMs / 1000)} second`} lifetime.` : rawMessage;
|
|
356
|
+
const upstream = error && typeof error === "object" ? error.upstream : null;
|
|
357
|
+
// 上游失败时把状态码与响应片段一并写进错误,否则只剩一句"脚本失败",真因无从判断
|
|
358
|
+
const message =
|
|
359
|
+
upstream && !baseMessage.includes(`HTTP ${upstream.status}`)
|
|
360
|
+
? `${baseMessage.length > 300 ? `${baseMessage.slice(0, 300)}…` : baseMessage} | upstream ${upstream.status}${upstream.snippet ? `: ${upstream.snippet}` : ""}`
|
|
361
|
+
: baseMessage;
|
|
347
362
|
const safeMessage = [...credentials].filter(Boolean).reduce((text, credential) => text.replaceAll(credential, "[redacted]"), message).slice(0, 500);
|
|
348
363
|
store.setStatus(taskId, controller.signal.aborted ? "failed" : "failed", Date.now(), safeMessage);
|
|
349
364
|
console.log(`${new Date().toLocaleTimeString()} task ${taskId.slice(0, 8)} -> failed ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
|
package/package.json
CHANGED
package/script-runner.js
CHANGED
|
@@ -2,8 +2,10 @@ import { File } from "node:buffer";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import vm from "node:vm";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const
|
|
5
|
+
/** 生成脚本的轮询生命周期上限:与 index.js 的任务总生命周期保持一致(60 分钟)。 */
|
|
6
|
+
const SCRIPT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
7
|
+
/** 脚本内单次上游 HTTP 请求的超时:长任务里一次素材下载或视频渲染回调可能耗时较久。 */
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 30 * 60 * 1000;
|
|
7
9
|
const mediaUploadCache = new Map();
|
|
8
10
|
const MEDIA_UPLOAD_CACHE_LIMIT = 500;
|
|
9
11
|
const abortPromises = new WeakMap();
|
|
@@ -242,7 +244,37 @@ async function responseData(response, responseType, limitBytes) {
|
|
|
242
244
|
return text;
|
|
243
245
|
}
|
|
244
246
|
|
|
245
|
-
|
|
247
|
+
/**
|
|
248
|
+
* 记录最后一次上游失败响应的状态码与响应体片段,供任务失败时附加到错误信息里。
|
|
249
|
+
* 只读前 512 字节并立即取消,避免把整页错误 HTML 读进内存。
|
|
250
|
+
*/
|
|
251
|
+
function noteUpstreamFailure(trace, response, url) {
|
|
252
|
+
if (!trace || !response || response.ok) return;
|
|
253
|
+
const entry = { status: response.status, url: String(url || response.url || ""), snippet: "" };
|
|
254
|
+
trace.last = entry;
|
|
255
|
+
const reader = response.clone().body?.getReader();
|
|
256
|
+
if (!reader) return;
|
|
257
|
+
const decoder = new TextDecoder();
|
|
258
|
+
const chunks = [];
|
|
259
|
+
let bytes = 0;
|
|
260
|
+
(async () => {
|
|
261
|
+
try {
|
|
262
|
+
while (bytes < 512 && entry.snippet.length < 200) {
|
|
263
|
+
const { done, value } = await reader.read();
|
|
264
|
+
if (done) break;
|
|
265
|
+
bytes += value.byteLength;
|
|
266
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
267
|
+
entry.snippet = chunks.join("").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// 片段抓取失败不影响任务本身
|
|
271
|
+
} finally {
|
|
272
|
+
reader.cancel().catch(() => {});
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function createRequest({ signal, requestLimitBytes, responseLimitBytes, credentials, baseUrl, upstreamTrace }) {
|
|
246
278
|
const recordCredentials = (target, headers) => {
|
|
247
279
|
for (const [name, value] of headers.entries()) {
|
|
248
280
|
if (/authorization|api.?key/i.test(name)) credentials.add(value.replace(/^bearer\s+/i, ""));
|
|
@@ -295,6 +327,7 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
|
|
|
295
327
|
const data = await responseData(response, options.responseType, responseLimitBytes);
|
|
296
328
|
if (!response.ok) {
|
|
297
329
|
const detail = typeof data === "string" ? data.slice(0, 300) : JSON.stringify(data).slice(0, 300);
|
|
330
|
+
if (upstreamTrace) upstreamTrace.last = { status: response.status, url: target.href, snippet: String(detail || "").slice(0, 200) };
|
|
298
331
|
throw new Error(`Upstream request failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
|
|
299
332
|
}
|
|
300
333
|
return {
|
|
@@ -326,6 +359,7 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
|
|
|
326
359
|
composed.dispose();
|
|
327
360
|
throw error;
|
|
328
361
|
}
|
|
362
|
+
noteUpstreamFailure(upstreamTrace, response, target);
|
|
329
363
|
if (!response.body) {
|
|
330
364
|
composed.dispose();
|
|
331
365
|
return response;
|
|
@@ -486,13 +520,43 @@ async function cacheMediaUpload(file, namespace, upload) {
|
|
|
486
520
|
}
|
|
487
521
|
}
|
|
488
522
|
|
|
523
|
+
function throwIfTaskFailed(result, kind) {
|
|
524
|
+
if (kind === "image" ? hasImageResult(result) : hasVideoResult(result)) return;
|
|
525
|
+
const status = isRecord(result) ? String(result.status || "").toLowerCase() : "";
|
|
526
|
+
if (["failed", "error", "cancelled", "canceled"].includes(status)) {
|
|
527
|
+
const detail = result.error;
|
|
528
|
+
const message =
|
|
529
|
+
typeof detail === "string"
|
|
530
|
+
? detail
|
|
531
|
+
: detail && typeof detail === "object"
|
|
532
|
+
? String(detail.message || JSON.stringify(detail))
|
|
533
|
+
: String(result.message || "Generation task failed.");
|
|
534
|
+
throw new Error(message.slice(0, 500));
|
|
535
|
+
}
|
|
536
|
+
if (status && !isPending(result) && !["completed", "succeeded", "success", "done"].includes(status)) {
|
|
537
|
+
throw new Error("Generation script returned an unsupported task status.");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function decorateUpstreamFailure(error, upstreamTrace) {
|
|
542
|
+
const last = upstreamTrace?.last;
|
|
543
|
+
if (!last) return error;
|
|
544
|
+
const target = error instanceof Error ? error : new Error(String(error));
|
|
545
|
+
if (target.upstream) return target;
|
|
546
|
+
Object.defineProperty(target, "upstream", { value: last, enumerable: true, writable: true });
|
|
547
|
+
return target;
|
|
548
|
+
}
|
|
549
|
+
|
|
489
550
|
export async function runGenerationScriptTask(task, options) {
|
|
490
551
|
if (!isRecord(task) || typeof task.script !== "string" || task.script.length > 256 * 1024) throw new Error("Generation script is missing or too large.");
|
|
491
552
|
if (task.kind !== "image" && task.kind !== "video") throw new Error("Generation task kind is unsupported.");
|
|
492
553
|
if (!isRecord(task.context)) throw new Error("Generation script context is invalid.");
|
|
493
|
-
|
|
554
|
+
// 调用方未传或传入非法值时回落到默认生命周期;否则 deadline 会被压成 1ms,任务瞬间失败
|
|
555
|
+
const requestedTimeoutMs = Number(options.timeoutMs);
|
|
556
|
+
const timeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0 ? requestedTimeoutMs : SCRIPT_TIMEOUT_MS;
|
|
494
557
|
const credentials = options.credentials instanceof Set ? options.credentials : new Set(options.credentials || []);
|
|
495
|
-
const
|
|
558
|
+
const upstreamTrace = { last: null };
|
|
559
|
+
const { request, scriptFetch } = createRequest({ ...options, credentials, baseUrl: task.context.baseUrl, upstreamTrace });
|
|
496
560
|
options.credentials = credentials;
|
|
497
561
|
const images = Array.isArray(task.context.images) ? task.context.images : [];
|
|
498
562
|
const videos = Array.isArray(task.context.videos) ? task.context.videos.map((item, index) => makeFile(item, `video-${index + 1}`)) : [];
|
|
@@ -511,29 +575,32 @@ export async function runGenerationScriptTask(task, options) {
|
|
|
511
575
|
reasoningEffort: String(task.context.reasoningEffort || ""),
|
|
512
576
|
ossPresignUrl: task.context.ossPresignUrl,
|
|
513
577
|
};
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
578
|
+
const pollIntervalMs = Math.max(0, Number(options.pollIntervalMs) || 0);
|
|
579
|
+
// 护栏只表达时间:任务总时长由 timeoutMs 决定,不再用"第 N 次轮询"这种累计次数
|
|
580
|
+
const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 0);
|
|
581
|
+
try {
|
|
582
|
+
let params = isRecord(task.context.params) ? { ...task.context.params, taskAction: "create" } : { taskAction: "create" };
|
|
583
|
+
let result = await runScript(task.script, { ...common, params }, request, scriptFetch, options.signal, timeoutMs);
|
|
584
|
+
throwIfTaskFailed(result, task.kind);
|
|
585
|
+
let handle = taskHandle(result);
|
|
586
|
+
while (handle) {
|
|
587
|
+
if (task.kind === "image" ? hasImageResult(result) : hasVideoResult(result)) break;
|
|
588
|
+
const remaining = deadline - Date.now();
|
|
589
|
+
if (remaining <= 0) throw new Error("Generation script polling exceeded the task lifetime.");
|
|
590
|
+
options.onStatus?.("polling");
|
|
591
|
+
params = {
|
|
592
|
+
...params,
|
|
593
|
+
taskAction: "poll",
|
|
594
|
+
taskId: handle.taskId,
|
|
595
|
+
...(handle.taskData ? { taskData: handle.taskData } : {}),
|
|
596
|
+
};
|
|
597
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
|
|
598
|
+
result = await runScript(task.script, { ...common, params }, request, scriptFetch, options.signal, timeoutMs);
|
|
599
|
+
throwIfTaskFailed(result, task.kind);
|
|
600
|
+
handle = taskHandle(result) || handle;
|
|
601
|
+
}
|
|
602
|
+
return result;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
throw decorateUpstreamFailure(error, upstreamTrace);
|
|
537
605
|
}
|
|
538
|
-
return result;
|
|
539
606
|
}
|