Skip to content

Commit a1cefcc

Browse files
committed
feat(rest): allow static assets to be served by a rest server
1 parent b18e95f commit a1cefcc

File tree

5 files changed

+195
-3
lines changed

5 files changed

+195
-3
lines changed

docs/site/Application.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,39 @@ This means you can call these `RestServer` functions to do all of your
206206
server-level setups in the app constructor without having to explicitly retrieve
207207
an instance of your server.
208208

209+
### Serve static files
210+
211+
The `RestServer` allows static files to be served. It can be set up by calling
212+
the `static()` API.
213+
214+
```ts
215+
app.static('/html', rootDirForHtml);
216+
```
217+
218+
or
219+
220+
```ts
221+
server.static(['/html', '/public'], rootDirForHtml);
222+
```
223+
224+
Static assets are not allowed to be mounted on `/` to avoid performance penalty
225+
as `/` matches all paths and incurs file system access for each HTTP request.
226+
227+
The static() API delegates to
228+
[serve-static](https://expressjs.com/en/resources/middleware/serve-static.html)
229+
to serve static files. Please see
230+
https://expressjs.com/en/starter/static-files.html and
231+
https://expressjs.com/en/4x/api.html#express.static for details.
232+
233+
**WARNING**:
234+
235+
> The static assets are served before LoopBack sequence of actions. If an error
236+
> is thrown, the `reject` action will NOT be triggered.
237+
209238
### Use unique bindings
210239

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

215244
```ts

packages/rest/src/rest.application.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
ControllerFactory,
1717
} from './router/routing-table';
1818
import {OperationObject, OpenApiSpec} from '@loopback/openapi-v3-types';
19+
import {ServeStaticOptions} from 'serve-static';
20+
import {PathParams} from 'express-serve-static-core';
1921

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

88+
/**
89+
* Mount static assets to the REST server.
90+
* See https://expressjs.com/en/4x/api.html#express.static
91+
* @param path The path(s) to serve the asset.
92+
* See examples at https://expressjs.com/en/4x/api.html#path-examples
93+
* To avoid performance penalty, `/` is not allowed for now.
94+
* @param rootDir The root directory from which to serve static assets
95+
* @param options Options for serve-static
96+
*/
97+
static(path: PathParams, rootDir: string, options?: ServeStaticOptions) {
98+
this.restServer.static(path, rootDir, options);
99+
}
100+
86101
/**
87102
* Register a new Controller-based route.
88103
*

packages/rest/src/rest.server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import {
3535
import {RestBindings} from './keys';
3636
import {RequestContext} from './request-context';
3737
import * as express from 'express';
38+
import {ServeStaticOptions} from 'serve-static';
39+
import {PathParams} from 'express-serve-static-core';
40+
import * as pathToRegExp from 'path-to-regexp';
3841

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

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

133136
protected _expressApp: express.Application;
137+
protected _routerForStaticAssets: express.Router;
134138

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

203+
// Place the assets router here before controllers
204+
this._setupRouterForStaticAssets();
205+
199206
// Mount our router & request handler
200207
this._expressApp.use((req, res, next) => {
201208
this._handleHttpRequest(req, res, options!).catch(next);
@@ -209,6 +216,17 @@ export class RestServer extends Context implements Server, HttpServerLike {
209216
);
210217
}
211218

219+
/**
220+
* Set up an express router for all static assets so that middleware for
221+
* all directories are invoked at the same phase
222+
*/
223+
protected _setupRouterForStaticAssets() {
224+
if (!this._routerForStaticAssets) {
225+
this._routerForStaticAssets = express.Router();
226+
this._expressApp.use(this._routerForStaticAssets);
227+
}
228+
}
229+
212230
protected _handleHttpRequest(
213231
request: Request,
214232
response: Response,
@@ -513,6 +531,25 @@ export class RestServer extends Context implements Server, HttpServerLike {
513531
);
514532
}
515533

534+
/**
535+
* Mount static assets to the REST server.
536+
* See https://expressjs.com/en/4x/api.html#express.static
537+
* @param path The path(s) to serve the asset.
538+
* See examples at https://expressjs.com/en/4x/api.html#path-examples
539+
* To avoid performance penalty, `/` is not allowed for now.
540+
* @param rootDir The root directory from which to serve static assets
541+
* @param options Options for serve-static
542+
*/
543+
static(path: PathParams, rootDir: string, options?: ServeStaticOptions) {
544+
const re = pathToRegExp(path, [], {end: false});
545+
if (re.test('/')) {
546+
throw new Error(
547+
'Static assets cannot be mount to "/" to avoid performance penalty.',
548+
);
549+
}
550+
this._routerForStaticAssets.use(path, express.static(rootDir, options));
551+
}
552+
516553
/**
517554
* Set the OpenAPI specification that defines the REST API schema for this
518555
* server. All routes, parameter definitions and return types will be defined
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<html>
2+
<header>
3+
<title>Test Page</title>
4+
</header>
5+
<body>
6+
<h1>Hello, World!</h1>
7+
</body>
8+
</html>

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

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Application, ApplicationConfig} from '@loopback/core';
6+
import {Application} from '@loopback/core';
77
import {
88
supertest,
99
expect,
@@ -17,6 +17,7 @@ import {IncomingMessage, ServerResponse} from 'http';
1717
import * as yaml from 'js-yaml';
1818
import * as path from 'path';
1919
import * as fs from 'fs';
20+
import {RestServerConfig} from '../../src';
2021

2122
describe('RestServer (integration)', () => {
2223
it('exports url property', async () => {
@@ -77,6 +78,108 @@ describe('RestServer (integration)', () => {
7778
.expect(500);
7879
});
7980

81+
it('does not allow static assets to be mounted at /', async () => {
82+
const root = path.join(__dirname, 'fixtures');
83+
const server = await givenAServer({
84+
rest: {
85+
port: 0,
86+
},
87+
});
88+
89+
expect(() => server.static('/', root)).to.throw(
90+
'Static assets cannot be mount to "/" to avoid performance penalty.',
91+
);
92+
93+
expect(() => server.static('', root)).to.throw(
94+
'Static assets cannot be mount to "/" to avoid performance penalty.',
95+
);
96+
97+
expect(() => server.static(['/'], root)).to.throw(
98+
'Static assets cannot be mount to "/" to avoid performance penalty.',
99+
);
100+
101+
expect(() => server.static(['/html', ''], root)).to.throw(
102+
'Static assets cannot be mount to "/" to avoid performance penalty.',
103+
);
104+
105+
expect(() => server.static(/.*/, root)).to.throw(
106+
'Static assets cannot be mount to "/" to avoid performance penalty.',
107+
);
108+
109+
expect(() => server.static('/(.*)', root)).to.throw(
110+
'Static assets cannot be mount to "/" to avoid performance penalty.',
111+
);
112+
});
113+
114+
it('allows static assets via api', async () => {
115+
const root = path.join(__dirname, 'fixtures');
116+
const server = await givenAServer({
117+
rest: {
118+
port: 0,
119+
},
120+
});
121+
122+
server.static('/html', root);
123+
const content = fs
124+
.readFileSync(path.join(root, 'index.html'))
125+
.toString('utf-8');
126+
await createClientForHandler(server.requestHandler)
127+
.get('/html/index.html')
128+
.expect('Content-Type', /text\/html/)
129+
.expect(200, content);
130+
});
131+
132+
it('allows static assets via api after start', async () => {
133+
const root = path.join(__dirname, 'fixtures');
134+
const server = await givenAServer({
135+
rest: {
136+
port: 0,
137+
},
138+
});
139+
await createClientForHandler(server.requestHandler)
140+
.get('/html/index.html')
141+
.expect(404);
142+
143+
server.static('/html', root);
144+
145+
await createClientForHandler(server.requestHandler)
146+
.get('/html/index.html')
147+
.expect(200);
148+
});
149+
150+
it('allows non-static routes after assets', async () => {
151+
const root = path.join(__dirname, 'fixtures');
152+
const server = await givenAServer({
153+
rest: {
154+
port: 0,
155+
},
156+
});
157+
server.static('/html', root);
158+
server.handler(dummyRequestHandler);
159+
160+
await createClientForHandler(server.requestHandler)
161+
.get('/html/does-not-exist.html')
162+
.expect(200, 'Hello');
163+
});
164+
165+
it('serve static assets if matches before other routes', async () => {
166+
const root = path.join(__dirname, 'fixtures');
167+
const server = await givenAServer({
168+
rest: {
169+
port: 0,
170+
},
171+
});
172+
server.static('/html', root);
173+
server.handler(dummyRequestHandler);
174+
175+
const content = fs
176+
.readFileSync(path.join(root, 'index.html'))
177+
.toString('utf-8');
178+
await createClientForHandler(server.requestHandler)
179+
.get('/html/index.html')
180+
.expect(200, content);
181+
});
182+
80183
it('allows cors', async () => {
81184
const server = await givenAServer({rest: {port: 0}});
82185
server.handler(dummyRequestHandler);
@@ -369,7 +472,7 @@ servers:
369472
await server.stop();
370473
});
371474

372-
async function givenAServer(options?: ApplicationConfig) {
475+
async function givenAServer(options?: {rest: RestServerConfig}) {
373476
const app = new Application(options);
374477
app.component(RestComponent);
375478
return await app.getServer(RestServer);

0 commit comments

Comments
 (0)