infinite-canvas-local-proxy 0.2.1 → 0.2.2

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
+ - 任务数量、并发数和单个生成脚本的请求次数均不设上限;任务提交后会立即启动。生成任务仍受单任务 30 分钟等待时限、单次上游请求 10 分钟超时、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
@@ -28,8 +28,6 @@ const TASK_TIMEOUT_MS = 30 * 60 * 1000;
28
28
  const TASK_RETENTION_MS = 24 * 60 * 60 * 1000;
29
29
  const TASK_REQUEST_LIMIT = 80 * 1024 * 1024;
30
30
  const TASK_RESPONSE_LIMIT = 100 * 1024 * 1024;
31
- const TASK_CONCURRENCY = 2;
32
- const TASK_OUTSTANDING_LIMIT = 4;
33
31
 
34
32
  function positiveInteger(value, fallback) {
35
33
  const parsed = Number(value);
@@ -236,20 +234,16 @@ export function createProxyServer(options = {}) {
236
234
  const taskRetentionMs = positiveInteger(options.taskRetentionMs ?? process.env.CANVAS_PROXY_TASK_RETENTION_MS, TASK_RETENTION_MS);
237
235
  const taskRequestLimit = positiveInteger(options.taskRequestLimit ?? process.env.CANVAS_PROXY_TASK_REQUEST_LIMIT_BYTES, TASK_REQUEST_LIMIT);
238
236
  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
237
  const queuedRequests = new Map();
242
- let runningTasks = 0;
243
238
 
244
239
  const sendTaskError = (res, status, error) => sendJson(res, status, { error });
245
240
 
246
241
  const startQueuedTasks = () => {
247
- while (runningTasks < taskConcurrency) {
242
+ while (true) {
248
243
  const next = queuedRequests.entries().next();
249
244
  if (next.done) return;
250
245
  const [taskId, taskRequest] = next.value;
251
246
  queuedRequests.delete(taskId);
252
- runningTasks += 1;
253
247
  const execute = taskRequest.runner === "script" ? executeScriptTask : taskRequest.runner === "video" ? executeVideoTask : executeTask;
254
248
  void execute(taskId, taskRequest)
255
249
  .catch(() => {
@@ -260,10 +254,7 @@ export function createProxyServer(options = {}) {
260
254
  }
261
255
  console.error("Persistent task worker failed; it will not be retried automatically.");
262
256
  })
263
- .finally(() => {
264
- runningTasks -= 1;
265
- startQueuedTasks();
266
- });
257
+ .finally(startQueuedTasks);
267
258
  }
268
259
  };
269
260
 
@@ -713,10 +704,6 @@ export function createProxyServer(options = {}) {
713
704
  return;
714
705
  }
715
706
  }
716
- if (runningTasks + queuedRequests.size >= taskOutstandingLimit) {
717
- sendTaskError(res, 429, "The local image task queue is full.");
718
- return;
719
- }
720
707
  const taskId = randomBytes(32).toString("base64url");
721
708
  const createdAt = Date.now();
722
709
  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.2",
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
@@ -4,9 +4,27 @@ import vm from "node:vm";
4
4
 
5
5
  const SCRIPT_TIMEOUT_MS = 30 * 60 * 1000;
6
6
  const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
7
- const MAX_SCRIPT_REQUESTS = 32;
8
7
  const mediaUploadCache = new Map();
9
8
  const MEDIA_UPLOAD_CACHE_LIMIT = 500;
9
+ const abortPromises = new WeakMap();
10
+
11
+ /** 每个任务信号只注册一次 abort 监听:轮询会反复执行脚本,逐次注册会累积成内存泄漏。 */
12
+ function abortPromiseFor(signal) {
13
+ if (!signal) return new Promise(() => {});
14
+ const cached = abortPromises.get(signal);
15
+ if (cached) return cached;
16
+ const promise = new Promise((_, reject) => {
17
+ const fail = () => reject(new DOMException("Aborted", "AbortError"));
18
+ if (signal.aborted) {
19
+ fail();
20
+ return;
21
+ }
22
+ signal.addEventListener("abort", fail, { once: true });
23
+ });
24
+ promise.catch(() => {});
25
+ abortPromises.set(signal, promise);
26
+ return promise;
27
+ }
10
28
 
11
29
  class ScriptFileReader {
12
30
  result = null;
@@ -75,6 +93,100 @@ function makeFile(value, fallbackName) {
75
93
  });
76
94
  }
77
95
 
96
+ function sniffMime(bytes) {
97
+ const ascii = (start, end) => bytes.toString("ascii", start, end);
98
+ if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
99
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
100
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "image/webp";
101
+ if (ascii(0, 4) === "GIF8") return "image/gif";
102
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE") return "audio/wav";
103
+ if (ascii(0, 3) === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0 && (bytes[1] & 0x06) !== 0)) return "audio/mpeg";
104
+ if (bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) return "video/webm";
105
+ if (ascii(0, 4) === "OggS") return "audio/ogg";
106
+ if (ascii(4, 8) === "ftyp") {
107
+ 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))];
108
+ if (brands.includes("qt ")) return "video/quicktime";
109
+ if (brands.some((brand) => ["M4A ", "M4B ", "M4P "].includes(brand))) return "audio/mp4";
110
+ if (brands.some((brand) => ["avif", "avis"].includes(brand))) return "image/avif";
111
+ if (brands.some((brand) => ["heic", "heix", "hevc", "hevx"].includes(brand))) return "image/heic";
112
+ if (brands.some((brand) => ["mif1", "msf1"].includes(brand))) return "image/heif";
113
+ return "video/mp4";
114
+ }
115
+ return null;
116
+ }
117
+
118
+ export function createScriptOssUploader(endpoint, namespace, scriptFetch, signal) {
119
+ const mimeExtensions = {
120
+ "image/avif": "avif",
121
+ "image/gif": "gif",
122
+ "image/heic": "heic",
123
+ "image/heif": "heif",
124
+ "image/jpeg": "jpg",
125
+ "image/png": "png",
126
+ "image/webp": "webp",
127
+ "video/mp4": "mp4",
128
+ "video/quicktime": "mov",
129
+ "video/webm": "webm",
130
+ "audio/mp4": "m4a",
131
+ "audio/mpeg": "mp3",
132
+ "audio/ogg": "ogg",
133
+ "audio/wav": "wav",
134
+ "audio/x-wav": "wav",
135
+ };
136
+ return async (file) => {
137
+ if (!(file instanceof Blob)) throw new Error("uploadToOss requires a Blob or File.");
138
+ if (typeof endpoint !== "string" || !endpoint.trim()) throw new Error("The application OSS presign endpoint was not provided to the local proxy.");
139
+ let presignEndpoint;
140
+ try {
141
+ presignEndpoint = new URL(endpoint);
142
+ } catch {
143
+ throw new Error("The application OSS presign endpoint must be an absolute URL.");
144
+ }
145
+ if (!["http:", "https:"].includes(presignEndpoint.protocol)) throw new Error("The application OSS presign endpoint must use HTTP or HTTPS.");
146
+ return cacheMediaUpload(file, `${namespace}:oss-mime-v2`, async () => {
147
+ const declaredType = file.type.split(";", 1)[0].trim().toLowerCase();
148
+ const contentType = declaredType && declaredType !== "application/octet-stream" ? declaredType : sniffMime(Buffer.from(await file.slice(0, 32).arrayBuffer()));
149
+ if (!contentType) throw new Error("Could not identify the reference media type from its content.");
150
+ const extension = mimeExtensions[contentType];
151
+ if (!extension) throw new Error(`OSS upload does not support MIME type "${contentType}".`);
152
+ const originalName = typeof file.name === "string" ? file.name : "reference";
153
+ const basename = originalName.split(/[\\/]/).at(-1)?.replace(/\.[^.]*$/, "") || "reference";
154
+ const presignResponse = await scriptFetch(endpoint, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify({ filename: `${basename}.${extension}`, contentType }),
158
+ signal,
159
+ });
160
+ const presignText = await presignResponse.text();
161
+ let presign;
162
+ try {
163
+ presign = presignText ? JSON.parse(presignText) : {};
164
+ } catch {
165
+ throw new Error(`OSS presign returned non-JSON (HTTP ${presignResponse.status}).`);
166
+ }
167
+ if (!presignResponse.ok) {
168
+ const detail = typeof presign.error === "string" ? presign.error : typeof presign.message === "string" ? presign.message : "";
169
+ throw new Error(detail || `Could not get OSS upload URL (HTTP ${presignResponse.status}).`);
170
+ }
171
+ if (typeof presign.uploadUrl !== "string" || typeof presign.publicUrl !== "string") {
172
+ throw new Error("OSS presign response must include uploadUrl and publicUrl.");
173
+ }
174
+ if (new URL(presign.uploadUrl).protocol !== "https:" || new URL(presign.publicUrl).protocol !== "https:") {
175
+ throw new Error("OSS upload and public media URLs must use HTTPS.");
176
+ }
177
+ const uploadContentType = typeof presign.contentType === "string" && presign.contentType ? presign.contentType : contentType;
178
+ const uploaded = await scriptFetch(presign.uploadUrl, {
179
+ method: "PUT",
180
+ headers: { "Content-Type": uploadContentType },
181
+ body: file,
182
+ signal,
183
+ });
184
+ if (!uploaded.ok) throw new Error(`OSS upload failed: HTTP ${uploaded.status}.`);
185
+ return presign.publicUrl;
186
+ });
187
+ };
188
+ }
189
+
78
190
  function bodySize(data) {
79
191
  if (data === undefined || data === null) return 0;
80
192
  if (typeof data === "string") return Buffer.byteLength(data);
@@ -90,6 +202,29 @@ function bodySize(data) {
90
202
  return Buffer.byteLength(JSON.stringify(data));
91
203
  }
92
204
 
205
+ function composeAbortSignal(signals, timeoutMs) {
206
+ const controller = new AbortController();
207
+ const listeners = [];
208
+ for (const signal of signals) {
209
+ if (!signal) continue;
210
+ if (signal.aborted) {
211
+ controller.abort(signal.reason);
212
+ break;
213
+ }
214
+ const abort = () => controller.abort(signal.reason);
215
+ signal.addEventListener("abort", abort, { once: true });
216
+ listeners.push([signal, abort]);
217
+ }
218
+ const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
219
+ return {
220
+ signal: controller.signal,
221
+ dispose() {
222
+ clearTimeout(timeout);
223
+ for (const [signal, abort] of listeners) signal.removeEventListener("abort", abort);
224
+ },
225
+ };
226
+ }
227
+
93
228
  async function responseData(response, responseType, limitBytes) {
94
229
  const contentType = response.headers.get("content-type") || "";
95
230
  const bytes = Buffer.from(await response.arrayBuffer());
@@ -108,11 +243,6 @@ async function responseData(response, responseType, limitBytes) {
108
243
  }
109
244
 
110
245
  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
- };
116
246
  const recordCredentials = (target, headers) => {
117
247
  for (const [name, value] of headers.entries()) {
118
248
  if (/authorization|api.?key/i.test(name)) credentials.add(value.replace(/^bearer\s+/i, ""));
@@ -124,7 +254,6 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
124
254
  }
125
255
  };
126
256
  const request = async (options) => {
127
- countRequest();
128
257
  if (!isRecord(options) || typeof options.url !== "string") throw new Error("A generation script request needs a URL.");
129
258
  const requestBase = String(baseUrl || "").trim();
130
259
  const target = /^https?:\/\//i.test(options.url) ? new URL(options.url) : new URL(options.url, `${requestBase.replace(/\/+$/, "")}/`);
@@ -180,7 +309,6 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
180
309
  }
181
310
  };
