feat: Add StaticAssetHandler.

This commit is contained in:
Ruben Verborgh
2021-01-20 22:45:46 +01:00
parent 693d48b9eb
commit 5a12315554
3 changed files with 159 additions and 0 deletions

View File

@@ -121,6 +121,7 @@ export * from './server/WebSocketHandler';
// Server/Middleware
export * from './server/middleware/CorsHandler';
export * from './server/middleware/HeaderHandler';
export * from './server/middleware/StaticAssetHandler';
export * from './server/middleware/WebSocketAdvertiser';
// Storage/Accessors

View File

@@ -0,0 +1,57 @@
import { createReadStream } from 'fs';
import * as mime from 'mime-types';
import { getLoggerFor } from '../../logging/LogUtil';
import { APPLICATION_OCTET_STREAM } from '../../util/ContentTypes';
import { pipeSafely } from '../../util/StreamUtil';
import type { HttpHandlerInput } from '../HttpHandler';
import { HttpHandler } from '../HttpHandler';
import type { HttpRequest } from '../HttpRequest';
/**
* Handler that serves static resources on specific paths.
*/
export class StaticAssetHandler extends HttpHandler {
private readonly assets: Record<string, string>;
private readonly logger = getLoggerFor(this);
/**
* Creates a handler for the provided static resources.
* @param assets - A mapping from URL paths to file paths.
*/
public constructor(assets: Record<string, string>) {
super();
this.assets = { ...assets };
}
public async canHandle({ request }: HttpHandlerInput): Promise<void> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
throw new Error('Only GET and HEAD requests are supported');
}
if (!(this.getAssetUrl(request) in this.assets)) {
throw new Error(`No static resource at ${request.url}`);
}
}
public async handle({ request, response }: HttpHandlerInput): Promise<void> {
// Determine the asset to serve
const filePath = this.assets[this.getAssetUrl(request)];
this.logger.debug(`Serving ${request.url} via static asset ${filePath}`);
// Send the response headers
const contentType = mime.lookup(filePath) || APPLICATION_OCTET_STREAM;
response.writeHead(200, { 'content-type': contentType });
// For HEAD, send an empty body
if (request.method === 'HEAD') {
response.end();
// For GET, stream the asset
} else {
const asset = createReadStream(filePath, 'utf8');
pipeSafely(asset, response);
}
}
private getAssetUrl({ url }: HttpRequest): string {
return !url ? '' : url.replace(/\?.*/u, '');
}
}

View File

@@ -0,0 +1,101 @@
import EventEmitter from 'events';
import fs from 'fs';
import { createResponse } from 'node-mocks-http';
import streamifyArray from 'streamify-array';
import { StaticAssetHandler } from '../../../../src/server/middleware/StaticAssetHandler';
const createReadStream = jest.spyOn(fs, 'createReadStream')
.mockReturnValue(streamifyArray([ 'file contents' ]) as any);
describe('a StaticAssetHandler', (): void => {
const handler = new StaticAssetHandler({
'/foo/bar/style': '/assets/styles/bar.css',
'/foo/bar/main': '/assets/scripts/bar.js',
'/foo/bar/unknown': '/assets/bar.unknown',
});
afterEach(jest.clearAllMocks);
it('does not handle POST requests.', async(): Promise<void> => {
const request = { method: 'POST' };
await expect(handler.canHandle({ request } as any)).rejects
.toThrow('Only GET and HEAD requests are supported');
});
it('does not handle requests without URL.', async(): Promise<void> => {
const request = { method: 'GET' };
await expect(handler.canHandle({ request } as any)).rejects
.toThrow('No static resource');
});
it('does not handle requests with unconfigured URLs.', async(): Promise<void> => {
const request = { method: 'GET', url: '/other' };
await expect(handler.canHandle({ request } as any)).rejects
.toThrow('No static resource');
});
it('handles a GET request to a known URL.', async(): Promise<void> => {
const request = { method: 'GET', url: '/foo/bar/style' };
const response = createResponse({ eventEmitter: EventEmitter });
const responseEnd = new Promise((resolve): any => response.on('end', resolve));
await handler.handleSafe({ request, response } as any);
expect(response.statusCode).toBe(200);
expect(response.getHeaders()).toHaveProperty('content-type', 'text/css');
await responseEnd;
expect(createReadStream).toHaveBeenCalledTimes(1);
expect(createReadStream).toHaveBeenCalledWith('/assets/styles/bar.css', 'utf8');
expect(response._getData()).toBe('file contents');
});
it('handles a HEAD request to a known URL.', async(): Promise<void> => {
const request = { method: 'HEAD', url: '/foo/bar/main' };
const response = createResponse({ eventEmitter: EventEmitter });
const responseEnd = new Promise((resolve): any => response.on('end', resolve));
await handler.handleSafe({ request, response } as any);
expect(response.statusCode).toBe(200);
expect(response.getHeaders()).toHaveProperty('content-type', 'application/javascript');
await responseEnd;
expect(createReadStream).toHaveBeenCalledTimes(0);
expect(response._getData()).toBe('');
});
it('handles a request to a known URL with a query string.', async(): Promise<void> => {
const request = { method: 'GET', url: '/foo/bar/style?abc=xyz' };
const response = createResponse({ eventEmitter: EventEmitter });
await handler.handleSafe({ request, response } as any);
expect(response.statusCode).toBe(200);
expect(response.getHeaders()).toHaveProperty('content-type', 'text/css');
expect(createReadStream).toHaveBeenCalledTimes(1);
expect(createReadStream).toHaveBeenCalledWith('/assets/styles/bar.css', 'utf8');
});
it('handles a request for an asset with an unknown content type.', async(): Promise<void> => {
const request = { method: 'GET', url: '/foo/bar/unknown' };
const response = createResponse({ eventEmitter: EventEmitter });
await handler.handleSafe({ request, response } as any);
expect(response.statusCode).toBe(200);
expect(response.getHeaders()).toHaveProperty('content-type', 'application/octet-stream');
expect(createReadStream).toHaveBeenCalledTimes(1);
expect(createReadStream).toHaveBeenCalledWith('/assets/bar.unknown', 'utf8');
});
it('handles a request for an asset that errors.', async(): Promise<void> => {
const request = { method: 'GET', url: '/foo/bar/main' };
const response = createResponse({ eventEmitter: EventEmitter });
const responseEnd = new Promise((resolve): any => response.on('end', resolve));
await handler.handleSafe({ request, response } as any);
createReadStream.mock.results[0].value.emit('error', new Error());
await responseEnd;
expect(response._getData()).toBe('');
});
});