neoctl-web 0.1.9 → 0.1.11

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/server.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import http from 'node:http';
2
- import fs from 'node:fs';
3
- import fsp from 'node:fs/promises';
4
- import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
6
  // Consume the private launcher secret before importing application/runtime modules.
7
7
  // Imports can initialize plugins or spawn children; none may inherit this value.
8
8
  let desktopControlConfig;
@@ -12,61 +12,61 @@ let desktopControlConfig;
12
12
  try { desktopControlConfig = raw ? JSON.parse(raw) : undefined; } catch {}
13
13
  }
14
14
 
15
- const { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } = await import('./core-runtime.mjs');
16
- const { createWebPluginHost } = await import('./plugins.mjs');
17
- const { createWebPluginSettings } = await import('./plugin-settings.mjs');
18
- const { createWebToolSettings } = await import('./tool-settings.mjs');
19
- const { createWorkspaceRuntimeManager } = await import('./runtime-workspaces.mjs');
20
- const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-cleanup.mjs');
21
- const { createCpaQuotaMonitor } = await import('./cpa-quota.mjs');
22
- const { createMemoryMonitor } = await import('./memory-monitor.mjs');
15
+ const { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } = await import('./core-runtime.mjs');
16
+ const { createWebPluginHost } = await import('./plugins.mjs');
17
+ const { createWebPluginSettings } = await import('./plugin-settings.mjs');
18
+ const { createWebToolSettings } = await import('./tool-settings.mjs');
19
+ const { createWorkspaceRuntimeManager } = await import('./runtime-workspaces.mjs');
20
+ const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-cleanup.mjs');
21
+ const { createCpaQuotaMonitor } = await import('./cpa-quota.mjs');
22
+ const { createMemoryMonitor } = await import('./memory-monitor.mjs');
23
23
  const { resolveWebStorage } = await import('./platform-paths.mjs');
24
- const { createControlSync, createLoginApplier, validateControlConfig } = await import('./control-sync.mjs');
25
-
24
+ const { createControlSync, createLoginApplier, validateControlConfig } = await import('./control-sync.mjs');
25
+
26
26
  const controlConfig = validateControlConfig(desktopControlConfig);
27
27
  desktopControlConfig = undefined;
28
28
  const controlEnabled = Boolean(controlConfig);
29
29
 