182
311
  const scriptFetch = async (input, init = {}) => {
183
- countRequest();
184
312
  const requestInput = input instanceof Request ? input : null;
185
313
  const target = new URL(requestInput ? requestInput.url : String(input));
186
314
  if (!["http:", "https:", "data:"].includes(target.protocol)) throw new Error("Generation scripts may only fetch HTTP, HTTPS or data URLs.");
@@ -190,17 +318,53 @@ function createRequest({ signal, requestLimitBytes, responseLimitBytes, credenti
190
318
  if (init.body instanceof FormData && /^multipart\/form-data\s*$/i.test(headers.get("content-type") || "")) headers.delete("content-type");
191
319
  recordCredentials(target, headers);
192
320
  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;
321
+ const composed = composeAbortSignal([signal, init.signal || requestInput?.signal], REQUEST_TIMEOUT_MS);
322
+ let response;
323
+ try {
324
+ response = await fetch(input, { ...init, headers, signal: composed.signal });
325
+ } catch (error) {
326
+ composed.dispose();
327
+ throw error;
328
+ }
329
+ if (!response.body) {
330
+ composed.dispose();
331
+ return response;
332
+ }
333
+ const reader = response.body.getReader();
196
334
  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);
335
+ const limited = new ReadableStream({
336
+ async pull(controller) {
337
+ try {
338
+ const { done, value } = await reader.read();
339
+ if (done) {
340
+ controller.close();
341
+ composed.dispose();
342
+ return;
343
+ }
344
+ received += value.byteLength;
345
+ if (received > responseLimitBytes) {
346
+ try {
347
+ await reader.cancel();
348
+ } finally {
349
+ composed.dispose();
350
+ }
351
+ controller.error(new Error("Script response exceeds the configured size limit."));
352
+ return;
353
+ }
354
+ controller.enqueue(value);
355
+ } catch (error) {
356
+ controller.error(error);
357
+ composed.dispose();
358
+ }
202
359
  },
203
- }));
360
+ async cancel(reason) {
361
+ try {
362
+ await reader.cancel(reason);
363
+ } finally {
364
+ composed.dispose();
365
+ }
366
+ },
367
+ });
204
368
  const wrapped = new Response(limited, { status: response.status, statusText: response.statusText, headers: response.headers });
