channel-worker 2.2.5 → 2.3.0

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Local HTTP cache server.
3
+ *
4
+ * Extensions running in Nstbrowser profiles share this single daemon-side
5
+ * cache instead of each downloading the same S3 ref (~1MB master cast)
6
+ * separately. First request misses, fetches via API; subsequent requests
7
+ * across ALL profiles on this machine hit the in-memory cache.
8
+ *
9
+ * Endpoint: GET /cache/s3?file_key=<key>
10
+ * → mirrors the channel-manager API /extension/resolve-s3-url response:
11
+ * { success, data: { base64, content_type, size } }
12
+ */
13
+
14
+ const http = require('http');
15
+ const url = require('url');
16
+
17
+ class CacheServer {
18
+ constructor(api, options = {}) {
19
+ this.api = api;
20
+ this.port = options.port || 8849;
21
+ this.host = options.host || '127.0.0.1';
22
+ this.maxEntries = options.maxEntries || 32; // ~32 × 1MB ≈ 32MB worst case
23
+ this.cache = new Map(); // file_key → { base64, contentType, size, ts }
24
+ this.server = null;
25
+ this.stats = { hits: 0, misses: 0, errors: 0 };
26
+ }
27
+
28
+ _evictIfNeeded() {
29
+ while (this.cache.size > this.maxEntries) {
30
+ // Drop oldest entry (Map preserves insertion order)
31
+ const oldestKey = this.cache.keys().next().value;
32
+ this.cache.delete(oldestKey);
33
+ }
34
+ }
35
+
36
+ async _resolveS3(fileKey) {
37
+ if (this.cache.has(fileKey)) {
38
+ this.stats.hits++;
39
+ // Touch — re-insert to mark as MRU
40
+ const entry = this.cache.get(fileKey);
41
+ this.cache.delete(fileKey);
42
+ this.cache.set(fileKey, entry);
43
+ return { ok: true, ...entry };
44
+ }
45
+ this.stats.misses++;
46
+ // Fetch via API client (extension/resolve-s3-url is POST)
47
+ const baseUrl = this.api.baseUrl;
48
+ const res = await fetch(`${baseUrl}/extension/resolve-s3-url`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json', 'x-worker-token': this.api.workerToken },
51
+ body: JSON.stringify({ file_key: fileKey, as_base64: true }),
52
+ });
53
+ const json = await res.json();
54
+ if (!json.success || !json.data?.base64) {
55
+ this.stats.errors++;
56
+ return { ok: false, error: json.message || 'resolve failed' };
57
+ }
58
+ const entry = {
59
+ base64: json.data.base64,
60
+ contentType: json.data.content_type || 'image/png',
61
+ size: json.data.size || json.data.base64.length,
62
+ ts: Date.now(),
63
+ };
64
+ this.cache.set(fileKey, entry);
65
+ this._evictIfNeeded();
66
+ return { ok: true, ...entry };
67
+ }
68
+
69
+ _handleRequest(req, res) {
70
+ const parsed = url.parse(req.url, true);
71
+ if (req.method === 'GET' && parsed.pathname === '/health') {
72
+ res.writeHead(200, { 'Content-Type': 'application/json' });
73
+ res.end(JSON.stringify({ ok: true, cache: this.cache.size, stats: this.stats }));
74
+ return;
75
+ }
76
+ if (req.method === 'GET' && parsed.pathname === '/cache/s3') {
77
+ const fileKey = parsed.query.file_key;
78
+ if (!fileKey) {
79
+ res.writeHead(400, { 'Content-Type': 'application/json' });
80
+ res.end(JSON.stringify({ success: false, message: 'file_key required' }));
81
+ return;
82
+ }
83
+ this._resolveS3(fileKey).then(result => {
84
+ if (!result.ok) {
85
+ res.writeHead(502, { 'Content-Type': 'application/json' });
86
+ res.end(JSON.stringify({ success: false, message: result.error }));
87
+ return;
88
+ }
89
+ // CORS — extensions may treat localhost as cross-origin
90
+ res.writeHead(200, {
91
+ 'Content-Type': 'application/json',
92
+ 'Access-Control-Allow-Origin': '*',
93
+ 'Cache-Control': 'no-store',
94
+ });
95
+ res.end(JSON.stringify({
96
+ success: true,
97
+ data: {
98
+ base64: result.base64,
99
+ content_type: result.contentType,
100
+ size: result.size,
101
+ },
102
+ }));
103
+ }).catch(err => {
104
+ res.writeHead(500, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ success: false, message: err.message }));
106
+ });
107
+ return;
108
+ }
109
+ res.writeHead(404, { 'Content-Type': 'application/json' });
110
+ res.end(JSON.stringify({ success: false, message: 'not found' }));
111
+ }
112
+
113
+ start() {
114
+ return new Promise((resolve, reject) => {
115
+ this.server = http.createServer((req, res) => this._handleRequest(req, res));
116
+ this.server.on('error', (err) => {
117
+ if (err.code === 'EADDRINUSE') {
118
+ console.error(`[cache-server] Port ${this.port} is already in use. ` +
119
+ `Another daemon may be running, or a previous instance crashed and the OS hasn't released the port yet ` +
120
+ `(usually clears in 30-60s). On Windows: 'netstat -ano | findstr ${this.port}' then 'taskkill /PID <pid> /F'. ` +
121
+ `On Mac/Linux: 'lsof -ti:${this.port} | xargs kill -9'.`);
122
+ }
123
+ reject(err);
124
+ });
125
+ this.server.listen(this.port, this.host, () => {
126
+ console.log(`[cache-server] Listening on http://${this.host}:${this.port} (max ${this.maxEntries} entries)`);
127
+ resolve();
128
+ });
129
+ });
130
+ }
131
+
132
+ stop() {
133
+ if (this.server) {
134
+ this.server.close();
135
+ this.server = null;
136
+ }
137
+ }
138
+
139
+ url() {
140
+ return `http://${this.host}:${this.port}`;
141
+ }
142
+ }
143
+
144
+ module.exports = { CacheServer };
@@ -146,6 +146,7 @@ class CommandPoller {
146
146
  channelManagerApi: 'https://api.channel.tunasm.art',
147
147
  profileId: profile_id,
148
148
  workerToken: this.config.worker_token || '',
149
+ daemonUrl: this.config.daemon_url || '',
149
150
  }));
