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

@@ -1,9 +1,10 @@
import * as nodePath from 'path'
import * as fs from 'fs'
import * as mkdirp from 'mkdirp'
import { Filesystem } from './path/Filesystem'
import ReadableStream = NodeJS.ReadableStream;
import WritableStream = NodeJS.WritableStream;
import * as mime from 'mime-types'
import {FileNotFoundError, Filesystem} from './path/Filesystem'
import {Collection} from '../collection/Collection'
import {Readable, Writable} from 'stream'
/**
* An item that could represent a path.
@@ -22,6 +23,36 @@ export function universalPath(...parts: PathLike[]): UniversalPath {
return main.concat(...concats)
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
* @see https://stackoverflow.com/a/14919494/4971138
*/
export function bytesToHumanFileSize(bytes: number, si = false, dp = 1): string {
const thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh) {
return bytes + ' B'
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
let u = -1
const r = 10 ** dp
do {
bytes /= thresh
++u
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)
return bytes.toFixed(dp) + ' ' + units[u]
}
/**
* Walk recursively over entries in a directory.
*
@@ -155,6 +186,13 @@ export class UniversalPath {
return `${this.prefix}${this.resourceLocalPath}`
}
/**
* Get the basename of the path.
*/
get toBase(): string {
return nodePath.basename(this.resourceLocalPath)
}
/**
* Append and resolve the given paths to this resource and return a new UniversalPath.
*
@@ -224,6 +262,44 @@ export class UniversalPath {
return walk(this.resourceLocalPath)
}
/**
* Resolves true if this resource is a directory.
*/
async isDirectory(): Promise<boolean> {
if ( this.filesystem ) {
const stat = await this.filesystem.stat({
storePath: this.resourceLocalPath,
})
return stat.isDirectory
}
try {
return (await fs.promises.stat(this.resourceLocalPath)).isDirectory()
} catch (e) {
return false
}
}
/**
* Resolves true if this resource is a regular file.
*/
async isFile(): Promise<boolean> {
if ( this.filesystem ) {
const stat = await this.filesystem.stat({
storePath: this.resourceLocalPath,
})
return stat.isFile
}
try {
return (await fs.promises.stat(this.resourceLocalPath)).isFile()
} catch (e) {
return false
}
}
/**
* Returns true if the given resource exists at the path.
*/
@@ -244,6 +320,20 @@ export class UniversalPath {
}
}
/**
* List any immediate children of this resource.
*/
async list(): Promise<Collection<UniversalPath>> {
if ( this.filesystem ) {
const files = await this.filesystem.list(this.resourceLocalPath)
return files.map(x => this.concat(x))
}
const paths = await fs.promises.readdir(this.resourceLocalPath)
return Collection.collect<string>(paths)
.map(x => this.concat(x))
}
/**
* Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux.
*/
@@ -290,7 +380,7 @@ export class UniversalPath {
/**
* Get a writable stream to this file's contents.
*/
async writeStream(): Promise<WritableStream> {
async writeStream(): Promise<Writable> {
if ( this.filesystem ) {
return this.filesystem.putStoreFileAsStream({
storePath: this.resourceLocalPath,
@@ -304,7 +394,7 @@ export class UniversalPath {
* Read the data from this resource's file as a string.
*/
async read(): Promise<string> {
let stream: ReadableStream
let stream: Readable
if ( this.filesystem ) {
stream = await this.filesystem.getStoreFileAsStream({
storePath: this.resourceLocalPath,
@@ -321,10 +411,37 @@ export class UniversalPath {
})
}
/**
* Get the size of this resource in bytes.
*/
async sizeInBytes(): Promise<number> {
if ( this.filesystem ) {
const stat = await this.filesystem.stat({
storePath: this.resourceLocalPath,
})
if ( stat.exists ) {
return stat.sizeInBytes
}
throw new FileNotFoundError(this.toString())
}
const stat = await fs.promises.stat(this.resourceLocalPath)
return stat.size
}
/**
* Get the size of this resource, formatted in a human-readable string.
*/
async sizeForHumans(): Promise<string> {
return bytesToHumanFileSize(await this.sizeInBytes())
}
/**
* Get a readable stream of this file's contents.
*/
async readStream(): Promise<ReadableStream> {
async readStream(): Promise<Readable> {
if ( this.filesystem ) {
return this.filesystem.getStoreFileAsStream({
storePath: this.resourceLocalPath,
@@ -334,17 +451,70 @@ export class UniversalPath {
}
}
/* get mime_type() {
return Mime.lookup(this.ext)
}
get content_type() {
return Mime.contentType(this.ext)
}
get charset() {
if ( this.mime_type ) {
return Mime.charset(this.mime_type)
/**
* Returns true if this path exists in the subtree of the given path.
* @param otherPath
*/
isChildOf(otherPath: UniversalPath): boolean {
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
return false
}
}*/
if ( this.prefix !== otherPath.prefix ) {
return false
}
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
return Boolean(relative && !relative.startsWith('..') && !nodePath.isAbsolute(relative))
}
/**
* Returns true if the given path exists in the subtree of this path.
* @param otherPath
*/
isParentOf(otherPath: UniversalPath): boolean {
return otherPath.isChildOf(this)
}
/**
* Returns true if the given path refers to the same resource as this path.
* @param otherPath
*/
is(otherPath: UniversalPath): boolean {
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
return false
}
if ( this.prefix !== otherPath.prefix ) {
return false
}
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
return relative === ''
}
/**
* Get the mime-type of this resource.
*/
get mimeType(): string | false {
return mime.lookup(this.resourceLocalPath)
}
/**
* Get the content-type header of this resource.
*/
get contentType(): string | false {
return mime.contentType(this.resourceLocalPath)
}
/**
* Get the charset of this resource.
*/
get charset(): string | false {
if ( this.mimeType ) {
return mime.charset(this.mimeType)
}
return false
}
}