From 5a123155541c9e9b1d08c8ad0be52d4dc4e2eabf Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 20 Jan 2021 22:45:46 +0100 Subject: [PATCH] feat: Add StaticAssetHandler. --- src/index.ts | 1 + src/server/middleware/StaticAssetHandler.ts | 57 ++++++++++ .../middleware/StaticAssetHandler.test.ts | 101 ++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 src/server/middleware/StaticAssetHandler.ts create mode 100644 test/unit/server/middleware/StaticAssetHandler.test.ts diff --git a/src/index.ts b/src/index.ts index 3ffdeaf01..360024465 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts new file mode 100644 index 000000000..1967b5c91 --- /dev/null +++ b/src/server/middleware/StaticAssetHandler.ts @@ -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; + 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) { + super(); + this.assets = { ...assets }; + } + + public async canHandle({ request }: HttpHandlerInput): Promise { + 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 { + // 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, ''); + } +} diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts new file mode 100644 index 000000000..b36a0639e --- /dev/null +++ b/test/unit/server/middleware/StaticAssetHandler.test.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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(''); + }); +});