infinite-canvas-local-proxy 0.2.1 → 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 +2 -3
- package/index.js +20 -18
- package/package.json +1 -1
- package/script-runner.js +280 -47
package/README.md
CHANGED
|
@@ -56,8 +56,8 @@ 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
|
-
-
|
|
60
|
-
- 可通过环境变量 `CANVAS_PROXY_DATA_DIR`、`
|
|
59
|
+
- 任务数量、并发数和单个生成脚本的请求次数均不设上限;任务提交后会立即启动。生成任务仍受单任务 60 分钟等待时限、单次上游请求 30 分钟超时、80 MiB 请求体和 100 MiB 响应大小限制。成功/失败记录保留 24 小时。
|
|
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/完成/失败等本地状态,不提供或伪造供应商生成百分比。代理必须保持运行,才能在浏览器关闭后继续执行代理持有的请求或自定义脚本轮询。
|
|
63
63
|
|
|
@@ -106,4 +106,3 @@ Node 22.5 及以上首次加载内置 SQLite 时会打印一行 `ExperimentalWar
|
|
|
106
106
|
rmdir /s /q "%LocalAppData%\npm-cache\_npx" # Windows
|
|
107
107
|
npm cache clean --force
|
|
108
108
|
```
|
|
109
|
-
|
package/index.js
CHANGED
|
@@ -24,12 +24,11 @@ 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;
|
|
31
|
-
const TASK_CONCURRENCY = 2;
|
|
32
|
-
const TASK_OUTSTANDING_LIMIT = 4;
|
|
33
32
|
|
|
34
33
|
function positiveInteger(value, fallback) {
|
|
35
34
|
const parsed = Number(value);
|
|
@@ -236,20 +235,16 @@ export function createProxyServer(options = {}) {
|
|
|
236
235
|
const taskRetentionMs = positiveInteger(options.taskRetentionMs ?? process.env.CANVAS_PROXY_TASK_RETENTION_MS, TASK_RETENTION_MS);
|
|
237
236
|
const taskRequestLimit = positiveInteger(options.taskRequestLimit ?? process.env.CANVAS_PROXY_TASK_REQUEST_LIMIT_BYTES, TASK_REQUEST_LIMIT);
|
|
238
237
|
const taskResponseLimit = positiveInteger(options.taskResponseLimit ?? process.env.CANVAS_PROXY_TASK_RESPONSE_LIMIT_BYTES, TASK_RESPONSE_LIMIT);
|
|
239
|
-
const taskConcurrency = positiveInteger(options.taskConcurrency ?? process.env.CANVAS_PROXY_TASK_CONCURRENCY, TASK_CONCURRENCY);
|
|
240
|
-
const taskOutstandingLimit = positiveInteger(options.taskOutstandingLimit ?? process.env.CANVAS_PROXY_TASK_OUTSTANDING_LIMIT, TASK_OUTSTANDING_LIMIT);
|
|
241
238
|
const queuedRequests = new Map();
|
|
242
|
-
let runningTasks = 0;
|
|
243
239
|
|
|
244
240
|
const sendTaskError = (res, status, error) => sendJson(res, status, { error });
|
|
245
241
|
|
|
246
242
|
const startQueuedTasks = () => {
|
|
247
|
-
while (
|
|
243
|
+
while (true) {
|
|
248
244
|
const next = queuedRequests.entries().next();
|
|
249
245
|
if (next.done) return;
|
|
250
246
|
const [taskId, taskRequest] = next.value;
|
|
251
247
|
queuedRequests.delete(taskId);
|
|
252
|
-
runningTasks += 1;
|
|
253
248
|
const execute = taskRequest.runner === "script" ? executeScriptTask : taskRequest.runner === "video" ? executeVideoTask : executeTask;
|
|
254
249
|
void execute(taskId, taskRequest)
|
|
255
250
|
.catch(() => {
|
|
@@ -260,10 +255,7 @@ export function createProxyServer(options = {}) {
|
|
|
260
255
|
}
|
|
261
256
|
console.error("Persistent task worker failed; it will not be retried automatically.");
|
|
262
257
|
})
|
|
263
|
-
.finally(
|
|
264
|
-
runningTasks -= 1;
|
|
265
|
-
startQueuedTasks();
|
|
266
|
-
});
|
|
258
|
+
.finally(startQueuedTasks);
|
|
267
259
|
}
|
|
268
260
|
};
|
|
269
261
|
|
|
@@ -327,7 +319,11 @@ export function createProxyServer(options = {}) {
|
|
|
327
319
|
store.setStatus(taskId, "running", startedAt);
|
|
328
320
|
const controller = new AbortController();
|
|
329
321
|
const credentials = new Set(taskRequest.credentials);
|
|
330
|
-
|
|
322
|
+
let timedOut = false;
|
|
323
|
+
const timeout = setTimeout(() => {
|
|
324
|
+
timedOut = true;
|
|
325
|
+
controller.abort();
|
|
326
|
+
}, taskTimeoutMs);
|
|
331
327
|
try {
|
|
332
328
|
const result = await runGenerationScriptTask(taskRequest.task, {
|
|
333
329
|
signal: controller.signal,
|
|
@@ -352,7 +348,17 @@ export function createProxyServer(options = {}) {
|
|
|
352
348
|
store.setSucceeded(taskId, Date.now(), 200, "application/json; charset=utf-8", path.basename(responsePath));
|
|
353
349
|
console.log(`${new Date().toLocaleTimeString()} task ${taskId.slice(0, 8)} -> completed ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
|
|
354
350
|
} catch (error) {
|
|
355
|
-
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;
|
|
356
362
|
const safeMessage = [...credentials].filter(Boolean).reduce((text, credential) => text.replaceAll(credential, "[redacted]"), message).slice(0, 500);
|
|
357
363
|
store.setStatus(taskId, controller.signal.aborted ? "failed" : "failed", Date.now(), safeMessage);
|
|
358
364
|
console.log(`${new Date().toLocaleTimeString()} task ${taskId.slice(0, 8)} -> failed ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
|
|
@@ -713,10 +719,6 @@ export function createProxyServer(options = {}) {
|
|
|
713
719
|
return;
|
|
714
720
|
}
|
|
715
721
|
}
|
|
716
|
-
if (runningTasks + queuedRequests.size >= taskOutstandingLimit) {
|
|
717
|
-
sendTaskError(res, 429, "The local image task queue is full.");
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
722
|
const taskId = randomBytes(32).toString("base64url");
|
|
721
723
|
const createdAt = Date.now();
|
|
722
724
|
const created = store.createOrGet({
|
package/package.json
CHANGED
package/script-runner.js
CHANGED
|
@@ -2,11 +2,31 @@ import { File } from "node:buffer";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import vm from "node:vm";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
5
|
+
/** 生成脚本的轮询生命周期上限:与 index.js 的任务总生命周期保持一致(60 分钟)。 */
|
|
6
|
+
const SCRIPT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
7
|
+
/** 脚本内单次上游 HTTP 请求的超时:长任务里一次素材下载或视频渲染回调可能耗时较久。 */
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 30 * 60 * 1000;
|
|
8
9
|
const mediaUploadCache = new Map();
|
|
9
10
|
const MEDIA_UPLOAD_CACHE_LIMIT = 500;
|
|
11
|
+
const abortPromises = new WeakMap();
|
|
12
|
+
|
|
13
|
+
/** 每个任务信号只注册一次 abort 监听:轮询会反复执行脚本,逐次注册会累积成内存泄漏。 */
|
|
14
|
+
function abortPromiseFor(signal) {
|
|
15
|
+
if (!signal) return new Promise(() => {});
|
|
16
|
+
const cached = abortPromises.get(signal);
|
|
17
|
+
if (cached) return cached;
|
|
18
|
+
const promise = new Promise((_, reject) => {
|
|
19
|
+
const fail = () => reject(new DOMException("Aborted", "AbortError"));
|
|
20
|
+
if (signal.aborted) {
|
|
21
|
+
fail();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
signal.addEventListener("abort", fail, { once: true });
|
|
25
|
+
});
|
|
26
|
+
promise.catch(() => {});
|
|
27
|
+
abortPromises.set(signal, promise);
|
|
28
|
+
return promise;
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
class ScriptFileReader {
|
|
12
32
|
result = null;
|
|
@@ -75,6 +95,100 @@ function makeFile(value, fallbackName) {
|
|
|
75
95
|
});
|
|
76
96
|
}
|
|
77
97
|
|
|
98
|
+
function sniffMime(bytes) {
|
|
99
|
+
const ascii = (start, end) => bytes.toString("ascii", start, end);
|
|
100
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
|
101
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
102
|
+
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "image/webp";
|
|
103
|
+
if (ascii(0, 4) === "GIF8") return "image/gif";
|
|
104
|
+
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE") return "audio/wav";
|
|
105
|
+
if (ascii(0, 3) === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0 && (bytes[1] & 0x06) !== 0)) return "audio/mpeg";
|
|
106
|
+
if (bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) return "video/webm";
|
|
107
|
+
if (ascii(0, 4) === "OggS") return "audio/ogg";
|
|
108
|
+
if (ascii(4, 8) === "ftyp") {
|
|
109
|
+
const brands = [ascii(8, 12), ...Array.from({ length: Math.floor(Math.max(0, bytes.length - 16) / 4) }, (_, index) => ascii(16 + index * 4, 20 + index * 4))];
|
|
110
|
+
if (brands.includes("qt ")) return "video/quicktime";
|
|
111
|
+
if (brands.some((brand) => ["M4A ", "M4B ", "M4P "].includes(brand))) return "audio/mp4";
|
|
112
|
+
if (brands.some((brand) => ["avif", "avis"].includes(brand))) return "image/avif";
|
|
113
|
+
if (brands.some((brand) => ["heic", "heix", "hevc", "hevx"].includes(brand))) return "image/heic";
|
|
114
|
+
if (brands.some((brand) => ["mif1", "msf1"].includes(brand))) return "image/heif";
|
|
115
|
+
return "video/mp4";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createScriptOssUploader(endpoint, namespace, scriptFetch, signal) {
|
|
121
|
+
const mimeExtensions = {
|
|
122
|
+
"image/avif": "avif",
|
|
123
|
+
"image/gif": "gif",
|
|
124
|
+
"image/heic": "heic",
|
|
125
|
+
"image/heif": "heif",
|
|
126
|
+
"image/jpeg": "jpg",
|
|
127
|
+
"image/png": "png",
|
|
128
|
+
"image/webp": "webp",
|
|
129
|
+
"video/mp4": "mp4",
|
|
130
|
+
"video/quicktime": "mov",
|
|
131
|
+
"video/webm": "webm",
|
|
132
|
+
"audio/mp4": "m4a",
|
|
133
|
+
"audio/mpeg": "mp3",
|
|
134
|
+
"audio/ogg": "ogg",
|
|
135
|
+
"audio/wav": "wav",
|
|
136
|
+
"audio/x-wav": "wav",
|
|
137
|
+
};
|
|
138
|
+
return async (file) => {
|
|
139
|
+
if (!(file instanceof Blob)) throw new Error("uploadToOss requires a Blob or File.");
|
|
140
|
+
if (typeof endpoint !== "string" || !endpoint.trim()) throw new Error("The application OSS presign endpoint was not provided to the local proxy.");
|
|
141
|
+
let presignEndpoint;
|
|
142
|
+
try {
|
|
143
|
+
presignEndpoint = new URL(endpoint);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error("The application OSS presign endpoint must be an absolute URL.");
|
|
146
|
+
}
|
|
147
|
+
if (!["http:", "https:"].includes(presignEndpoint.protocol)) throw new Error("The application OSS presign endpoint must use HTTP or HTTPS.");
|
|
148
|
+
return cacheMediaUpload(file, `${namespace}:oss-mime-v2`, async () => {
|
|
149
|
+
const declaredType = file.type.split(";", 1)[0].trim().toLowerCase();
|
|
150
|
+
const contentType = declaredType && declaredType !== "application/octet-stream" ? declaredType : sniffMime(Buffer.from(await file.slice(0, 32).arrayBuffer()));
|
|
151
|
+
if (!contentType) throw new Error("Could not identify the reference media type from its content.");
|
|
152
|
+
const extension = mimeExtensions[contentType];
|
|
153
|
+
if (!extension) throw new Error(`OSS upload does not support MIME type "${contentType}".`);
|
|
154
|
+
const originalName = typeof file.name === "string" ? file.name : "reference";
|
|
155
|
+
const basename = originalName.split(/[\\/]/).at(-1)?.replace(/\.[^.]*$/, "") || "reference";
|
|
156
|
+
const presignResponse = await scriptFetch(endpoint, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "Content-Type": "application/json" },
|
|
159
|
+
body: JSON.stringify({ filename: `${basename}.${extension}`, contentType }),
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
const presignText = await presignResponse.text();
|
|
163
|
+
let presign;
|
|
164
|
+
try {
|
|
165
|
+
presign = presignText ? JSON.parse(presignText) : {};
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error(`OSS presign returned non-JSON (HTTP ${presignResponse.status}).`);
|
|
168
|
+
}
|
|
169
|
+
if (!presignResponse.ok) {
|
|
170
|
+
const detail = typeof presign.error === "string" ? presign.error : typeof presign.message === "string" ? presign.message : "";
|
|
171
|
+
throw new Error(detail || `Could not get OSS upload URL (HTTP ${presignResponse.status}).`);
|
|
172
|
+
}
|
|
173
|
+
if (typeof presign.uploadUrl !== "string" || typeof presign.publicUrl !== "string") {
|
|
174
|
+
throw new Error("OSS presign response must include uploadUrl and publicUrl.");
|
|
175
|
+
}
|
|
176
|
+
if (new URL(presign.uploadUrl).protocol !== "https:" || new URL(presign.publicUrl).protocol !== "https:") {
|
|
177
|
+
throw new Error("OSS upload and public media URLs must use HTTPS.");
|
|
178
|
+
}
|
|
179
|
+
const uploadContentType = typeof presign.contentType === "string" && presign.contentType ? presign.contentType : contentType;
|
|
180
|
+
const uploaded = await scriptFetch(presign.uploadUrl, {
|
|
181
|
+
method: "PUT",
|
|
182
|
+
headers: { "Content-Type": uploadContentType },
|
|
183
|
+
body: file,
|
|
184
|
+
signal,
|
|
185
|
+
});
|
|
186
|
+
if (!uploaded.ok) throw new Error(`OSS upload failed: HTTP ${uploaded.status}.`);
|
|
187
|
+
return presign.publicUrl;
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
78
192
|
function bodySize(data) {
|
|
79
193
|
if (data === undefined || data === null) return 0;
|
|
80
194
|
if (typeof data === "string") return Buffer.byteLength(data);
|
|
@@ -90,6 +204,29 @@ function bodySize(data) {
|
|
|
90
204
|
return Buffer.byteLength(JSON.stringify(data));
|
|
91
205
|
}
|
|
92
206
|
|
|
207
|
+
function composeAbortSignal(signals, timeoutMs) {
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const listeners = [];
|
|
210
|
+
for (const signal of signals) {
|
|
211
|
+
if (!signal) continue;
|
|
212
|
+
if (signal.aborted) {
|
|
213
|
+
controller.abort(signal.reason);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
const abort = () => controller.abort(signal.reason);
|
|
217
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
218
|
+
listeners.push([signal, abort]);
|
|
219
|
+
}
|
|
220
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
|
|
221
|
+
return {
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
dispose() {
|
|
224
|
+
clearTimeout(timeout);
|
|
225
|
+
for (const [signal, abort] of listeners) signal.removeEventListener("abort", abort);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
93
230
|
async function responseData(response, responseType, limitBytes) {
|
|
94
231
|
const contentType = response.headers.get("content-type") || "";
|
|
95
232
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -107,12 +244,37 @@ async function responseData(response, responseType, limitBytes) {
|
|
|
107
244
|
return text;
|
|
108
245
|
}
|
|
109
246
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 }) {
|
|
116
278
|
const recordCredentials = (target, headers) => {
|
|
117
279
|
for (const [name, value] of headers.entries()) {
|
|
118
280
|
if (/authorization|api.?key/i.test(name)) credentials.add(value.replace(/^bearer\s+/i, ""));
|
|
@@ -124,7 +286,6 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
|
|
|
124
286
|
}
|
|
125
287
|
};
|
|
126
288
|
const request = async (options) => {
|
|
127
|
-
countRequest();
|
|
128
289
|
if (!isRecord(options) || typeof options.url !== "string") throw new Error("A generation script request needs a URL.");
|
|
129
290
|
const requestBase = String(baseUrl || "").trim();
|
|
130
291
|
const target = /^https?:\/\//i.test(options.url) ? new URL(options.url) : new URL(options.url, `${requestBase.replace(/\/+$/, "")}/`);
|
|
@@ -166,6 +327,7 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
|
|
|
166
327
|
const data = await responseData(response, options.responseType, responseLimitBytes);
|
|
167
328
|
if (!response.ok) {
|
|
168
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) };
|
|
169
331
|
throw new Error(`Upstream request failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
|
|
170
332
|
}
|
|
171
333
|
return {
|
|
@@ -180,7 +342,6 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
|
|
|
180
342
|
}
|
|
181
343
|
};
|
|
182
344
|
const scriptFetch = async (input, init = {}) => {
|
|
183
|
-
countRequest();
|
|
184
345
|
const requestInput = input instanceof Request ? input : null;
|
|
185
346
|
const target = new URL(requestInput ? requestInput.url : String(input));
|
|
186
347
|
if (!["http:", "https:", "data:"].includes(target.protocol)) throw new Error("Generation scripts may only fetch HTTP, HTTPS or data URLs.");
|
|
@@ -190,17 +351,54 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
|
|
|
190
351
|
if (init.body instanceof FormData && /^multipart\/form-data\s*$/i.test(headers.get("content-type") || "")) headers.delete("content-type");
|
|
191
352
|
recordCredentials(target, headers);
|
|
192
353
|
if (bodySize(init.body) > requestLimitBytes) throw new Error("Generation script request exceeds the configured size limit.");
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
354
|
+
const composed = composeAbortSignal([signal, init.signal || requestInput?.signal], REQUEST_TIMEOUT_MS);
|
|
355
|
+
let response;
|
|
356
|
+
try {
|
|
357
|
+
response = await fetch(input, { ...init, headers, signal: composed.signal });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
composed.dispose();
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
noteUpstreamFailure(upstreamTrace, response, target);
|
|
363
|
+
if (!response.body) {
|
|
364
|
+
composed.dispose();
|
|
365
|
+
return response;
|
|
366
|
+
}
|
|
367
|
+
const reader = response.body.getReader();
|
|
196
368
|
let received = 0;
|
|
197
|
-
const limited =
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
369
|
+
const limited = new ReadableStream({
|
|
370
|
+
async pull(controller) {
|
|
371
|
+
try {
|
|
372
|
+
const { done, value } = await reader.read();
|
|
373
|
+
if (done) {
|
|
374
|
+
controller.close();
|
|
375
|
+
composed.dispose();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
received += value.byteLength;
|
|
379
|
+
if (received > responseLimitBytes) {
|
|
380
|
+
try {
|
|
381
|
+
await reader.cancel();
|
|
382
|
+
} finally {
|
|
383
|
+
composed.dispose();
|
|
384
|
+
}
|
|
385
|
+
controller.error(new Error("Script response exceeds the configured size limit."));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
controller.enqueue(value);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
controller.error(error);
|
|
391
|
+
composed.dispose();
|
|
392
|
+
}
|
|
202
393
|
},
|
|
203
|
-
|
|
394
|
+
async cancel(reason) {
|
|
395
|
+
try {
|
|
396
|
+
await reader.cancel(reason);
|
|
397
|
+
} finally {
|
|
398
|
+
composed.dispose();
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
});
|
|
204
402
|
const wrapped = new Response(limited, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
205
403
|
Object.defineProperties(wrapped, {
|
|
206
404
|
url: { value: response.url },
|
|
@@ -282,6 +480,7 @@ async function runScript(script, input, request, scriptFetch, signal, timeoutMs)
|
|
|
282
480
|
sleep,
|
|
283
481
|
signal,
|
|
284
482
|
cacheMediaUpload,
|
|
483
|
+
uploadToOss: createScriptOssUploader(input.ossPresignUrl, input.baseUrl, scriptFetch, signal),
|
|
285
484
|
console: Object.freeze({ log() {}, warn() {}, error() {} }),
|
|
286
485
|
});
|
|
287
486
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
@@ -291,7 +490,7 @@ async function runScript(script, input, request, scriptFetch, signal, timeoutMs)
|
|
|
291
490
|
const timedOut = new Promise((_, reject) => {
|
|
292
491
|
timeout = setTimeout(() => reject(new Error("Generation script timed out.")), timeoutMs);
|
|
293
492
|
});
|
|
294
|
-
const aborted =
|
|
493
|
+
const aborted = abortPromiseFor(signal);
|
|
295
494
|
try {
|
|
296
495
|
return await Promise.race([result, timedOut, aborted]);
|
|
297
496
|
} finally {
|
|
@@ -321,13 +520,43 @@ async function cacheMediaUpload(file, namespace, upload) {
|
|
|
321
520
|
}
|
|
322
521
|
}
|
|
323
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
|
+
|
|
324
550
|
export async function runGenerationScriptTask(task, options) {
|
|
325
551
|
if (!isRecord(task) || typeof task.script !== "string" || task.script.length > 256 * 1024) throw new Error("Generation script is missing or too large.");
|
|
326
552
|
if (task.kind !== "image" && task.kind !== "video") throw new Error("Generation task kind is unsupported.");
|
|
327
553
|
if (!isRecord(task.context)) throw new Error("Generation script context is invalid.");
|
|
328
|
-
|
|
554
|
+
// 调用方未传或传入非法值时回落到默认生命周期;否则 deadline 会被压成 1ms,任务瞬间失败
|
|
555
|
+
const requestedTimeoutMs = Number(options.timeoutMs);
|
|
556
|
+
const timeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0 ? requestedTimeoutMs : SCRIPT_TIMEOUT_MS;
|
|
329
557
|
const credentials = options.credentials instanceof Set ? options.credentials : new Set(options.credentials || []);
|
|
330
|
-
const
|
|
558
|
+
const upstreamTrace = { last: null };
|
|
559
|
+
const { request, scriptFetch } = createRequest({ ...options, credentials, baseUrl: task.context.baseUrl, upstreamTrace });
|
|
331
560
|
options.credentials = credentials;
|
|
332
561
|
const images = Array.isArray(task.context.images) ? task.context.images : [];
|
|
333
562
|
const videos = Array.isArray(task.context.videos) ? task.context.videos.map((item, index) => makeFile(item, `video-${index + 1}`)) : [];
|
|
@@ -344,30 +573,34 @@ export async function runGenerationScriptTask(task, options) {
|
|
|
344
573
|
apiKey: String(task.context.apiKey || ""),
|
|
345
574
|
systemPrompt: String(task.context.systemPrompt || ""),
|
|
346
575
|
reasoningEffort: String(task.context.reasoningEffort || ""),
|
|
576
|
+
ossPresignUrl: task.context.ossPresignUrl,
|
|
347
577
|
};
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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);
|
|
371
605
|
}
|
|
372
|
-
return result;
|
|
373
606
|
}
|