mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add StaticAssetHandler.
This commit is contained in:
@@ -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
|
||||
|
||||
57
src/server/middleware/StaticAssetHandler.ts
Normal file
57
src/server/middleware/StaticAssetHandler.ts
Normal 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, '');
|
||||
}
|
||||
}
|
||||
101
test/unit/server/middleware/StaticAssetHandler.test.ts
Normal file
101
test/unit/server/middleware/StaticAssetHandler.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user