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