diff --git a/package.json b/package.json index 7d1033a..a2cbecd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@extollo/lib", - "version": "0.14.11", + "version": "0.14.12", "description": "The framework library that lifts up your code.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/util/index.ts b/src/util/index.ts index cc9e89d..a4a9af1 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -33,6 +33,8 @@ export * from './support/debug' export * from './support/path/Filesystem' export * from './support/path/LocalFilesystem' export * from './support/path/SSHFilesystem' +export * from './support/path/ReadOnlyFilesystem' +export * from './support/path/HTTPFilesystem' export * from './support/Safe' @@ -43,3 +45,5 @@ export * from './support/global' export * from './support/Pipe' export * from './support/Messages' export * from './support/types' + +export * from './support/path-helpers' diff --git a/src/util/logging/FileLogger.ts b/src/util/logging/FileLogger.ts index cbed2ee..61b8d5f 100644 --- a/src/util/logging/FileLogger.ts +++ b/src/util/logging/FileLogger.ts @@ -1,7 +1,7 @@ import {Logger} from './Logger' import {LogMessage} from './types' import {Injectable} from '../../di' -import {universalPath} from '../support/path' +import {universalPath} from '../support/path-helpers' import {appPath, env} from '../../lifecycle/Application' import {Writable} from 'stream' diff --git a/src/util/support/path-helpers.ts b/src/util/support/path-helpers.ts new file mode 100644 index 0000000..4b10312 --- /dev/null +++ b/src/util/support/path-helpers.ts @@ -0,0 +1,21 @@ +import {PathLike, UniversalPath} from './path' +import {HTTPFilesystem} from './path/HTTPFilesystem' +import {make} from '../../make' +import {Filesystem} from './path/Filesystem' +import {Maybe} from './types' + +/** + * Create a new UniversalPath from the given path-like segments. + * @param parts + */ +export function universalPath(...parts: PathLike[]): UniversalPath { + let [main, ...concats] = parts // eslint-disable-line prefer-const + if ( !(main instanceof UniversalPath) ) { + let fs: Maybe = undefined + if ( main.toLowerCase().startsWith('https://') || main.toLowerCase().startsWith('http://') ) { + fs = make(HTTPFilesystem) + } + main = new UniversalPath(main, fs) + } + return main.concat(...concats) +} diff --git a/src/util/support/path.ts b/src/util/support/path.ts index e629ab4..c5b065f 100644 --- a/src/util/support/path.ts +++ b/src/util/support/path.ts @@ -12,18 +12,6 @@ import {Pipeline} from './Pipe' */ export type PathLike = string | UniversalPath -/** - * Create a new UniversalPath from the given path-like segments. - * @param parts - */ -export function universalPath(...parts: PathLike[]): UniversalPath { - let [main, ...concats] = parts // eslint-disable-line prefer-const - if ( !(main instanceof UniversalPath) ) { - main = new UniversalPath(main) - } - return main.concat(...concats) -} - /** * Format bytes as human-readable text. * @@ -144,7 +132,7 @@ export class UniversalPath { * Return a new copy of this UniversalPath instance. */ clone(): UniversalPath { - return new UniversalPath(this.initial) + return new UniversalPath(this.initial, this.filesystem) } /** @@ -224,7 +212,7 @@ export class UniversalPath { */ public concat(...paths: PathLike[]): UniversalPath { const resolved = nodePath.join(this.unqualified, ...(paths.map(p => typeof p === 'string' ? p : p.unqualified))) - return new UniversalPath(`${this.prefix}${resolved}`) + return new UniversalPath(`${this.prefix}${resolved}`, this.filesystem) } /** diff --git a/src/util/support/path/HTTPFilesystem.ts b/src/util/support/path/HTTPFilesystem.ts new file mode 100644 index 0000000..547dd03 --- /dev/null +++ b/src/util/support/path/HTTPFilesystem.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs' +import * as mime from 'mime-types' +import * as path from 'path' +import {FilesystemOperationNotSupported, ReadOnlyFilesystem} from './ReadOnlyFilesystem' +import {UniversalPath} from '../path' +import {Awaitable, Maybe} from '../types' +import {Readable} from 'stream' +import {FileMetadata, Stat} from './Filesystem' +import {Collection} from '../../collection/Collection' +import {RequestInfo, RequestInit, Response} from 'node-fetch' +import {unsafeESMImport} from '../../unsafe' + +const fetch = (url: RequestInfo, init?: RequestInit): Promise => unsafeESMImport('node-fetch').then(({default: nodeFetch}) => nodeFetch(url, init)) + +/** + * A filesystem implementation that reads files over HTTPS. + */ +export class HTTPFilesystem extends ReadOnlyFilesystem { + getPrefix(): string { + return 'https://' + } + + async getStoreFileAsTemp(args: { storePath: string }): Promise { + const temp = this.tempName() + const write = fs.createWriteStream(temp) + const read = await this.getStoreFileAsStream(args) + + return new Promise((res, rej) => { + write.on('finish', () => { + res(new UniversalPath(temp)) + }) + + write.on('error', rej) + read.on('error', rej) + read.pipe(write) + }) + } + + async getStoreFileAsStream(args: { storePath: string }): Promise { + if ( args.storePath.startsWith('/') ) { + args.storePath = args.storePath.slice(1) + } + + const result = await fetch(`${this.getPrefix()}${args.storePath}`) + if ( result.body ) { + return (new Readable()).wrap(result.body) + } + + return Readable.from([]) + } + + async stat(args: { storePath: string }): Promise { + if ( args.storePath.startsWith('/') ) { + args.storePath = args.storePath.slice(1) + } + + let response: Maybe = undefined + try { + response = await fetch(`${this.getPrefix()}${args.storePath}`) + } catch (e) {} // eslint-disable-line no-empty + + return { + path: new UniversalPath(args.storePath, this), + exists: Boolean(response?.ok), + sizeInBytes: response?.size || 0, + mimeType: mime.lookup(path.basename(args.storePath)) || undefined, + tags: [], + accessed: undefined, + modified: undefined, + created: undefined, + isFile: true, + isDirectory: false, + } + } + + getMetadata(storePath: string): Awaitable { + const mimeType = mime.lookup(path.basename(storePath)) + return { + ...(mimeType ? { mimeType } : {}), + } + } + + list(): Awaitable> { + throw new FilesystemOperationNotSupported(this, 'list') + } +} diff --git a/src/util/support/path/ReadOnlyFilesystem.ts b/src/util/support/path/ReadOnlyFilesystem.ts new file mode 100644 index 0000000..a34b3f2 --- /dev/null +++ b/src/util/support/path/ReadOnlyFilesystem.ts @@ -0,0 +1,42 @@ +import {ErrorWithContext} from '../../error/ErrorWithContext' +import {Filesystem} from './Filesystem' +import {Awaitable} from '../types' +import {Writable} from 'stream' + +/** + * Error thrown when attempting to perform an operation on a filesystem that doesn't support it. + */ +export class FilesystemOperationNotSupported extends ErrorWithContext { + constructor(fs: Filesystem, operation: string, context: any = {}) { + super(`The filesystem ${fs.constructor.name} does not support the ${operation} operation.`, context) + } +} + +/** + * Abstract base class for filesystems that are read-only (e.g. HTTP/S). + */ +export abstract class ReadOnlyFilesystem extends Filesystem { + putLocalFile(): Awaitable { + throw new FilesystemOperationNotSupported(this, 'putLocalFile') + } + + putStoreFileAsStream(): Awaitable { + throw new FilesystemOperationNotSupported(this, 'putStoreFileAsStream') + } + + touch(): Awaitable { + throw new FilesystemOperationNotSupported(this, 'touch') + } + + remove(): Awaitable { + throw new FilesystemOperationNotSupported(this, 'remove') + } + + mkdir(): Awaitable { + throw new FilesystemOperationNotSupported(this, 'mkdir') + } + + setMetadata(): Awaitable { + throw new FilesystemOperationNotSupported(this, 'setMetadata') + } +}