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 */
44863ab158 const listenHost = readOnly ? '0.0.0.0' : 'localhost';
44863ab159 const httpServer = server.listen(port, listenHost);
b69ab31160 const wsServer = new WebSocket.Server({noServer: true, path: '/ws'});
b69ab31161 wsServer.on('connection', async (socket, connectionRequest) => {
b69ab31162 // We require websocket connections to contain the token as a URL search parameter.
b69ab31163 let providedToken: string | undefined;
b69ab31164 let cwd: string | undefined;
b69ab31165 let platform: string | undefined;
b69ab31166 let sessionId: string | undefined;
b69ab31167 if (connectionRequest.url) {
b69ab31168 const searchParams = getSearchParams(connectionRequest.url);
b69ab31169 providedToken = searchParams.get('token');
b69ab31170 const cwdParam = searchParams.get('cwd');
b69ab31171 platform = searchParams.get('platform') as string;
b69ab31172 sessionId = searchParams.get('sessionId');
b69ab31173 if (cwdParam) {
b69ab31174 cwd = decodeURIComponent(cwdParam);
b69ab31175 }
b69ab31176 }
44863ab177 // In read-only mode, skip token auth (public demo)
44863ab178 if (!readOnly) {
44863ab179 if (!providedToken) {
44863ab180 const reason = 'No token provided in websocket request';
44863ab181 logInfo('closing ws:', reason);
44863ab182 socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason);
44863ab183 return;
44863ab184 }
44863ab185 if (!areTokensEqual(providedToken, sensitiveToken)) {
44863ab186 const reason = 'Invalid token';
44863ab187 logInfo('closing ws:', reason);
44863ab188 socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason);
44863ab189 return;
44863ab190 }
b69ab31191 }
b69ab31192
b69ab31193 let platformImpl: ServerPlatform | undefined = undefined;
b69ab31194 switch (platform as PlatformName) {
b69ab31195 case 'androidStudio':
b69ab31196 platformImpl = (await import('../platform/androidstudioServerPlatform')).platform;
b69ab31197 break;
b69ab31198 case 'androidStudioRemote':
b69ab31199 platformImpl = (await import('../platform/androidStudioRemoteServerPlatform')).platform;
b69ab31200 break;
b69ab31201 case 'webview':
b69ab31202 platformImpl = (await import('../platform/webviewServerPlatform')).platform;
b69ab31203 break;
b69ab31204 case 'chromelike_app':
b69ab31205 platformImpl = (await import('../platform/chromelikeAppServerPlatform')).platform;
b69ab31206 break;
b69ab31207 case 'visualStudio':
b69ab31208 platformImpl = (await import('../platform/visualStudioServerPlatform')).platform;
b69ab31209 break;
b69ab31210 case 'obsidian':
b69ab31211 platformImpl = (await import('../platform/obsidianServerPlatform')).platform;
b69ab31212 break;
b69ab31213 default:
b69ab31214 case undefined:
b69ab31215 break;
b69ab31216 }
b69ab31217 if (sessionId != null && platformImpl) {
b69ab31218 platformImpl.sessionId = sessionId;
b69ab31219 }
b69ab31220
b69ab31221 const dispose = onClientConnection({
b69ab31222 postMessage(message: string | ArrayBuffer) {
b69ab31223 socket.send(message);
b69ab31224 return Promise.resolve(true);
b69ab31225 },
b69ab31226 onDidReceiveMessage(handler) {
b69ab31227 const emitter = socket.on('message', handler);
b69ab31228 const dispose = () => emitter.off('message', handler);
b69ab31229 return {dispose};
b69ab31230 },
b69ab31231 cwd: cwd ?? originalProcessCwd,
b69ab31232 logFileLocation: logFileLocation === 'stdout' ? undefined : logFileLocation,
b69ab31233 command,
b69ab31234 version: slVersion,
b69ab31235
b69ab31236 appMode: {mode: 'isl'},
b69ab31237 platform: platformImpl,
4bb999b238 readOnly,
b69ab31239 });
b69ab31240 socket.on('close', () => {
b69ab31241 dispose();
b69ab31242
b69ab31243 // After disposing, we may not have anymore servers alive anymore.
b69ab31244 // We can proactively clean up the server so you get the latest version next time you try.
b69ab31245 // This way, we only reuse servers if you keep the tab open.
b69ab31246 // Note: since we trigger this cleanup on dispose, if you start a server with `--no-open`,
b69ab31247 // it won't clean itself up until you connect at least once.
b69ab31248 if (!foreground) {
b69ab31249 // We do this on a 1-minute delay in case you close a tab and quickly re-open it.
b69ab31250 setTimeout(() => {
b69ab31251 checkIfServerShouldCleanItselfUp();
b69ab31252 }, 60_000);
b69ab31253 }
b69ab31254 });
b69ab31255 });
b69ab31256 httpServer.on('upgrade', (request, socket, head) => {
b69ab31257 wsServer.handleUpgrade(request, socket, head, socket => {
b69ab31258 wsServer.emit('connection', socket, request);
b69ab31259 });
b69ab31260 });
b69ab31261
b69ab31262 server.on('error', onError);
b69ab31263
b69ab31264 // return successful result when the server is successfully listening
b69ab31265 server.on('listening', () => {
b69ab31266 // Chdir to drive root so the "cwd" directory can be deleted on Windows.
b69ab31267 if (process.platform === 'win32') {
b69ab31268 process.chdir('\\');
b69ab31269 }
b69ab31270 resolve({type: 'success', port: (server.address() as AddressInfo).port, pid: process.pid});
b69ab31271 });
b69ab31272 });
b69ab31273}
b69ab31274
b69ab31275function checkIfServerShouldCleanItselfUp() {
b69ab31276 if (repositoryCache.numberOfActiveServers() === 0) {
b69ab31277 process.exit(0);
b69ab31278 }
b69ab31279}
b69ab31280
b69ab31281function getSearchParams(url: string): Map<string, string> {
b69ab31282 const searchParamsArray = urlModule
b69ab31283 .parse(url)
b69ab31284 .search?.replace(/^\?/, '')
b69ab31285 .split('&')
b69ab31286 .map((pair: string): [string, string] => pair.split('=') as [string, string]);
b69ab31287
b69ab31288 return new Map(searchParamsArray);
b69ab31289}
b69ab31290
b69ab31291const extensionToMIMEType: {[key: string]: string} = {
b69ab31292 css: 'text/css',
b69ab31293 html: 'text/html',
b69ab31294 js: 'text/javascript',
b69ab31295 ttf: 'font/ttf',
b69ab31296};
b69ab31297
b69ab31298const requestUrlToResource: {[key: string]: string} = {
b69ab31299 '/': 'index.html',
b69ab31300 ...allGeneratedFileResources(),
b69ab31301};
b69ab31302
b69ab31303function allGeneratedFileResources(): Record<string, string> {
b69ab31304 const resources = Object.fromEntries(
b69ab31305 Object.entries(grammars).map(([_, grammar]) => {
b69ab31306 const p = `generated/textmate/${grammar.fileName}.${grammar.fileFormat}`;
b69ab31307 return ['/' + p, p];
b69ab31308 }),
b69ab31309 );
b69ab31310 // the WASM file is not in the manifest but is needed to highlight
b69ab31311 resources['/generated/textmate/onig.wasm'] = 'generated/textmate/onig.wasm';
b69ab31312 return resources;
b69ab31313}
b69ab31314
b69ab31315function htmlEscape(str: string): string {
b69ab31316 return str
b69ab31317 .replace(/&/g, '&amp;')
b69ab31318 .replace(/</g, '&lt;')
b69ab31319 .replace(/>/g, '&gt;')
b69ab31320 .replace(/"/g, '&quot;')
b69ab31321 .replace(/'/g, '&#27;');
b69ab31322}