30
- installRuntimeRouterIdleCleanup();
31
- console.log(`neo core: ${coreRuntimeInfo.source} ${coreRuntimeInfo.version} (${coreRuntimeInfo.location})`);
32
- process.env.NEO_CORE_VERSION = coreRuntimeInfo.version;
33
- process.env.NEO_CLIENT_REVISION ||= `${coreRuntimeInfo.version}-${Date.now().toString(36)}`;
34
-
35
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
- const root = path.resolve(process.env.DIST_DIR || path.join(__dirname, 'dist'));
37
- const host = process.env.APP_HOST || '0.0.0.0';
38
- const port = Number(process.env.APP_PORT || process.env.PORT || 5173);
39
- const runtimeTarget = new URL(process.env.NEO_RUNTIME_TARGET || 'http://127.0.0.1:3101');
40
- const { dataRoot, workspaceRoot } = resolveWebStorage();
41
- const promptLibraryFile = path.resolve(process.env.NEO_PROMPT_LIBRARY_FILE || path.join(dataRoot, 'prompt-library.json'));
42
- const uploadsDir = path.resolve(process.env.NEO_UPLOADS_DIR || path.join(dataRoot, 'uploads'));
43
- const pluginDir = path.resolve(process.env.NEO_WEB_PLUGIN_DIR || path.join(__dirname, 'plugins'));
44
- const pluginDataDir = path.resolve(process.env.NEO_WEB_PLUGIN_DATA_DIR || dataRoot);
45
- const cpaConfigFile = path.resolve(process.env.NEO_CPA_CONFIG_FILE || path.join(dataRoot, 'cpa-config.json'));
46
- const memoryMonitorFile = path.resolve(process.env.NEO_MEMORY_MONITOR_FILE || path.join(dataRoot, 'memory-monitor.json'));
47
- const pluginSettingsFile = path.resolve(process.env.NEO_WEB_PLUGIN_SETTINGS_FILE || path.join(dataRoot, 'plugins.json'));
48
- const toolSettingsFile = path.resolve(process.env.NEO_WEB_TOOL_SETTINGS_FILE || path.join(dataRoot, 'tools.json'));
49
- const maxUploadBytes = Number(process.env.NEO_UPLOAD_MAX_BYTES || 25 * 1024 * 1024);
50
- const pluginSettings = await createWebPluginSettings(pluginSettingsFile);
51
- const toolSettings = await createWebToolSettings(toolSettingsFile);
52
- const pluginEnv = process.env.NEO_WEB_PLUGINS;
53
- const pluginResources = await loadNeoPlugins({ directories: pluginDir, appDataDir: pluginDataDir });
54
- const pluginHost = createWebPluginHost({
55
- plugins: pluginResources,
56
- enabled: pluginEnv?.trim() ? pluginEnv : pluginSettings.globalEnabledIds(),
57
- locked: Boolean(pluginEnv?.trim()),
58
- settings: pluginSettings,
59
- });
60
- const embedRuntime = process.env.NEO_EMBED_RUNTIME !== 'false';
61
- const cpaQuotaMonitor = createCpaQuotaMonitor({ configFile: cpaConfigFile });
62
- const memoryMonitor = createMemoryMonitor({
63
- storageFile: memoryMonitorFile,
64
- sampleMs: process.env.NEO_MEMORY_SAMPLE_MS,
65
- retentionMs: process.env.NEO_MEMORY_RETENTION_MS,
66
- publicSamples: process.env.NEO_MEMORY_PUBLIC_SAMPLES,
67
- maxPersistedSamples: process.env.NEO_MEMORY_MAX_PERSISTED_SAMPLES,
68
- maxPersistedBytes: process.env.NEO_MEMORY_MAX_PERSISTED_BYTES,
69
- });
30
+ installRuntimeRouterIdleCleanup();
31
+ console.log(`neo core: ${coreRuntimeInfo.source} ${coreRuntimeInfo.version} (${coreRuntimeInfo.location})`);
32
+ process.env.NEO_CORE_VERSION = coreRuntimeInfo.version;
33
+ process.env.NEO_CLIENT_REVISION ||= `${coreRuntimeInfo.version}-${Date.now().toString(36)}`;
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const root = path.resolve(process.env.DIST_DIR || path.join(__dirname, 'dist'));
37
+ const host = process.env.APP_HOST || '0.0.0.0';
38
+ const port = Number(process.env.APP_PORT || process.env.PORT || 5173);
39
+ const runtimeTarget = new URL(process.env.NEO_RUNTIME_TARGET || 'http://127.0.0.1:3101');
40
+ const { dataRoot, workspaceRoot } = resolveWebStorage();
41
+ const promptLibraryFile = path.resolve(process.env.NEO_PROMPT_LIBRARY_FILE || path.join(dataRoot, 'prompt-library.json'));
42
+ const uploadsDir = path.resolve(process.env.NEO_UPLOADS_DIR || path.join(dataRoot, 'uploads'));
43
+ const pluginDir = path.resolve(process.env.NEO_WEB_PLUGIN_DIR || path.join(__dirname, 'plugins'));
44
+ const pluginDataDir = path.resolve(process.env.NEO_WEB_PLUGIN_DATA_DIR || dataRoot);
45
+ const cpaConfigFile = path.resolve(process.env.NEO_CPA_CONFIG_FILE || path.join(dataRoot, 'cpa-config.json'));
46
+ const memoryMonitorFile = path.resolve(process.env.NEO_MEMORY_MONITOR_FILE || path.join(dataRoot, 'memory-monitor.json'));
47
+ const pluginSettingsFile = path.resolve(process.env.NEO_WEB_PLUGIN_SETTINGS_FILE || path.join(dataRoot, 'plugins.json'));
48
+ const toolSettingsFile = path.resolve(process.env.NEO_WEB_TOOL_SETTINGS_FILE || path.join(dataRoot, 'tools.json'));
49
+ const maxUploadBytes = Number(process.env.NEO_UPLOAD_MAX_BYTES || 25 * 1024 * 1024);
50
+ const pluginSettings = await createWebPluginSettings(pluginSettingsFile);
51
+ const toolSettings = await createWebToolSettings(toolSettingsFile);
52
+ const pluginEnv = process.env.NEO_WEB_PLUGINS;
53
+ const pluginResources = await loadNeoPlugins({ directories: pluginDir, appDataDir: pluginDataDir });
54
+ const pluginHost = createWebPluginHost({
55
+ plugins: pluginResources,
56
+ enabled: pluginEnv?.trim() ? pluginEnv : pluginSettings.globalEnabledIds(),
57
+ locked: Boolean(pluginEnv?.trim()),
58
+ settings: pluginSettings,
59
+ });
60
+ const embedRuntime = process.env.NEO_EMBED_RUNTIME !== 'false';
61
+ const cpaQuotaMonitor = createCpaQuotaMonitor({ configFile: cpaConfigFile });
62
+ const memoryMonitor = createMemoryMonitor({
63
+ storageFile: memoryMonitorFile,
64
+ sampleMs: process.env.NEO_MEMORY_SAMPLE_MS,
65
+ retentionMs: process.env.NEO_MEMORY_RETENTION_MS,
66
+ publicSamples: process.env.NEO_MEMORY_PUBLIC_SAMPLES,
67
+ maxPersistedSamples: process.env.NEO_MEMORY_MAX_PERSISTED_SAMPLES,
68
+ maxPersistedBytes: process.env.NEO_MEMORY_MAX_PERSISTED_BYTES,
69
+ });
70
70
  // Weak references do not defeat the existing router's idle-session cleanup.
71
71
  const controlRepls = new Set();
72
72
  function activeControlRepls() {
@@ -78,59 +78,59 @@ function activeControlRepls() {
78
78
  }
79
79
  return active;
80
80
  }
