diff --git a/src/event/EventBus.ts b/src/event/EventBus.ts index 5620ab9..76d6e49 100644 --- a/src/event/EventBus.ts +++ b/src/event/EventBus.ts @@ -1,4 +1,4 @@ -import {Singleton, StaticClass} from '../di' +import {Instantiable, Singleton, StaticClass} from '../di' import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types' import {Awaitable, Collection, uuid4} from '../util' @@ -13,7 +13,7 @@ export class EventBus implements Bus { */ protected subscribers: Collection> = new Collection>() - subscribe(event: StaticClass, subscriber: EventSubscriber): Awaitable { + subscribe(event: StaticClass>, subscriber: EventSubscriber): Awaitable { const entry: EventSubscriberEntry = { id: uuid4(), event, diff --git a/src/event/types.ts b/src/event/types.ts index 57f5d0e..d62d7b0 100644 --- a/src/event/types.ts +++ b/src/event/types.ts @@ -1,5 +1,5 @@ import {Awaitable, Rehydratable} from '../util' -import {StaticClass} from '../di' +import {Instantiable, StaticClass} from '../di' /** * A closure that should be executed with the given event is fired. @@ -14,7 +14,7 @@ export interface EventSubscriberEntry { id: string /** The event class subscribed to. */ - event: StaticClass + event: StaticClass> /** The closure to execute when the event is fired. */ subscriber: EventSubscriber @@ -41,7 +41,7 @@ export interface Dispatchable extends Rehydratable { * An event-driven bus that manages subscribers and dispatched items. */ export interface Bus { - subscribe(eventClass: StaticClass, subscriber: EventSubscriber): Awaitable + subscribe(eventClass: StaticClass>, subscriber: EventSubscriber): Awaitable unsubscribe(subscriber: EventSubscriber): Awaitable dispatch(event: Dispatchable): Awaitable } diff --git a/src/http/response/FileResponseFactory.ts b/src/http/response/FileResponseFactory.ts index 58600ae..f4b459c 100644 --- a/src/http/response/FileResponseFactory.ts +++ b/src/http/response/FileResponseFactory.ts @@ -1,6 +1,7 @@ import {ResponseFactory} from './ResponseFactory' import {Request} from '../lifecycle/Request' import {ErrorWithContext, UniversalPath} from '../../util' +import {Logging} from '../../service/Logging' /** * Helper function that creates a FileResponseFactory for the given path. @@ -28,6 +29,7 @@ export class FileResponseFactory extends ResponseFactory { }) } + request.make(Logging).debug(`Setting Content-Type of ${this.path} to ${this.path.contentType}...`) 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() diff --git a/src/http/servers/static.ts b/src/http/servers/static.ts index 53c3ae0..cdca4cb 100644 --- a/src/http/servers/static.ts +++ b/src/http/servers/static.ts @@ -156,6 +156,15 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler { // If the resolved path is a directory, send the directory listing response if ( await filePath.isDirectory() ) { + if ( !options.directoryListing ) { + throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { + basePath: basePath.toString(), + filePath: filePath.toString(), + route: route.path, + reason: 'Path is a directory, and directory listing is disabled', + }) + } + if ( !route.path.endsWith('/') ) { return redirect(`${route.path}/`) } diff --git a/src/orm/model/Model.ts b/src/orm/model/Model.ts index 7e044dd..095fe27 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -1,5 +1,5 @@ import {ModelKey, QueryRow, QuerySource} from '../types' -import {Container, Inject, StaticClass} from '../../di' +import {Container, Inject, Instantiable, StaticClass} from '../../di' import {DatabaseService} from '../DatabaseService' import {ModelBuilder} from './ModelBuilder' import {getFieldsMeta, ModelField} from './Field' @@ -804,7 +804,7 @@ export abstract class Model> extends AppClass implements Bus (this as any)[thisFieldName] = object[objectFieldName] } - subscribe(event: StaticClass, subscriber: EventSubscriber): Awaitable { + subscribe(event: StaticClass>, subscriber: EventSubscriber): Awaitable { const entry: EventSubscriberEntry = { id: uuid4(), event, diff --git a/src/resources/assets/.gitkeep b/src/resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index e72f049..0faf660 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -1,5 +1,5 @@ import {Inject, Singleton} from '../di' -import {HTTPStatus, withTimeout} from '../util' +import {HTTPStatus, universalPath, UniversalPath, withTimeout} from '../util' import {Unit} from '../lifecycle/Unit' import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http' import {Logging} from './Logging' @@ -17,6 +17,11 @@ import {ExecuteResolvedRoutePostflightHTTPModule} from '../http/kernel/module/Ex import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBodyHTTPModule' import {Config} from './Config' import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule' +import {Routing} from './Routing' +import {Route} from '../http/routing/Route' +import {staticServer} from '../http/servers/static' +import {EventBus} from '../event/EventBus' +import {PackageDiscovered} from '../support/PackageDiscovered' /** * Application unit that starts the HTTP/S server, creates Request and Response objects @@ -33,9 +38,39 @@ export class HTTPServer extends Unit { @Inject() protected readonly kernel!: HTTPKernel + @Inject() + protected readonly routing!: Routing + + @Inject() + protected readonly bus!: EventBus + /** The underlying native Node.js server. */ protected server?: Server + /** + * Register an asset directory for the given package. + * This creates a static server route for the package with the + * configured vendor prefix. + * @param packageName + * @param basePath + */ + public async registerVendorAssets(packageName: string, basePath: UniversalPath): Promise { + if ( this.config.get('server.builtIns.vendor.enabled', true) ) { + this.logging.debug(`Registering vendor assets route for package ${packageName} on ${basePath}...`) + await this.routing.registerRoutes(() => { + const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor') + Route.group(prefix, () => { + Route.group(packageName, () => { + Route.get('/**', staticServer({ + basePath, + directoryListing: false, + })) + }) + }) + }) + } + } + public async up(): Promise { const port = this.config.get('server.port', 8000) @@ -51,6 +86,8 @@ export class HTTPServer extends Unit { ParseIncomingBodyHTTPModule.register(this.kernel) InjectRequestEventBusHTTPModule.register(this.kernel) + await this.registerBuiltIns() + await new Promise(res => { this.server = createServer(this.handler) @@ -111,4 +148,34 @@ export class HTTPServer extends Unit { await extolloReq.response.send() } } + + /** Register built-in servers and routes. */ + protected async registerBuiltIns(): Promise { + const extolloAssets = universalPath(__dirname, '..', 'resources', 'assets') + await this.registerVendorAssets('@extollo/lib', extolloAssets) + + this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => { + if ( event.packageConfig?.extollo?.assets?.discover && event.packageConfig.name ) { + this.logging.debug(`Registering vendor assets for discovered package: ${event.packageConfig.name}`) + const basePath = event.packageConfig?.extollo?.assets?.basePath + if ( basePath && Array.isArray(basePath) ) { + const assetPath = event.packageJson.concat('..', ...basePath) + await this.registerVendorAssets(event.packageConfig.name, assetPath) + } + } + }) + + if ( this.config.get('server.builtIns.assets.enabled', true) ) { + const prefix = this.config.get('server.builtIns.assets.prefix', '/assets') + this.logging.debug(`Registering built-in assets server with prefix: ${prefix}`) + await this.routing.registerRoutes(() => { + Route.group(prefix, () => { + Route.get('/**', staticServer({ + directoryListing: false, + basePath: ['resources', 'assets'], + })) + }) + }) + } + } } diff --git a/src/service/Routing.ts b/src/service/Routing.ts index bd7e8cf..718a252 100644 --- a/src/service/Routing.ts +++ b/src/service/Routing.ts @@ -1,6 +1,6 @@ -import {Singleton, Inject} from '../di' -import {UniversalPath, Collection, Pipe, universalPath} from '../util' -import {Unit} from '../lifecycle/Unit' +import {Inject, Singleton} from '../di' +import {Awaitable, Collection, Pipe, universalPath, UniversalPath} from '../util' +import {Unit, UnitStatus} from '../lifecycle/Unit' import {Logging} from './Logging' import {Route} from '../http/routing/Route' import {HTTPMethod} from '../http/lifecycle/Request' @@ -8,6 +8,8 @@ import {ViewEngineFactory} from '../views/ViewEngineFactory' import {ViewEngine} from '../views/ViewEngine' import {lib} from '../lib' import {Config} from './Config' +import {EventBus} from '../event/EventBus' +import {PackageDiscovered} from '../support/PackageDiscovered' /** * Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers. @@ -20,6 +22,9 @@ export class Routing extends Unit { @Inject() protected readonly config!: Config + @Inject() + protected readonly bus!: EventBus + protected compiledRoutes: Collection = new Collection() public async up(): Promise { @@ -45,6 +50,44 @@ export class Routing extends Unit { this.compiledRoutes.each(route => { this.logging.verbose(`${route}`) }) + + this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => { + const loadFrom = event.packageConfig?.extollo?.routes?.loadFrom + if ( Array.isArray(loadFrom) ) { + for ( const path of loadFrom ) { + const loadFile = event.packageJson.concat('..', path) + this.logging.debug(`Loading routes for package ${event.packageConfig.name} from ${loadFile}...`) + await import(loadFile.toLocal) + } + + await this.recompile() + } + }) + } + + /** + * Execute a callback that registers routes and recompiles the routing stack. + * @param callback + */ + public async registerRoutes(callback: () => Awaitable): Promise { + await callback() + + if ( this.status === UnitStatus.Started ) { + await this.recompile() + } + } + + /** + * Recompile registered routes into resolved handler stacks. + */ + public async recompile(): Promise { + this.logging.debug('Recompiling routes...') + this.compiledRoutes = this.compiledRoutes.concat(new Collection(await Route.compile())) + + this.logging.debug(`Re-compiled ${this.compiledRoutes.length} route(s).`) + this.compiledRoutes.each(route => { + this.logging.verbose(`${route}`) + }) } /** @@ -90,7 +133,7 @@ export class Routing extends Unit { } public getVendorPath(namespace: string, ...parts: string[]): UniversalPath { - return this.getVendorBase().concat(encodeURIComponent(namespace), ...parts) + return this.getVendorBase().concat(namespace, ...parts) } public getVendorBase(): UniversalPath { diff --git a/src/support/types.ts b/src/support/types.ts index 4293d0d..2384a2a 100644 --- a/src/support/types.ts +++ b/src/support/types.ts @@ -11,6 +11,13 @@ export interface ExtolloPackageDiscoveryConfig { recursiveDependencies?: { discover?: boolean, }, + routes?: { + loadFrom?: string[], + }, + assets?: { + discover?: boolean, + basePath?: string[], + }, }, } diff --git a/src/util/support/path.ts b/src/util/support/path.ts index 7c04638..6a4dadb 100644 --- a/src/util/support/path.ts +++ b/src/util/support/path.ts @@ -497,14 +497,14 @@ export class UniversalPath { * Get the mime-type of this resource. */ get mimeType(): string | false { - return mime.lookup(this.resourceLocalPath) + return mime.lookup(this.toBase) } /** * Get the content-type header of this resource. */ get contentType(): string | false { - return mime.contentType(this.resourceLocalPath) + return mime.contentType(this.toBase) } /**