From 745eef798a6fe8a0900c99a10c0b04db959f6663 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Tue, 27 Jul 2021 23:20:08 +0200 Subject: [PATCH] feat: Cache static assets. Closes https://github.com/solid/community-server/issues/861 --- config/http/static/default.json | 1 + src/server/middleware/StaticAssetHandler.ts | 19 +++++++++++++++++-- .../middleware/StaticAssetHandler.test.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/config/http/static/default.json b/config/http/static/default.json index f3b8bbe00..27b3eb457 100644 --- a/config/http/static/default.json +++ b/config/http/static/default.json @@ -5,6 +5,7 @@ "comment": "Servers static files on fixed URLs.", "@id": "urn:solid-server:default:StaticAssetHandler", "@type": "StaticAssetHandler", + "options_expires": 86400, "assets": [ { "StaticAssetHandler:_assets_key": "/favicon.ico", diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts index b91d31c93..61091453f 100644 --- a/src/server/middleware/StaticAssetHandler.ts +++ b/src/server/middleware/StaticAssetHandler.ts @@ -20,20 +20,23 @@ import type { HttpRequest } from '../HttpRequest'; export class StaticAssetHandler extends HttpHandler { private readonly mappings: Record; private readonly pathMatcher: RegExp; + private readonly expires: number; private readonly logger = getLoggerFor(this); /** * Creates a handler for the provided static resources. * @param assets - A mapping from URL paths to paths, * where URL paths ending in a slash are interpreted as entire folders. + * @param options - Cache expiration time in seconds. */ - public constructor(assets: Record) { + public constructor(assets: Record, options: { expires?: number } = {}) { super(); this.mappings = {}; for (const [ url, path ] of Object.entries(assets)) { this.mappings[url] = resolveAssetPath(path); } this.pathMatcher = this.createPathMatcher(assets); + this.expires = Number.isInteger(options.expires) ? Math.max(0, options.expires!) : 0; } /** @@ -90,7 +93,10 @@ export class StaticAssetHandler extends HttpHandler { // Write a 200 response when the asset becomes readable asset.once('readable', (): void => { const contentType = mime.lookup(filePath) || APPLICATION_OCTET_STREAM; - response.writeHead(200, { 'content-type': contentType }); + response.writeHead(200, { + 'content-type': contentType, + ...this.getCacheHeaders(), + }); // With HEAD, only write the headers if (request.method === 'HEAD') { @@ -120,4 +126,13 @@ export class StaticAssetHandler extends HttpHandler { }); }); } + + private getCacheHeaders(): Record { + return this.expires <= 0 ? + {} : + { + 'cache-control': `max-age=${this.expires}`, + expires: new Date(Date.now() + (this.expires * 1000)).toUTCString(), + }; + } } diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts index 131951d94..bc7dbd7c7 100644 --- a/test/unit/server/middleware/StaticAssetHandler.test.ts +++ b/test/unit/server/middleware/StaticAssetHandler.test.ts @@ -213,4 +213,21 @@ describe('A StaticAssetHandler', (): void => { const response = createResponse({ eventEmitter: EventEmitter }); await expect(handler.canHandle({ request, response } as any)).rejects.toThrow(); }); + + it('caches responses when the expires option is set.', async(): Promise => { + jest.spyOn(Date, 'now').mockReturnValue(0); + const cachedHandler = new StaticAssetHandler({ + '/foo/bar/style': '/assets/styles/bar.css', + }, { + expires: 86400, + }); + const request = { method: 'GET', url: '/foo/bar/style' }; + const response = createResponse(); + await cachedHandler.handleSafe({ request, response } as any); + jest.restoreAllMocks(); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('cache-control', 'max-age=86400'); + expect(response.getHeaders()).toHaveProperty('expires', 'Fri, 02 Jan 1970 00:00:00 GMT'); + }); });