index.ts 6.04 KB
Newer Older
liuzhe-lz's avatar
liuzhe-lz committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
 *  Currently the REST server that dispatches web UI and `Experiment` requests.
 *  In future it should handle WebSocket connections as well.
 *
 *  To add new APIs to REST server, modify `rootRouter()` function.
 *
 *  This file contains API URL constants. They must be synchronized with:
 *    - nni/experiment/rest.py
 *    - ts/webui/src/static/const.ts
 *    - ts/webui/src/components/public-child/OpenRow.tsx
 *  Remember to update them if the values are changed, or if this file is moved.
 *
 *  TODO:
17
18
19
20
 *    1. Refactor ClusterJobRestServer to an express-ws application so it doesn't require extra port.
 *    2. Provide public API to register express app, so this can be decoupled with other modules' implementation.
 *    3. Refactor NNIRestHandler. It's a mess.
 *    4. Deal with log path mismatch between REST API and file system.
liuzhe-lz's avatar
liuzhe-lz committed
21
22
 **/

liuzhe-lz's avatar
liuzhe-lz committed
23
import assert from 'assert/strict';
liuzhe-lz's avatar
liuzhe-lz committed
24
25
26
27
28
29
30
31
import type { Server } from 'http';
import type { AddressInfo } from 'net';
import path from 'path';

import express, { Request, Response, Router } from 'express';
import httpProxy from 'http-proxy';
import { Deferred } from 'ts-deferred';

32
import globals from 'common/globals';
liuzhe-lz's avatar
liuzhe-lz committed
33
34
35
import { Logger, getLogger } from 'common/log';
import { createRestHandler } from './restHandler';

36
37
const logger: Logger = getLogger('RestServer');

liuzhe-lz's avatar
liuzhe-lz committed
38
39
40
41
42
43
44
45
46
47
48
/**
 *  The singleton REST server that dispatches web UI and `Experiment` requests.
 *
 *  RestServer must be initialized with start() after NNI manager constructing, but not necessarily after initializing.
 *  This is because RestServer needs NNI manager instance to register API handlers.
 **/
export class RestServer {
    private port: number;
    private urlPrefix: string;
    private server: Server | null = null;

liuzhe-lz's avatar
liuzhe-lz committed
49
50
51
52
    constructor(port: number, urlPrefix: string) {
        assert(!urlPrefix.startsWith('/') && !urlPrefix.endsWith('/'));
        this.port = port;
        this.urlPrefix = urlPrefix;
53
        globals.shutdown.register('RestServer', this.shutdown.bind(this));
liuzhe-lz's avatar
liuzhe-lz committed
54
55
56
57
58
59
    }

    // The promise is resolved when it's ready to serve requests.
    // This worth nothing for now,
    // but for example if we connect to tuner using WebSocket then it must be launched after promise resolved.
    public start(): Promise<void> {
60
        logger.info(`Starting REST server at port ${this.port}, URL prefix: "/${this.urlPrefix}"`);
liuzhe-lz's avatar
liuzhe-lz committed
61
62

        const app = express();
63
        app.use('/' + this.urlPrefix, rootRouter());
liuzhe-lz's avatar
liuzhe-lz committed
64
        app.all('*', (_req: Request, res: Response) => { res.status(404).send(`Outside prefix "/${this.urlPrefix}"`); });
liuzhe-lz's avatar
liuzhe-lz committed
65
66
67
68
69
70
71
        this.server = app.listen(this.port);

        const deferred = new Deferred<void>();
        this.server.on('listening', () => {
            if (this.port === 0) {  // Currently for unit test, can be public feature in future.
                this.port = (<AddressInfo>this.server!.address()).port;
            }
72
            logger.info('REST server started.');
liuzhe-lz's avatar
liuzhe-lz committed
73
74
            deferred.resolve();
        });
75
        this.server.on('error', (error: Error) => { globals.shutdown.criticalError('RestServer', error); });
liuzhe-lz's avatar
liuzhe-lz committed
76
77
78
79
        return deferred.promise;
    }

    public shutdown(): Promise<void> {
80
        logger.info('Stopping REST server.');
liuzhe-lz's avatar
liuzhe-lz committed
81
        if (this.server === null) {
82
            logger.warning('REST server is not running.');
liuzhe-lz's avatar
liuzhe-lz committed
83
84
85
86
            return Promise.resolve();
        }
        const deferred = new Deferred<void>();
        this.server.close(() => {
87
            logger.info('REST server stopped.');
liuzhe-lz's avatar
liuzhe-lz committed
88
89
90
91
92
93
94
95
96
97
98
99
100
101
            deferred.resolve();
        });
        return deferred.promise;
    }
}

/**
 *  You will need to modify this function if you want to add a new module, for example, project management.
 *
 *  Each module should have a unique URL prefix and a "Router". Check express' reference about Application and Router.
 *  Note that the order of `use()` calls does matter so you must not put a router after web UI.
 *  
 *  In fact experiments management should have a separate prefix and module.
 **/
102
function rootRouter(): Router {
liuzhe-lz's avatar
liuzhe-lz committed
103
104
105
106
    const router = Router();
    router.use(express.json({ limit: '50mb' }));

    /* NNI manager APIs */
107
    router.use('/api/v1/nni', restHandlerFactory());
liuzhe-lz's avatar
liuzhe-lz committed
108
109
110
111
112

    /* Download log files */
    // The REST API path "/logs" does not match file system path "/log".
    // Here we use an additional router to workaround this problem.
    const logRouter = Router();
113
    logRouter.get('*', express.static(globals.paths.logDirectory));
liuzhe-lz's avatar
liuzhe-lz committed
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
    router.use('/logs', logRouter);

    /* NAS model visualization */
    router.use('/netron', netronProxy());

    /* Web UI */
    router.get('*', express.static(webuiPath));
    // React Router handles routing inside the browser. We must send index.html to all routes.
    // path.resolve() is required by Response.sendFile() API.
    router.get('*', (_req: Request, res: Response) => { res.sendFile(path.join(webuiPath, 'index.html')); });

    /* 404 as catch-all */
    router.all('*', (_req: Request, res: Response) => { res.status(404).send('Not Found'); });
    return router;
}

function netronProxy(): Router {
    const router = Router();
    const proxy = httpProxy.createProxyServer();
    router.all('*', (req: Request, res: Response): void => {
        delete req.headers.host;
        proxy.web(req, res, { changeOrigin: true, target: netronUrl });
    });
    return router;
}

let webuiPath: string = path.resolve('static');
let netronUrl: string = 'https://netron.app';
142
let restHandlerFactory = createRestHandler;
liuzhe-lz's avatar
liuzhe-lz committed
143
144
145

export namespace UnitTestHelpers {
    export function getPort(server: RestServer): number {
146
        return (server as any).port;
liuzhe-lz's avatar
liuzhe-lz committed
147
148
149
150
151
152
153
154
155
    }

    export function setWebuiPath(mockPath: string): void {
        webuiPath = path.resolve(mockPath);
    }

    export function setNetronUrl(mockUrl: string): void {
        netronUrl = mockUrl;
    }
156
157

    export function disableNniManager(): void {
158
        restHandlerFactory = (): Router => Router();
159
160
161
162
163
164
165
    }

    export function reset(): void {
        webuiPath = path.resolve('static');
        netronUrl = 'https://netron.app';
        restHandlerFactory = createRestHandler;
    }
liuzhe-lz's avatar
liuzhe-lz committed
166
}