addons/isl-server/proxy/startServer.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {PlatformName} from 'isl/src/types';
b69ab319import type {IOType} from 'node:child_process';
b69ab3110import type {ChildProcessResponse} from './child';
b69ab3111import type {StartServerArgs, StartServerResult} from './server';
b69ab3112
b69ab3113import child_process from 'node:child_process';
b69ab3114import crypto from 'node:crypto';
b69ab3115import fs from 'node:fs';
b69ab3116import os from 'node:os';
b69ab3117import path from 'node:path';
b69ab3118import {
b69ab3119 deleteExistingServerFile,
b69ab3120 ensureExistingServerFolder,
b69ab3121 writeExistingServerFile,
b69ab3122} from './existingServerStateFiles';
b69ab3123import * as lifecycle from './serverLifecycle';
b69ab3124
b69ab3125const DEFAULT_PORT = '3001';
b69ab3126
b69ab3127const HELP_MESSAGE = `\
b69ab3128usage: isl [--port PORT]
b69ab3129
b69ab3130optional arguments:
b69ab3131 -h, --help Show this message
b69ab3132 -f, --foreground Run the server process in the foreground.
b69ab3133 --no-open Do not try to open a browser after starting the server
b69ab3134 -p, --port Port to listen on (default: ${DEFAULT_PORT})
b69ab3135 --json Output machine-readable JSON
b69ab3136 --stdout Write server logs to stdout instead of a tmp file
b69ab3137 --dev Open on port 3000 despite hosting ${DEFAULT_PORT} (or custom port with -p)
b69ab3138 This is useless unless running from source to hook into Vite dev mode
b69ab3139 --kill Do not start Sapling Web, just kill any previously running Sapling Web server on the specified port
b69ab3140 Note that this will disrupt other windows still using the previous Sapling Web server.
b69ab3141 --force Kill any existing Sapling Web server on the specified port, then start a new server.
b69ab3142 Note that this will disrupt other windows still using the previous Sapling Web server.
b69ab3143 --command name Set which command to run for sl commands (default: sl)
b69ab3144 --cwd dir Sets the current working directory, allowing changing the repo.
b69ab3145 --sl-version v Set version number of sl was used to spawn the server (default: '(dev)')
b69ab3146 --platform Set which platform implementation to use by changing the resulting URL.
b69ab3147 Used to embed Sapling Web into non-browser web environments like IDEs.
b69ab3148 --session id Provide a specific ID for this session used in analytics.
b69ab3149`;
b69ab3150
b69ab3151type JsonOutput =
b69ab3152 | {
b69ab3153 port: number;
b69ab3154 url: string;
b69ab3155 token: string;
b69ab3156 /** Process ID for the server. */
b69ab3157 pid: number;
b69ab3158 wasServerReused: boolean;
b69ab3159 logFileLocation: string | 'stdout';
b69ab3160 cwd: string;
b69ab3161 command: string;
b69ab3162 }
b69ab3163 | {error: string};
b69ab3164
b69ab3165function errorAndExit(message: string, code = 1): never {
b69ab3166 // eslint-disable-next-line no-console
b69ab3167 console.error(message);
b69ab3168 process.exit(code);
b69ab3169}
b69ab3170
b69ab3171type Args = {
b69ab3172 help: boolean;
b69ab3173 foreground: boolean;
b69ab3174 openUrl: boolean;
b69ab3175 port: number;
b69ab3176 isDevMode: boolean;
b69ab3177 json: boolean;
b69ab3178 stdout: boolean;
b69ab3179 platform: string | undefined;
b69ab3180 kill: boolean;
b69ab3181 force: boolean;
b69ab3182 slVersion: string;
b69ab3183 command: string;
b69ab3184 cwd: string | undefined;
b69ab3185 sessionId: string | undefined;
b69ab3186};
b69ab3187
b69ab3188// Rudimentary arg parser to avoid the need for a third-party dependency.
b69ab3189export function parseArgs(args: Array<string> = process.argv.slice(2)): Args {
b69ab3190 let help = false;
b69ab3191 // Before we added arg parsing, the $PORT environment variable was the only
b69ab3192 // way to set the port. Once callers have been updated to use --port, drop
b69ab3193 // support for the environment variable.
b69ab3194 let port = normalizePort(process.env.PORT || DEFAULT_PORT);
b69ab3195 let openUrl = true;
b69ab3196
b69ab3197 const len = args.length;
b69ab3198 let isDevMode = false;
b69ab3199 let json = false;
b69ab31100 let stdout = false;
b69ab31101 let foreground = false;
b69ab31102 let kill = false;
b69ab31103 let force = false;
b69ab31104 let command = process.env.SL ?? 'sl';
b69ab31105 let cwd: string | undefined = undefined;
b69ab31106 let slVersion = '(dev)';
b69ab31107 let platform: string | undefined = undefined;
b69ab31108 let sessionId: string | undefined = undefined;
44863ab109 let readOnly = false;
b69ab31110 let i = 0;
b69ab31111 function consumeArgValue(arg: string) {
b69ab31112 if (i >= len) {
b69ab31113 errorAndExit(`no value supplied for ${arg}`);
b69ab31114 } else {
b69ab31115 return args[++i];
b69ab31116 }
b69ab31117 }
b69ab31118 while (i < len) {
b69ab31119 const arg = args[i];
b69ab31120 switch (arg) {
b69ab31121 case '--no-open': {
b69ab31122 openUrl = false;
b69ab31123 break;
b69ab31124 }
b69ab31125 case '--foreground':
b69ab31126 case '-f': {
b69ab31127 foreground = true;
b69ab31128 break;
b69ab31129 }
b69ab31130 case '--port':
b69ab31131 case '-p': {
b69ab31132 const rawPort = consumeArgValue(arg);
b69ab31133 const parsedPort = normalizePort(rawPort);
b69ab31134 if (parsedPort !== false) {
b69ab31135 port = parsedPort as number;
b69ab31136 } else {
b69ab31137 errorAndExit(`could not parse port: '${rawPort}'`);
b69ab31138 }
b69ab31139 break;
b69ab31140 }
b69ab31141 case '--dev': {
b69ab31142 isDevMode = true;
b69ab31143 break;
b69ab31144 }
b69ab31145 case '--kill': {
b69ab31146 kill = true;
b69ab31147 break;
b69ab31148 }
b69ab31149 case '--force': {
b69ab31150 force = true;
b69ab31151 break;
b69ab31152 }
b69ab31153 case '--command': {
b69ab31154 command = consumeArgValue(arg);
b69ab31155 break;
b69ab31156 }
b69ab31157 case '--cwd': {
b69ab31158 cwd = consumeArgValue(arg);
b69ab31159 break;
b69ab31160 }
b69ab31161 case '--sl-version': {
b69ab31162 slVersion = consumeArgValue(arg);
b69ab31163 break;
b69ab31164 }
b69ab31165 case '--json': {
b69ab31166 json = true;
b69ab31167 break;
b69ab31168 }
b69ab31169 case '--stdout': {
b69ab31170 stdout = true;
b69ab31171 break;
b69ab31172 }
b69ab31173 case '--session': {
b69ab31174 sessionId = consumeArgValue(arg);
b69ab31175 break;
b69ab31176 }
b69ab31177 case '--platform': {
b69ab31178 platform = consumeArgValue(arg);
b69ab31179 if (!isValidCustomPlatform(platform)) {
b69ab31180 errorAndExit(
b69ab31181 `"${platform}" is not a valid platform. Valid options: ${validPlatforms.join(', ')}`,
b69ab31182 );
b69ab31183 }
b69ab31184 break;
b69ab31185 }
44863ab186 case '--read-only': {
44863ab187 readOnly = true;
44863ab188 break;
44863ab189 }
b69ab31190 case '--help':
b69ab31191 case '-h': {
b69ab31192 help = true;
b69ab31193 break;
b69ab31194 }
b69ab31195 default: {
b69ab31196 errorAndExit(`unexpected arg: ${arg}`);
b69ab31197 }
b69ab31198 }
b69ab31199 ++i;
b69ab31200 }
b69ab31201
b69ab31202 if (port === false) {
b69ab31203 errorAndExit('port was not a positive integer');
b69ab31204 }
b69ab31205
b69ab31206 if (stdout && !foreground) {
b69ab31207 // eslint-disable-next-line no-console
b69ab31208 console.info('NOTE: setting --foreground because --stdout was specified');
b69ab31209 foreground = true;
b69ab31210 }
b69ab31211
b69ab31212 if (kill && force) {
b69ab31213 // eslint-disable-next-line no-console
b69ab31214 console.info('NOTE: setting --kill and --force is redundant');
b69ab31215 }
b69ab31216
b69ab31217 return {
b69ab31218 help,
b69ab31219 foreground,
b69ab31220 openUrl,
b69ab31221 port,
b69ab31222 isDevMode,
b69ab31223 json,
b69ab31224 stdout,
b69ab31225 platform,
b69ab31226 kill,
b69ab31227 force,
b69ab31228 slVersion,
b69ab31229 command,
b69ab31230 cwd,
b69ab31231 sessionId,
44863ab232 readOnly,
b69ab31233 };
b69ab31234}
b69ab31235
b69ab31236const CRYPTO_KEY_LENGTH = 128;
b69ab31237
b69ab31238/** Generates a 128-bit secure random token. */
b69ab31239function generateToken(): Promise<string> {
b69ab31240 // crypto.generateKey() was introduced in v15.0.0. For earlier versions of
b69ab31241 // Node, we can use crypto.createDiffieHellman().
b69ab31242 if (typeof crypto.generateKey === 'function') {
b69ab31243 const {generateKey} = crypto;
b69ab31244 return new Promise((res, rej) =>
b69ab31245 generateKey('hmac', {length: CRYPTO_KEY_LENGTH}, (err, key) =>
b69ab31246 err ? rej(err) : res(key.export().toString('hex')),
b69ab31247 ),
b69ab31248 );
b69ab31249 } else {
b69ab31250 return Promise.resolve(
b69ab31251 crypto.createDiffieHellman(CRYPTO_KEY_LENGTH).generateKeys().toString('hex'),
b69ab31252 );
b69ab31253 }
b69ab31254}
b69ab31255
b69ab31256const validPlatforms: Array<PlatformName> = [
b69ab31257 'androidStudio',
b69ab31258 'androidStudioRemote',
b69ab31259 'webview',
b69ab31260 'chromelike_app',
b69ab31261 'visualStudio',
b69ab31262 'obsidian',
b69ab31263];
b69ab31264function isValidCustomPlatform(name: string): name is PlatformName {
b69ab31265 return validPlatforms.includes(name as PlatformName);
b69ab31266}
b69ab31267/** Return the "index" html path like `androidStudio.html`. */
b69ab31268function getPlatformIndexHtmlPath(name?: string): string {
b69ab31269 if (name == null || name === 'browser') {
b69ab31270 return '';
b69ab31271 }
b69ab31272 if (name === 'chromelike_app') {
b69ab31273 // need to match isl/build/.vite/manifest.json
b69ab31274 return 'chromelikeApp.html';
b69ab31275 }
b69ab31276 if (isValidCustomPlatform(name)) {
b69ab31277 return `${encodeURIComponent(name)}.html`;
b69ab31278 }
b69ab31279 return '';
b69ab31280}
b69ab31281
b69ab31282/**
b69ab31283 * This calls the `startServer()` function that launches the server for ISL,
b69ab31284 * though the mechanism is conditional on the `foreground` param:
b69ab31285 *
b69ab31286 * - If `foreground` is true, then `startServer()` will be called directly as
b69ab31287 * part of this process and it will continue run in the foreground. The user
b69ab31288 * can do ctrl+c to kill the server (or ctrl+z to suspend it), as they would
b69ab31289 * for any other process.
b69ab31290 * - If `foreground` is false, then we will spawn a new process via
b69ab31291 * `child_process.fork()` that runs `child.ts` in this folder. IPC is done via
b69ab31292 * `child.on('message')` and `process.send()`, though once this process has
b69ab31293 * confirmed that the server is up and running, it can exit while the child
b69ab31294 * will continue to run in the background.
b69ab31295 */
b69ab31296function callStartServer(args: StartServerArgs): Promise<StartServerResult> {
b69ab31297 if (args.foreground) {
b69ab31298 return import('./server').then(({startServer}) => startServer(args));
b69ab31299 } else {
b69ab31300 return new Promise(resolve => {
b69ab31301 // We pass the args via an environment variable because StartServerArgs
b69ab31302 // contains sensitive information and users on the system can see the
b69ab31303 // command line arguments of other users' processes, but not the
b69ab31304 // environment variables of other users' processes.
b69ab31305 //
b69ab31306 // We could also consider streaming the input as newline-delimited JSON
b69ab31307 // via stdin, though the max length for an environment variable seems
b69ab31308 // large enough for our needs.
b69ab31309 const env = {
b69ab31310 ...process.env,
b69ab31311 ISL_SERVER_ARGS: JSON.stringify({...args, logInfo: null}),
b69ab31312 };
b69ab31313 const options = {
b69ab31314 env,
b69ab31315 detached: true,
b69ab31316 // Child process should not inherit fds from the parent process, or
b69ab31317 // else something like `node run-proxy.js --json | jq` will never
b69ab31318 // terminate because the child process will keep stdout from the
b69ab31319 // parent process open, so jq will continue to read from it.
b69ab31320 stdio: 'ignore' as IOType,
b69ab31321 };
b69ab31322 const pathToChildModule = path.join(path.dirname(__filename), 'child');
b69ab31323 const child = child_process.fork(pathToChildModule, [], options);
b69ab31324 child.on('message', (message: ChildProcessResponse) => {
b69ab31325 switch (message.type) {
b69ab31326 case 'result': {
b69ab31327 resolve(message.result);
b69ab31328 break;
b69ab31329 }
b69ab31330 case 'message': {
b69ab31331 args.logInfo(...message.args);
b69ab31332 break;
b69ab31333 }
b69ab31334 }
b69ab31335 });
b69ab31336 });
b69ab31337 }
b69ab31338}
b69ab31339
b69ab31340export async function runProxyMain(args: Args) {
b69ab31341 const {
b69ab31342 help,
b69ab31343 foreground,
b69ab31344 openUrl,
b69ab31345 port,
b69ab31346 isDevMode,
b69ab31347 json,
b69ab31348 stdout,
b69ab31349 platform,
b69ab31350 kill,
b69ab31351 force,
b69ab31352 slVersion,
b69ab31353 command,
b69ab31354 sessionId,
44863ab355 readOnly,
b69ab31356 } = args;
b69ab31357 if (help) {
b69ab31358 errorAndExit(HELP_MESSAGE, 0);
b69ab31359 }
b69ab31360
b69ab31361 const cwd = args.cwd ?? process.cwd();
b69ab31362
b69ab31363 function info(...args: Parameters<typeof console.log>): void {
b69ab31364 if (json) {
b69ab31365 return;
b69ab31366 }
b69ab31367 // eslint-disable-next-line no-console
b69ab31368 console.info(...args);
b69ab31369 }
b69ab31370
b69ab31371 /**
b69ab31372 * Output JSON information for use with `--json`.
b69ab31373 * Should only be called once per lifecycle of the server.
b69ab31374 */
b69ab31375 function outputJson(data: JsonOutput) {
b69ab31376 if (!json) {
b69ab31377 return;
b69ab31378 }
b69ab31379 // eslint-disable-next-line no-console
b69ab31380 console.log(JSON.stringify(data));
b69ab31381 }
b69ab31382
b69ab31383 /////////////////////////////
b69ab31384
b69ab31385 if (force) {
b69ab31386 // like kill, but don't exit the process, so we go on to start a fresh server
b69ab31387 let foundPid;
b69ab31388 try {
b69ab31389 foundPid = await killServerIfItExists(port, info);
b69ab31390 info(`killed Sapling Web server process ${foundPid}`);
b69ab31391 } catch (err: unknown) {
b69ab31392 info(`did not stop previous Sapling Web server: ${(err as Error).toString()}`);
b69ab31393 }
b69ab31394 } else if (kill) {
b69ab31395 let foundPid;
b69ab31396 try {
b69ab31397 foundPid = await killServerIfItExists(port, info);
b69ab31398 } catch (err: unknown) {
b69ab31399 errorAndExit((err as Error).toString());
b69ab31400 }
b69ab31401 info(`killed Sapling Web server process ${foundPid}`);
b69ab31402 process.exit(0);
b69ab31403 }
b69ab31404
b69ab31405 // Since our spawned server can run processes and make authenticated requests,
b69ab31406 // we require a token in requests to match the one created here.
b69ab31407 // The sensitive token is given the to the client to authenticate requests.
b69ab31408 // The challenge token can be queried by the client to authenticate the server.
b69ab31409 const [sensitiveToken, challengeToken] = await Promise.all([generateToken(), generateToken()]);
b69ab31410
b69ab31411 const logFileLocation = stdout
b69ab31412 ? 'stdout'
b69ab31413 : path.join(
b69ab31414 await fs.promises.mkdtemp(path.join(os.tmpdir(), 'isl-server-log')),
b69ab31415 'isl-server.log',
b69ab31416 );
b69ab31417
b69ab31418 /**
b69ab31419 * Returns the URL the user can use to open ISL. Because this is often handed
b69ab31420 * off as an argument to another process, we must take great care when
b69ab31421 * constructing this argument.
b69ab31422 */
b69ab31423 function getURL(port: number, token: string, cwd: string): URL {
b69ab31424 // Although `port` is where our server is actually hosting from,
b69ab31425 // in dev mode Vite will start on 3000 and proxy requests to the server.
b69ab31426 // We only get the source build by opening from port 3000.
b69ab31427 const VITE_DEFAULT_PORT = 3000;
b69ab31428
b69ab31429 let serverPort: number;
b69ab31430 if (isDevMode) {
b69ab31431 serverPort = VITE_DEFAULT_PORT;
b69ab31432 } else {
b69ab31433 if (!Number.isInteger(port) || port < 0) {
b69ab31434 throw Error(`illegal port: \`${port}\``);
b69ab31435 }
b69ab31436 serverPort = port;
b69ab31437 }
b69ab31438 const urlArgs: Record<string, string> = {
b69ab31439 token: encodeURIComponent(token),
b69ab31440 cwd: encodeURIComponent(cwd),
b69ab31441 };
b69ab31442 if (sessionId) {
b69ab31443 urlArgs.sessionId = encodeURIComponent(sessionId);
b69ab31444 }
b69ab31445 const platformPath = getPlatformIndexHtmlPath(platform);
b69ab31446 const url = `http://localhost:${serverPort}/${platformPath}?${Object.entries(urlArgs)
b69ab31447 .map(([key, value]) => `${key}=${value}`)
b69ab31448 .join('&')}`;
b69ab31449 return new URL(url);
b69ab31450 }
b69ab31451
b69ab31452 /////////////////////////////
b69ab31453
b69ab31454 const result = await callStartServer({
b69ab31455 foreground,
b69ab31456 port,
b69ab31457 sensitiveToken,
b69ab31458 challengeToken,
b69ab31459 logFileLocation,
b69ab31460 logInfo: info,
b69ab31461 command,
b69ab31462 slVersion,
44863ab463 readOnly,
b69ab31464 });
b69ab31465
b69ab31466 if (result.type === 'addressInUse' && !force) {
b69ab31467 // This port is already in use. Determine if it's a pre-existing ISL server,
b69ab31468 // and find the appropriate saved token, and reconstruct URL if recovered.
b69ab31469
b69ab31470 const existingServerInfo = await lifecycle.readExistingServerFileWithRetries(port);
b69ab31471 if (!existingServerInfo) {
b69ab31472 const errorMessage =
b69ab31473 'failed to find existing server file. This port might not be being used by a Sapling Web server.\n' +
b69ab31474 suggestDebugPortIssue(port);
b69ab31475 if (json) {
b69ab31476 outputJson({
b69ab31477 error: errorMessage,
b69ab31478 });
b69ab31479 } else {
b69ab31480 info(errorMessage);
b69ab31481 }
b69ab31482 process.exit(1);
b69ab31483 }
b69ab31484
b69ab31485 const pid = await lifecycle.checkIfServerIsAliveAndIsISL(info, port, existingServerInfo);
b69ab31486 if (pid == null) {
b69ab31487 const errorMessage =
b69ab31488 `port ${port} is already in use, but not by an Sapling Web server.\n` +
b69ab31489 suggestDebugPortIssue(port);
b69ab31490 if (json) {
b69ab31491 outputJson({
b69ab31492 error: errorMessage,
b69ab31493 });
b69ab31494 } else {
b69ab31495 info(errorMessage);
b69ab31496 }
b69ab31497 process.exit(1);
b69ab31498 }
b69ab31499
b69ab31500 let killAndSpawnAgain = false;
b69ab31501 if (existingServerInfo.command !== command) {
b69ab31502 info(
b69ab31503 `warning: Starting a fresh server to use command '${command}' (existing server was using '${existingServerInfo.command}').`,
b69ab31504 );
b69ab31505 killAndSpawnAgain = true;
b69ab31506 } else if (existingServerInfo.slVersion !== slVersion) {
b69ab31507 info(
b69ab31508 `warning: sl version has changed since last server was started. Starting a fresh server to use latest version '${slVersion}'.`,
b69ab31509 );
b69ab31510 killAndSpawnAgain = true;
b69ab31511 }
b69ab31512
b69ab31513 if (killAndSpawnAgain) {
b69ab31514 try {
b69ab31515 await killServerIfItExists(port, info);
b69ab31516 } catch (err: unknown) {
b69ab31517 errorAndExit(`did not stop previous Sapling Web server: ${(err as Error).toString()}`);
b69ab31518 }
b69ab31519
b69ab31520 // Now that we killed the server, try the whole thing again to spawn a new instance.
b69ab31521 // We're guaranteed to not go down the same code path since the last authentic server was killed.
b69ab31522 // We also know --force or --kill could not have been supplied.
b69ab31523 await runProxyMain(args);
b69ab31524 return;
b69ab31525 }
b69ab31526
b69ab31527 const url = getURL(port as number, existingServerInfo.sensitiveToken, cwd);
b69ab31528 info('re-used existing Sapling Web server');
b69ab31529 info('\naccess Sapling Web with this link:');
b69ab31530 info(String(url));
b69ab31531
b69ab31532 if (json) {
b69ab31533 outputJson({
b69ab31534 url: url.href,
b69ab31535 port: port as number,
b69ab31536 token: existingServerInfo.sensitiveToken,
b69ab31537 pid,
b69ab31538 wasServerReused: true,
b69ab31539 logFileLocation: existingServerInfo.logFileLocation,
b69ab31540 cwd,
b69ab31541 command: existingServerInfo.command,
b69ab31542 });
b69ab31543 } else if (openUrl) {
b69ab31544 maybeOpenURL(url);
b69ab31545 }
b69ab31546 process.exit(0);
b69ab31547 } else if (result.type === 'success') {
b69ab31548 // The server successfully started on this port
b69ab31549 // Save the server information for reuse and print the URL for use
b69ab31550
b69ab31551 try {
b69ab31552 await ensureExistingServerFolder();
b69ab31553 await deleteExistingServerFile(port);
b69ab31554 await writeExistingServerFile(port, {
b69ab31555 sensitiveToken,
b69ab31556 challengeToken,
b69ab31557 logFileLocation,
b69ab31558 command,
b69ab31559 slVersion,
b69ab31560 });
b69ab31561 } catch (error) {
b69ab31562 info(
b69ab31563 'failed to save server information reuse. ' +
b69ab31564 'This server will remain, but future invocations on this port will not be able to reuse this instance.',
b69ab31565 error,
b69ab31566 );
b69ab31567 }
b69ab31568
b69ab31569 const {port: portInUse} = result;
b69ab31570 const url = getURL(portInUse, sensitiveToken, cwd);
b69ab31571 info('started a new server');
b69ab31572 info('\naccess Sapling Web with this link:');
b69ab31573 info(String(url));
b69ab31574 if (json) {
b69ab31575 outputJson({
b69ab31576 url: url.href,
b69ab31577 port: portInUse,
b69ab31578 token: sensitiveToken,
b69ab31579 pid: result.pid,
b69ab31580 wasServerReused: false,
b69ab31581 logFileLocation,
b69ab31582 cwd,
b69ab31583 command,
b69ab31584 });
b69ab31585 }
b69ab31586
b69ab31587 if (openUrl) {
b69ab31588 maybeOpenURL(url);
b69ab31589 }
b69ab31590
b69ab31591 // If --foreground was not specified, we can kill this process, but the
b69ab31592 // web server in the child process will stay alive.
b69ab31593 if (!foreground) {
b69ab31594 process.exit(0);
b69ab31595 }
b69ab31596 } else if (result.type === 'error') {
b69ab31597 errorAndExit(result.error);
b69ab31598 }
b69ab31599}
b69ab31600
b69ab31601/**
b69ab31602 * Finds any existing ISL server process running on `port`.
b69ab31603 * If one is found, it is killed and the PID is returned.
b69ab31604 * Otherwise, an error is returned
b69ab31605 */
b69ab31606export async function killServerIfItExists(
b69ab31607 port: number,
b69ab31608 info: typeof console.info,
b69ab31609): Promise<number> {
b69ab31610 const existingServerInfo = await lifecycle.readExistingServerFileWithRetries(port);
b69ab31611 if (!existingServerInfo) {
b69ab31612 throw new Error(`could not find existing server information to kill on port ${port}`);
b69ab31613 }
b69ab31614 const pid = await lifecycle.checkIfServerIsAliveAndIsISL(
b69ab31615 info,
b69ab31616 port,
b69ab31617 existingServerInfo,
b69ab31618 /* silent */ true,
b69ab31619 );
b69ab31620 if (!pid) {
b69ab31621 throw new Error(`could not find existing server process to kill on port ${port}`);
b69ab31622 }
b69ab31623 try {
b69ab31624 process.kill(pid);
b69ab31625 } catch (err) {
b69ab31626 throw new Error(
b69ab31627 `could not kill previous Sapling Web server process with PID ${pid}. This instance may no longer be running.`,
b69ab31628 );
b69ab31629 }
b69ab31630 return pid;
b69ab31631}
b69ab31632
b69ab31633/**
b69ab31634 * Normalize a port into a number or false.
b69ab31635 */
b69ab31636function normalizePort(val: string): number | false {
b69ab31637 const port = parseInt(val, 10);
b69ab31638 return !isNaN(port) && port >= 0 ? port : false;
b69ab31639}
b69ab31640
b69ab31641/**
b69ab31642 * Text to include in an error message to help the user self-diagnose their
b69ab31643 * "port already in use" issue.
b69ab31644 */
b69ab31645function suggestDebugPortIssue(port: number): string {
b69ab31646 if (process.platform !== 'win32') {
b69ab31647 return (
b69ab31648 `try running \`lsof -i :${port}\` to see what is running on port ${port}, ` +
b69ab31649 'or just try using a different port.'
b69ab31650 );
b69ab31651 } else {
b69ab31652 return 'try using a different port.';
b69ab31653 }
b69ab31654}
b69ab31655
b69ab31656/**
b69ab31657 * Because `url` will be passed to the "opener" executable for the platform,
b69ab31658 * the caller must take responsibility for ensuring the integrity of the
b69ab31659 * `url` argument.
b69ab31660 */
b69ab31661function maybeOpenURL(url: URL): void {
b69ab31662 const {href} = url;
b69ab31663 // Basic sanity checking: this does not eliminate all illegal inputs.
b69ab31664 if (!href.startsWith('http://') || href.indexOf(' ') !== -1) {
b69ab31665 throw Error(`illegal URL: \`href\``);
b69ab31666 }
b69ab31667
b69ab31668 let openCommand: string;
b69ab31669 let shell = false;
b69ab31670 let args: string[] = [href];
b69ab31671 switch (process.platform) {
b69ab31672 case 'darwin': {
b69ab31673 openCommand = '/usr/bin/open';
b69ab31674 break;
b69ab31675 }
b69ab31676 case 'win32': {
b69ab31677 // START ["title"] command
b69ab31678 openCommand = 'start';
b69ab31679 // Trust `href`. Use naive quoting.
b69ab31680 args = ['"ISL"', `"${href}"`];
b69ab31681 // START is a shell (cmd.exe) builtin, not a standalone exe.
b69ab31682 shell = true;
b69ab31683 break;
b69ab31684 }
b69ab31685 default: {
b69ab31686 openCommand = 'xdg-open';
b69ab31687 break;
b69ab31688 }
b69ab31689 }
b69ab31690
b69ab31691 // Note that if openCommand does not exist on the host, this will fail with
b69ab31692 // ENOENT. Often, this is fine: the user could start isl on a headless
b69ab31693 // machine, but then set up tunneling to reach the server from another host.
b69ab31694 const child = child_process.spawn(openCommand, args, {
b69ab31695 detached: true,
b69ab31696 shell,
b69ab31697 stdio: 'ignore' as IOType,
b69ab31698 windowsHide: true,
b69ab31699 windowsVerbatimArguments: true,
b69ab31700 });
b69ab31701
b69ab31702 // While `/usr/bin/open` on macOS and `start` on Windows are expected to be
b69ab31703 // available, xdg-open is not guaranteed, so report an appropriate error in
b69ab31704 // this case.
b69ab31705 child.on('error', (error: NodeJS.ErrnoException) => {
b69ab31706 if (error.code === 'ENOENT') {
b69ab31707 // eslint-disable-next-line no-console
b69ab31708 console.error(
b69ab31709 `command \`${openCommand}\` not found: run with --no-open to suppress this message`,
b69ab31710 );
b69ab31711 } else {
b69ab31712 // eslint-disable-next-line no-console
b69ab31713 console.error(
b69ab31714 `unexpected error running command \`${openCommand} ${args.join(' ')}\`:`,
b69ab31715 error,
b69ab31716 );
b69ab31717 }
b69ab31718 });
b69ab31719}