205
369
  Object.defineProperties(wrapped, {
206
370
  url: { value: response.url },
@@ -282,6 +446,7 @@ async function runScript(script, input, request, scriptFetch, signal, timeoutMs)
282
446
  sleep,
283
447
  signal,
284
448
  cacheMediaUpload,
449
+ uploadToOss: createScriptOssUploader(input.ossPresignUrl, input.baseUrl, scriptFetch, signal),
285
450
  console: Object.freeze({ log() {}, warn() {}, error() {} }),
286
451
  });
287
452
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
@@ -291,7 +456,7 @@ async function runScript(script, input, request, scriptFetch, signal, timeoutMs)
291
456
  const timedOut = new Promise((_, reject) => {
292
457
  timeout = setTimeout(() => reject(new Error("Generation script timed out.")), timeoutMs);
293
458
  });
294
- const aborted = signal ? new Promise((_, reject) => signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true })) : new Promise(() => {});
459
+ const aborted = abortPromiseFor(signal);
295
460
  try {
296
461
  return await Promise.race([result, timedOut, aborted]);
297
462
  } finally {
@@ -344,6 +509,7 @@ export async function runGenerationScriptTask(task, options) {
344
509
  apiKey: String(task.context.apiKey || ""),
345
510
  systemPrompt: String(task.context.systemPrompt || ""),
346
511
  reasoningEffort: String(task.context.reasoningEffort || ""),
512
+ ossPresignUrl: task.context.ossPresignUrl,
347
513
  };
348
514
  let params = isRecord(task.context.params) ? { ...task.context.params, taskAction: "create" } : { taskAction: "create" };
349
515
  let result = await runScript(task.script, { ...common, params }, request, scriptFetch, options.signal, timeoutMs);