| 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 | |
| 8 | import type {PlatformName} from 'isl/src/types'; |
| 9 | import type {AddressInfo} from 'node:net'; |
| 10 | import type {ServerPlatform} from '../src/serverPlatform'; |
| 11 | |
| 12 | import {grammars} from 'isl/src/generated/textmate/TextMateGrammarManifest'; |
| 13 | import fs from 'node:fs'; |
| 14 | import http from 'node:http'; |
| 15 | import path from 'node:path'; |
| 16 | import urlModule from 'node:url'; |
| 17 | import WebSocket from 'ws'; |
| 18 | import {repositoryCache} from '../src/RepositoryCache'; |
| 19 | import {CLOSED_AND_SHOULD_NOT_RECONNECT_CODE} from '../src/constants'; |
| 20 | import {onClientConnection} from '../src/index'; |
| 21 | import {areTokensEqual} from './proxyUtils'; |
| 22 | |
| 23 | const ossSmartlogDir = path.join(__dirname, '../../isl'); |
| 24 | |
| 25 | export type StartServerArgs = { |
| 26 | port: number; |
| 27 | sensitiveToken: string; |
| 28 | challengeToken: string; |
| 29 | logFileLocation: string; |
| 30 | logInfo: (...args: Parameters<typeof console.log>) => void; |
| 31 | command: string; |
| 32 | slVersion: string; |
| 33 | foreground: boolean; |
| 34 | readOnly?: boolean; |
| 35 | }; |
| 36 | |
| 37 | export type StartServerResult = |
| 38 | | {type: 'addressInUse'} |
| 39 | | {type: 'success'; port: number; pid: number} |
| 40 | | {type: 'error'; error: string}; |
| 41 | |
| 42 | export type ServerChallengeResponse = { |
| 43 | challengeToken: string; |
| 44 | /** Process ID for the server. */ |
| 45 | pid: number; |
| 46 | }; |
| 47 | |
| 48 | export function startServer({ |
| 49 | port, |
| 50 | sensitiveToken, |
| 51 | challengeToken, |
| 52 | logFileLocation, |
| 53 | logInfo, |
| 54 | command, |
| 55 | slVersion, |
| 56 | foreground, |
| 57 | readOnly, |
| 58 | }: StartServerArgs): Promise<StartServerResult> { |
| 59 | const originalProcessCwd = process.cwd(); |
| 60 | const serverRoot = path.isAbsolute(ossSmartlogDir) |
| 61 | ? ossSmartlogDir |
| 62 | : path.join(originalProcessCwd, ossSmartlogDir); |
| 63 | |
| 64 | return new Promise(resolve => { |
| 65 | try { |
| 66 | const files = JSON.parse( |
| 67 | fs.readFileSync(path.join(serverRoot, 'build/assetList.json'), 'utf-8'), |
| 68 | ) as Array<string>; |
| 69 | |
| 70 | for (const file of files) { |
| 71 | // `file` might have OS slash like `"assets\\stylex.0f7433cc.css". |
| 72 | // Normalize it to URL slash. |
| 73 | requestUrlToResource['/' + file.replace(/\\/g, '/')] = file; |
| 74 | } |
| 75 | } catch (e) { |
| 76 | // ignore... |
| 77 | } |
| 78 | |
| 79 | // Anything not part of the asset-manifest we need to explicitly serve |
| 80 | requestUrlToResource[`/favicon.ico`] = 'favicon.ico'; |
| 81 | |
| 82 | /** |
| 83 | * Event listener for HTTP server "error" event. |
| 84 | */ |
| 85 | function onError(error: {syscall?: string; code?: string}) { |
| 86 | if (error.syscall !== 'listen') { |
| 87 | resolve({type: 'error', error: error.toString()}); |
| 88 | throw error; |
| 89 | } |
| 90 | |
| 91 | // handle specific listen errors with friendly messages |
| 92 | switch (error.code) { |
| 93 | case 'EACCES': { |
| 94 | resolve({type: 'error', error: `Port ${port} requires elevated privileges`}); |
| 95 | throw error; |
| 96 | } |
| 97 | case 'EADDRINUSE': { |
| 98 | resolve({type: 'addressInUse'}); |
| 99 | return; |
| 100 | } |
| 101 | default: |
| 102 | resolve({type: 'error', error: error.toString()}); |
| 103 | throw error; |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Create HTTP server. |
| 109 | */ |
| 110 | const server = http.createServer(async (req, res) => { |
| 111 | if (req.url) { |
| 112 | // Only the websocket is sensitive and requires the token. |
| 113 | // Normal resource requests don't need to check the token. |
| 114 | const {pathname} = urlModule.parse(req.url); |
| 115 | // eslint-disable-next-line no-prototype-builtins |
| 116 | if (pathname != null && requestUrlToResource.hasOwnProperty(pathname)) { |
| 117 | const relativePath = requestUrlToResource[pathname]; |
| 118 | let contents: string | Buffer; |
| 119 | try { |
| 120 | contents = await fs.promises.readFile(path.join(serverRoot, 'build', relativePath)); |
| 121 | } catch (e: unknown) { |
| 122 | res.writeHead(500, {'Content-Type': 'text/plain'}); |
| 123 | res.end(htmlEscape((e as Error).toString())); |
| 124 | return; |
| 125 | } |
| 126 | |
| 127 | const lastDot = relativePath.lastIndexOf('.'); |
| 128 | const ext = relativePath.slice(lastDot + 1); |
| 129 | const contentType = extensionToMIMEType[ext] ?? 'text/plain'; |
| 130 | |
| 131 | res.writeHead(200, {'Content-Type': contentType}); |
| 132 | res.end(contents); |
| 133 | return; |
| 134 | } else if (pathname === '/challenge_authenticity') { |
| 135 | // requests to /challenge_authenticity?token=... allow using the sensitive token to ask |
| 136 | // for the secondary challenge token. |
| 137 | const requestToken = getSearchParams(req.url).get('token'); |
| 138 | if (requestToken && areTokensEqual(requestToken, sensitiveToken)) { |
| 139 | // they know the original token, we can tell them our challenge token |
| 140 | res.writeHead(200, {'Content-Type': 'text/json'}); |
| 141 | const response: ServerChallengeResponse = {challengeToken, pid: process.pid}; |
| 142 | res.end(JSON.stringify(response)); |
| 143 | } else { |
| 144 | res.writeHead(401, {'Content-Type': 'text/json'}); |
| 145 | res.end(JSON.stringify({error: 'invalid token'})); |
| 146 | } |
| 147 | return; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | res.writeHead(404, {'Content-Type': 'text/html'}); |
| 152 | res.end('<html><body>Not Found!</body></html>'); |
| 153 | }); |
| 154 | |
| 155 | /** |
| 156 | * Listen on localhost:port. |
| 157 | */ |
| 158 | const listenHost = readOnly ? '0.0.0.0' : 'localhost'; |
| 159 | const httpServer = server.listen(port, listenHost); |
| 160 | const wsServer = new WebSocket.Server({noServer: true, path: '/ws'}); |
| 161 | wsServer.on('connection', async (socket, connectionRequest) => { |
| 162 | // We require websocket connections to contain the token as a URL search parameter. |
| 163 | let providedToken: string | undefined; |
| 164 | let cwd: string | undefined; |
| 165 | let platform: string | undefined; |
| 166 | let sessionId: string | undefined; |
| 167 | if (connectionRequest.url) { |
| 168 | const searchParams = getSearchParams(connectionRequest.url); |
| 169 | providedToken = searchParams.get('token'); |
| 170 | const cwdParam = searchParams.get('cwd'); |
| 171 | platform = searchParams.get('platform') as string; |
| 172 | sessionId = searchParams.get('sessionId'); |
| 173 | if (cwdParam) { |
| 174 | cwd = decodeURIComponent(cwdParam); |
| 175 | } |
| 176 | } |
| 177 | // In read-only mode, skip token auth (public demo) |
| 178 | if (!readOnly) { |
| 179 | if (!providedToken) { |
| 180 | const reason = 'No token provided in websocket request'; |
| 181 | logInfo('closing ws:', reason); |
| 182 | socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason); |
| 183 | return; |
| 184 | } |
| 185 | if (!areTokensEqual(providedToken, sensitiveToken)) { |
| 186 | const reason = 'Invalid token'; |
| 187 | logInfo('closing ws:', reason); |
| 188 | socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason); |
| 189 | return; |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | let platformImpl: ServerPlatform | undefined = undefined; |
| 194 | switch (platform as PlatformName) { |
| 195 | case 'androidStudio': |
| 196 | platformImpl = (await import('../platform/androidstudioServerPlatform')).platform; |
| 197 | break; |
| 198 | case 'androidStudioRemote': |
| 199 | platformImpl = (await import('../platform/androidStudioRemoteServerPlatform')).platform; |
| 200 | break; |
| 201 | case 'webview': |
| 202 | platformImpl = (await import('../platform/webviewServerPlatform')).platform; |
| 203 | break; |
| 204 | case 'chromelike_app': |
| 205 | platformImpl = (await import('../platform/chromelikeAppServerPlatform')).platform; |
| 206 | break; |
| 207 | case 'visualStudio': |
| 208 | platformImpl = (await import('../platform/visualStudioServerPlatform')).platform; |
| 209 | break; |
| 210 | case 'obsidian': |
| 211 | platformImpl = (await import('../platform/obsidianServerPlatform')).platform; |
| 212 | break; |
| 213 | default: |
| 214 | case undefined: |
| 215 | break; |
| 216 | } |
| 217 | if (sessionId != null && platformImpl) { |
| 218 | platformImpl.sessionId = sessionId; |
| 219 | } |
| 220 | |
| 221 | const dispose = onClientConnection({ |
| 222 | postMessage(message: string | ArrayBuffer) { |
| 223 | socket.send(message); |
| 224 | return Promise.resolve(true); |
| 225 | }, |
| 226 | onDidReceiveMessage(handler) { |
| 227 | const emitter = socket.on('message', handler); |
| 228 | const dispose = () => emitter.off('message', handler); |
| 229 | return {dispose}; |
| 230 | }, |
| 231 | cwd: cwd ?? originalProcessCwd, |
| 232 | logFileLocation: logFileLocation === 'stdout' ? undefined : logFileLocation, |
| 233 | command, |
| 234 | version: slVersion, |
| 235 | |
| 236 | appMode: {mode: 'isl'}, |
| 237 | platform: platformImpl, |
| 238 | readOnly, |
| 239 | }); |
| 240 | socket.on('close', () => { |
| 241 | dispose(); |
| 242 | |
| 243 | // After disposing, we may not have anymore servers alive anymore. |
| 244 | // We can proactively clean up the server so you get the latest version next time you try. |
| 245 | // This way, we only reuse servers if you keep the tab open. |
| 246 | // Note: since we trigger this cleanup on dispose, if you start a server with `--no-open`, |
| 247 | // it won't clean itself up until you connect at least once. |
| 248 | if (!foreground) { |
| 249 | // We do this on a 1-minute delay in case you close a tab and quickly re-open it. |
| 250 | setTimeout(() => { |
| 251 | checkIfServerShouldCleanItselfUp(); |
| 252 | }, 60_000); |
| 253 | } |
| 254 | }); |
| 255 | }); |
| 256 | httpServer.on('upgrade', (request, socket, head) => { |
| 257 | wsServer.handleUpgrade(request, socket, head, socket => { |
| 258 | wsServer.emit('connection', socket, request); |
| 259 | }); |
| 260 | }); |
| 261 | |
| 262 | server.on('error', onError); |
| 263 | |
| 264 | // return successful result when the server is successfully listening |
| 265 | server.on('listening', () => { |
| 266 | // Chdir to drive root so the "cwd" directory can be deleted on Windows. |
| 267 | if (process.platform === 'win32') { |
| 268 | process.chdir('\\'); |
| 269 | } |
| 270 | resolve({type: 'success', port: (server.address() as AddressInfo).port, pid: process.pid}); |
| 271 | }); |
| 272 | }); |
| 273 | } |
| 274 | |
| 275 | function checkIfServerShouldCleanItselfUp() { |
| 276 | if (repositoryCache.numberOfActiveServers() === 0) { |
| 277 | process.exit(0); |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | function getSearchParams(url: string): Map<string, string> { |
| 282 | const searchParamsArray = urlModule |
| 283 | .parse(url) |
| 284 | .search?.replace(/^\?/, '') |
| 285 | .split('&') |
| 286 | .map((pair: string): [string, string] => pair.split('=') as [string, string]); |
| 287 | |
| 288 | return new Map(searchParamsArray); |
| 289 | } |
| 290 | |
| 291 | const extensionToMIMEType: {[key: string]: string} = { |
| 292 | css: 'text/css', |
| 293 | html: 'text/html', |
| 294 | js: 'text/javascript', |
| 295 | ttf: 'font/ttf', |
| 296 | }; |
| 297 | |
| 298 | const requestUrlToResource: {[key: string]: string} = { |
| 299 | '/': 'index.html', |
| 300 | ...allGeneratedFileResources(), |
| 301 | }; |
| 302 | |
| 303 | function allGeneratedFileResources(): Record<string, string> { |
| 304 | const resources = Object.fromEntries( |
| 305 | Object.entries(grammars).map(([_, grammar]) => { |
| 306 | const p = `generated/textmate/${grammar.fileName}.${grammar.fileFormat}`; |
| 307 | return ['/' + p, p]; |
| 308 | }), |
| 309 | ); |
| 310 | // the WASM file is not in the manifest but is needed to highlight |
| 311 | resources['/generated/textmate/onig.wasm'] = 'generated/textmate/onig.wasm'; |
| 312 | return resources; |
| 313 | } |
| 314 | |
| 315 | function htmlEscape(str: string): string { |
| 316 | return str |
| 317 | .replace(/&/g, '&') |
| 318 | .replace(/</g, '<') |
| 319 | .replace(/>/g, '>') |
| 320 | .replace(/"/g, '"') |
| 321 | .replace(/'/g, ''); |
| 322 | } |
| 323 | |