import { Exception } from '../interfaces'; import { Handler } from '../handler'; import { Message } from '../interfaces'; import { fromReadableStream } from '../utils'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { Inject } from 'injection-js'; import { Injectable } from 'injection-js'; import { InjectionToken } from 'injection-js'; import { Observable } from 'rxjs'; import { Optional } from 'injection-js'; import { catchError } from 'rxjs/operators'; import { from } from 'rxjs'; import { mapTo } from 'rxjs/operators'; import { mergeMap } from 'rxjs/operators'; import { of } from 'rxjs'; import { tap } from 'rxjs/operators'; import { throwError } from 'rxjs'; import md5File from 'md5-file'; /** * File server options */ export interface FileServerOpts { maxAge?: number; root?: string; } export const FILE_SERVER_OPTS = new InjectionToken<FileServerOpts>( 'FILE_SERVER_OPTS' ); export const FILE_SERVER_DEFAULT_OPTS: FileServerOpts = { maxAge: 600, root: os.homedir() }; /** * File server */ @Injectable() export class FileServer extends Handler { private opts: FileServerOpts; constructor(@Optional() @Inject(FILE_SERVER_OPTS) opts: FileServerOpts) { super(); this.opts = opts ? { ...FILE_SERVER_DEFAULT_OPTS, ...opts } : FILE_SERVER_DEFAULT_OPTS; } handle(message$: Observable<Message>): Observable<Message> { return message$.pipe( mergeMap((message: Message): Observable<Message> => { const { request, response } = message; const fpath = this.makeFPath(message); // Etag is the fie hash const etag = request.headers['If-None-Match']; return of(message).pipe( // NOTE: exception thrown if not found mergeMap( (_message: Message): Observable<string> => from(md5File(fpath)) ), // set the response headers tap((hash: string) => { response.headers['Cache-Control'] = `max-age=${this.opts.maxAge}`; response.headers['Etag'] = hash; }), // flip to cached/not cached pipes mergeMap((hash: string): Observable<Message> => { const cached = etag === hash; // cached pipe const cached$ = of(hash).pipe( tap(() => (response.statusCode = 304)), mapTo(message) ); // not cached pipe const notCached$ = of(hash).pipe( mergeMap( (): Observable<Buffer> => fromReadableStream(fs.createReadStream(fpath)) ), tap((buffer: Buffer) => { response.body = buffer; response.statusCode = 200; }), mapTo(message) ); return cached ? cached$ : notCached$; }), catchError(() => throwError(new Exception({ statusCode: 404 }))) ); }) ); } // private methods private makeFPath(message: Message): string { const { context, request, response } = message; const router = context.router; // NOTE: we never allow dot files and router.validate takes care of that let tail = router.tailOf(router.validate(request.path), request.route); // TODO: hack if this is a client-side route and not a path, deploy default if (!tail.includes('.')) { tail = 'index.html'; response.headers['Content-Type'] = 'text/html'; } return path.join(this.opts.root, tail); } }