From cc6cc2889abc9d35df312448ed77f2ebc02d6a3b Mon Sep 17 00:00:00 2001 From: Amir Omidi Date: Tue, 17 Mar 2020 16:32:06 -0700 Subject: [PATCH] Simple web server component (#9648) * Simple web server component * More testing --- extensions/azurecore/package.json | 1 + .../account-provider/utils/simpleWebServer.ts | 109 ++++++++++++++++++ .../utils/simpleWebServer.test.ts | 65 +++++++++++ extensions/azurecore/yarn.lock | 7 ++ 4 files changed, 182 insertions(+) create mode 100644 extensions/azurecore/src/account-provider/utils/simpleWebServer.ts create mode 100644 extensions/azurecore/src/test/account-provider/utils/simpleWebServer.test.ts diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json index 4b50201c13..40515642a8 100644 --- a/extensions/azurecore/package.json +++ b/extensions/azurecore/package.json @@ -198,6 +198,7 @@ "@azure/arm-resourcegraph": "^2.0.0", "@azure/arm-subscriptions": "1.0.0", "adal-node": "^0.2.1", + "axios": "^0.19.2", "request": "2.88.0", "vscode-nls": "^4.0.0" }, diff --git a/extensions/azurecore/src/account-provider/utils/simpleWebServer.ts b/extensions/azurecore/src/account-provider/utils/simpleWebServer.ts new file mode 100644 index 0000000000..a1d7f807f7 --- /dev/null +++ b/extensions/azurecore/src/account-provider/utils/simpleWebServer.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as http from 'http'; +import * as url from 'url'; +import { AddressInfo } from 'net'; + +export type WebHandler = (req: http.IncomingMessage, reqUrl: url.UrlWithParsedQuery, res: http.ServerResponse) => void; + +export class AlreadyRunningError extends Error { } + +export class SimpleWebServer { + private hasStarted: boolean; + + private readonly pathMappings = new Map(); + private readonly server: http.Server; + private lastUsed: number; + private shutoffInterval: NodeJS.Timer; + + constructor(private readonly autoShutoffTimer = 5 * 60 * 1000) { // Default to five minutes. + this.bumpLastUsed(); + this.autoShutoff(); + this.server = http.createServer((req, res) => { + this.bumpLastUsed(); + const reqUrl = url.parse(req.url!, /* parseQueryString */ true); + + const handler = this.pathMappings.get(reqUrl.pathname); + if (handler) { + return handler(req, reqUrl, res); + } + + res.writeHead(404); + res.end(); + }); + } + + private bumpLastUsed(): void { + this.lastUsed = new Date().getTime(); + } + + public async shutdown(): Promise { + clearInterval(this.shutoffInterval); + return new Promise((resolve, reject) => { + this.server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + public async startup(): Promise { + if (this.hasStarted) { + throw new AlreadyRunningError(); + } + this.hasStarted = true; + let portTimeout: NodeJS.Timer; + const portPromise = new Promise((resolve, reject) => { + portTimeout = setTimeout(() => { + reject(new Error('Timed out waiting for the server to start')); + }, 5000); + + this.server.on('listening', () => { + // TODO: What are string addresses? + const address = this.server.address() as AddressInfo; + if (address!.port === undefined) { + reject(new Error('Port was not defined')); + } + resolve(address.port.toString()); + }); + + this.server.on('error', () => { + reject(new Error('Server error')); + }); + + this.server.on('close', () => { + reject(new Error('Server closed')); + }); + + this.server.listen(0); + }); + + const clearPortTimeout = () => { + clearTimeout(portTimeout); + }; + + portPromise.finally(clearPortTimeout); + + return portPromise; + } + + public on(pathMapping: string, handler: WebHandler) { + this.pathMappings.set(pathMapping, handler); + } + + private autoShutoff(): void { + this.shutoffInterval = setInterval(() => { + const time = new Date().getTime(); + + if (time - this.lastUsed > this.autoShutoffTimer) { + console.log('Shutting off webserver...'); + this.shutdown().catch(console.error); + } + }, 1000); + } +} diff --git a/extensions/azurecore/src/test/account-provider/utils/simpleWebServer.test.ts b/extensions/azurecore/src/test/account-provider/utils/simpleWebServer.test.ts new file mode 100644 index 0000000000..9103e564c7 --- /dev/null +++ b/extensions/azurecore/src/test/account-provider/utils/simpleWebServer.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as should from 'should'; +import 'mocha'; +import axios from 'axios'; + +import { SimpleWebServer } from '../../../account-provider/utils/simpleWebServer'; + +let server: SimpleWebServer; + +// These tests don't work on Linux systems because gnome-keyring doesn't like running on headless machines. +describe('AccountProvider.SimpleWebServer', function (): void { + beforeEach(async function (): Promise { + server = new SimpleWebServer(); + }); + + it('Starts up and returns a port', async function (): Promise { + const port = await server.startup(); + should(port).be.any.String().and.not.be.undefined().and.not.be.null(); + }); + + it('Double startup should fail', async function (): Promise { + await server.startup(); + server.startup().should.be.rejected(); + }); + + it('404 on unknown requests', async function (): Promise { + const status = 404; + + const server = new SimpleWebServer(); + const port = await server.startup(); + try { + const result = await axios.get(`http://localhost:${port}/hello`); + should(result).be.undefined(); + } catch (ex) { + should(ex.response.status).equal(status); + } + }); + + it('Responds to GET requests', async function (): Promise { + const msg = 'Hello World'; + const status = 200; + + const server = new SimpleWebServer(); + const port = await server.startup(); + server.on('/hello', (req, reqUrl, res) => { + res.writeHead(status); + res.write(msg); + res.end(); + }); + + const response = await axios.get(`http://localhost:${port}/hello`); + should(response.status).equal(status); + should(response.data).equal(msg); + }); + + it('Server shuts off', async function (): Promise { + await server.startup(); + server.shutdown().should.not.be.rejected(); + }); + +}); diff --git a/extensions/azurecore/yarn.lock b/extensions/azurecore/yarn.lock index 8edb919b1f..7c7df9940e 100644 --- a/extensions/azurecore/yarn.lock +++ b/extensions/azurecore/yarn.lock @@ -237,6 +237,13 @@ axios@^0.19.0: follow-redirects "1.5.10" is-buffer "^2.0.2" +axios@^0.19.2: + version "0.19.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" + integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== + dependencies: + follow-redirects "1.5.10" + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"