Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/site/Application.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,39 @@ This means you can call these `RestServer` functions to do all of your
server-level setups in the app constructor without having to explicitly retrieve
an instance of your server.

### Serve static files

The `RestServer` allows static files to be served. It can be set up by calling
the `static()` API.

```ts
app.static('/html', rootDirForHtml);
```

or

```ts
server.static(['/html', '/public'], rootDirForHtml);
```

Static assets are not allowed to be mounted on `/` to avoid performance penalty
as `/` matches all paths and incurs file system access for each HTTP request.

The static() API delegates to
[serve-static](https://expressjs.com/en/resources/middleware/serve-static.html)
to serve static files. Please see
https://expressjs.com/en/starter/static-files.html and
https://expressjs.com/en/4x/api.html#express.static for details.

**WARNING**:

> The static assets are served before LoopBack sequence of actions. If an error
> is thrown, the `reject` action will NOT be triggered.

### Use unique bindings

Use binding names that are prefixed with a unique string that does not overlap
with loopback's bindings. As an example, if your application is built for your
with LoopBack's bindings. As an example, if your application is built for your
employer FooCorp, you can prefix your bindings with `fooCorp`.

```ts
Expand Down
2 changes: 1 addition & 1 deletion docs/site/Defining-the-API-using-code-first-approach.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ There are various tools available to LoopBack which allows this bottom-up
approach of building your application to be simple through the usages of
metadata and decorators.

### Start with LoopBack artfiacts
### Start with LoopBack artifacts

With TypeScript's
[experimental decorator](https://www.typescriptlang.org/docs/handbook/decorators.html)
Expand Down
2 changes: 1 addition & 1 deletion docs/site/Repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ We can now access key-value stores such as [Redis](https://redis.io/) using the
### Define a KeyValue Datasource

We first need to define a datasource to configure the key-value store. For
better flexibility, we spilt the datasource definition into two files. The json
better flexibility, we split the datasource definition into two files. The json
file captures the configuration properties and it can be possibly overridden by
dependency injection.

Expand Down
15 changes: 15 additions & 0 deletions packages/rest/src/rest.application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
ControllerFactory,
} from './router/routing-table';
import {OperationObject, OpenApiSpec} from '@loopback/openapi-v3-types';
import {ServeStaticOptions} from 'serve-static';
import {PathParams} from 'express-serve-static-core';

export const ERR_NO_MULTI_SERVER = format(
'RestApplication does not support multiple servers!',
Expand Down Expand Up @@ -83,6 +85,19 @@ export class RestApplication extends Application implements HttpServerLike {
this.restServer.handler(handlerFn);
}

/**
* Mount static assets to the REST server.
* See https://expressjs.com/en/4x/api.html#express.static
* @param path The path(s) to serve the asset.
* See examples at https://expressjs.com/en/4x/api.html#path-examples
* To avoid performance penalty, `/` is not allowed for now.
* @param rootDir The root directory from which to serve static assets
* @param options Options for serve-static
*/
static(path: PathParams, rootDir: string, options?: ServeStaticOptions) {
this.restServer.static(path, rootDir, options);
}

/**
* Register a new Controller-based route.
*
Expand Down
37 changes: 37 additions & 0 deletions packages/rest/src/rest.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import {
import {RestBindings} from './keys';
import {RequestContext} from './request-context';
import * as express from 'express';
import {ServeStaticOptions} from 'serve-static';
import {PathParams} from 'express-serve-static-core';
import * as pathToRegExp from 'path-to-regexp';

const debug = require('debug')('loopback:rest:server');

Expand Down Expand Up @@ -131,6 +134,7 @@ export class RestServer extends Context implements Server, HttpServerLike {
protected _httpServer: HttpServer | undefined;

protected _expressApp: express.Application;
protected _routerForStaticAssets: express.Router;

get listening(): boolean {
return this._httpServer ? this._httpServer.listening : false;
Expand Down Expand Up @@ -196,6 +200,9 @@ export class RestServer extends Context implements Server, HttpServerLike {
};
this._expressApp.use(cors(corsOptions));

// Place the assets router here before controllers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗️ The static router must be mounted AFTER dynamic routes. This is super important for performance. ❗️

In your current design, every REST API endpoint like /products/123 is going to hit the file system to check if there is a file matching the URL, e.g. /html/products/123.

Please add a test to verify what happens when there is both a LB4 route (e.g. a controller method) and a static file mapped at the same URL.

The correct order of middleware:

  • LB4 router
  • static files
  • error handler

This is BTW the same design we have in LB 3.x, it took us few iterations and lot of learning until we figured it out correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your current design, every REST API endpoint like /products/123 is going to hit the file system to check if there is a file matching the URL, e.g. /html/products/123.

That's not true. /products/123 won't match /html and it will be skipped without touching the file system.

The correct order of middleware:
LB4 router
static files
error handler

I'm fine with this order but the LB4 router does not pass control to next at the moment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought express makes a map of the routes in memory of all the files mounted for static serving? Isn't that the point of the express Router?

this._setupRouterForStaticAssets();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be moved to be inside the if statement below so we only instantiate this router if a static asset is being added.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I have it outside of the if statement on purpose as we need to register the middleware slot so that .static() will work.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you are calling this function from the .static() method as well ... so maybe remove it from there?


// Mount our router & request handler
this._expressApp.use((req, res, next) => {
this._handleHttpRequest(req, res, options!).catch(next);
Expand All @@ -209,6 +216,17 @@ export class RestServer extends Context implements Server, HttpServerLike {
);
}

/**
* Set up an express router for all static assets so that middleware for
* all directories are invoked at the same phase
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note that each new Router layer adds a non-negligible delay to the observed latency (time needed to handle the request) and also reduces the amount of requests that the server can handle every second.

I don't understand what's the purpose of creating a new express.Router() dedicated to serving static assets? Is this needed to support app.static API? If it is, then I am proposing to use a different implementation where we collect all information provided by app.static (e.g. list of arguments passed to every invocation) and then invoke static middleware factory only when constructing the ultimate request handler.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be mostly an implementation detail that we can easily change later. If you prefer, then I am ok to keep the currently proposed implementation for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the need to support app.static() api. This is the same express problem that we worked around in LB3 with middleware phases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that and wanted to proposed a different solution to that problem. Let's not waste too much time on this, I can later send a follow-up pull request to update the implementation to what I am envisioning. However, based on my other comment, we may need the extra express.Router instance to allow sequence-based implementation, in which case this discussion is not relevant.

*/
protected _setupRouterForStaticAssets() {
if (!this._routerForStaticAssets) {
this._routerForStaticAssets = express.Router();
this._expressApp.use(this._routerForStaticAssets);
}
}

protected _handleHttpRequest(
request: Request,
response: Response,
Expand Down Expand Up @@ -513,6 +531,25 @@ export class RestServer extends Context implements Server, HttpServerLike {
);
}

/**
* Mount static assets to the REST server.
* See https://expressjs.com/en/4x/api.html#express.static
* @param path The path(s) to serve the asset.
* See examples at https://expressjs.com/en/4x/api.html#path-examples
* To avoid performance penalty, `/` is not allowed for now.
* @param rootDir The root directory from which to serve static assets
* @param options Options for serve-static
*/
static(path: PathParams, rootDir: string, options?: ServeStaticOptions) {
const re = pathToRegExp(path, [], {end: false});
if (re.test('/')) {
throw new Error(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);
}
this._routerForStaticAssets.use(path, express.static(rootDir, options));
}

/**
* Set the OpenAPI specification that defines the REST API schema for this
* server. All routes, parameter definitions and return types will be defined
Expand Down
8 changes: 8 additions & 0 deletions packages/rest/test/integration/fixtures/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<header>
<title>Test Page</title>
</header>
<body>
<h1>Hello, World!</h1>
</body>
</html>
107 changes: 105 additions & 2 deletions packages/rest/test/integration/rest.server.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Application, ApplicationConfig} from '@loopback/core';
import {Application} from '@loopback/core';
import {
supertest,
expect,
Expand All @@ -17,6 +17,7 @@ import {IncomingMessage, ServerResponse} from 'http';
import * as yaml from 'js-yaml';
import * as path from 'path';
import * as fs from 'fs';
import {RestServerConfig} from '../../src';

describe('RestServer (integration)', () => {
it('exports url property', async () => {
Expand Down Expand Up @@ -77,6 +78,108 @@ describe('RestServer (integration)', () => {
.expect(500);
});

it('does not allow static assets to be mounted at /', async () => {
const root = path.join(__dirname, 'fixtures');
const server = await givenAServer({
rest: {
port: 0,
},
});

expect(() => server.static('/', root)).to.throw(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);

expect(() => server.static('', root)).to.throw(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);

expect(() => server.static(['/'], root)).to.throw(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);

expect(() => server.static(['/html', ''], root)).to.throw(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);

expect(() => server.static(/.*/, root)).to.throw(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);

expect(() => server.static('/(.*)', root)).to.throw(
'Static assets cannot be mount to "/" to avoid performance penalty.',
);
});

it('allows static assets via api', async () => {
const root = path.join(__dirname, 'fixtures');
const server = await givenAServer({
rest: {
port: 0,
},
});

server.static('/html', root);
const content = fs
.readFileSync(path.join(root, 'index.html'))
.toString('utf-8');
await createClientForHandler(server.requestHandler)
.get('/html/index.html')
.expect('Content-Type', /text\/html/)
.expect(200, content);
});

it('allows static assets via api after start', async () => {
const root = path.join(__dirname, 'fixtures');
const server = await givenAServer({
rest: {
port: 0,
},
});
await createClientForHandler(server.requestHandler)
.get('/html/index.html')
.expect(404);

server.static('/html', root);

await createClientForHandler(server.requestHandler)
.get('/html/index.html')
.expect(200);
});

it('allows non-static routes after assets', async () => {
const root = path.join(__dirname, 'fixtures');
const server = await givenAServer({
rest: {
port: 0,
},
});
server.static('/html', root);
server.handler(dummyRequestHandler);

await createClientForHandler(server.requestHandler)
.get('/html/does-not-exist.html')
.expect(200, 'Hello');
});

it('serve static assets if matches before other routes', async () => {
const root = path.join(__dirname, 'fixtures');
const server = await givenAServer({
rest: {
port: 0,
},
});
server.static('/html', root);
server.handler(dummyRequestHandler);

const content = fs
.readFileSync(path.join(root, 'index.html'))
.toString('utf-8');
await createClientForHandler(server.requestHandler)
.get('/html/index.html')
.expect(200, content);
});

it('allows cors', async () => {
const server = await givenAServer({rest: {port: 0}});
server.handler(dummyRequestHandler);
Expand Down Expand Up @@ -369,7 +472,7 @@ servers:
await server.stop();
});

async function givenAServer(options?: ApplicationConfig) {
async function givenAServer(options?: {rest: RestServerConfig}) {
const app = new Application(options);
app.component(RestComponent);
return await app.getServer(RestServer);
Expand Down