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.
- package/lib/cache-server.js +196 -0
- package/lib/command-poller.js +2 -0
- package/lib/daemon.js +12 -0
- package/package.json +1 -1
|
@@ -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 };
|
package/lib/command-poller.js
CHANGED
|
@@ -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 {
|