File-based response support & static server
All checks were successful
continuous-integration/drone/push Build is passing

- Clean up UniversalPath implementation
    - Use Readable/Writable types correctly for stream methods
    - Add .list() methods for getting child files

- Make Response body specify explicit types and support
  writing Readable streams to the body

- Create a static file server that supports directory listing
This commit is contained in:
2021-07-07 20:13:23 -05:00
parent b3b5b169e8
commit f496046461
14 changed files with 893 additions and 165 deletions

View File

@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
constructor(
public readonly status: HTTPStatus = 500,
public readonly message: string = '',
context?: {[key: string]: any},
) {
super('HTTP ERROR')
super('HTTP ERROR', context)
this.message = message || HTTPMessage[status]
}
}

View File

@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
import {plaintext} from '../../response/StringResponseFactory'
import {ResponseFactory} from '../../response/ResponseFactory'
import {json} from '../../response/JSONResponseFactory'
import {UniversalPath} from '../../../util'
import {file} from '../../response/FileResponseFactory'
/**
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
if ( object instanceof ResponseFactory ) {
await object.write(request)
} else if ( object instanceof UniversalPath ) {
await file(object).write(request)
} else if ( typeof object !== 'undefined' ) {
await json(object).write(request)
} else {

View File

@@ -2,6 +2,7 @@ import {Request} from './Request'
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
import {ServerResponse} from 'http'
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
import {Readable} from 'stream'
/**
* Error thrown when the server tries to re-send headers after they have been sent once.
@@ -47,7 +48,7 @@ export class Response {
private isBlockingWriteback = false
/** The body contents that should be written to the response. */
public body = ''
public body: string | Buffer | Uint8Array | Readable = ''
/**
* Behavior subject fired right before the response content is written.
@@ -192,18 +193,29 @@ export class Response {
* Write the headers and specified data to the client.
* @param data
*/
public async write(data: unknown): Promise<void> {
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
return new Promise<void>((res, rej) => {
if ( !this.sentHeaders ) {
this.sendHeaders()
}
this.serverResponse.write(data, error => {
if ( error ) {
rej(error)
} else {
res()
}
})
if ( data instanceof Readable ) {
data.pipe(this.serverResponse)
.on('finish', () => {
res()
})
.on('error', error => {
rej(error)
})
} else {
this.serverResponse.write(data, error => {
if ( error ) {
rej(error)
} else {
res()
}
})
}
})
}
@@ -212,9 +224,14 @@ export class Response {
*/
public async send(): Promise<void> {
await this.sending$.next(this)
this.setHeader('Content-Length', String(this.body?.length ?? 0))
if ( !(this.body instanceof Readable) ) {
this.setHeader('Content-Length', String(this.body?.length ?? 0))
}
await this.write(this.body ?? '')
this.end()
await this.sent$.next(this)
}

View File

@@ -0,0 +1,36 @@
import {ResponseFactory} from './ResponseFactory'
import {Request} from '../lifecycle/Request'
import {ErrorWithContext, UniversalPath} from '../../util'
/**
* Helper function that creates a FileResponseFactory for the given path.
* @param path
*/
export function file(path: UniversalPath): FileResponseFactory {
return new FileResponseFactory(path)
}
/**
* HTTP response factory that sends a file referenced by a given UniversalPath.
*/
export class FileResponseFactory extends ResponseFactory {
constructor(
/** The file to be sent. */
public readonly path: UniversalPath,
) {
super()
}
public async write(request: Request): Promise<Request> {
if ( !(await this.path.isFile()) ) {
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
path: this.path,
})
}
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
request.response.body = await this.path.readStream()
return request
}
}

169
src/http/servers/static.ts Normal file
View File

@@ -0,0 +1,169 @@
import {Request} from '../lifecycle/Request'
import {ActivatedRoute} from '../routing/ActivatedRoute'
import {Config} from '../../service/Config'
import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util'
import {Application} from '../../lifecycle/Application'
import {HTTPError} from '../HTTPError'
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
import {redirect} from '../response/TemporaryRedirectResponseFactory'
import {file} from '../response/FileResponseFactory'
import {RouteHandler} from '../routing/Route'
/**
* Defines the behavior of the static server.
*/
export interface StaticServerOptions {
/** If true, browsing to a directory route will show the directory listing page. */
directoryListing?: boolean
/** The path to the directory whose files should be served. */
basePath?: string | string[] | UniversalPath
/** If specified, only files with these extensions will be served. */
allowedExtensions?: string[]
/** If specified, files with these extensions will not be served. */
excludedExtensions?: string[]
}
/**
* HTTPError class thrown by the static server.
*/
export class StaticServerHTTPError extends HTTPError {
}
/**
* Build the response factory that shows the directory listing.
* @param dirname
* @param dirPath
*/
async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise<ViewResponseFactory> {
return view('@extollo:static:dirlist', {
dirname,
contents: (await (await dirPath.list())
.promiseMap(async path => {
const isDirectory = await path.isDirectory()
return {
isDirectory,
name: path.toBase,
size: isDirectory ? '-' : await path.sizeForHumans(),
}
}))
.sortBy(row => {
return `${row.isDirectory ? 0 : 1}${row.name}`
})
.all(),
})
}
/**
* Returns true if the given file path has an extension that is allowed by
* the static server options.
* @param filePath
* @param options
*/
function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean {
return (
(
!options.allowedExtensions
|| options.allowedExtensions.includes(filePath.ext)
)
&& (
!options.excludedExtensions
|| !options.excludedExtensions.includes(filePath.ext)
)
)
}
/**
* Resolve the configured base path into a universal path.
* Defaults to `{app path}/resources/static` if none provided.
* @param appPath
* @param basePath
*/
function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath {
if ( basePath instanceof UniversalPath ) {
return basePath
}
if ( !basePath ) {
return appPath.concat('resources', 'static')
}
if ( Array.isArray(basePath) ) {
return appPath.concat(...basePath)
}
if ( basePath.startsWith('/') ) {
return universalPath(basePath)
}
return appPath.concat(basePath)
}
/**
* Get a route handler that serves a directory as static files.
* @param options
*/
export function staticServer(options: StaticServerOptions = {}): RouteHandler {
return async (request: Request) => {
const config = <Config> request.make(Config)
const route = <ActivatedRoute> request.make(ActivatedRoute)
const app = <Application> request.make(Application)
const staticConfig = config.get('server.builtIns.static', {})
const mergedOptions = {
...staticConfig,
...options,
}
// Resolve the path to the resource on the filesystem
const basePath = getBasePath(app.appPath(), mergedOptions.basePath)
const filePath = basePath.concat(...Collection.normalize<string>(route.params[0]))
// If the resolved path is outside of the base path, fail out
if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
reason: 'Resolved file is not a child of the base path.',
})
}
// If the resolved file is an invalid file extension, fail out
if ( !isValidFileExtension(filePath, mergedOptions) ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
allowedExtensions: mergedOptions.allowedExtensions,
excludedExtensions: mergedOptions.excludedExtensions,
reason: 'Resolved file is not an allowed extension type',
})
}
// If the resolved file does not exist on the filesystem, fail out
if ( !(await filePath.exists()) ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
reason: 'Resolved file does not exist on the filesystem',
})
}
// If the resolved path is a directory, send the directory listing response
if ( await filePath.isDirectory() ) {
if ( !route.path.endsWith('/') ) {
return redirect(`${route.path}/`)
}
return getDirectoryListingResponse(route.path, filePath)
}
// Otherwise, just send the file as the response body
return file(filePath)
}
}