10.7 KB319 lines
Blame
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
8import type {PlatformName} from 'isl/src/types';
9import type {AddressInfo} from 'node:net';
10import type {ServerPlatform} from '../src/serverPlatform';
11
12import {grammars} from 'isl/src/generated/textmate/TextMateGrammarManifest';
13import fs from 'node:fs';
14import http from 'node:http';
15import path from 'node:path';
16import urlModule from 'node:url';
17import WebSocket from 'ws';
18import {repositoryCache} from '../src/RepositoryCache';
19import {CLOSED_AND_SHOULD_NOT_RECONNECT_CODE} from '../src/constants';
20import {onClientConnection} from '../src/index';
21import {areTokensEqual} from './proxyUtils';
22
23const ossSmartlogDir = path.join(__dirname, '../../isl');
24
25export 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
37export type StartServerResult =
38 | {type: 'addressInUse'}
39 | {type: 'success'; port: number; pid: number}
40 | {type: 'error'; error: string};
41
42export type ServerChallengeResponse = {
43 challengeToken: string;
44 /** Process ID for the server. */
45 pid: number;
46};
47
48export 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 httpServer = server.listen(port, 'localhost');
159 const wsServer = new WebSocket.Server({noServer: true, path: '/ws'});
160 wsServer.on('connection', async (socket, connectionRequest) => {
161 // We require websocket connections to contain the token as a URL search parameter.
162 let providedToken: string | undefined;
163 let cwd: string | undefined;
164 let platform: string | undefined;
165 let sessionId: string | undefined;
166 if (connectionRequest.url) {
167 const searchParams = getSearchParams(connectionRequest.url);
168 providedToken = searchParams.get('token');
169 const cwdParam = searchParams.get('cwd');
170 platform = searchParams.get('platform') as string;
171 sessionId = searchParams.get('sessionId');
172 if (cwdParam) {
173 cwd = decodeURIComponent(cwdParam);
174 }
175 }
176 if (!providedToken) {
177 const reason = 'No token provided in websocket request';
178 logInfo('closing ws:', reason);
179 socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason);
180 return;
181 }
182 if (!areTokensEqual(providedToken, sensitiveToken)) {
183 const reason = 'Invalid token';
184 logInfo('closing ws:', reason);
185 socket.close(CLOSED_AND_SHOULD_NOT_RECONNECT_CODE, reason);
186 return;
187 }
188
189 let platformImpl: ServerPlatform | undefined = undefined;
190 switch (platform as PlatformName) {
191 case 'androidStudio':
192 platformImpl = (await import('../platform/androidstudioServerPlatform')).platform;
193 break;
194 case 'androidStudioRemote':
195 platformImpl = (await import('../platform/androidStudioRemoteServerPlatform')).platform;
196 break;
197 case 'webview':
198 platformImpl = (await import('../platform/webviewServerPlatform')).platform;
199 break;
200 case 'chromelike_app':
201 platformImpl = (await import('../platform/chromelikeAppServerPlatform')).platform;
202 break;
203 case 'visualStudio':
204 platformImpl = (await import('../platform/visualStudioServerPlatform')).platform;
205 break;
206 case 'obsidian':
207 platformImpl = (await import('../platform/obsidianServerPlatform')).platform;
208 break;
209 default:
210 case undefined:
211 break;
212 }
213 if (sessionId != null && platformImpl) {
214 platformImpl.sessionId = sessionId;
215 }
216
217 const dispose = onClientConnection({
218 postMessage(message: string | ArrayBuffer) {
219 socket.send(message);
220 return Promise.resolve(true);
221 },
222 onDidReceiveMessage(handler) {
223 const emitter = socket.on('message', handler);
224 const dispose = () => emitter.off('message', handler);
225 return {dispose};
226 },
227 cwd: cwd ?? originalProcessCwd,
228 logFileLocation: logFileLocation === 'stdout' ? undefined : logFileLocation,
229 command,
230 version: slVersion,
231
232 appMode: {mode: 'isl'},
233 platform: platformImpl,
234 readOnly,
235 });
236 socket.on('close', () => {
237 dispose();
238
239 // After disposing, we may not have anymore servers alive anymore.
240 // We can proactively clean up the server so you get the latest version next time you try.
241 // This way, we only reuse servers if you keep the tab open.
242 // Note: since we trigger this cleanup on dispose, if you start a server with `--no-open`,
243 // it won't clean itself up until you connect at least once.
244 if (!foreground) {
245 // We do this on a 1-minute delay in case you close a tab and quickly re-open it.
246 setTimeout(() => {
247 checkIfServerShouldCleanItselfUp();
248 }, 60_000);
249 }
250 });
251 });
252 httpServer.on('upgrade', (request, socket, head) => {
253 wsServer.handleUpgrade(request, socket, head, socket => {
254 wsServer.emit('connection', socket, request);
255 });
256 });
257
258 server.on('error', onError);
259
260 // return successful result when the server is successfully listening
261 server.on('listening', () => {
262 // Chdir to drive root so the "cwd" directory can be deleted on Windows.
263 if (process.platform === 'win32') {
264 process.chdir('\\');
265 }
266 resolve({type: 'success', port: (server.address() as AddressInfo).port, pid: process.pid});
267 });
268 });
269}
270
271function checkIfServerShouldCleanItselfUp() {
272 if (repositoryCache.numberOfActiveServers() === 0) {
273 process.exit(0);
274 }
275}
276
277function getSearchParams(url: string): Map<string, string> {
278 const searchParamsArray = urlModule
279 .parse(url)
280 .search?.replace(/^\?/, '')
281 .split('&')
282 .map((pair: string): [string, string] => pair.split('=') as [string, string]);
283
284 return new Map(searchParamsArray);
285}
286
287const extensionToMIMEType: {[key: string]: string} = {
288 css: 'text/css',
289 html: 'text/html',
290 js: 'text/javascript',
291 ttf: 'font/ttf',
292};
293
294const requestUrlToResource: {[key: string]: string} = {
295 '/': 'index.html',
296 ...allGeneratedFileResources(),
297};
298
299function allGeneratedFileResources(): Record<string, string> {
300 const resources = Object.fromEntries(
301 Object.entries(grammars).map(([_, grammar]) => {
302 const p = `generated/textmate/${grammar.fileName}.${grammar.fileFormat}`;
303 return ['/' + p, p];
304 }),
305 );
306 // the WASM file is not in the manifest but is needed to highlight
307 resources['/generated/textmate/onig.wasm'] = 'generated/textmate/onig.wasm';
308 return resources;
309}
310
311function htmlEscape(str: string): string {
312 return str
313 .replace(/&/g, '&amp;')
314 .replace(/</g, '&lt;')
315 .replace(/>/g, '&gt;')
316 .replace(/"/g, '&quot;')
317 .replace(/'/g, '&#27;');
318}
319