unitup 0.2.0 → 0.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.
- package/.agents/AGENTS.md +35 -0
- package/.agents/skills/multi-runtime-management/SKILL.md +80 -0
- package/.agents/skills/platform-service-adapters/SKILL.md +108 -0
- package/.agents/skills/unitup-development-and-qa/SKILL.md +88 -0
- package/.agents/skills/zero-downtime-deployment/SKILL.md +135 -0
- package/index.d.ts +136 -0
- package/package.json +1 -1
- package/src/cli.js +374 -2
- package/src/core/drain-manager.js +70 -0
- package/src/core/generation-manager.js +414 -0
- package/src/core/index.js +8 -0
- package/src/core/ipc.js +236 -0
- package/src/core/lock.js +133 -0
- package/src/core/process-manager.js +313 -0
- package/src/core/readiness.js +243 -0
- package/src/core/router.js +390 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +407 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +186 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows.js +4 -1
- package/src/service/config.js +8 -0
- package/src/service/normalize.js +30 -1
- package/src/unit.js +5 -1
- package/src/utils.js +80 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getUnitupDir, sanitizeServiceName } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finds an available TCP port on 127.0.0.1.
|
|
8
|
+
* @returns {Promise<number>}
|
|
9
|
+
*/
|
|
10
|
+
export function findFreePort() {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const srv = net.createServer();
|
|
13
|
+
srv.unref();
|
|
14
|
+
srv.on('error', reject);
|
|
15
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
16
|
+
const port = srv.address().port;
|
|
17
|
+
srv.close(() => resolve(port));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GenerationManager manages the runtime instances (generations) and persistent state
|
|
24
|
+
* of Unitup-managed services.
|
|
25
|
+
*/
|
|
26
|
+
export class GenerationManager {
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {import('./process-manager.js').ProcessManager} options.processManager
|
|
30
|
+
* @param {string} [options.stateDir]
|
|
31
|
+
*/
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.processManager = options.processManager;
|
|
34
|
+
this.stateDir = options.stateDir || path.join(getUnitupDir(), 'state');
|
|
35
|
+
this.cache = new Map(); // serviceName -> state object
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the path to the state file for a service.
|
|
40
|
+
* @param {string} serviceName
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
getStateFilePath(serviceName) {
|
|
44
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
45
|
+
return path.join(this.stateDir, `${safeName}.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Loads state from disk or returns default structure.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} serviceName
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
loadState(serviceName) {
|
|
55
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
56
|
+
if (this.cache.has(safeName)) {
|
|
57
|
+
return this.cache.get(safeName);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const filePath = this.getStateFilePath(safeName);
|
|
61
|
+
let state = {
|
|
62
|
+
service: safeName,
|
|
63
|
+
activeGeneration: null,
|
|
64
|
+
generations: {}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (fs.existsSync(filePath)) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (parsed && typeof parsed === 'object') {
|
|
72
|
+
state = {
|
|
73
|
+
service: safeName,
|
|
74
|
+
activeGeneration: parsed.activeGeneration ?? null,
|
|
75
|
+
generations: parsed.generations && typeof parsed.generations === 'object' ? parsed.generations : {}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Fallback to empty state on read error
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.cache.set(safeName, state);
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Persists state atomically to disk.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} serviceName
|
|
91
|
+
* @param {object} [stateToSave]
|
|
92
|
+
*/
|
|
93
|
+
saveState(serviceName, stateToSave) {
|
|
94
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
95
|
+
const state = stateToSave || this.loadState(safeName);
|
|
96
|
+
this.cache.set(safeName, state);
|
|
97
|
+
|
|
98
|
+
if (!fs.existsSync(this.stateDir)) {
|
|
99
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const filePath = this.getStateFilePath(safeName);
|
|
103
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
104
|
+
|
|
105
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
|
|
106
|
+
fs.renameSync(tmpPath, filePath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates and starts a new generation for a service.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} serviceName
|
|
113
|
+
* @param {object} config - Service configuration or launch options
|
|
114
|
+
* @param {string} config.command
|
|
115
|
+
* @param {string[]} [config.args]
|
|
116
|
+
* @param {string} [config.cwd]
|
|
117
|
+
* @param {object} [config.env]
|
|
118
|
+
* @param {object} [options]
|
|
119
|
+
* @param {number} [options.port] - Optional specific internal port
|
|
120
|
+
* @returns {Promise<object>} The new Generation object
|
|
121
|
+
*/
|
|
122
|
+
async create(serviceName, config = {}, options = {}) {
|
|
123
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
124
|
+
const state = this.loadState(safeName);
|
|
125
|
+
|
|
126
|
+
// Compute next generation ID
|
|
127
|
+
const existingIds = Object.keys(state.generations)
|
|
128
|
+
.map(Number)
|
|
129
|
+
.filter((n) => !Number.isNaN(n));
|
|
130
|
+
const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
|
|
131
|
+
|
|
132
|
+
// Allocate internal port
|
|
133
|
+
const internalPort = options.port || (await findFreePort());
|
|
134
|
+
|
|
135
|
+
// Prepare child environment with PORT and standard port aliases set to internalPort
|
|
136
|
+
const env = {
|
|
137
|
+
...(config.env || {}),
|
|
138
|
+
PORT: String(internalPort),
|
|
139
|
+
NODE_PORT: String(internalPort),
|
|
140
|
+
HTTP_PORT: String(internalPort),
|
|
141
|
+
APP_PORT: String(internalPort),
|
|
142
|
+
SERVER_PORT: String(internalPort),
|
|
143
|
+
UNITUP_INTERNAL_PORT: String(internalPort),
|
|
144
|
+
UNITUP_PUBLIC_PORT: String(config.port || options.publicPort || ''),
|
|
145
|
+
UNITUP_GENERATION_ID: String(nextId),
|
|
146
|
+
UNITUP_SERVICE_NAME: safeName
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let stdoutPath = config.logs?.stdout;
|
|
150
|
+
let stderrPath = config.logs?.stderr;
|
|
151
|
+
if (stdoutPath && stdoutPath.includes('[generation]')) {
|
|
152
|
+
stdoutPath = stdoutPath.replace('[generation]', String(nextId));
|
|
153
|
+
}
|
|
154
|
+
if (stderrPath && stderrPath.includes('[generation]')) {
|
|
155
|
+
stderrPath = stderrPath.replace('[generation]', String(nextId));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const proc = this.processManager.start(config.command, config.args || [], {
|
|
159
|
+
cwd: config.cwd,
|
|
160
|
+
env,
|
|
161
|
+
stdoutPath,
|
|
162
|
+
stderrPath
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const generation = {
|
|
166
|
+
id: nextId,
|
|
167
|
+
service: safeName,
|
|
168
|
+
pid: proc.pid,
|
|
169
|
+
internalPort,
|
|
170
|
+
status: 'starting',
|
|
171
|
+
createdAt: new Date().toISOString()
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
state.generations[String(nextId)] = generation;
|
|
175
|
+
this.saveState(safeName, state);
|
|
176
|
+
|
|
177
|
+
return generation;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Gets the currently active generation for a service.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} serviceName
|
|
184
|
+
* @returns {object|null}
|
|
185
|
+
*/
|
|
186
|
+
getActive(serviceName) {
|
|
187
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
188
|
+
const state = this.loadState(safeName);
|
|
189
|
+
if (!state.activeGeneration) return null;
|
|
190
|
+
|
|
191
|
+
const gen = state.generations[String(state.activeGeneration)];
|
|
192
|
+
if (!gen || gen.status !== 'active') return null;
|
|
193
|
+
return gen;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Gets the previous generation (e.g. for rollback).
|
|
198
|
+
*
|
|
199
|
+
* @param {string} serviceName
|
|
200
|
+
* @returns {object|null}
|
|
201
|
+
*/
|
|
202
|
+
getPrevious(serviceName) {
|
|
203
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
204
|
+
const state = this.loadState(safeName);
|
|
205
|
+
|
|
206
|
+
// Look for generation with status 'previous'
|
|
207
|
+
for (const gen of Object.values(state.generations)) {
|
|
208
|
+
if (gen.status === 'previous') {
|
|
209
|
+
return gen;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Fallback: highest ID that is not current activeGeneration
|
|
214
|
+
const candidates = Object.values(state.generations)
|
|
215
|
+
.filter((g) => g.id !== state.activeGeneration && g.status !== 'stopped')
|
|
216
|
+
.sort((a, b) => b.id - a.id);
|
|
217
|
+
|
|
218
|
+
return candidates[0] || null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Gets a specific generation by ID.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} serviceName
|
|
225
|
+
* @param {number|string} id
|
|
226
|
+
* @returns {object|null}
|
|
227
|
+
*/
|
|
228
|
+
getGeneration(serviceName, id) {
|
|
229
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
230
|
+
const state = this.loadState(safeName);
|
|
231
|
+
return state.generations[String(id)] || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Marks a generation as active and demotes existing active generation to 'previous'.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} generation
|
|
238
|
+
*/
|
|
239
|
+
markActive(generation) {
|
|
240
|
+
if (!generation || !generation.service) return;
|
|
241
|
+
const safeName = sanitizeServiceName(generation.service);
|
|
242
|
+
const state = this.loadState(safeName);
|
|
243
|
+
|
|
244
|
+
if (state.activeGeneration && state.activeGeneration !== generation.id) {
|
|
245
|
+
const prev = state.generations[String(state.activeGeneration)];
|
|
246
|
+
if (prev && prev.status === 'active') {
|
|
247
|
+
prev.status = 'previous';
|
|
248
|
+
prev.deactivatedAt = new Date().toISOString();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (state.canaryGeneration === generation.id) {
|
|
253
|
+
state.canaryGeneration = null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let current = state.generations[String(generation.id)];
|
|
257
|
+
if (!current) {
|
|
258
|
+
current = { ...generation };
|
|
259
|
+
state.generations[String(generation.id)] = current;
|
|
260
|
+
}
|
|
261
|
+
current.status = 'active';
|
|
262
|
+
current.activatedAt = new Date().toISOString();
|
|
263
|
+
state.activeGeneration = generation.id;
|
|
264
|
+
this.saveState(safeName, state);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Marks a generation as canary with a traffic weight.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} generation
|
|
271
|
+
* @param {number|string} [weight=0.1]
|
|
272
|
+
*/
|
|
273
|
+
markCanary(generation, weight = 0.1) {
|
|
274
|
+
if (!generation || !generation.service) return;
|
|
275
|
+
const safeName = sanitizeServiceName(generation.service);
|
|
276
|
+
const state = this.loadState(safeName);
|
|
277
|
+
|
|
278
|
+
let current = state.generations[String(generation.id)];
|
|
279
|
+
if (!current) {
|
|
280
|
+
current = { ...generation };
|
|
281
|
+
state.generations[String(generation.id)] = current;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parsed = typeof weight === 'string' ? Number.parseFloat(weight.replace('%', '')) : Number(weight);
|
|
285
|
+
const num = Number.isNaN(parsed) ? 0.1 : parsed;
|
|
286
|
+
const normalizedWeight = num > 1 ? num / 100 : num;
|
|
287
|
+
|
|
288
|
+
current.status = 'canary';
|
|
289
|
+
current.canaryWeight = Math.max(0, Math.min(1, normalizedWeight));
|
|
290
|
+
current.canaryAt = new Date().toISOString();
|
|
291
|
+
state.canaryGeneration = generation.id;
|
|
292
|
+
this.saveState(safeName, state);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Gets the active canary generation for a service, if any.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} serviceName
|
|
299
|
+
* @returns {object|null}
|
|
300
|
+
*/
|
|
301
|
+
getCanary(serviceName) {
|
|
302
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
303
|
+
const state = this.loadState(safeName);
|
|
304
|
+
if (!state.canaryGeneration) {
|
|
305
|
+
for (const g of Object.values(state.generations)) {
|
|
306
|
+
if (g.status === 'canary') return g;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const gen = state.generations[String(state.canaryGeneration)];
|
|
311
|
+
return gen && gen.status === 'canary' ? gen : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Marks a generation as draining.
|
|
316
|
+
*
|
|
317
|
+
* @param {object} generation
|
|
318
|
+
*/
|
|
319
|
+
markDraining(generation) {
|
|
320
|
+
if (!generation || !generation.service) return;
|
|
321
|
+
const safeName = sanitizeServiceName(generation.service);
|
|
322
|
+
const state = this.loadState(safeName);
|
|
323
|
+
|
|
324
|
+
const current = state.generations[String(generation.id)];
|
|
325
|
+
if (current) {
|
|
326
|
+
current.status = 'draining';
|
|
327
|
+
current.drainingAt = new Date().toISOString();
|
|
328
|
+
if (!current.deactivatedAt && current.activatedAt) {
|
|
329
|
+
current.deactivatedAt = current.drainingAt;
|
|
330
|
+
}
|
|
331
|
+
this.saveState(safeName, state);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Removes or marks stopped an old generation.
|
|
337
|
+
*
|
|
338
|
+
* @param {object} generation
|
|
339
|
+
* @param {boolean} [purge=false]
|
|
340
|
+
*/
|
|
341
|
+
remove(generation, purge = false) {
|
|
342
|
+
if (!generation || !generation.service) return;
|
|
343
|
+
const safeName = sanitizeServiceName(generation.service);
|
|
344
|
+
const state = this.loadState(safeName);
|
|
345
|
+
|
|
346
|
+
if (state.canaryGeneration === generation.id) {
|
|
347
|
+
state.canaryGeneration = null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (purge) {
|
|
351
|
+
delete state.generations[String(generation.id)];
|
|
352
|
+
if (state.activeGeneration === generation.id) {
|
|
353
|
+
state.activeGeneration = null;
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
const current = state.generations[String(generation.id)];
|
|
357
|
+
if (current) {
|
|
358
|
+
current.status = 'stopped';
|
|
359
|
+
current.stoppedAt = new Date().toISOString();
|
|
360
|
+
if (!current.deactivatedAt && current.activatedAt) {
|
|
361
|
+
current.deactivatedAt = current.stoppedAt;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.saveState(safeName, state);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Scans state for dead processes and cleans up stale records.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} serviceName
|
|
373
|
+
* @returns {number} Count of cleaned up generations
|
|
374
|
+
*/
|
|
375
|
+
cleanupStale(serviceName) {
|
|
376
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
377
|
+
const state = this.loadState(safeName);
|
|
378
|
+
let cleaned = 0;
|
|
379
|
+
|
|
380
|
+
for (const [id, gen] of Object.entries(state.generations)) {
|
|
381
|
+
if (gen.status !== 'stopped' && gen.pid) {
|
|
382
|
+
if (!this.processManager.isAlive(gen.pid)) {
|
|
383
|
+
gen.status = 'stopped';
|
|
384
|
+
gen.stoppedAt = new Date().toISOString();
|
|
385
|
+
if (!gen.deactivatedAt && gen.activatedAt) {
|
|
386
|
+
gen.deactivatedAt = gen.stoppedAt;
|
|
387
|
+
}
|
|
388
|
+
if (state.activeGeneration === gen.id) {
|
|
389
|
+
state.activeGeneration = null;
|
|
390
|
+
}
|
|
391
|
+
cleaned++;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (cleaned > 0) {
|
|
397
|
+
this.saveState(safeName, state);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return cleaned;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Lists all generations for a service.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} serviceName
|
|
407
|
+
* @returns {object[]}
|
|
408
|
+
*/
|
|
409
|
+
listGenerations(serviceName) {
|
|
410
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
411
|
+
const state = this.loadState(safeName);
|
|
412
|
+
return Object.values(state.generations).sort((a, b) => a.id - b.id);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ProcessManager } from './process-manager.js';
|
|
2
|
+
export { GenerationManager, findFreePort } from './generation-manager.js';
|
|
3
|
+
export { Router } from './router.js';
|
|
4
|
+
export { ReadinessChecker, ReadinessTimeoutError, ProcessStartupError, PortMismatchError } from './readiness.js';
|
|
5
|
+
export { DrainManager } from './drain-manager.js';
|
|
6
|
+
export { DeploymentLock, DeploymentLockError } from './lock.js';
|
|
7
|
+
export { IPCServer, IPCClient, getIpcPath } from './ipc.js';
|
|
8
|
+
export { Supervisor } from './supervisor.js';
|
package/src/core/ipc.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getUnitupDir, sanitizeServiceName } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the IPC endpoint path for a service (Unix domain socket or Windows Named Pipe).
|
|
8
|
+
* @param {string} serviceName
|
|
9
|
+
* @param {string} [ipcDir]
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function getIpcPath(serviceName, ipcDir) {
|
|
13
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
return `\\\\.\\pipe\\unitup-${safeName}`;
|
|
16
|
+
}
|
|
17
|
+
const dir = ipcDir || path.join(getUnitupDir(), 'ipc');
|
|
18
|
+
return path.join(dir, `${safeName}.sock`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lightweight IPC Server hosted by the supervisor.
|
|
23
|
+
*/
|
|
24
|
+
export class IPCServer {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {string} options.serviceName
|
|
28
|
+
* @param {function(object, function(object): void): Promise<object>} options.handler
|
|
29
|
+
* @param {string} [options.ipcDir]
|
|
30
|
+
*/
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.serviceName = options.serviceName;
|
|
33
|
+
this.handler = options.handler;
|
|
34
|
+
this.ipcPath = getIpcPath(this.serviceName, options.ipcDir);
|
|
35
|
+
this.server = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start() {
|
|
39
|
+
if (process.platform !== 'win32') {
|
|
40
|
+
const dir = path.dirname(this.ipcPath);
|
|
41
|
+
if (!fs.existsSync(dir)) {
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
if (fs.existsSync(this.ipcPath)) {
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(this.ipcPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const server = net.createServer((socket) => {
|
|
55
|
+
let buffer = '';
|
|
56
|
+
|
|
57
|
+
socket.on('data', async (chunk) => {
|
|
58
|
+
buffer += chunk.toString('utf8');
|
|
59
|
+
const lines = buffer.split('\n');
|
|
60
|
+
buffer = lines.pop(); // preserve unfinished line
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
if (!trimmed) continue;
|
|
65
|
+
|
|
66
|
+
let message;
|
|
67
|
+
try {
|
|
68
|
+
message = JSON.parse(trimmed);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
socket.write(`${JSON.stringify({ type: 'error', error: 'Invalid JSON' })}\n`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sendProgress = (progressData) => {
|
|
75
|
+
if (!socket.destroyed) {
|
|
76
|
+
socket.write(`${JSON.stringify({ type: 'progress', ...progressData })}\n`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.handler(message, sendProgress);
|
|
82
|
+
if (!socket.destroyed) {
|
|
83
|
+
socket.write(`${JSON.stringify({ type: 'result', data: result })}\n`);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!socket.destroyed) {
|
|
87
|
+
socket.write(
|
|
88
|
+
`${JSON.stringify({
|
|
89
|
+
type: 'error',
|
|
90
|
+
error: err.message,
|
|
91
|
+
name: err.name,
|
|
92
|
+
stack: err.stack
|
|
93
|
+
})}\n`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.on('error', reject);
|
|
102
|
+
server.listen(this.ipcPath, () => {
|
|
103
|
+
this.server = server;
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async close() {
|
|
110
|
+
if (!this.server) return;
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
this.server.close(() => {
|
|
113
|
+
this.server = null;
|
|
114
|
+
if (process.platform !== 'win32' && fs.existsSync(this.ipcPath)) {
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(this.ipcPath);
|
|
117
|
+
} catch {
|
|
118
|
+
// ignore
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
resolve();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async stop() {
|
|
127
|
+
return this.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Lightweight IPC Client used by CLI to talk to the running supervisor.
|
|
133
|
+
*/
|
|
134
|
+
export class IPCClient {
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} serviceName
|
|
137
|
+
* @param {string} [ipcDir]
|
|
138
|
+
*/
|
|
139
|
+
constructor(serviceName, ipcDir) {
|
|
140
|
+
this.serviceName = serviceName;
|
|
141
|
+
this.ipcPath = getIpcPath(serviceName, ipcDir);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Checks if the supervisor IPC socket is reachable.
|
|
146
|
+
* @param {number} [timeout=1000]
|
|
147
|
+
* @returns {Promise<boolean>}
|
|
148
|
+
*/
|
|
149
|
+
async isAlive(timeout = 1000) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const socket = net.connect(this.ipcPath);
|
|
152
|
+
let connected = false;
|
|
153
|
+
|
|
154
|
+
socket.once('connect', () => {
|
|
155
|
+
connected = true;
|
|
156
|
+
socket.destroy();
|
|
157
|
+
resolve(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
socket.setTimeout(timeout, () => {
|
|
161
|
+
socket.destroy();
|
|
162
|
+
resolve(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
socket.once('error', () => {
|
|
166
|
+
resolve(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sends a request to the supervisor and streams progress updates.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} payload
|
|
175
|
+
* @param {function(object): void} [onProgress]
|
|
176
|
+
* @param {number} [timeout=120000]
|
|
177
|
+
* @returns {Promise<any>}
|
|
178
|
+
*/
|
|
179
|
+
async send(payload, onProgress, timeout = 120000) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const socket = net.connect(this.ipcPath);
|
|
182
|
+
let buffer = '';
|
|
183
|
+
let timer = null;
|
|
184
|
+
|
|
185
|
+
if (timeout > 0) {
|
|
186
|
+
timer = setTimeout(() => {
|
|
187
|
+
socket.destroy();
|
|
188
|
+
reject(new Error(`IPC request timed out after ${timeout}ms.`));
|
|
189
|
+
}, timeout);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
socket.once('connect', () => {
|
|
193
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
socket.on('data', (chunk) => {
|
|
197
|
+
buffer += chunk.toString('utf8');
|
|
198
|
+
const lines = buffer.split('\n');
|
|
199
|
+
buffer = lines.pop();
|
|
200
|
+
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
if (!trimmed) continue;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const msg = JSON.parse(trimmed);
|
|
207
|
+
if (msg.type === 'progress') {
|
|
208
|
+
if (onProgress) onProgress(msg);
|
|
209
|
+
} else if (msg.type === 'result') {
|
|
210
|
+
if (timer) clearTimeout(timer);
|
|
211
|
+
socket.destroy();
|
|
212
|
+
resolve(msg.data);
|
|
213
|
+
} else if (msg.type === 'error') {
|
|
214
|
+
if (timer) clearTimeout(timer);
|
|
215
|
+
socket.destroy();
|
|
216
|
+
const err = new Error(msg.error);
|
|
217
|
+
err.name = msg.name || 'Error';
|
|
218
|
+
reject(err);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Ignore parse errors on individual chunks
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
socket.once('error', (err) => {
|
|
227
|
+
if (timer) clearTimeout(timer);
|
|
228
|
+
reject(err);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
socket.once('close', () => {
|
|
232
|
+
if (timer) clearTimeout(timer);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|