81
- const workspaceRuntime = createWorkspaceRuntimeManager({
82
- projectRoot: process.cwd(),
83
- workspaceRoot,
84
- registryFile: path.join(dataRoot, 'session-workspaces.json'),
85
- createRuntime: (runtimeOptions) => createWebRuntime({
86
- ...runtimeOptions,
87
- ...pluginHost.runtimePlugins(runtimeOptions.sessionId),
88
- globalToolOverrides: toolSettings.globalOverrides(),
89
- sessionToolOverrides: toolSettings.sessionOverrides(runtimeOptions.sessionId),
90
- persistGlobalToolOverrides: (overrides) => toolSettings.setGlobalOverrides(overrides),
91
- persistSessionToolOverrides: (sessionId, overrides) => toolSettings.setSessionOverrides(sessionId, overrides),
92
- resolveSessionToolOverrides: (sessionId) => toolSettings.sessionOverrides(sessionId),
93
- }),
94
- });
95
-
96
- const DEFAULT_APP_PROMPT_LIBRARY = [];
97
-
98
- const mime = {
99
- '.html': 'text/html; charset=utf-8',
100
- '.js': 'text/javascript; charset=utf-8',
101
- '.mjs': 'text/javascript; charset=utf-8',
102
- '.css': 'text/css; charset=utf-8',
103
- '.json': 'application/json; charset=utf-8',
104
- '.svg': 'image/svg+xml',
105
- '.png': 'image/png',
106
- '.jpg': 'image/jpeg',
107
- '.jpeg': 'image/jpeg',
108
- '.webp': 'image/webp',
109
- '.ico': 'image/x-icon',
110
- };
111
-
112
- function shouldProxy(pathname) {
113
- return pathname === '/events' || pathname.startsWith('/api/') || pathname === '/api' || pathname.startsWith('/vendor/');
114
- }
115
-
116
- const server = http.createServer((req, res) => {
117
- void routeRequest(req, res);
118
- });
119
-
120
- server.keepAliveTimeout = 70_000;
121
- server.headersTimeout = 75_000;
122
- if (embedRuntime) await startEmbeddedRuntime();
123
- await cpaQuotaMonitor.start();
124
- await memoryMonitor.start();
125
- await new Promise((resolve, reject) => {
126
- server.once('error', reject);
127
- server.listen(port, host, () => {
128
- server.off('error', reject);
129
- console.log(`neo web listening on http://${host}:${port}, dist=${root}, runtime=${runtimeTarget.href}`);
130
- resolve();
131
- });
132
- });
133
-
81
+ const workspaceRuntime = createWorkspaceRuntimeManager({
82
+ projectRoot: process.cwd(),
83
+ workspaceRoot,
84
+ registryFile: path.join(dataRoot, 'session-workspaces.json'),
85
+ createRuntime: (runtimeOptions) => createWebRuntime({
86
+ ...runtimeOptions,
87
+ ...pluginHost.runtimePlugins(runtimeOptions.sessionId),
88
+ globalToolOverrides: toolSettings.globalOverrides(),
89
+ sessionToolOverrides: toolSettings.sessionOverrides(runtimeOptions.sessionId),
90
+ persistGlobalToolOverrides: (overrides) => toolSettings.setGlobalOverrides(overrides),
91
+ persistSessionToolOverrides: (sessionId, overrides) => toolSettings.setSessionOverrides(sessionId, overrides),
92
+ resolveSessionToolOverrides: (sessionId) => toolSettings.sessionOverrides(sessionId),
93
+ }),
94
+ });
95
+
96
+ const DEFAULT_APP_PROMPT_LIBRARY = [];
97
+
98
+ const mime = {
99
+ '.html': 'text/html; charset=utf-8',
100
+ '.js': 'text/javascript; charset=utf-8',
101
+ '.mjs': 'text/javascript; charset=utf-8',
102
+ '.css': 'text/css; charset=utf-8',
103
+ '.json': 'application/json; charset=utf-8',
104
+ '.svg': 'image/svg+xml',
105
+ '.png': 'image/png',
106
+ '.jpg': 'image/jpeg',
107
+ '.jpeg': 'image/jpeg',
108
+ '.webp': 'image/webp',
109
+ '.ico': 'image/x-icon',
110
+ };
111
+
112
+ function shouldProxy(pathname) {
113
+ return pathname === '/events' || pathname.startsWith('/api/') || pathname === '/api' || pathname.startsWith('/vendor/');
114
+ }
115
+
116
+ const server = http.createServer((req, res) => {
117
+ void routeRequest(req, res);
118
+ });
119
+
120
+ server.keepAliveTimeout = 70_000;
121
+ server.headersTimeout = 75_000;
122
+ if (embedRuntime) await startEmbeddedRuntime();
123
+ await cpaQuotaMonitor.start();
124
+ await memoryMonitor.start();
125
+ await new Promise((resolve, reject) => {
126
+ server.once('error', reject);
127
+ server.listen(port, host, () => {
128
+ server.off('error', reject);
129
+ console.log(`neo web listening on http://${host}:${port}, dist=${root}, runtime=${runtimeTarget.href}`);
130
+ resolve();
131
+ });
132
+ });
133
+
134
134
  // Optional Desktop-only background work starts after HTTP listen and never delays UI.
