addons/isl-server/proxy/server.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 {AddressInfo} from 'node:net';
b69ab3110import type {ServerPlatform} from '../src/serverPlatform';
b69ab3111
b69ab3112import {grammars} from 'isl/src/generated/textmate/TextMateGrammarManifest';
b69ab3113import fs from 'node:fs';
b69ab3114import http from 'node:http';
b69ab3115import path from 'node:path';
b69ab3116import urlModule from 'node:url';
b69ab3117import WebSocket from 'ws';
b69ab3118import {repositoryCache} from '../src/RepositoryCache';
b69ab3119import {CLOSED_AND_SHOULD_NOT_RECONNECT_CODE} from '../src/constants';
b69ab3120import {onClientConnection} from '../src/index';
b69ab3121import {areTokensEqual} from './proxyUtils';
b69ab3122
b69ab3123const ossSmartlogDir = path.join(__dirname, '../../isl');
b69ab3124
b69ab3125export type StartServerArgs = {
b69ab3126 port: number;
b69ab3127 sensitiveToken: string;
b69ab3128 challengeToken: string;
b69ab3129 logFileLocation: string;
b69ab3130 logInfo: (...args: Parameters<typeof console.log>) => void;
b69ab3131 command: string;
b69ab3132 slVersion: string;
b69ab3133 foreground: boolean;
4bb999b34 readOnly?: boolean;
b69ab3135};
b69ab3136
b69ab3137export type StartServerResult =
b69ab3138 | {type: 'addressInUse'}
b69ab3139 | {type: 'success'; port: number; pid: number}
b69ab3140 | {type: 'error'; error: string};
b69ab3141
b69ab3142export type ServerChallengeResponse = {
b69ab3143 challengeToken: string;
b69ab3144 /** Process ID for the server. */
b69ab3145 pid: number;
b69ab3146};
b69ab3147
b69ab3148export function startServer({
b69ab3149 port,
b69ab3150 sensitiveToken,
b69ab3151 challengeToken,
b69ab3152 logFileLocation,
b69ab3153 logInfo,
b69ab3154 command,
b69ab3155 slVersion,
b69ab3156 foreground,
4bb999b57 readOnly,
b69ab3158}: StartServerArgs): Promise<StartServerResult> {
b69ab3159 const originalProcessCwd = process.cwd();
b69ab3160 const serverRoot = path.isAbsolute(ossSmartlogDir)
b69ab3161 ? ossSmartlogDir
b69ab3162 : path.join(originalProcessCwd, ossSmartlogDir);
b69ab3163
b69ab3164 return new Promise(resolve => {
b69ab3165 try {
b69ab3166 const files = JSON.parse(
b69ab3167 fs.readFileSync(path.join(serverRoot, 'build/assetList.json'), 'utf-8'),
b69ab3168 ) as Array<string>;
b69ab3169
b69ab3170 for (const file of files) {
b69ab3171 // `file` might have OS slash like `"assets\\stylex.0f7433cc.css".
b69ab3172 // Normalize it to URL slash.
b69ab3173 requestUrlToResource['/' + file.replace(/\\/g, '/')] = file;
b69ab3174 }
b69ab3175 } catch (e) {
b69ab3176 // ignore...
b69ab3177 }
b69ab3178
b69ab3179 // Anything not part of the asset-manifest we need to explicitly serve
b69ab3180 requestUrlToResource[`/favicon.ico`] = 'favicon.ico';
b69ab3181
b69ab3182 /**
b69ab3183 * Event listener for HTTP server "error" event.
b69ab3184 */
b69ab3185 function onError(error: {syscall?: string; code?: string}) {
b69ab3186 if (error.syscall !== 'listen') {
b69ab3187 resolve({type: 'error', error: error.toString()});
b69ab3188 throw error;
b69ab3189 }
b69ab3190
b69ab3191 // handle specific listen errors with friendly messages
b69ab3192 switch (error.code) {
b69ab3193 case 'EACCES': {
b69ab3194 resolve({type: 'error', error: `Port ${port} requires elevated privileges`});
b69ab3195 throw error;
b69ab3196 }
b69ab3197 case 'EADDRINUSE': {
b69ab3198 resolve({type: 'addressInUse'});
b69ab3199 return;
b69ab31100 }
b69ab31101 default:
b69ab31102 resolve({type: 'error', error: error.toString()});
b69ab31103 throw error;
b69ab31104 }
b69ab31105 }
b69ab31106
b69ab31107 /**
b69ab31108 * Create HTTP server.
b69ab31109 */
b69ab31110 const server = http.createServer(async (req, res) => {
b69ab31111 if (req.url) {
b69ab31112 // Only the websocket is sensitive and requires the token.
b69ab31113 // Normal resource requests don't need to check the token.
b69ab31114 const {pathname} = urlModule.parse(req.url);
b69ab31115 // eslint-disable-next-line no-prototype-builtins
b69ab31116 if (pathname != null && requestUrlToResource.hasOwnProperty(pathname)) {
b69ab31117 const relativePath = requestUrlToResource[pathname];
b69ab31118 let contents: string | Buffer;
b69ab31119 try {
b69ab31120 contents = await fs.promises.readFile(path.join(serverRoot, 'build', relativePath));
b69ab31121 } catch (e: unknown) {
b69ab31122 res.writeHead(500, {'Content-Type': 'text/plain'});
b69ab31123 res.end(htmlEscape((e as Error).toString()));
b69ab31124 return;
b69ab31125 }
b69ab31126
b69ab31127 const lastDot = relativePath.lastIndexOf('.');
b69ab31128 const ext = relativePath.slice(lastDot + 1);
b69ab31129 const contentType = extensionToMIMEType[ext] ?? 'text/plain';
b69ab31130
b69ab31131 res.writeHead(200, {'Content-Type': contentType});
b69ab31132 res.end(contents);
b69ab31133 return;
b69ab31134 } else if (pathname === '/challenge_authenticity') {
b69ab31135 // requests to /challenge_authenticity?token=... allow using the sensitive token to ask
b69ab31136 // for the secondary challenge token.
b69ab31137 const requestToken = getSearchParams(req.url).get('token');
b69ab31138 if (requestToken && areTokensEqual(requestToken, sensitiveToken)) {
b69ab31139 // they know the original token, we can tell them our challenge token
b69ab31140 res.writeHead(200, {'Content-Type': 'text/json'});
b69ab31141 const response: ServerChallengeResponse = {challengeToken, pid: process.pid};
b69ab31142 res.end(JSON.stringify(response));
b69ab31143 } else {
b69ab31144 res.writeHead(401, {'Content-Type': 'text/json'});
b69ab31145 res.end(JSON.stringify({error: 'invalid token'}));
b69ab31146 }
b69ab31147 return;
b69ab31148 }
b69ab31149 }
b69ab31150
b69ab31151 res.writeHead(404, {'Content-Type': 'text/html'});
b69ab31152 res.end('<html><body>Not Found!</body></html>');
b69ab31153 });
b69ab31154
b69ab31155 /**
b69ab31156 * Listen on localhost:port.
b69ab31157 */
b69ab31158 const httpServer = server.listen(port, 'localhost');
b69ab31159 const wsServer = new WebSocket.Server({noServer: true, path: '/ws'});
b69ab31160 wsServer.on('connection', async (socket, connectionRequest) => {
b69ab31161 // We require websocket connections to contain the token as a URL search parameter.
b69ab31162 let providedToken: string | undefined;
b69ab31163 let cwd: string | undefined;
b69ab31164 let platform: string | undefined;
b69ab31165 let sessionId: string | undefined;
b69ab31166 if (connectionRequest.url) {
b69ab31167 const searchParams = getSearchParams(connectionRequest.url);
b69ab31168 providedToken = searchParams.get('token');
b69ab31169 const cwdParam = searchParams.get('cwd');
b69ab31170 platform = searchParams.get('platform') as string;
b69ab31171 sessionId = searchParams.get('sessionId');
b69ab31172 if (cwdParam) {
b69ab31173 cwd = decodeURIComponent(cwdParam);
b69ab31174 }
b69ab31175 }
b69ab31176 if (!providedToken) {
b69ab31177 const reason = 'No token provided in websocket request';
b69ab31178 logInfo('closing ws:', reason);
b69ab31179 socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason);
b69ab31180 return;
b69ab31181 }
b69ab31182 if (!areTokensEqual(providedToken, sensitiveToken)) {
b69ab31183 const reason = 'Invalid token';
b69ab31184 logInfo('closing ws:', reason);
b69ab31185 socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason);
b69ab31186 return;
b69ab31187 }
b69ab31188
b69ab31189 let platformImpl: ServerPlatform | undefined = undefined;
b69ab31190 switch (platform as PlatformName) {
b69ab31191 case 'androidStudio':
b69ab31192 platformImpl = (await import('../platform/androidstudioServerPlatform')).platform;
b69ab31193 break;
b69ab31194 case 'androidStudioRemote':
b69ab31195 platformImpl = (await import('../platform/androidStudioRemoteServerPlatform')).platform;
b69ab31196 break;
b69ab31197 case 'webview':
b69ab31198 platformImpl = (await import('../platform/webviewServerPlatform')).platform;
b69ab31199 break;
b69ab31200 case 'chromelike_app':
b69ab31201 platformImpl = (await import('../platform/chromelikeAppServerPlatform')).platform;
b69ab31202 break;
b69ab31203 case 'visualStudio':
b69ab31204 platformImpl = (await import('../platform/visualStudioServerPlatform')).platform;
b69ab31205 break;
b69ab31206 case 'obsidian':
b69ab31207 platformImpl = (await import('../platform/obsidianServerPlatform')).platform;
b69ab31208 break;
b69ab31209 default:
b69ab31210 case undefined:
b69ab31211 break;
b69ab31212 }
b69ab31213 if (sessionId != null && platformImpl) {
b69ab31214 platformImpl.sessionId = sessionId;
b69ab31215 }
b69ab31216
b69ab31217 const dispose = onClientConnection({
b69ab31218 postMessage(message: string | ArrayBuffer) {
b69ab31219 socket.send(message);
b69ab31220 return Promise.resolve(true);
b69ab31221 },
b69ab31222 onDidReceiveMessage(handler) {
b69ab31223 const emitter = socket.on('message', handler);
b69ab31224 const dispose = () => emitter.off('message', handler);
b69ab31225 return {dispose};
b69ab31226 },
b69ab31227 cwd: cwd ?? originalProcessCwd,
b69ab31228 logFileLocation: logFileLocation === 'stdout' ? undefined : logFileLocation,
b69ab31229 command,
b69ab31230 version: slVersion,
b69ab31231
b69ab31232 appMode: {mode: 'isl'},
b69ab31233 platform: platformImpl,
4bb999b234 readOnly,
b69ab31235 });
b69ab31236 socket.on('close', () => {
b69ab31237 dispose();
b69ab31238
b69ab31239 // After disposing, we may not have anymore servers alive anymore.
b69ab31240 // We can proactively clean up the server so you get the latest version next time you try.
b69ab31241 // This way, we only reuse servers if you keep the tab open.
b69ab31242 // Note: since we trigger this cleanup on dispose, if you start a server with `--no-open`,
b69ab31243 // it won't clean itself up until you connect at least once.
b69ab31244 if (!foreground) {
b69ab31245 // We do this on a 1-minute delay in case you close a tab and quickly re-open it.
b69ab31246 setTimeout(() => {
b69ab31247 checkIfServerShouldCleanItselfUp();
b69ab31248 }, 60_000);
b69ab31249 }
b69ab31250 });
b69ab31251 });
b69ab31252 httpServer.on('upgrade', (request, socket, head) => {
b69ab31253 wsServer.handleUpgrade(request, socket, head, socket => {
b69ab31254 wsServer.emit('connection', socket, request);
b69ab31255 });
b69ab31256 });
b69ab31257
b69ab31258 server.on('error', onError);
b69ab31259
b69ab31260 // return successful result when the server is successfully listening
b69ab31261 server.on('listening', () => {
b69ab31262 // Chdir to drive root so the "cwd" directory can be deleted on Windows.
b69ab31263 if (process.platform === 'win32') {
b69ab31264 process.chdir('\\');
b69ab31265 }
b69ab31266 resolve({type: 'success', port: (server.address() as AddressInfo).port, pid: process.pid});
b69ab31267 });
b69ab31268 });
b69ab31269}
b69ab31270
b69ab31271function checkIfServerShouldCleanItselfUp() {
b69ab31272 if (repositoryCache.numberOfActiveServers() === 0) {
b69ab31273 process.exit(0);
b69ab31274 }
b69ab31275}
b69ab31276
b69ab31277function getSearchParams(url: string): Map<string, string> {
b69ab31278 const searchParamsArray = urlModule
b69ab31279 .parse(url)
b69ab31280 .search?.replace(/^\?/, '')
b69ab31281 .split('&')
b69ab31282 .map((pair: string): [string, string] => pair.split('=') as [string, string]);
b69ab31283
b69ab31284 return new Map(searchParamsArray);
b69ab31285}
b69ab31286
b69ab31287const extensionToMIMEType: {[key: string]: string} = {
b69ab31288 css: 'text/css',
b69ab31289 html: 'text/html',
b69ab31290 js: 'text/javascript',
b69ab31291 ttf: 'font/ttf',
b69ab31292};
b69ab31293
b69ab31294const requestUrlToResource: {[key: string]: string} = {
b69ab31295 '/': 'index.html',
b69ab31296 ...allGeneratedFileResources(),
b69ab31297};
b69ab31298
b69ab31299function allGeneratedFileResources(): Record<string, string> {
b69ab31300 const resources = Object.fromEntries(
b69ab31301 Object.entries(grammars).map(([_, grammar]) => {
b69ab31302 const p = `generated/textmate/${grammar.fileName}.${grammar.fileFormat}`;
b69ab31303 return ['/' + p, p];
b69ab31304 }),
b69ab31305 );
b69ab31306 // the WASM file is not in the manifest but is needed to highlight
b69ab31307 resources['/generated/textmate/onig.wasm'] = 'generated/textmate/onig.wasm';
b69ab31308 return resources;
b69ab31309}
b69ab31310
b69ab31311function htmlEscape(str: string): string {
b69ab31312 return str
b69ab31313 .replace(/&/g, '&amp;')
b69ab31314 .replace(/</g, '&lt;')
b69ab31315 .replace(/>/g, '&gt;')
b69ab31316 .replace(/"/g, '&quot;')
b69ab31317 .replace(/'/g, '&#27;');
b69ab31318}