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