135
135
  if (controlEnabled) {
136
136
  const controlSync = createControlSync({
@@ -141,351 +141,351 @@ if (controlEnabled) {
141
141
  server.once('close', () => { void controlSync.stop(); });
142
142
  }
143
143
 
144
- async function startEmbeddedRuntime() {
145
- const runtimeHost = runtimeTarget.hostname || '127.0.0.1';
146
- const runtimePort = runtimeTarget.port || '3101';
147
- await runWebServer(['--host', runtimeHost, '--port', runtimePort], {
148
- createRuntime: workspaceRuntime.createRuntime,
144
+ async function startEmbeddedRuntime() {
145
+ const runtimeHost = runtimeTarget.hostname || '127.0.0.1';
146
+ const runtimePort = runtimeTarget.port || '3101';
147
+ await runWebServer(['--host', runtimeHost, '--port', runtimePort], {
148
+ createRuntime: workspaceRuntime.createRuntime,
149
149
  createRepl(runtime) {
150
150
  const repl = workspaceRuntime.createRepl(runtime);
151
151
  if (controlEnabled) controlRepls.add(new WeakRef(repl));
152
152
  return repl;
153
- },
154
- });
155
- }
156
-
157
- async function routeRequest(req, res) {
158
- const url = new URL(req.url || '/', 'http://localhost');
159
- try {
160
- if (await pluginHost.route(req, res, url, { readJsonBody, sendJson })) return;
161
- if (req.method === 'GET' && url.pathname === '/api/prompt-library') {
162
- return sendJson(res, { items: await readPromptLibrary() });
163
- }
164
- if (req.method === 'GET' && url.pathname === '/api/cpa-quota') {
165
- return sendJson(res, cpaQuotaMonitor.getPublicState());
166
- }
167
- if (req.method === 'GET' && url.pathname === '/api/memory') {
168
- return sendJson(res, memoryMonitor.getPublicState());
169
- }
170
- if (req.method === 'POST' && url.pathname === '/api/cpa-config') {
171
- const body = await readJsonBody(req);
172
- const current = cpaQuotaMonitor.getPublicState();
173
- const password = body?.preservePassword && current.config.hasPassword
174
- ? undefined
175
- : String(body?.password || '');
176
- return sendJson(res, { ok: true, ...(await cpaQuotaMonitor.updateConfig({
177
- url: body?.url,
178
- password,
179
- preservePassword: body?.preservePassword,
180
- })) });
181
- }
182
- if (req.method === 'POST' && url.pathname === '/api/prompt-library') {
183
- const body = await readJsonBody(req);
184
- const item = normalizePromptItem(body?.item);
185
- if (!item) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'invalid prompt item' }, 400);
186
- const items = await readPromptLibrary();
187
- const index = items.findIndex((entry) => entry.id === item.id);
188
- if (index >= 0) items.splice(index, 1, item);
189
- else items.unshift(item);
190
- await writePromptLibrary(items);
191
- return sendJson(res, { ok: true, item, items });
192
- }
193
- if (req.method === 'POST' && url.pathname === '/api/prompt-library/delete') {
194
- const body = await readJsonBody(req);
195
- const id = String(body?.id || '').trim();
196
- if (!id) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'missing prompt id' }, 400);
197
- const items = await readPromptLibrary();
198
- const nextItems = items.filter((entry) => entry.id !== id);
199
- await writePromptLibrary(nextItems);
200
- return sendJson(res, { ok: true, items: nextItems });
201
- }
202
- if (req.method === 'POST' && url.pathname === '/api/prompt-library/reorder') {
203
- const body = await readJsonBody(req);
204
- const ids = Array.isArray(body?.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
205
- if (!ids.length || new Set(ids).size !== ids.length) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'invalid prompt order' }, 400);
206
- const items = await readPromptLibrary();
207
- const byId = new Map(items.map((item) => [item.id, item]));
208
- if (ids.some((id) => !byId.has(id))) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'prompt order contains unknown id' }, 400);
209
- const ordered = ids.map((id) => byId.get(id));
210
- const included = new Set(ids);
211
- ordered.push(...items.filter((item) => !included.has(item.id)));
212
- await writePromptLibrary(ordered);
213
- return sendJson(res, { ok: true, items: ordered });
214
- }
215
- if (req.method === 'GET' && url.pathname.startsWith('/api/uploads/')) {
216
- const storedName = decodeURIComponent(url.pathname.slice('/api/uploads/'.length));
217
- return serveUploadedFile(res, storedName);
218
- }
219
- if (req.method === 'GET' && url.pathname.startsWith('/api/local-images/')) {
220
- const encodedPath = decodeURIComponent(url.pathname.slice('/api/local-images/'.length));
221
- return serveLocalImage(res, encodedPath);
222
- }
223
- if (req.method === 'POST' && url.pathname === '/api/uploads') {
224
- const body = await readJsonBody(req);
225
- const file = await storeUploadedFile(body);
226
- return sendJson(res, { ok: true, file });
227
- }
228
- if (shouldProxy(url.pathname)) {
229
- return proxy(req, res);
230
- }
231
- return serveStatic(res, url);
232
- } catch (error) {
233
- sendJson(res, { errorCode: 'WEB_REQUEST_FAILED', error: error instanceof Error ? error.message : String(error) }, 500);
234
- }
235
- }
236
-
237
- async function proxy(req, res) {
238
- const target = new URL(req.url || '/', runtimeTarget);
239
- target.protocol = runtimeTarget.protocol;
240
- target.hostname = runtimeTarget.hostname;
241
- target.port = runtimeTarget.port;
242
-
243
- const method = req.method || 'GET';
244
- const requestBody = method === 'GET' || method === 'HEAD' ? undefined : await readRequestBody(req);
245
- const requestHeaders = new Headers();
246
- for (const [key, value] of Object.entries(req.headers)) {
247
- if (value === undefined) continue;
248
- if (['host', 'content-length', 'connection', 'expect'].includes(key.toLowerCase())) continue;
249
- requestHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
250
- }
251
-
252
- let upstream;
253
- try {
254
- upstream = await fetch(target, {
255
- method,
256
- headers: requestHeaders,
257
- body: requestBody,
258
- });
259
-
260
- const responseHeaders = {};
261
- for (const [key, value] of upstream.headers.entries()) {
262
- if (['connection', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) continue;
263
- responseHeaders[key] = value;
264
- }
265
- res.writeHead(upstream.status, responseHeaders);
266
- if (!upstream.body) {
267
- res.end();
268
- return;
269
- }
270
-
271
- res.on('close', () => {
272
- upstream.body?.cancel().catch(() => {});
273
- });
274
-
275
- for await (const chunk of upstream.body) {
276
- if (res.destroyed) break;
277
- if (!res.write(chunk) && !await waitForDrainOrClose(res)) break;
278
- }
279
- if (!res.writableEnded) res.end();
280
- } catch (error) {
281
- const isTerminatedSocket =
282
- error?.name === 'TypeError' &&
283
- error?.message === 'terminated' &&
284
- error?.cause?.code === 'UND_ERR_SOCKET';
285
- if (error?.name === 'AbortError' || isTerminatedSocket || res.destroyed) return;
286
- throw error;
287
- }
288
- }
289
-
290
- function waitForDrainOrClose(res) {
291
- if (res.destroyed) return Promise.resolve(false);
292
- return new Promise((resolve) => {
293
- const cleanup = () => {
294
- res.off('drain', onDrain);
295
- res.off('close', onClose);
296
- res.off('error', onClose);
297
- };
298
- const onDrain = () => {
299
- cleanup();
300
- resolve(true);
301
- };
302
- const onClose = () => {
303
- cleanup();
304
- resolve(false);
305
- };
306
- res.once('drain', onDrain);
307
- res.once('close', onClose);
308
- res.once('error', onClose);
309
- });
310
- }
311
-
312
- async function serveStatic(res, url) {
313
- let pathname = decodeURIComponent(url.pathname);
314
- if (pathname === '/') pathname = '/index.html';
315
- let filePath = path.resolve(root, `.${pathname}`);
316
- if (!filePath.startsWith(root + path.sep) && filePath !== root) {
317
- res.writeHead(403);
318
- res.end('forbidden');
319
- return;
320
- }
321
- try {
322
- const stat = await fsp.stat(filePath);
323
- if (stat.isDirectory()) filePath = path.join(filePath, 'index.html');
324
- } catch {
325
- filePath = path.join(root, 'index.html');
326
- }
327
-
328
- try {
329
- const body = await fsp.readFile(filePath);
330
- const ext = path.extname(filePath).toLowerCase();
331
- const cache = filePath.includes(`${path.sep}assets${path.sep}`) ? 'public, max-age=31536000, immutable' : 'no-store';
332
- res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Cache-Control': cache });
333
- res.end(body);
334
- } catch {
335
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
336
- res.end('not found');
337
- }
338
- }
339
-
340
- async function readPromptLibrary() {
341
- try {
342
- const raw = await fsp.readFile(promptLibraryFile, 'utf8');
343
- const parsed = JSON.parse(raw);
344
- if (!Array.isArray(parsed)) throw new Error('prompt library file must contain an array');
345
- return parsed.map(normalizePromptItem).filter(Boolean);
346
- } catch (error) {
347
- if (error?.code !== 'ENOENT') throw error;
348
- const items = DEFAULT_APP_PROMPT_LIBRARY.map(clonePromptItem);
349
- await writePromptLibrary(items);
350
- return items;
351
- }
352
- }
353
-
354
- async function writePromptLibrary(items) {
355
- await fsp.mkdir(path.dirname(promptLibraryFile), { recursive: true });
356
- await fsp.writeFile(promptLibraryFile, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
357
- }
358
-
359
- function normalizePromptItem(item) {
360
- if (!item || typeof item !== 'object') return null;
361
- const title = String(item.title || '').trim();
362
- const content = String(item.content || '').trim();
363
- if (!title || !content) return null;
364
- return {
365
- id: String(item.id || createPromptId()).trim(),
366
- title,
367
- content,
368
- usage: String(item.usage || '').trim(),
369
- };
370
- }
371
-
372
- function clonePromptItem(item) {
373
- return { ...item };
374
- }
375
-
376
- function createPromptId() {
377
- return `prompt-${Math.random().toString(36).slice(2, 10)}`;
378
- }
379
-
380
- async function storeUploadedFile(payload) {
381
- const name = sanitizeUploadName(payload?.name);
382
- if (!name) throw new Error('invalid upload name');
383
- const data = String(payload?.data || '').trim();
384
- if (!data) throw new Error('missing upload data');
385
- const buffer = Buffer.from(data, 'base64');
386
- if (!buffer.length) throw new Error('empty upload data');
387
- if (buffer.length > maxUploadBytes) throw new Error(`upload too large: max ${maxUploadBytes} bytes`);
388
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
389
- const random = Math.random().toString(36).slice(2, 8);
390
- const storedName = `${stamp}-${random}-${name}`;
391
- await fsp.mkdir(uploadsDir, { recursive: true });
392
- const absolutePath = path.join(uploadsDir, storedName);
393
- await fsp.writeFile(absolutePath, buffer);
394
- return {
395
- id: `upload-${random}`,
396
- name,
397
- storedName,
398
- size: buffer.length,
399
- mimeType: normalizeMimeType(payload?.mimeType),
400
- absolutePath,
401
- relativePath: path.relative(__dirname, absolutePath) || storedName,
402
- url: `/api/uploads/${encodeURIComponent(storedName)}`,
403
- };
404
- }
405
-
406
- async function serveUploadedFile(res, storedName) {
407
- const safeName = path.basename(String(storedName || '').trim());
408
- if (!safeName || safeName !== storedName) {
409
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
410
- res.end('invalid upload name');
411
- return;
412
- }
413
- const uploadRoot = path.resolve(uploadsDir);
414
- const filePath = path.resolve(uploadRoot, safeName);
415
- if (!filePath.startsWith(uploadRoot + path.sep)) {
416
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
417
- res.end('forbidden');
418
- return;
419
- }
420
- try {
421
- const body = await fsp.readFile(filePath);
422
- res.writeHead(200, {
423
- 'Content-Type': mime[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
424
- 'Cache-Control': 'public, max-age=31536000, immutable',
425
- });
426
- res.end(body);
427
- } catch {
428
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
429
- res.end('not found');
430
- }
431
- }
432
-
433
- async function serveLocalImage(res, encodedPath) {
434
- let filePath = '';
435
- try {
436
- filePath = Buffer.from(encodedPath, 'base64url').toString('utf8');
437
- } catch {
438
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
439
- res.end('invalid image path');
440
- return;
441
- }
442
- const absolutePath = path.resolve(filePath);
443
- const contentType = mime[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream';
444
- if (!contentType.startsWith('image/')) {
445
- res.writeHead(415, { 'Content-Type': 'text/plain; charset=utf-8' });
446
- res.end('not an image');
447
- return;
448
- }
449
- try {
450
- const fileStat = await fsp.stat(absolutePath);
451
- if (!fileStat.isFile()) throw new Error('not a file');
452
- res.writeHead(200, {
453
- 'Content-Type': contentType,
454
- 'Content-Length': String(fileStat.size),
455
- 'Cache-Control': 'no-store',
456
- });
457
- fs.createReadStream(absolutePath)
458
- .on('error', () => res.destroy())
459
- .pipe(res);
460
- } catch {
461
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
462
- res.end('not found');
463
- }
464
- }
465
-
466
- function sanitizeUploadName(value) {
467
- const base = path.basename(String(value || '').trim()).replace(/[<>:"/\\|?*\u0000-\u001f]+/g, '-');
468
- return base.replace(/\s+/g, ' ').trim().slice(0, 180);
469
- }
470
-
471
- function normalizeMimeType(value) {
472
- const mimeType = String(value || '').trim();
473
- return mimeType || 'application/octet-stream';
474
- }
475
-
476
- async function readJsonBody(req) {
477
- const body = await readRequestBody(req);
478
- if (!body.length) return {};
479
- return JSON.parse(body.toString('utf8'));
480
- }
481
-
482
- async function readRequestBody(req) {
483
- const chunks = [];
484
- for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
485
- return Buffer.concat(chunks);
486
- }
487
-
488
- function sendJson(res, value, status = 200) {
489
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
490
- res.end(JSON.stringify(value));
491
- }
153
+ },
154
+ });
155
+ }
156
+
157
+ async function routeRequest(req, res) {
158
+ const url = new URL(req.url || '/', 'http://localhost');
159
+ try {
160
+ if (await pluginHost.route(req, res, url, { readJsonBody, sendJson })) return;
161
+ if (req.method === 'GET' && url.pathname === '/api/prompt-library') {
162
+ return sendJson(res, { items: await readPromptLibrary() });
163
+ }
164
+ if (req.method === 'GET' && url.pathname === '/api/cpa-quota') {
165
+ return sendJson(res, cpaQuotaMonitor.getPublicState());
166
+ }
167
+ if (req.method === 'GET' && url.pathname === '/api/memory') {
168
+ return sendJson(res, memoryMonitor.getPublicState());
169
+ }
170
+ if (req.method === 'POST' && url.pathname === '/api/cpa-config') {
171
+ const body = await readJsonBody(req);
172
+ const current = cpaQuotaMonitor.getPublicState();
173
+ const password = body?.preservePassword && current.config.hasPassword
174
+ ? undefined
175
+ : String(body?.password || '');
176
+ return sendJson(res, { ok: true, ...(await cpaQuotaMonitor.updateConfig({
177
+ url: body?.url,
178
+ password,
179
+ preservePassword: body?.preservePassword,
180
+ })) });
181
+ }
182
+ if (req.method === 'POST' && url.pathname === '/api/prompt-library') {
183
+ const body = await readJsonBody(req);
184
+ const item = normalizePromptItem(body?.item);
185
+ if (!item) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'invalid prompt item' }, 400);
186
+ const items = await readPromptLibrary();
187
+ const index = items.findIndex((entry) => entry.id === item.id);
188
+ if (index >= 0) items.splice(index, 1, item);
189
+ else items.unshift(item);
190
+ await writePromptLibrary(items);
191
+ return sendJson(res, { ok: true, item, items });
192
+ }
193
+ if (req.method === 'POST' && url.pathname === '/api/prompt-library/delete') {
194
+ const body = await readJsonBody(req);
195
+ const id = String(body?.id || '').trim();
196
+ if (!id) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'missing prompt id' }, 400);
197
+ const items = await readPromptLibrary();
198
+ const nextItems = items.filter((entry) => entry.id !== id);
199
+ await writePromptLibrary(nextItems);
200
+ return sendJson(res, { ok: true, items: nextItems });
201
+ }
202
+ if (req.method === 'POST' && url.pathname === '/api/prompt-library/reorder') {
203
+ const body = await readJsonBody(req);
204
+ const ids = Array.isArray(body?.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
205
+ if (!ids.length || new Set(ids).size !== ids.length) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'invalid prompt order' }, 400);
206
+ const items = await readPromptLibrary();
207
+ const byId = new Map(items.map((item) => [item.id, item]));
208
+ if (ids.some((id) => !byId.has(id))) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'prompt order contains unknown id' }, 400);
209
+ const ordered = ids.map((id) => byId.get(id));
210
+ const included = new Set(ids);
211
+ ordered.push(...items.filter((item) => !included.has(item.id)));
212
+ await writePromptLibrary(ordered);
213
+ return sendJson(res, { ok: true, items: ordered });
214
+ }
215
+ if (req.method === 'GET' && url.pathname.startsWith('/api/uploads/')) {
216
+ const storedName = decodeURIComponent(url.pathname.slice('/api/uploads/'.length));
217
+ return serveUploadedFile(res, storedName);
218
+ }
219
+ if (req.method === 'GET' && url.pathname.startsWith('/api/local-images/')) {
220
+ const encodedPath = decodeURIComponent(url.pathname.slice('/api/local-images/'.length));
221
+ return serveLocalImage(res, encodedPath);
222
+ }
223
+ if (req.method === 'POST' && url.pathname === '/api/uploads') {
224
+ const body = await readJsonBody(req);
225
+ const file = await storeUploadedFile(body);
226
+ return sendJson(res, { ok: true, file });
227
+ }
228
+ if (shouldProxy(url.pathname)) {
229
+ return proxy(req, res);
230
+ }
231
+ return serveStatic(res, url);
232
+ } catch (error) {
233
+ sendJson(res, { errorCode: 'WEB_REQUEST_FAILED', error: error instanceof Error ? error.message : String(error) }, 500);
234
+ }
235
+ }
236
+
237
+ async function proxy(req, res) {
238
+ const target = new URL(req.url || '/', runtimeTarget);
239
+ target.protocol = runtimeTarget.protocol;
240
+ target.hostname = runtimeTarget.hostname;
241
+ target.port = runtimeTarget.port;
242
+
243
+ const method = req.method || 'GET';
244
+ const requestBody = method === 'GET' || method === 'HEAD' ? undefined : await readRequestBody(req);
245
+ const requestHeaders = new Headers();
246
+ for (const [key, value] of Object.entries(req.headers)) {
247
+ if (value === undefined) continue;
248
+ if (['host', 'content-length', 'connection', 'expect'].includes(key.toLowerCase())) continue;
249
+ requestHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
250
+ }
251
+
252
+ let upstream;
253
+ try {
254
+ upstream = await fetch(target, {
255
+ method,
256
+ headers: requestHeaders,
257
+ body: requestBody,
258
+ });
259
+
260
+ const responseHeaders = {};
261
+ for (const [key, value] of upstream.headers.entries()) {
262
+ if (['connection', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) continue;
263
+ responseHeaders[key] = value;
264
+ }
265
+ res.writeHead(upstream.status, responseHeaders);
266
+ if (!upstream.body) {
267
+ res.end();
268
+ return;
269
+ }
270
+
271
+ res.on('close', () => {
272
+ upstream.body?.cancel().catch(() => {});
273
+ });
274
+
275
+ for await (const chunk of upstream.body) {
276
+ if (res.destroyed) break;
277
+ if (!res.write(chunk) && !await waitForDrainOrClose(res)) break;
278
+ }
279
+ if (!res.writableEnded) res.end();
280
+ } catch (error) {
281
+ const isTerminatedSocket =
282
+ error?.name === 'TypeError' &&
283
+ error?.message === 'terminated' &&
284
+ error?.cause?.code === 'UND_ERR_SOCKET';
285
+ if (error?.name === 'AbortError' || isTerminatedSocket || res.destroyed) return;
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ function waitForDrainOrClose(res) {
291
+ if (res.destroyed) return Promise.resolve(false);
292
+ return new Promise((resolve) => {
293
+ const cleanup = () => {
294
+ res.off('drain', onDrain);
295
+ res.off('close', onClose);
296
+ res.off('error', onClose);
297
+ };
298
+ const onDrain = () => {
299
+ cleanup();
300
+ resolve(true);
301
+ };
302
+ const onClose = () => {
303
+ cleanup();
304
+ resolve(false);
305
+ };
306
+ res.once('drain', onDrain);
307
+ res.once('close', onClose);
308
+ res.once('error', onClose);
309
+ });
310
+ }
311
+
312
+ async function serveStatic(res, url) {
313
+ let pathname = decodeURIComponent(url.pathname);
314
+ if (pathname === '/') pathname = '/index.html';
315
+ let filePath = path.resolve(root, `.${pathname}`);
316
+ if (!filePath.startsWith(root + path.sep) && filePath !== root) {
317
+ res.writeHead(403);
318
+ res.end('forbidden');
319
+ return;
320
+ }
321
+ try {
322
+ const stat = await fsp.stat(filePath);
323
+ if (stat.isDirectory()) filePath = path.join(filePath, 'index.html');
324
+ } catch {
325
+ filePath = path.join(root, 'index.html');
326
+ }
327
+
328
+ try {
329
+ const body = await fsp.readFile(filePath);
330
+ const ext = path.extname(filePath).toLowerCase();
331
+ const cache = filePath.includes(`${path.sep}assets${path.sep}`) ? 'public, max-age=31536000, immutable' : 'no-store';
332
+ res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Cache-Control': cache });
333
+ res.end(body);
334
+ } catch {
335
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
336
+ res.end('not found');
337
+ }
338
+ }
339
+
340
+ async function readPromptLibrary() {
341
+ try {
342
+ const raw = await fsp.readFile(promptLibraryFile, 'utf8');
343
+ const parsed = JSON.parse(raw);
344
+ if (!Array.isArray(parsed)) throw new Error('prompt library file must contain an array');
345
+ return parsed.map(normalizePromptItem).filter(Boolean);
346
+ } catch (error) {
347
+ if (error?.code !== 'ENOENT') throw error;
348
+ const items = DEFAULT_APP_PROMPT_LIBRARY.map(clonePromptItem);
349
+ await writePromptLibrary(items);
350
+ return items;
351
+ }
352
+ }
353
+
354
+ async function writePromptLibrary(items) {
355
+ await fsp.mkdir(path.dirname(promptLibraryFile), { recursive: true });
356
+ await fsp.writeFile(promptLibraryFile, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
357
+ }
358
+
359
+ function normalizePromptItem(item) {
360
+ if (!item || typeof item !== 'object') return null;
361
+ const title = String(item.title || '').trim();
362
+ const content = String(item.content || '').trim();
363
+ if (!title || !content) return null;
364
+ return {
365
+ id: String(item.id || createPromptId()).trim(),
366
+ title,
367
+ content,
368
+ usage: String(item.usage || '').trim(),
369
+ };
370
+ }
371
+
372
+ function clonePromptItem(item) {
373
+ return { ...item };
374
+ }
375
+
376
+ function createPromptId() {
377
+ return `prompt-${Math.random().toString(36).slice(2, 10)}`;
378
+ }
379
+
380
+ async function storeUploadedFile(payload) {
381
+ const name = sanitizeUploadName(payload?.name);
382
+ if (!name) throw new Error('invalid upload name');
383
+ const data = String(payload?.data || '').trim();
384
+ if (!data) throw new Error('missing upload data');
385
+ const buffer = Buffer.from(data, 'base64');
386
+ if (!buffer.length) throw new Error('empty upload data');
387
+ if (buffer.length > maxUploadBytes) throw new Error(`upload too large: max ${maxUploadBytes} bytes`);
388
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
389
+ const random = Math.random().toString(36).slice(2, 8);
390
+ const storedName = `${stamp}-${random}-${name}`;
391
+ await fsp.mkdir(uploadsDir, { recursive: true });
392
+ const absolutePath = path.join(uploadsDir, storedName);
393
+ await fsp.writeFile(absolutePath, buffer);
394
+ return {
395
+ id: `upload-${random}`,
396
+ name,
397
+ storedName,
398
+ size: buffer.length,
399
+ mimeType: normalizeMimeType(payload?.mimeType),
400
+ absolutePath,
401
+ relativePath: path.relative(__dirname, absolutePath) || storedName,
402
+ url: `/api/uploads/${encodeURIComponent(storedName)}`,
403
+ };
404
+ }
405
+
406
+ async function serveUploadedFile(res, storedName) {
407
+ const safeName = path.basename(String(storedName || '').trim());
408
+ if (!safeName || safeName !== storedName) {
409
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
410
+ res.end('invalid upload name');
411
+ return;
412
+ }
413
+ const uploadRoot = path.resolve(uploadsDir);
414
+ const filePath = path.resolve(uploadRoot, safeName);
415
+ if (!filePath.startsWith(uploadRoot + path.sep)) {
416
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
417
+ res.end('forbidden');
418
+ return;
419
+ }
420
+ try {
421
+ const body = await fsp.readFile(filePath);
422
+ res.writeHead(200, {
423
+ 'Content-Type': mime[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
424
+ 'Cache-Control': 'public, max-age=31536000, immutable',
425
+ });
426
+ res.end(body);
427
+ } catch {
428
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
429
+ res.end('not found');
430
+ }
431
+ }
432
+
433
+ async function serveLocalImage(res, encodedPath) {
434
+ let filePath = '';
435
+ try {
436
+ filePath = Buffer.from(encodedPath, 'base64url').toString('utf8');
437
+ } catch {
438
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
439
+ res.end('invalid image path');
440
+ return;
441
+ }
442
+ const absolutePath = path.resolve(filePath);
443
+ const contentType = mime[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream';
444
+ if (!contentType.startsWith('image/')) {
445
+ res.writeHead(415, { 'Content-Type': 'text/plain; charset=utf-8' });
446
+ res.end('not an image');
447
+ return;
448
+ }
449
+ try {
450
+ const fileStat = await fsp.stat(absolutePath);
451
+ if (!fileStat.isFile()) throw new Error('not a file');
452
+ res.writeHead(200, {
453
+ 'Content-Type': contentType,
454
+ 'Content-Length': String(fileStat.size),
455
+ 'Cache-Control': 'no-store',
456
+ });
457
+ fs.createReadStream(absolutePath)
458
+ .on('error', () => res.destroy())
459
+ .pipe(res);
460
+ } catch {
461
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
462
+ res.end('not found');
463
+ }
464
+ }
465
+
466
+ function sanitizeUploadName(value) {
467
+ const base = path.basename(String(value || '').trim()).replace(/[<>:"/\\|?*\u0000-\u001f]+/g, '-');
468
+ return base.replace(/\s+/g, ' ').trim().slice(0, 180);
469
+ }
470
+
471
+ function normalizeMimeType(value) {
472
+ const mimeType = String(value || '').trim();
473
+ return mimeType || 'application/octet-stream';
474
+ }
475
+
476
+ async function readJsonBody(req) {
477
+ const body = await readRequestBody(req);
478
+ if (!body.length) return {};
479
+ return JSON.parse(body.toString('utf8'));
480
+ }
481
+
482
+ async function readRequestBody(req) {
483
+ const chunks = [];
484
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
485
+ return Buffer.concat(chunks);
486
+ }
487
+
488
+ function sendJson(res, value, status = 200) {
489
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
490
+ res.end(JSON.stringify(value));
491
+ }