@fapost/jig-ui 0.3.1-next.62 → 0.3.1-next.64
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/dist/args.js +36 -9
- package/dist/http/app.js +10 -3
- package/dist/main.js +134 -27
- package/dist/registry/instance.js +44 -12
- package/dist/service.js +130 -0
- package/package.json +1 -1
package/dist/args.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
export const DEFAULT_PORT = 4870;
|
|
3
|
+
const SUBCOMMANDS = ['start', 'stop', 'status', 'open', 'serve'];
|
|
4
|
+
/** Subcommands that listen on a port; the others only talk to the instance that does. */
|
|
5
|
+
const TAKES_PORT = new Set(['run', 'start', 'serve']);
|
|
3
6
|
/** Parses the command line (without the node and script entries). Pure: it never exits or prints. */
|
|
4
7
|
export function parseCliArgs(argv) {
|
|
8
|
+
let positionals;
|
|
5
9
|
let values;
|
|
6
10
|
try {
|
|
7
|
-
({ values } = parseArgs({
|
|
11
|
+
({ values, positionals } = parseArgs({
|
|
8
12
|
args: argv,
|
|
9
13
|
strict: true,
|
|
10
|
-
allowPositionals:
|
|
14
|
+
allowPositionals: true,
|
|
11
15
|
options: {
|
|
12
16
|
port: { type: 'string' },
|
|
13
17
|
'no-open': { type: 'boolean' },
|
|
@@ -23,12 +27,35 @@ export function parseCliArgs(argv) {
|
|
|
23
27
|
return { kind: 'help' };
|
|
24
28
|
if (values.version)
|
|
25
29
|
return { kind: 'version' };
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
return { kind: '
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const [name, ...rest] = positionals;
|
|
31
|
+
if (rest.length > 0)
|
|
32
|
+
return { kind: 'error', message: `Unexpected argument: ${rest[0]}` };
|
|
33
|
+
if (name !== undefined && !SUBCOMMANDS.includes(name)) {
|
|
34
|
+
return { kind: 'error', message: `Unknown command: ${name}. Run jig-ui --help.` };
|
|
35
|
+
}
|
|
36
|
+
const command = name ?? 'run';
|
|
37
|
+
if (values.port !== undefined && !TAKES_PORT.has(command)) {
|
|
38
|
+
return { kind: 'error', message: `--port does not apply to ${command}.` };
|
|
39
|
+
}
|
|
40
|
+
if (values['no-open'] && command !== 'run') {
|
|
41
|
+
return { kind: 'error', message: `--no-open does not apply to ${command}.` };
|
|
42
|
+
}
|
|
43
|
+
let port = DEFAULT_PORT;
|
|
44
|
+
if (values.port !== undefined) {
|
|
45
|
+
port = /^\d+$/.test(values.port) ? Number(values.port) : Number.NaN;
|
|
46
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
47
|
+
return { kind: 'error', message: `Invalid --port value: ${values.port}. Use a number from 1 to 65535.` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
switch (command) {
|
|
51
|
+
case 'start':
|
|
52
|
+
case 'serve':
|
|
53
|
+
return { kind: command, port };
|
|
54
|
+
case 'stop':
|
|
55
|
+
case 'status':
|
|
56
|
+
case 'open':
|
|
57
|
+
return { kind: command };
|
|
58
|
+
default:
|
|
59
|
+
return { kind: 'run', port, open: !values['no-open'] };
|
|
32
60
|
}
|
|
33
|
-
return { kind: 'run', port, open };
|
|
34
61
|
}
|
package/dist/http/app.js
CHANGED
|
@@ -79,9 +79,16 @@ function upgradeRefused(c, code) {
|
|
|
79
79
|
* The API, chained so that its type carries every route's answers: the page derives its types
|
|
80
80
|
* from `AppType` and never declares them a second time.
|
|
81
81
|
*/
|
|
82
|
-
function apiRoutes(source, version) {
|
|
82
|
+
function apiRoutes(source, version, stop) {
|
|
83
83
|
return (new Hono()
|
|
84
84
|
.get('/api/instance', (c) => c.json({ version }, 200))
|
|
85
|
+
.post('/api/instance/stop', (c) => {
|
|
86
|
+
if (!stop)
|
|
87
|
+
return refuse(c, 404, 'not-found', 'This instance cannot be stopped from here.');
|
|
88
|
+
// After the reply is written, or the caller sees a cut connection instead of the 202.
|
|
89
|
+
setTimeout(stop, 50);
|
|
90
|
+
return c.json({ stopping: true }, 202);
|
|
91
|
+
})
|
|
85
92
|
.get('/api/workspace', async (c) => c.json({ projects: await source.workspace(), machine: source.machine() }, 200))
|
|
86
93
|
// Moves this machine's Jig to its newest release and answers once it has: a git fetch, not minutes.
|
|
87
94
|
.post('/api/self-update', async (c) => {
|
|
@@ -283,7 +290,7 @@ function apiRoutes(source, version) {
|
|
|
283
290
|
}
|
|
284
291
|
})));
|
|
285
292
|
}
|
|
286
|
-
export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
|
|
293
|
+
export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT, stop }) {
|
|
287
294
|
const app = new Hono();
|
|
288
295
|
// Before the guard, so its refusals carry them too; HSTS means nothing on http://127.0.0.1.
|
|
289
296
|
app.use(secureHeaders({ strictTransportSecurity: false }));
|
|
@@ -299,7 +306,7 @@ export function createApp({ port, token, source, version = readPackageVersion(),
|
|
|
299
306
|
console.error(error);
|
|
300
307
|
return refuse(c, 500, 'internal', 'Jig UI hit an error it did not expect; its terminal says what.');
|
|
301
308
|
});
|
|
302
|
-
app.route('/', apiRoutes(source, version));
|
|
309
|
+
app.route('/', apiRoutes(source, version, stop));
|
|
303
310
|
app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
|
|
304
311
|
if (existsSync(join(webRoot, 'index.html'))) {
|
|
305
312
|
app.use('/*', serveStatic({ root: webRoot }));
|
package/dist/main.js
CHANGED
|
@@ -8,16 +8,32 @@ import { openBrowser } from './open-browser.js';
|
|
|
8
8
|
import { configDir } from './registry/config-dir.js';
|
|
9
9
|
import { connectThrough, probeInstance, readInstance, removeInstance, writeInstance } from './registry/instance.js';
|
|
10
10
|
import { mainCheckout } from './source/checkout.js';
|
|
11
|
+
import { instanceStatus, startInstance, stopInstance } from './service.js';
|
|
11
12
|
import { localSource } from './source/local.js';
|
|
12
13
|
const HOST = '127.0.0.1';
|
|
13
14
|
const HELP = `Usage: jig-ui [options]
|
|
15
|
+
jig-ui start [--port <n>]
|
|
16
|
+
jig-ui stop | status | open
|
|
17
|
+
|
|
18
|
+
Run without a command, Jig UI runs in this terminal and opens the browser.
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
start Start Jig UI in the background
|
|
22
|
+
stop Stop the running Jig UI
|
|
23
|
+
status Say whether Jig UI is running (exit code 3 when it is not)
|
|
24
|
+
open Open the running Jig UI in the browser
|
|
25
|
+
serve Run in this terminal without a browser or a folder: what start runs
|
|
14
26
|
|
|
15
27
|
Options:
|
|
16
|
-
--port <n> Port to listen on (default 4870)
|
|
17
|
-
--no-open Print the link instead of opening the browser
|
|
28
|
+
--port <n> Port to listen on (default 4870); run, start and serve
|
|
29
|
+
--no-open Print the link instead of opening the browser; run only
|
|
18
30
|
-v, --version Print the version and exit
|
|
19
31
|
-h, --help Print this help and exit
|
|
20
32
|
`;
|
|
33
|
+
/** The exit code of `status` and `open` when Jig UI is not running. */
|
|
34
|
+
const NOT_RUNNING = 3;
|
|
35
|
+
/** An instance whose port stays silent: maybe busy, so nothing is removed or started in its place. */
|
|
36
|
+
const silent = ({ port, pid }) => `Jig UI on port ${port} (pid ${pid}) does not answer. Try again; if it stays silent, end that process yourself.`;
|
|
21
37
|
function fail(message) {
|
|
22
38
|
console.error(message);
|
|
23
39
|
process.exit(1);
|
|
@@ -71,36 +87,40 @@ async function winnerOfRace(dir) {
|
|
|
71
87
|
}
|
|
72
88
|
return undefined;
|
|
73
89
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
console.log(readPackageVersion());
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const { port, open } = command;
|
|
87
|
-
const dir = configDir(process.env, process.platform, homedir());
|
|
88
|
-
const place = await here();
|
|
90
|
+
/**
|
|
91
|
+
* Runs the server in this process. `run` also connects the folder and opens the browser; `serve`
|
|
92
|
+
* (what `start` runs in the background) does neither and keeps quiet about Ctrl+C.
|
|
93
|
+
*/
|
|
94
|
+
async function runServer(command, dir) {
|
|
95
|
+
const { port } = command;
|
|
96
|
+
const foreground = command.kind === 'run';
|
|
97
|
+
const open = foreground && command.open;
|
|
98
|
+
const place = foreground ? await here() : undefined;
|
|
89
99
|
const running = await runningInstance(dir);
|
|
90
100
|
if (running) {
|
|
91
101
|
console.log(`Jig UI is already running on port ${running.port}.`);
|
|
92
102
|
// ponytail: a --port equal to the default is indistinguishable from no flag; no note then.
|
|
93
103
|
if (port !== running.port && port !== DEFAULT_PORT)
|
|
94
104
|
console.log(`--port ${port} is ignored.`);
|
|
95
|
-
|
|
105
|
+
if (foreground)
|
|
106
|
+
await show(running, open, dir, await connectHere(running, place));
|
|
96
107
|
return;
|
|
97
108
|
}
|
|
98
109
|
const version = readPackageVersion();
|
|
99
110
|
const token = randomBytes(32).toString('base64url');
|
|
100
|
-
const
|
|
111
|
+
const shutdown = () => {
|
|
112
|
+
void removeInstance(dir, token).finally(() => {
|
|
113
|
+
server.close(() => process.exit(0));
|
|
114
|
+
if ('closeAllConnections' in server)
|
|
115
|
+
server.closeAllConnections();
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
const server = serve({ fetch: createApp({ port, token, source: localSource(dir), version, stop: shutdown }).fetch, hostname: HOST, port }, () => {
|
|
101
119
|
void (async () => {
|
|
102
120
|
await writeInstance(dir, { port, token, pid: process.pid, version });
|
|
103
121
|
console.log(`Jig UI ${version} is running at http://${HOST}:${port}/`);
|
|
122
|
+
if (!foreground)
|
|
123
|
+
return;
|
|
104
124
|
await show({ port, token }, open, dir, await connectHere({ port, token }, place));
|
|
105
125
|
console.log('Press Ctrl+C to stop.');
|
|
106
126
|
})().catch((error) => fail(`Could not start: ${error instanceof Error ? error.message : String(error)}`));
|
|
@@ -113,17 +133,104 @@ export async function main() {
|
|
|
113
133
|
if (!winner)
|
|
114
134
|
fail(`Port ${port} is already in use. Stop the other process or choose another port with --port.`);
|
|
115
135
|
console.log(`Jig UI is already running on port ${winner.port}.`);
|
|
116
|
-
|
|
136
|
+
if (foreground)
|
|
137
|
+
await show(winner, open, dir, await connectHere(winner, place));
|
|
117
138
|
process.exit(0);
|
|
118
139
|
});
|
|
119
140
|
});
|
|
120
|
-
const shutdown = () => {
|
|
121
|
-
void removeInstance(dir, token).finally(() => {
|
|
122
|
-
server.close(() => process.exit(0));
|
|
123
|
-
if ('closeAllConnections' in server)
|
|
124
|
-
server.closeAllConnections();
|
|
125
|
-
});
|
|
126
|
-
};
|
|
127
141
|
process.on('SIGINT', shutdown);
|
|
128
142
|
process.on('SIGTERM', shutdown);
|
|
129
143
|
}
|
|
144
|
+
async function startBackground(port, dir) {
|
|
145
|
+
const result = await startInstance(dir, port);
|
|
146
|
+
if (result.kind === 'failed') {
|
|
147
|
+
console.error(`Could not start Jig UI. The end of ${result.log}:\n${result.tail}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
if (result.kind === 'not-answering')
|
|
151
|
+
fail(silent(result.info));
|
|
152
|
+
if (result.kind === 'already-running') {
|
|
153
|
+
console.log(`Jig UI is already running on port ${result.info.port}.`);
|
|
154
|
+
if (port !== result.info.port && port !== DEFAULT_PORT)
|
|
155
|
+
console.log(`--port ${port} is ignored.`);
|
|
156
|
+
console.log(`Open it at ${link(result.info)}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
console.log(`Jig UI ${result.info.version} is running at http://${HOST}:${result.info.port}/ in the background.`);
|
|
160
|
+
console.log(`Open it at ${link(result.info)}`);
|
|
161
|
+
console.log(`Log: ${result.log}`);
|
|
162
|
+
}
|
|
163
|
+
async function stopBackground(dir) {
|
|
164
|
+
const result = await stopInstance(dir);
|
|
165
|
+
switch (result.kind) {
|
|
166
|
+
case 'not-running':
|
|
167
|
+
console.log('Jig UI is not running.');
|
|
168
|
+
return;
|
|
169
|
+
case 'stale':
|
|
170
|
+
console.log(`Jig UI is not running: the file of an instance on port ${result.info.port} was left behind and is removed. No process was signalled.`);
|
|
171
|
+
return;
|
|
172
|
+
case 'stopped':
|
|
173
|
+
console.log(`Stopped Jig UI on port ${result.info.port}.`);
|
|
174
|
+
return;
|
|
175
|
+
case 'not-answering':
|
|
176
|
+
console.error(silent(result.info));
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
case 'timeout':
|
|
180
|
+
console.error(`Jig UI on port ${result.info.port} (pid ${result.info.pid}) did not stop in time.`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function statusOf(dir) {
|
|
185
|
+
const status = await instanceStatus(dir);
|
|
186
|
+
if (status.kind === 'running') {
|
|
187
|
+
const { info } = status;
|
|
188
|
+
console.log(`Jig UI ${info.version} is running at http://${HOST}:${info.port}/ (pid ${info.pid}).`);
|
|
189
|
+
console.log(`Open it at ${link(info)}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (status.kind === 'not-answering') {
|
|
193
|
+
console.error(silent(status.info));
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
console.log(status.kind === 'stale'
|
|
198
|
+
? `Jig UI is not running; the file of an instance on port ${status.info.port} was left behind. jig-ui stop removes it.`
|
|
199
|
+
: 'Jig UI is not running. Start it with jig-ui start.');
|
|
200
|
+
process.exitCode = NOT_RUNNING;
|
|
201
|
+
}
|
|
202
|
+
async function openRunning(dir) {
|
|
203
|
+
const status = await instanceStatus(dir);
|
|
204
|
+
if (status.kind === 'running')
|
|
205
|
+
return show(status.info, true, dir);
|
|
206
|
+
if (status.kind === 'not-answering')
|
|
207
|
+
fail(silent(status.info));
|
|
208
|
+
console.log('Jig UI is not running. Start it with jig-ui start.');
|
|
209
|
+
process.exitCode = NOT_RUNNING;
|
|
210
|
+
}
|
|
211
|
+
export async function main() {
|
|
212
|
+
const command = parseCliArgs(process.argv.slice(2));
|
|
213
|
+
if (command.kind === 'error')
|
|
214
|
+
fail(command.message);
|
|
215
|
+
if (command.kind === 'help') {
|
|
216
|
+
process.stdout.write(HELP);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (command.kind === 'version') {
|
|
220
|
+
console.log(readPackageVersion());
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const dir = configDir(process.env, process.platform, homedir());
|
|
224
|
+
switch (command.kind) {
|
|
225
|
+
case 'start':
|
|
226
|
+
return startBackground(command.port, dir);
|
|
227
|
+
case 'stop':
|
|
228
|
+
return stopBackground(dir);
|
|
229
|
+
case 'status':
|
|
230
|
+
return statusOf(dir);
|
|
231
|
+
case 'open':
|
|
232
|
+
return openRunning(dir);
|
|
233
|
+
default:
|
|
234
|
+
return runServer(command, dir);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -35,21 +35,37 @@ export async function removeInstance(dir, token) {
|
|
|
35
35
|
await rm(join(dir, OPEN_FILE), { force: true });
|
|
36
36
|
await rm(join(dir, FILE), { force: true });
|
|
37
37
|
}
|
|
38
|
+
const causeCode = (error) => error.cause?.code;
|
|
39
|
+
/** A connection refused outright: nothing listens on the port. */
|
|
40
|
+
const refused = (error) => causeCode(error) === 'ECONNREFUSED';
|
|
41
|
+
export async function askInstance(info, timeoutMs = 1000) {
|
|
42
|
+
for (let attempt = 0;; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/instance`, {
|
|
45
|
+
headers: { cookie: `${cookieName(info.port)}=${info.token}` },
|
|
46
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
47
|
+
});
|
|
48
|
+
// An unread body holds the connection, and the next probe of a stop's wait would queue behind it.
|
|
49
|
+
await res.body?.cancel();
|
|
50
|
+
return res.ok ? 'up' : 'gone';
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (refused(error))
|
|
54
|
+
return 'gone';
|
|
55
|
+
// A kept-alive connection to a server that has since stopped breaks (reset, or closed as it
|
|
56
|
+
// is used, as on Linux); only a timeout means silence, and a new connection tells the rest.
|
|
57
|
+
if (error.name !== 'TimeoutError' && attempt === 0)
|
|
58
|
+
continue;
|
|
59
|
+
return 'silent';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
38
63
|
/** True when an instance answers on its port with its token; a crash leaves the file behind. */
|
|
39
64
|
export async function probeInstance(info, timeoutMs = 1000) {
|
|
40
|
-
|
|
41
|
-
const res = await fetch(`http://127.0.0.1:${info.port}/api/instance`, {
|
|
42
|
-
headers: { cookie: `${cookieName(info.port)}=${info.token}` },
|
|
43
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
44
|
-
});
|
|
45
|
-
return res.ok;
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
65
|
+
return (await askInstance(info, timeoutMs)) === 'up';
|
|
50
66
|
}
|
|
51
67
|
// An instance before 0.2.0 answers a failure it did not foresee in plain text: neither of these.
|
|
52
|
-
const
|
|
68
|
+
const Reply = z.union([
|
|
53
69
|
z.object({ project: z.object({ id: z.string() }) }),
|
|
54
70
|
z.object({ error: z.object({ code: z.string(), message: z.string() }) }),
|
|
55
71
|
]);
|
|
@@ -62,7 +78,7 @@ export async function connectThrough(info, path, timeoutMs = 15_000) {
|
|
|
62
78
|
body: JSON.stringify({ path }),
|
|
63
79
|
signal: AbortSignal.timeout(timeoutMs),
|
|
64
80
|
});
|
|
65
|
-
const body =
|
|
81
|
+
const body = Reply.safeParse(await res.json().catch(() => undefined)).data;
|
|
66
82
|
if (body && 'project' in body)
|
|
67
83
|
return { id: body.project.id, created: res.status === 201 };
|
|
68
84
|
return body?.error ?? { code: 'unexpected', message: `Jig UI answered ${res.status}.` };
|
|
@@ -71,3 +87,19 @@ export async function connectThrough(info, path, timeoutMs = 15_000) {
|
|
|
71
87
|
return { code: 'unreachable', message: error instanceof Error ? error.message : String(error) };
|
|
72
88
|
}
|
|
73
89
|
}
|
|
90
|
+
/** Asks the instance to shut down, as `connectThrough` asks it to connect: the token proves the instance is ours. */
|
|
91
|
+
export async function requestStop(info, timeoutMs = 5000) {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/instance/stop`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { cookie: `${cookieName(info.port)}=${info.token}` },
|
|
96
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
97
|
+
});
|
|
98
|
+
await res.body?.cancel();
|
|
99
|
+
return res.status === 404 ? 'unsupported' : 'stopping';
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// A timeout or reset after the request was sent may still be a server going down; a refusal is not.
|
|
103
|
+
return refused(error) ? 'unreachable' : 'stopping';
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn as spawnProcess } from 'node:child_process';
|
|
2
|
+
import { mkdir, open, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { askInstance, readInstance, removeInstance, requestStop, } from './registry/instance.js';
|
|
7
|
+
/** The log of the background instance; overwritten at each start, but it grows while the instance runs (task server-log-rotation). */
|
|
8
|
+
export const LOG_FILE = 'server.log';
|
|
9
|
+
/** How long the instance gets to answer a probe; one that stays silent is busy, never stale. */
|
|
10
|
+
const PROBE_MS = 3000;
|
|
11
|
+
/** How long a start waits for the new instance, and a stop for the old one to go. */
|
|
12
|
+
const WAIT_MS = 10_000;
|
|
13
|
+
const POLL_MS = 100;
|
|
14
|
+
const TAIL_LINES = 20;
|
|
15
|
+
/** The program that runs the instance: this very CLI, next to this file in `dist` and in `server`. */
|
|
16
|
+
const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
17
|
+
export const realDeps = {
|
|
18
|
+
probe: askInstance,
|
|
19
|
+
requestStop: (info) => requestStop(info),
|
|
20
|
+
kill: (pid, signal) => process.kill(pid, signal),
|
|
21
|
+
spawn(command, args, { cwd, fd }) {
|
|
22
|
+
const child = spawnProcess(command, args, { detached: true, stdio: ['ignore', fd, fd], windowsHide: true, cwd });
|
|
23
|
+
const exit = new Promise((resolve) => {
|
|
24
|
+
// A process that cannot start never exits and writes no log: its error is all there is to say.
|
|
25
|
+
child.on('error', (error) => resolve({ code: -1, error: error.message }));
|
|
26
|
+
child.on('exit', (code) => resolve({ code }));
|
|
27
|
+
});
|
|
28
|
+
return { exit, unref: () => child.unref(), kill: () => child.kill() };
|
|
29
|
+
},
|
|
30
|
+
delay: (ms) => sleep(ms),
|
|
31
|
+
now: () => Date.now(),
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Reads the instance file and asks the instance. A file whose port refuses, or answers as someone
|
|
35
|
+
* else, is stale; one whose port stays silent is `not-answering`: maybe alive and busy. It deletes nothing.
|
|
36
|
+
*/
|
|
37
|
+
export async function instanceStatus(dir, deps = realDeps) {
|
|
38
|
+
const info = await readInstance(dir);
|
|
39
|
+
if (!info)
|
|
40
|
+
return { kind: 'not-running' };
|
|
41
|
+
const answer = await deps.probe(info, PROBE_MS);
|
|
42
|
+
return { kind: answer === 'up' ? 'running' : answer === 'gone' ? 'stale' : 'not-answering', info };
|
|
43
|
+
}
|
|
44
|
+
async function logTail(log) {
|
|
45
|
+
try {
|
|
46
|
+
return (await readFile(log, 'utf8')).trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Starts the instance in the background and waits until it answers. The child is the CLI itself
|
|
54
|
+
* as `serve`: detached from this terminal, its output in the log, its working directory the
|
|
55
|
+
* config directory, so it holds no folder of the person's.
|
|
56
|
+
*/
|
|
57
|
+
export async function startInstance(dir, port, deps = realDeps) {
|
|
58
|
+
const status = await instanceStatus(dir, deps);
|
|
59
|
+
if (status.kind === 'running')
|
|
60
|
+
return { kind: 'already-running', info: status.info };
|
|
61
|
+
// A second server would only lose the port to it, and the log would be overwritten for nothing.
|
|
62
|
+
if (status.kind === 'not-answering')
|
|
63
|
+
return status;
|
|
64
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
const log = join(dir, LOG_FILE);
|
|
66
|
+
const handle = await open(log, 'w', 0o600);
|
|
67
|
+
let child;
|
|
68
|
+
try {
|
|
69
|
+
child = deps.spawn(process.execPath, [CLI_PATH, 'serve', '--port', String(port)], { cwd: dir, fd: handle.fd });
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
// The child has its own copy of the descriptor.
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
child.unref();
|
|
76
|
+
let ended;
|
|
77
|
+
void child.exit.then((value) => {
|
|
78
|
+
ended = value;
|
|
79
|
+
});
|
|
80
|
+
const deadline = deps.now() + WAIT_MS;
|
|
81
|
+
while (deps.now() < deadline) {
|
|
82
|
+
await deps.delay(POLL_MS);
|
|
83
|
+
const info = await readInstance(dir);
|
|
84
|
+
if (info && (await deps.probe(info, PROBE_MS)) === 'up')
|
|
85
|
+
return { kind: 'started', info, log };
|
|
86
|
+
// A child that exited with 0 found another instance already there: the next poll sees it.
|
|
87
|
+
if (ended && ended.code !== 0)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
// Too slow is a failure too: end the child rather than let it come up after we said it did not.
|
|
91
|
+
if (!ended)
|
|
92
|
+
child.kill();
|
|
93
|
+
return { kind: 'failed', log, tail: ended?.error ?? (await logTail(log)) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Stops the instance the file names. An instance that answers is asked over HTTP, with its token:
|
|
97
|
+
* that works on every system and cannot hit a process that merely reused the pid. A stale file
|
|
98
|
+
* only goes away, and no signal is sent, because its pid may belong to anything by now. A silent
|
|
99
|
+
* port changes nothing: removing the file of a busy instance would leave it running unseen.
|
|
100
|
+
*/
|
|
101
|
+
export async function stopInstance(dir, deps = realDeps) {
|
|
102
|
+
const status = await instanceStatus(dir, deps);
|
|
103
|
+
if (status.kind === 'not-running' || status.kind === 'not-answering')
|
|
104
|
+
return status;
|
|
105
|
+
const { info } = status;
|
|
106
|
+
if (status.kind === 'stale') {
|
|
107
|
+
await removeInstance(dir, info.token);
|
|
108
|
+
return status;
|
|
109
|
+
}
|
|
110
|
+
const requested = await deps.requestStop(info);
|
|
111
|
+
// An instance from before the endpoint existed: the probe just proved that its pid is the one that answers.
|
|
112
|
+
if (requested === 'unsupported') {
|
|
113
|
+
try {
|
|
114
|
+
deps.kill(info.pid, 'SIGTERM');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Gone between the probe and the signal: the wait below sees it.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const deadline = deps.now() + WAIT_MS;
|
|
121
|
+
while (deps.now() < deadline) {
|
|
122
|
+
if ((await deps.probe(info, PROBE_MS)) === 'gone') {
|
|
123
|
+
// The server removes its files itself; this covers a hard exit.
|
|
124
|
+
await removeInstance(dir, info.token);
|
|
125
|
+
return { kind: 'stopped', info };
|
|
126
|
+
}
|
|
127
|
+
await deps.delay(POLL_MS);
|
|
128
|
+
}
|
|
129
|
+
return { kind: 'timeout', info };
|
|
130
|
+
}
|