channel-worker 2.2.6 → 2.3.1

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,196 @@
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
+ const { execSync } = require('child_process');
17
+
18
+ class CacheServer {
19
+ constructor(api, options = {}) {
20
+ this.api = api;
21
+ this.port = options.port || 8849;
22
+ this.host = options.host || '127.0.0.1';
23
+ this.maxEntries = options.maxEntries || 32; // ~32 × 1MB ≈ 32MB worst case
24
+ this.cache = new Map(); // file_key → { base64, contentType, size, ts }
25
+ this.server = null;
26
+ this.stats = { hits: 0, misses: 0, errors: 0 };
27
+ }
28
+
29
+ _evictIfNeeded() {
30
+ while (this.cache.size > this.maxEntries) {
31
+ // Drop oldest entry (Map preserves insertion order)
32
+ const oldestKey = this.cache.keys().next().value;
33
+ this.cache.delete(oldestKey);
34
+ }
35
+ }
36
+
37
+ async _resolveS3(fileKey) {
38
+ if (this.cache.has(fileKey)) {
39
+ this.stats.hits++;
40
+ // Touch — re-insert to mark as MRU
41
+ const entry = this.cache.get(fileKey);
42
+ this.cache.delete(fileKey);
43
+ this.cache.set(fileKey, entry);
44
+ return { ok: true, ...entry };
45
+ }
46
+ this.stats.misses++;
47
+ // Fetch via API client (extension/resolve-s3-url is POST)
48
+ const baseUrl = this.api.baseUrl;
49
+ const res = await fetch(`${baseUrl}/extension/resolve-s3-url`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json', 'x-worker-token': this.api.workerToken },
52
+ body: JSON.stringify({ file_key: fileKey, as_base64: true }),
53
+ });
54
+ const json = await res.json();
55
+ if (!json.success || !json.data?.base64) {
56
+ this.stats.errors++;
57
+ return { ok: false, error: json.message || 'resolve failed' };
58
+ }
59
+ const entry = {
60
+ base64: json.data.base64,
61
+ contentType: json.data.content_type || 'image/png',
62
+ size: json.data.size || json.data.base64.length,
63
+ ts: Date.now(),
64
+ };
65
+ this.cache.set(fileKey, entry);
66
+ this._evictIfNeeded();
67
+ return { ok: true, ...entry };
68
+ }
69
+
70
+ _handleRequest(req, res) {
71
+ const parsed = url.parse(req.url, true);
72
+ if (req.method === 'GET' && parsed.pathname === '/health') {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ res.end(JSON.stringify({ ok: true, cache: this.cache.size, stats: this.stats }));
75
+ return;
76
+ }
77
+ if (req.method === 'GET' && parsed.pathname === '/cache/s3') {
78
+ const fileKey = parsed.query.file_key;
79
+ if (!fileKey) {
80
+ res.writeHead(400, { 'Content-Type': 'application/json' });
81
+ res.end(JSON.stringify({ success: false, message: 'file_key required' }));
82
+ return;
83
+ }
84
+ this._resolveS3(fileKey).then(result => {
85
+ if (!result.ok) {
86
+ res.writeHead(502, { 'Content-Type': 'application/json' });
87
+ res.end(JSON.stringify({ success: false, message: result.error }));
88
+ return;
89
+ }
90
+ // CORS — extensions may treat localhost as cross-origin
91
+ res.writeHead(200, {
92
+ 'Content-Type': 'application/json',
93
+ 'Access-Control-Allow-Origin': '*',
94
+ 'Cache-Control': 'no-store',
95
+ });
96
+ res.end(JSON.stringify({
97
+ success: true,
98
+ data: {
99
+ base64: result.base64,
100
+ content_type: result.contentType,
101
+ size: result.size,
102
+ },
103
+ }));
104
+ }).catch(err => {
105
+ res.writeHead(500, { 'Content-Type': 'application/json' });
106
+ res.end(JSON.stringify({ success: false, message: err.message }));
107
+ });
108
+ return;
109
+ }
110
+ res.writeHead(404, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({ success: false, message: 'not found' }));
112
+ }
113
+
114
+ // Find PID(s) holding our port and kill them. Cross-platform best-effort.
115
+ // Used when bind fails with EADDRINUSE — typically a leaked previous daemon.
116
+ _killPortHolder() {
117
+ try {
118
+ if (process.platform === 'win32') {
119
+ // netstat columns: Proto Local Foreign State PID
120
+ const out = execSync(`netstat -ano -p TCP | findstr :${this.port} | findstr LISTENING`,
121
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
122
+ const pids = new Set();
123
+ for (const line of out.split('\n')) {
124
+ const m = line.trim().match(/\s(\d+)\s*$/);
125
+ if (m) pids.add(m[1]);
126
+ }
127
+ for (const pid of pids) {
128
+ if (Number(pid) === process.pid) continue;
129
+ console.log(`[cache-server] Killing leaked PID ${pid} holding port ${this.port}`);
130
+ try { execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' }); } catch {}
131
+ }
132
+ return pids.size;
133
+ }
134
+ // mac/linux
135
+ const out = execSync(`lsof -ti:${this.port} -sTCP:LISTEN`,
136
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
137
+ const pids = out.split('\n').map(s => s.trim()).filter(Boolean);
138
+ for (const pid of pids) {
139
+ if (Number(pid) === process.pid) continue;
140
+ console.log(`[cache-server] Killing leaked PID ${pid} holding port ${this.port}`);
141
+ try { execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); } catch {}
142
+ }
143
+ return pids.length;
144
+ } catch {
145
+ return 0;
146
+ }
147
+ }
148
+
149
+ _bindOnce() {
150
+ return new Promise((resolve, reject) => {
151
+ const server = http.createServer((req, res) => this._handleRequest(req, res));
152
+ server.on('error', (err) => reject(err));
153
+ server.listen(this.port, this.host, () => {
154
+ this.server = server;
155
+ resolve();
156
+ });
157
+ });
158
+ }
159
+
160
+ async start() {
161
+ try {
162
+ await this._bindOnce();
163
+ } catch (err) {
164
+ if (err.code !== 'EADDRINUSE') throw err;
165
+ console.warn(`[cache-server] Port ${this.port} busy — trying to kill leaked holder...`);
166
+ const killed = this._killPortHolder();
167
+ if (killed === 0) {
168
+ console.error(`[cache-server] No leaked process found for port ${this.port}. ` +
169
+ `Maybe TIME_WAIT — wait 30-60s and retry.`);
170
+ throw err;
171
+ }
172
+ // Wait for OS to release the port after kill, then retry once
173
+ await new Promise(r => setTimeout(r, 500));
174
+ try {
175
+ await this._bindOnce();
176
+ } catch (err2) {
177
+ console.error(`[cache-server] Still cannot bind ${this.port} after killing leaked PIDs.`);
178
+ throw err2;
179
+ }
180
+ }
181
+ console.log(`[cache-server] Listening on http://${this.host}:${this.port} (max ${this.maxEntries} entries)`);
182
+ }
183
+
184
+ stop() {
185
+ if (this.server) {
186
+ this.server.close();
187
+ this.server = null;
188
+ }
189
+ }
190
+
191
+ url() {
192
+ return `http://${this.host}:${this.port}`;
193
+ }
194
+ }
195
+
196
+ 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}`);
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.6",
3
+ "version": "2.3.1",
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": {