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 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
- - 默认最多同时执行 2 个任务、最多保留 4 个排队/运行任务;请求体上限 80 MiB、上游响应上限 100 MiB、单任务等待上限 30 分钟,成功/失败记录保留 24 小时。
60
- - 可通过环境变量 `CANVAS_PROXY_DATA_DIR`、`CANVAS_PROXY_TASK_CONCURRENCY`、`CANVAS_PROXY_TASK_OUTSTANDING_LIMIT`、`CANVAS_PROXY_TASK_REQUEST_LIMIT_BYTES`、`CANVAS_PROXY_TASK_RESPONSE_LIMIT_BYTES`、`CANVAS_PROXY_TASK_TIMEOUT_MS` 和 `CANVAS_PROXY_TASK_RETENTION_MS` 调整目录与限制。
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
- const TASK_TIMEOUT_MS = 30 * 60 * 1000;
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 (runningTasks < taskConcurrency) {
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
- const timeout = setTimeout(() => controller.abort(), taskTimeoutMs);
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 message = error && typeof error.message === "string" ? error.message : typeof error === "string" ? error : "Generation script failed.";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinite-canvas-local-proxy",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Minimal local forwarding proxy that lets the Infinite Canvas web app call AI provider APIs without CORS restrictions.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- const SCRIPT_TIMEOUT_MS = 30 * 60 * 1000;
6
- const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
7
- const MAX_SCRIPT_REQUESTS = 32;
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
- function createRequest({ signal, requestLimitBytes, responseLimitBytes, credentials, baseUrl }) {
111
- let requestCount = 0;
112
- const countRequest = () => {
113
- requestCount += 1;
114
- if (requestCount > MAX_SCRIPT_REQUESTS) throw new Error("A generation script exceeded the request count limit.");
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 signals = [signal, init.signal || requestInput?.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(Boolean);
194
- const response = await fetch(input, { ...init, headers, signal: AbortSignal.any(signals) });
195
- if (!response.body) return response;
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 = response.body.pipeThrough(new TransformStream({
198
- transform(chunk, controller) {
199
- received += chunk.byteLength;
200
- if (received > responseLimitBytes) throw new Error("Script response exceeds the configured size limit.");
201
- controller.enqueue(chunk);
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 = signal ? new Promise((_, reject) => signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true })) : new Promise(() => {});
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
- const timeoutMs = options.timeoutMs;
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 { request, scriptFetch } = createRequest({ ...options, credentials, baseUrl: task.context.baseUrl });
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
- let params = isRecord(task.context.params) ? { ...task.context.params, taskAction: "create" } : { taskAction: "create" };
349
- let result = await runScript(task.script, { ...common, params }, request, scriptFetch, options.signal, timeoutMs);
350
- let handle = taskHandle(result);
351
- let polls = 0;
352
- while (handle && polls < 1800) {
353
- if (task.kind === "image" ? hasImageResult(result) : hasVideoResult(result)) break;
354
- const status = isRecord(result) ? String(result.status || "").toLowerCase() : "";
355
- if (["failed", "error", "cancelled", "canceled"].includes(status)) throw new Error(String(result.error?.message || result.error || result.message || "Generation task failed."));
356
- if (status && !isPending(result) && !["completed", "succeeded", "success", "done"].includes(status)) throw new Error("Generation script returned an unsupported task status.");
357
- options.onStatus?.("polling");
358
- params = {
359
- ...params,
360
- taskAction: "poll",
361
- taskId: handle.taskId,
362
- ...(handle.taskData ? { taskData: handle.taskData } : {}),
363
- };
364
- await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs));
365
- result = await runScript(task.script, { ...common, params }, request, scriptFetch, options.signal, timeoutMs);
366
- handle = taskHandle(result) || handle;
367
- polls += 1;
368
- }
369
- if (handle && polls >= 1800 && ! (task.kind === "image" ? hasImageResult(result) : hasVideoResult(result))) {
370
- throw new Error("Generation script polling exceeded the task lifetime.");
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
  }