10.8 KB323 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 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
275function checkIfServerShouldCleanItselfUp() {
276 if (repositoryCache.numberOfActiveServers() === 0) {
277 process.exit(0);
278 }
279}
280
281function 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
291const extensionToMIMEType: {[key: string]: string} = {
292 css: 'text/css',
293 html: 'text/html',
294 js: 'text/javascript',
295 ttf: 'font/ttf',
296};
297
298const requestUrlToResource: {[key: string]: string} = {
299 '/': 'index.html',
300 ...allGeneratedFileResources(),
301};
302
303function 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
315function htmlEscape(str: string): string {
316 return str
317 .replace(/&/g, '&amp;')
318 .replace(/</g, '&lt;')
319 .replace(/>/g, '&gt;')
320 .replace(/"/g, '&quot;')
321 .replace(/'/g, '&#27;');
322}
323