Skip to content

Commit 57a94a5

Browse files
Hage Yaaparaymondfeng
authored andcommitted
fix: optimize serving static files
Optimize serving static files
1 parent 9fd37c9 commit 57a94a5

File tree

5 files changed

+183
-56
lines changed

5 files changed

+183
-56
lines changed

packages/rest/src/http-handler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {Request, Response} from './types';
1919

2020
import {RestBindings} from './keys';
2121
import {RequestContext} from './request-context';
22+
import {PathParams} from 'express-serve-static-core';
23+
import {ServeStaticOptions} from 'serve-static';
2224

2325
export class HttpHandler {
2426
protected _apiDefinitions: SchemasObject;
@@ -48,6 +50,14 @@ export class HttpHandler {
4850
this._apiDefinitions = Object.assign({}, this._apiDefinitions, defs);
4951
}
5052

53+
registerStaticAssets(
54+
path: PathParams,
55+
rootDir: string,
56+
options?: ServeStaticOptions,
57+
) {
58+
this._routes.registerStaticAssets(path, rootDir, options);
59+
}
60+
5161
getApiDefinitions() {
5262
return this._apiDefinitions;
5363
}

packages/rest/src/rest.server.ts

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import {
1414
} from '@loopback/openapi-v3-types';
1515
import {AssertionError} from 'assert';
1616
import * as cors from 'cors';
17+
import * as debugFactory from 'debug';
1718
import * as express from 'express';
1819
import {PathParams} from 'express-serve-static-core';
1920
import {IncomingMessage, ServerResponse} from 'http';
21+
import {ServerOptions} from 'https';
2022
import {safeDump} from 'js-yaml';
21-
import * as pathToRegExp from 'path-to-regexp';
2223
import {ServeStaticOptions} from 'serve-static';
2324
import {HttpHandler} from './http-handler';
2425
import {RestBindings} from './keys';
@@ -34,7 +35,6 @@ import {
3435
RouteEntry,
3536
RoutingTable,
3637
} from './router/routing-table';
37-
3838
import {DefaultSequence, SequenceFunction, SequenceHandler} from './sequence';
3939
import {
4040
FindRoute,
@@ -45,9 +45,8 @@ import {
4545
Response,
4646
Send,
4747
} from './types';
48-
import {ServerOptions} from 'https';
4948

50-
const debug = require('debug')('loopback:rest:server');
49+
const debug = debugFactory('loopback:rest:server');
5150

5251
export type HttpRequestListener = (
5352
req: IncomingMessage,
@@ -127,7 +126,6 @@ export class RestServer extends Context implements Server, HttpServerLike {
127126
protected _httpServer: HttpServer | undefined;
128127

129128
protected _expressApp: express.Application;
130-
protected _routerForStaticAssets: express.Router;
131129

132130
get listening(): boolean {
133131
return this._httpServer ? this._httpServer.listening : false;
@@ -217,9 +215,6 @@ export class RestServer extends Context implements Server, HttpServerLike {
217215
};
218216
this._expressApp.use(cors(corsOptions));
219217

220-
// Place the assets router here before controllers
221-
this._setupRouterForStaticAssets();
222-
223218
// Set up endpoints for OpenAPI spec/ui
224219
this._setupOpenApiSpecEndpoints();
225220

@@ -236,17 +231,6 @@ export class RestServer extends Context implements Server, HttpServerLike {
236231
);
237232
}
238233

239-
/**
240-
* Set up an express router for all static assets so that middleware for
241-
* all directories are invoked at the same phase
242-
*/
243-
protected _setupRouterForStaticAssets() {
244-
if (!this._routerForStaticAssets) {
245-
this._routerForStaticAssets = express.Router();
246-
this._expressApp.use(this._routerForStaticAssets);
247-
}
248-
}
249-
250234
/**
251235
* Mount /openapi.json, /openapi.yaml for specs and /swagger-ui, /explorer
252236
* to redirect to externally hosted API explorer
@@ -626,18 +610,11 @@ export class RestServer extends Context implements Server, HttpServerLike {
626610
* See https://expressjs.com/en/4x/api.html#express.static
627611
* @param path The path(s) to serve the asset.
628612
* See examples at https://expressjs.com/en/4x/api.html#path-examples
629-
* To avoid performance penalty, `/` is not allowed for now.
630613
* @param rootDir The root directory from which to serve static assets
631614
* @param options Options for serve-static
632615
*/
633616
static(path: PathParams, rootDir: string, options?: ServeStaticOptions) {
634-
const re = pathToRegExp(path, [], {end: false});
635-
if (re.test('/')) {
636-
throw new Error(
637-
'Static assets cannot be mount to "/" to avoid performance penalty.',
638-
);
639-
}
640-
this._routerForStaticAssets.use(path, express.static(rootDir, options));
617+
this.httpHandler.registerStaticAssets(path, rootDir, options);
641618
}
642619

643620
/**

packages/rest/src/router/routing-table.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
OperationRetval,
2828
} from '../types';
2929

30+
import * as express from 'express';
31+
3032
import {ControllerSpec} from '@loopback/openapi-v3';
3133

3234
import * as assert from 'assert';
@@ -35,6 +37,9 @@ const debug = require('debug')('loopback:rest:routing-table');
3537
import {CoreBindings} from '@loopback/core';
3638
import {validateApiPath} from './openapi-path';
3739
import {TrieRouter} from './trie-router';
40+
import {RequestContext} from '../request-context';
41+
import {ServeStaticOptions} from 'serve-static';
42+
import {PathParams} from 'express-serve-static-core';
3843

3944
/**
4045
* A controller instance with open properties/methods
@@ -84,6 +89,19 @@ export interface RestRouter {
8489
export class RoutingTable {
8590
constructor(private readonly _router: RestRouter = new TrieRouter()) {}
8691

92+
private _staticAssetsRoute: StaticAssetsRoute;
93+
94+
registerStaticAssets(
95+
path: PathParams,
96+
rootDir: string,
97+
options?: ServeStaticOptions,
98+
) {
99+
if (!this._staticAssetsRoute) {
100+
this._staticAssetsRoute = new StaticAssetsRoute();
101+
}
102+
this._staticAssetsRoute.registerAssets(path, rootDir, options);
103+
}
104+
87105
/**
88106
* Register a controller as the route
89107
* @param spec
@@ -149,6 +167,7 @@ export class RoutingTable {
149167
}
150168

151169
validateApiPath(route.path);
170+
152171
this._router.add(route);
153172
}
154173

@@ -181,6 +200,17 @@ export class RoutingTable {
181200
return found;
182201
}
183202

203+
// this._staticAssetsRoute will be set only if app.static() was called
204+
if (this._staticAssetsRoute) {
205+
debug(
206+
'No API route found for %s %s, trying to find a static asset',
207+
request.method,
208+
request.path,
209+
);
210+
211+
return this._staticAssetsRoute;
212+
}
213+
184214
debug('No route found for %s %s', request.method, request.path);
185215
throw new HttpErrors.NotFound(
186216
`Endpoint "${request.method} ${request.path}" not found.`,
@@ -271,6 +301,88 @@ export abstract class BaseRoute implements RouteEntry {
271301
}
272302
}
273303

304+
export class StaticAssetsRoute implements RouteEntry, ResolvedRoute {
305+
// ResolvedRoute API
306+
readonly pathParams: PathParameterValues = [];
307+
readonly schemas: SchemasObject = {};
308+
309+
// RouteEntry implementation
310+
readonly verb: string = 'get';
311+
readonly path: string = '/*';
312+
readonly spec: OperationObject = {
313+
description: 'LoopBack static assets route',
314+
'x-visibility': 'undocumented',
315+
responses: {},
316+
};
317+
318+
private readonly _expressRouter: express.Router = express.Router();
319+
320+
public registerAssets(
321+
path: PathParams,
322+
rootDir: string,
323+
options?: ServeStaticOptions,
324+
) {
325+
this._expressRouter.use(path, express.static(rootDir, options));
326+
}
327+
328+
updateBindings(requestContext: Context): void {
329+
// no-op
330+
}
331+
332+
async invokeHandler(
333+
{request, response}: RequestContext,
334+
args: OperationArgs,
335+
): Promise<OperationRetval> {
336+
const handled = await executeRequestHandler(
337+
this._expressRouter,
338+
request,
339+
response,
340+
);
341+
342+
if (!handled) {
343+
// Express router called next, which means no route was matched
344+
throw new HttpErrors.NotFound(
345+
`Endpoint "${request.method} ${request.path}" not found.`,
346+
);
347+
}
348+
}
349+
350+
describe(): string {
351+
return 'final route to handle static assets';
352+
}
353+
}
354+
355+
/**
356+
* Execute an Express-style callback-based request handler.
357+
*
358+
* @param handler
359+
* @param request
360+
* @param response
361+
* @returns A promise resolved to:
362+
* - `true` when the request was handled
363+
* - `false` when the handler called `next()` to proceed to the next
364+
* handler (middleware) in the chain.
365+
*/
366+
function executeRequestHandler(
367+
handler: express.RequestHandler,
368+
request: Request,
369+
response: express.Response,
370+
): Promise<boolean> {
371+
return new Promise((resolve, reject) => {
372+
const onceFinished = () => resolve(true);
373+
response.once('finish', onceFinished);
374+
handler(request, response, (err: Error) => {
375+
response.removeListener('finish', onceFinished);
376+
if (err) {
377+
reject(err);
378+
} else {
379+
// Express router called next, which means no route was matched
380+
resolve(false);
381+
}
382+
});
383+
});
384+
}
385+
274386
export function createResolvedRoute(
275387
route: RouteEntry,
276388
pathParams: PathParameterValues,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright IBM Corp. 2017,2018. All Rights Reserved.
2+
// Node module: @loopback/rest
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {createRestAppClient, Client} from '@loopback/testlab';
7+
import {RestApplication} from '../..';
8+
import * as path from 'path';
9+
import * as fs from 'fs';
10+
11+
const FIXTURES = path.resolve(__dirname, '../../../fixtures');
12+
13+
describe('RestApplication (integration)', () => {
14+
let restApp: RestApplication;
15+
let client: Client;
16+
17+
beforeEach(givenAnApplication);
18+
beforeEach(givenClient);
19+
afterEach(async () => {
20+
await restApp.stop();
21+
});
22+
23+
it('serves static assets', async () => {
24+
const root = FIXTURES;
25+
const content = fs
26+
.readFileSync(path.join(root, 'index.html'))
27+
.toString('utf-8');
28+
await client
29+
.get('/index.html')
30+
.expect(200)
31+
.expect(content);
32+
});
33+
34+
it('returns 404 if asset is not found', async () => {
35+
await client.get('/404.html').expect(404);
36+
});
37+
38+
function givenAnApplication() {
39+
const root = FIXTURES;
40+
restApp = new RestApplication({rest: {port: 0, host: '127.0.0.1'}});
41+
restApp.static('/', root);
42+
}
43+
44+
async function givenClient() {
45+
await restApp.start();
46+
return (client = createRestAppClient(restApp));
47+
}
48+
});

packages/rest/test/integration/rest.server.integration.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -80,37 +80,20 @@ describe('RestServer (integration)', () => {
8080
.expect(500);
8181
});
8282

83-
it('does not allow static assets to be mounted at /', async () => {
83+
it('allows static assets to be mounted at /', async () => {
8484
const root = FIXTURES;
8585
const server = await givenAServer({
8686
rest: {
8787
port: 0,
8888
},
8989
});
9090

91-
expect(() => server.static('/', root)).to.throw(
92-
'Static assets cannot be mount to "/" to avoid performance penalty.',
93-
);
94-
95-
expect(() => server.static('', root)).to.throw(
96-
'Static assets cannot be mount to "/" to avoid performance penalty.',
97-
);
98-
99-
expect(() => server.static(['/'], root)).to.throw(
100-
'Static assets cannot be mount to "/" to avoid performance penalty.',
101-
);
102-
103-
expect(() => server.static(['/html', ''], root)).to.throw(
104-
'Static assets cannot be mount to "/" to avoid performance penalty.',
105-
);
106-
107-
expect(() => server.static(/.*/, root)).to.throw(
108-
'Static assets cannot be mount to "/" to avoid performance penalty.',
109-
);
110-
111-
expect(() => server.static('/(.*)', root)).to.throw(
112-
'Static assets cannot be mount to "/" to avoid performance penalty.',
113-
);
91+
expect(() => server.static('/', root)).to.not.throw();
92+
expect(() => server.static('', root)).to.not.throw();
93+
expect(() => server.static(['/'], root)).to.not.throw();
94+
expect(() => server.static(['/html', ''], root)).to.not.throw();
95+
expect(() => server.static(/.*/, root)).to.not.throw();
96+
expect(() => server.static('/(.*)', root)).to.not.throw();
11497
});
11598

11699
it('allows static assets via api', async () => {
@@ -164,7 +147,7 @@ describe('RestServer (integration)', () => {
164147
.expect(200, 'Hello');
165148
});
166149

167-
it('serve static assets if matches before other routes', async () => {
150+
it('gives precedence to API routes over static assets', async () => {
168151
const root = FIXTURES;
169152
const server = await givenAServer({
170153
rest: {
@@ -174,12 +157,9 @@ describe('RestServer (integration)', () => {
174157
server.static('/html', root);
175158
server.handler(dummyRequestHandler);
176159

177-
const content = fs
178-
.readFileSync(path.join(root, 'index.html'))
179-
.toString('utf-8');
180160
await createClientForHandler(server.requestHandler)
181161
.get('/html/index.html')
182-
.expect(200, content);
162+
.expect(200, 'Hello');
183163
});
184164

185165
it('allows cors', async () => {

0 commit comments

Comments
 (0)