File-based response support & static server
All checks were successful
continuous-integration/drone/push Build is passing
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:
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
36
src/http/response/FileResponseFactory.ts
Normal file
36
src/http/response/FileResponseFactory.ts
Normal 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
169
src/http/servers/static.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user