150
151
  extensionPath = uniqueExtPath;
151
152
  console.log(`[commands] Extension dir: ${uniqueExtPath}`);
@@ -222,6 +223,7 @@ class CommandPoller {
222
223
  profileId: nst_profile_id,
223
224
  workerToken: this.config.worker_token || '',
224
225
  workerType: 'veo3',
226
+ daemonUrl: this.config.daemon_url || '',
225
227
  }));
226
228
  extensionPath = uniqueExtPath;
227
229
  console.log(`[commands] Veo3 ext dir: ${uniqueExtPath}`);
@@ -1323,17 +1325,9 @@ class CommandPoller {
1323
1325
  const lastActivity = this._profileLastActivity[profileId]
1324
1326
  || (name && this._profileLastActivity[name])
1325
1327
  || 0;
1326
- // If not tracked: check if it has commands if not, close it (orphaned profile)
1327
- if (!lastActivity) {
1328
- try {
1329
- const cmdCount = name ? await this.api.rendererHasCommands(name) : 0;
1330
- if (cmdCount > 0) continue; // has work, skip
1331
- } catch {}
1332
- // No commands + not tracked = orphaned, close
1333
- console.log(`[profile-timeout] Closing orphaned profile ${browser.name || profileId} (not tracked, no commands)`);
1334
- try { await this.nst.stopProfile(profileId); } catch {}
1335
- continue;
1336
- }
1328
+ // Only close profiles that daemon has launched (tracked in _profileLastActivity).
1329
+ // User-launched profiles have no tracking entry — leave them alone.
1330
+ if (!lastActivity) continue;
1337
1331
  if ((now - lastActivity) > IDLE_TIMEOUT) {
1338
1332
  console.log(`[profile-timeout] Closing idle profile ${browser.name || profileId} (idle ${Math.round((now - lastActivity) / 1000)}s)`);
1339
1333
  try {
package/lib/daemon.js CHANGED
@@ -4,6 +4,7 @@ const { JobPoller } = require('./job-poller');
4
4
  const { CommandPoller } = require('./command-poller');
5
5
  const { UpdateChecker, getLocalVersion } = require('./updater');
6
6
  const { checkAndUpdateExtension } = require('./extension-updater');
7
+ const { CacheServer } = require('./cache-server');
7
8
 
8
9
  class Daemon {
9
10
  constructor(config) {
@@ -11,6 +12,9 @@ class Daemon {
11
12
  this.api = new ApiClient(config.api_url, config.worker_token);
12
13
  this.heartbeat = new Heartbeat(this.api, config.worker_id, 30000, config);
13
14
  this.poller = new JobPoller(this.api, config);
15
+ this.cacheServer = new CacheServer(this.api, { port: config.cache_port || 8849 });
16
+ // Pass daemon URL down so command-poller can write it into per-profile config
17
+ config.daemon_url = this.cacheServer.url();
14
18
  this.commandPoller = new CommandPoller(this.api, config);
15
19
  this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
16
20
  this.extCheckTimer = null;
@@ -28,6 +32,13 @@ class Daemon {
28
32
  ╚══════════════════════════════════════╝
29
33
  `);
30
34
 
35
+ // Start local cache server (extension shares this across all profiles)
36
+ try {
37
+ await this.cacheServer.start();
38
+ } catch (err) {
39
+ console.warn(`[daemon] Cache server failed to start (port in use?): ${err.message}`);
40
+ }
41
+
31
42
  // Register with dashboard
32
43
  try {
33
44
  await this.api.register({
@@ -98,6 +109,7 @@ class Daemon {
98
109
  this.commandPoller.stop();
99
110
  this.updateChecker.stop();
100
111
  if (this.extCheckTimer) clearInterval(this.extCheckTimer);
112
+ try { this.cacheServer.stop(); } catch {}
101
113
 
102
114
  // Mark offline
103
115
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.2.5",
3
+ "version": "2.3.0",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {