21.0 KB720 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {PlatformName} from 'isl/src/types';
9import type {IOType} from 'node:child_process';
10import type {ChildProcessResponse} from './child';
11import type {StartServerArgs, StartServerResult} from './server';
12
13import child_process from 'node:child_process';
14import crypto from 'node:crypto';
15import fs from 'node:fs';
16import os from 'node:os';
17import path from 'node:path';
18import {
19 deleteExistingServerFile,
20 ensureExistingServerFolder,
21 writeExistingServerFile,
22} from './existingServerStateFiles';
23import * as lifecycle from './serverLifecycle';
24
25const DEFAULT_PORT = '3001';
26
27const HELP_MESSAGE = `\
28usage: isl [--port PORT]
29
30optional arguments:
31 -h, --help Show this message
32 -f, --foreground Run the server process in the foreground.
33 --no-open Do not try to open a browser after starting the server
34 -p, --port Port to listen on (default: ${DEFAULT_PORT})
35 --json Output machine-readable JSON
36 --stdout Write server logs to stdout instead of a tmp file
37 --dev Open on port 3000 despite hosting ${DEFAULT_PORT} (or custom port with -p)
38 This is useless unless running from source to hook into Vite dev mode
39 --kill Do not start Sapling Web, just kill any previously running Sapling Web server on the specified port
40 Note that this will disrupt other windows still using the previous Sapling Web server.
41 --force Kill any existing Sapling Web server on the specified port, then start a new server.
42 Note that this will disrupt other windows still using the previous Sapling Web server.
43 --command name Set which command to run for sl commands (default: sl)
44 --cwd dir Sets the current working directory, allowing changing the repo.
45 --sl-version v Set version number of sl was used to spawn the server (default: '(dev)')
46 --platform Set which platform implementation to use by changing the resulting URL.
47 Used to embed Sapling Web into non-browser web environments like IDEs.
48 --session id Provide a specific ID for this session used in analytics.
49`;
50
51type JsonOutput =
52 | {
53 port: number;
54 url: string;
55 token: string;
56 /** Process ID for the server. */
57 pid: number;
58 wasServerReused: boolean;
59 logFileLocation: string | 'stdout';
60 cwd: string;
61 command: string;
62 }
63 | {error: string};
64
65function errorAndExit(message: string, code = 1): never {
66 // eslint-disable-next-line no-console
67 console.error(message);
68 process.exit(code);
69}
70
71type Args = {
72 help: boolean;
73 foreground: boolean;
74 openUrl: boolean;
75 port: number;
76 isDevMode: boolean;
77 json: boolean;
78 stdout: boolean;
79 platform: string | undefined;
80 kill: boolean;
81 force: boolean;
82 slVersion: string;
83 command: string;
84 cwd: string | undefined;
85 sessionId: string | undefined;
86};
87
88// Rudimentary arg parser to avoid the need for a third-party dependency.
89export function parseArgs(args: Array<string> = process.argv.slice(2)): Args {
90 let help = false;
91 // Before we added arg parsing, the $PORT environment variable was the only
92 // way to set the port. Once callers have been updated to use --port, drop
93 // support for the environment variable.
94 let port = normalizePort(process.env.PORT || DEFAULT_PORT);
95 let openUrl = true;
96
97 const len = args.length;
98 let isDevMode = false;
99 let json = false;
100 let stdout = false;
101 let foreground = false;
102 let kill = false;
103 let force = false;
104 let command = process.env.SL ?? 'sl';
105 let cwd: string | undefined = undefined;
106 let slVersion = '(dev)';
107 let platform: string | undefined = undefined;
108 let sessionId: string | undefined = undefined;
109 let readOnly = false;
110 let i = 0;
111 function consumeArgValue(arg: string) {
112 if (i >= len) {
113 errorAndExit(`no value supplied for ${arg}`);
114 } else {
115 return args[++i];
116 }
117 }
118 while (i < len) {
119 const arg = args[i];
120 switch (arg) {
121 case '--no-open': {
122 openUrl = false;
123 break;
124 }
125 case '--foreground':
126 case '-f': {
127 foreground = true;
128 break;
129 }
130 case '--port':
131 case '-p': {
132 const rawPort = consumeArgValue(arg);
133 const parsedPort = normalizePort(rawPort);
134 if (parsedPort !== false) {
135 port = parsedPort as number;
136 } else {
137 errorAndExit(`could not parse port: '${rawPort}'`);
138 }
139 break;
140 }
141 case '--dev': {
142 isDevMode = true;
143 break;
144 }
145 case '--kill': {
146 kill = true;
147 break;
148 }
149 case '--force': {
150 force = true;
151 break;
152 }
153 case '--command': {
154 command = consumeArgValue(arg);
155 break;
156 }
157 case '--cwd': {
158 cwd = consumeArgValue(arg);
159 break;
160 }
161 case '--sl-version': {
162 slVersion = consumeArgValue(arg);
163 break;
164 }
165 case '--json': {
166 json = true;
167 break;
168 }
169 case '--stdout': {
170 stdout = true;
171 break;
172 }
173 case '--session': {
174 sessionId = consumeArgValue(arg);
175 break;
176 }
177 case '--platform': {
178 platform = consumeArgValue(arg);
179 if (!isValidCustomPlatform(platform)) {
180 errorAndExit(
181 `"${platform}" is not a valid platform. Valid options: ${validPlatforms.join(', ')}`,
182 );
183 }
184 break;
185 }
186 case '--read-only': {
187 readOnly = true;
188 break;
189 }
190 case '--help':
191 case '-h': {
192 help = true;
193 break;
194 }
195 default: {
196 errorAndExit(`unexpected arg: ${arg}`);
197 }
198 }
199 ++i;
200 }
201
202 if (port === false) {
203 errorAndExit('port was not a positive integer');
204 }
205
206 if (stdout && !foreground) {
207 // eslint-disable-next-line no-console
208 console.info('NOTE: setting --foreground because --stdout was specified');
209 foreground = true;
210 }
211
212 if (kill && force) {
213 // eslint-disable-next-line no-console
214 console.info('NOTE: setting --kill and --force is redundant');
215 }
216
217 return {
218 help,
219 foreground,
220 openUrl,
221 port,
222 isDevMode,
223 json,
224 stdout,
225 platform,
226 kill,
227 force,
228 slVersion,
229 command,
230 cwd,
231 sessionId,
232 readOnly,
233 };
234}
235
236const CRYPTO_KEY_LENGTH = 128;
237
238/** Generates a 128-bit secure random token. */
239function generateToken(): Promise<string> {
240 // crypto.generateKey() was introduced in v15.0.0. For earlier versions of
241 // Node, we can use crypto.createDiffieHellman().
242 if (typeof crypto.generateKey === 'function') {
243 const {generateKey} = crypto;
244 return new Promise((res, rej) =>
245 generateKey('hmac', {length: CRYPTO_KEY_LENGTH}, (err, key) =>
246 err ? rej(err) : res(key.export().toString('hex')),
247 ),
248 );
249 } else {
250 return Promise.resolve(
251 crypto.createDiffieHellman(CRYPTO_KEY_LENGTH).generateKeys().toString('hex'),
252 );
253 }
254}
255
256const validPlatforms: Array<PlatformName> = [
257 'androidStudio',
258 'androidStudioRemote',
259 'webview',
260 'chromelike_app',
261 'visualStudio',
262 'obsidian',
263];
264function isValidCustomPlatform(name: string): name is PlatformName {
265 return validPlatforms.includes(name as PlatformName);
266}
267/** Return the "index" html path like `androidStudio.html`. */
268function getPlatformIndexHtmlPath(name?: string): string {
269 if (name == null || name === 'browser') {
270 return '';
271 }
272 if (name === 'chromelike_app') {
273 // need to match isl/build/.vite/manifest.json
274 return 'chromelikeApp.html';
275 }
276 if (isValidCustomPlatform(name)) {
277 return `${encodeURIComponent(name)}.html`;
278 }
279 return '';
280}
281
282/**
283 * This calls the `startServer()` function that launches the server for ISL,
284 * though the mechanism is conditional on the `foreground` param:
285 *
286 * - If `foreground` is true, then `startServer()` will be called directly as
287 * part of this process and it will continue run in the foreground. The user
288 * can do ctrl+c to kill the server (or ctrl+z to suspend it), as they would
289 * for any other process.
290 * - If `foreground` is false, then we will spawn a new process via
291 * `child_process.fork()` that runs `child.ts` in this folder. IPC is done via
292 * `child.on('message')` and `process.send()`, though once this process has
293 * confirmed that the server is up and running, it can exit while the child
294 * will continue to run in the background.
295 */
296function callStartServer(args: StartServerArgs): Promise<StartServerResult> {
297 if (args.foreground) {
298 return import('./server').then(({startServer}) => startServer(args));
299 } else {
300 return new Promise(resolve => {
301 // We pass the args via an environment variable because StartServerArgs
302 // contains sensitive information and users on the system can see the
303 // command line arguments of other users' processes, but not the
304 // environment variables of other users' processes.
305 //
306 // We could also consider streaming the input as newline-delimited JSON
307 // via stdin, though the max length for an environment variable seems
308 // large enough for our needs.
309 const env = {
310 ...process.env,
311 ISL_SERVER_ARGS: JSON.stringify({...args, logInfo: null}),
312 };
313 const options = {
314 env,
315 detached: true,
316 // Child process should not inherit fds from the parent process, or
317 // else something like `node run-proxy.js --json | jq` will never
318 // terminate because the child process will keep stdout from the
319 // parent process open, so jq will continue to read from it.
320 stdio: 'ignore' as IOType,
321 };
322 const pathToChildModule = path.join(path.dirname(__filename), 'child');
323 const child = child_process.fork(pathToChildModule, [], options);
324 child.on('message', (message: ChildProcessResponse) => {
325 switch (message.type) {
326 case 'result': {
327 resolve(message.result);
328 break;
329 }
330 case 'message': {
331 args.logInfo(...message.args);
332 break;
333 }
334 }
335 });
336 });
337 }
338}
339
340export async function runProxyMain(args: Args) {
341 const {
342 help,
343 foreground,
344 openUrl,
345 port,
346 isDevMode,
347 json,
348 stdout,
349 platform,
350 kill,
351 force,
352 slVersion,
353 command,
354 sessionId,
355 readOnly,
356 } = args;
357 if (help) {
358 errorAndExit(HELP_MESSAGE, 0);
359 }
360
361 const cwd = args.cwd ?? process.cwd();
362
363 function info(...args: Parameters<typeof console.log>): void {
364 if (json) {
365 return;
366 }
367 // eslint-disable-next-line no-console
368 console.info(...args);
369 }
370
371 /**
372 * Output JSON information for use with `--json`.
373 * Should only be called once per lifecycle of the server.
374 */
375 function outputJson(data: JsonOutput) {
376 if (!json) {
377 return;
378 }
379 // eslint-disable-next-line no-console
380 console.log(JSON.stringify(data));
381 }
382
383 /////////////////////////////
384
385 if (force) {
386 // like kill, but don't exit the process, so we go on to start a fresh server
387 let foundPid;
388 try {
389 foundPid = await killServerIfItExists(port, info);
390 info(`killed Sapling Web server process ${foundPid}`);
391 } catch (err: unknown) {
392 info(`did not stop previous Sapling Web server: ${(err as Error).toString()}`);
393 }
394 } else if (kill) {
395 let foundPid;
396 try {
397 foundPid = await killServerIfItExists(port, info);
398 } catch (err: unknown) {
399 errorAndExit((err as Error).toString());
400 }
401 info(`killed Sapling Web server process ${foundPid}`);
402 process.exit(0);
403 }
404
405 // Since our spawned server can run processes and make authenticated requests,
406 // we require a token in requests to match the one created here.
407 // The sensitive token is given the to the client to authenticate requests.
408 // The challenge token can be queried by the client to authenticate the server.
409 const [sensitiveToken, challengeToken] = await Promise.all([generateToken(), generateToken()]);
410
411 const logFileLocation = stdout
412 ? 'stdout'
413 : path.join(
414 await fs.promises.mkdtemp(path.join(os.tmpdir(), 'isl-server-log')),
415 'isl-server.log',
416 );
417
418 /**
419 * Returns the URL the user can use to open ISL. Because this is often handed
420 * off as an argument to another process, we must take great care when
421 * constructing this argument.
422 */
423 function getURL(port: number, token: string, cwd: string): URL {
424 // Although `port` is where our server is actually hosting from,
425 // in dev mode Vite will start on 3000 and proxy requests to the server.
426 // We only get the source build by opening from port 3000.
427 const VITE_DEFAULT_PORT = 3000;
428
429 let serverPort: number;
430 if (isDevMode) {
431 serverPort = VITE_DEFAULT_PORT;
432 } else {
433 if (!Number.isInteger(port) || port < 0) {
434 throw Error(`illegal port: \`${port}\``);
435 }
436 serverPort = port;
437 }
438 const urlArgs: Record<string, string> = {
439 token: encodeURIComponent(token),
440 cwd: encodeURIComponent(cwd),
441 };
442 if (sessionId) {
443 urlArgs.sessionId = encodeURIComponent(sessionId);
444 }
445 const platformPath = getPlatformIndexHtmlPath(platform);
446 const url = `http://localhost:${serverPort}/${platformPath}?${Object.entries(urlArgs)
447 .map(([key, value]) => `${key}=${value}`)
448 .join('&')}`;
449 return new URL(url);
450 }
451
452 /////////////////////////////
453
454 const result = await callStartServer({
455 foreground,
456 port,
457 sensitiveToken,
458 challengeToken,
459 logFileLocation,
460 logInfo: info,
461 command,
462 slVersion,
463 readOnly,
464 });
465
466 if (result.type === 'addressInUse' && !force) {
467 // This port is already in use. Determine if it's a pre-existing ISL server,
468 // and find the appropriate saved token, and reconstruct URL if recovered.
469
470 const existingServerInfo = await lifecycle.readExistingServerFileWithRetries(port);
471 if (!existingServerInfo) {
472 const errorMessage =
473 'failed to find existing server file. This port might not be being used by a Sapling Web server.\n' +
474 suggestDebugPortIssue(port);
475 if (json) {
476 outputJson({
477 error: errorMessage,
478 });
479 } else {
480 info(errorMessage);
481 }
482 process.exit(1);
483 }
484
485 const pid = await lifecycle.checkIfServerIsAliveAndIsISL(info, port, existingServerInfo);
486 if (pid == null) {
487 const errorMessage =
488 `port ${port} is already in use, but not by an Sapling Web server.\n` +
489 suggestDebugPortIssue(port);
490 if (json) {
491 outputJson({
492 error: errorMessage,
493 });
494 } else {
495 info(errorMessage);
496 }
497 process.exit(1);
498 }
499
500 let killAndSpawnAgain = false;
501 if (existingServerInfo.command !== command) {
502 info(
503 `warning: Starting a fresh server to use command '${command}' (existing server was using '${existingServerInfo.command}').`,
504 );
505 killAndSpawnAgain = true;
506 } else if (existingServerInfo.slVersion !== slVersion) {
507 info(
508 `warning: sl version has changed since last server was started. Starting a fresh server to use latest version '${slVersion}'.`,
509 );
510 killAndSpawnAgain = true;
511 }
512
513 if (killAndSpawnAgain) {
514 try {
515 await killServerIfItExists(port, info);
516 } catch (err: unknown) {
517 errorAndExit(`did not stop previous Sapling Web server: ${(err as Error).toString()}`);
518 }
519
520 // Now that we killed the server, try the whole thing again to spawn a new instance.
521 // We're guaranteed to not go down the same code path since the last authentic server was killed.
522 // We also know --force or --kill could not have been supplied.
523 await runProxyMain(args);
524 return;
525 }
526
527 const url = getURL(port as number, existingServerInfo.sensitiveToken, cwd);
528 info('re-used existing Sapling Web server');
529 info('\naccess Sapling Web with this link:');
530 info(String(url));
531
532 if (json) {
533 outputJson({
534 url: url.href,
535 port: port as number,
536 token: existingServerInfo.sensitiveToken,
537 pid,
538 wasServerReused: true,
539 logFileLocation: existingServerInfo.logFileLocation,
540 cwd,
541 command: existingServerInfo.command,
542 });
543 } else if (openUrl) {
544 maybeOpenURL(url);
545 }
546 process.exit(0);
547 } else if (result.type === 'success') {
548 // The server successfully started on this port
549 // Save the server information for reuse and print the URL for use
550
551 try {
552 await ensureExistingServerFolder();
553 await deleteExistingServerFile(port);
554 await writeExistingServerFile(port, {
555 sensitiveToken,
556 challengeToken,
557 logFileLocation,
558 command,
559 slVersion,
560 });
561 } catch (error) {
562 info(
563 'failed to save server information reuse. ' +
564 'This server will remain, but future invocations on this port will not be able to reuse this instance.',
565 error,
566 );
567 }
568
569 const {port: portInUse} = result;
570 const url = getURL(portInUse, sensitiveToken, cwd);
571 info('started a new server');
572 info('\naccess Sapling Web with this link:');
573 info(String(url));
574 if (json) {
575 outputJson({
576 url: url.href,
577 port: portInUse,
578 token: sensitiveToken,
579 pid: result.pid,
580 wasServerReused: false,
581 logFileLocation,
582 cwd,
583 command,
584 });
585 }
586
587 if (openUrl) {
588 maybeOpenURL(url);
589 }
590
591 // If --foreground was not specified, we can kill this process, but the
592 // web server in the child process will stay alive.
593 if (!foreground) {
594 process.exit(0);
595 }
596 } else if (result.type === 'error') {
597 errorAndExit(result.error);
598 }
599}
600
601/**
602 * Finds any existing ISL server process running on `port`.
603 * If one is found, it is killed and the PID is returned.
604 * Otherwise, an error is returned
605 */
606export async function killServerIfItExists(
607 port: number,
608 info: typeof console.info,
609): Promise<number> {
610 const existingServerInfo = await lifecycle.readExistingServerFileWithRetries(port);
611 if (!existingServerInfo) {
612 throw new Error(`could not find existing server information to kill on port ${port}`);
613 }
614 const pid = await lifecycle.checkIfServerIsAliveAndIsISL(
615 info,
616 port,
617 existingServerInfo,
618 /* silent */ true,
619 );
620 if (!pid) {
621 throw new Error(`could not find existing server process to kill on port ${port}`);
622 }
623 try {
624 process.kill(pid);
625 } catch (err) {
626 throw new Error(
627 `could not kill previous Sapling Web server process with PID ${pid}. This instance may no longer be running.`,
628 );
629 }
630 return pid;
631}
632
633/**
634 * Normalize a port into a number or false.
635 */
636function normalizePort(val: string): number | false {
637 const port = parseInt(val, 10);
638 return !isNaN(port) && port >= 0 ? port : false;
639}
640
641/**
642 * Text to include in an error message to help the user self-diagnose their
643 * "port already in use" issue.
644 */
645function suggestDebugPortIssue(port: number): string {
646 if (process.platform !== 'win32') {
647 return (
648 `try running \`lsof -i :${port}\` to see what is running on port ${port}, ` +
649 'or just try using a different port.'
650 );
651 } else {
652 return 'try using a different port.';
653 }
654}
655
656/**
657 * Because `url` will be passed to the "opener" executable for the platform,
658 * the caller must take responsibility for ensuring the integrity of the
659 * `url` argument.
660 */
661function maybeOpenURL(url: URL): void {
662 const {href} = url;
663 // Basic sanity checking: this does not eliminate all illegal inputs.
664 if (!href.startsWith('http://') || href.indexOf(' ') !== -1) {
665 throw Error(`illegal URL: \`href\``);
666 }
667
668 let openCommand: string;
669 let shell = false;
670 let args: string[] = [href];
671 switch (process.platform) {
672 case 'darwin': {
673 openCommand = '/usr/bin/open';
674 break;
675 }
676 case 'win32': {
677 // START ["title"] command
678 openCommand = 'start';
679 // Trust `href`. Use naive quoting.
680 args = ['"ISL"', `"${href}"`];
681 // START is a shell (cmd.exe) builtin, not a standalone exe.
682 shell = true;
683 break;
684 }
685 default: {
686 openCommand = 'xdg-open';
687 break;
688 }
689 }
690
691 // Note that if openCommand does not exist on the host, this will fail with
692 // ENOENT. Often, this is fine: the user could start isl on a headless
693 // machine, but then set up tunneling to reach the server from another host.
694 const child = child_process.spawn(openCommand, args, {
695 detached: true,
696 shell,
697 stdio: 'ignore' as IOType,
698 windowsHide: true,
699 windowsVerbatimArguments: true,
700 });
701
702 // While `/usr/bin/open` on macOS and `start` on Windows are expected to be
703 // available, xdg-open is not guaranteed, so report an appropriate error in
704 // this case.
705 child.on('error', (error: NodeJS.ErrnoException) => {
706 if (error.code === 'ENOENT') {
707 // eslint-disable-next-line no-console
708 console.error(
709 `command \`${openCommand}\` not found: run with --no-open to suppress this message`,
710 );
711 } else {
712 // eslint-disable-next-line no-console
713 console.error(
714 `unexpected error running command \`${openCommand} ${args.join(' ')}\`:`,
715 error,
716 );
717 }
718 });
719}
720