import {Inject, Singleton} from '../di' import {Awaitable, Collection, ErrorWithContext, Maybe, Pipeline, 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' import {ViewEngineFactory} from '../views/ViewEngineFactory' import {ViewEngine} from '../views/ViewEngine' import {lib} from '../lib' import {Config} from './Config' import {PackageDiscovered} from '../support/PackageDiscovered' import {staticServer} from '../http/servers/static' import {Bus} from '../support/bus' import {RequestLocalStorage} from '../http/RequestLocalStorage' /** * Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers. */ @Singleton() export class Routing extends Unit { @Inject() protected readonly logging!: Logging @Inject() protected readonly config!: Config @Inject() protected readonly bus!: Bus @Inject() protected readonly request!: RequestLocalStorage protected compiledRoutes: Collection> = new Collection>() public async up(): Promise { this.app().registerFactory(new ViewEngineFactory()) const engine = this.make(ViewEngine) this.logging.verbose('Registering @extollo view engine namespace.') engine.registerNamespace('extollo', lib().concat('resources', 'views')) const jsSuffix = `.routes.js` const tsSuffix = `.routes.ts` for await ( const entry of this.path.walk() ) { if ( !entry.endsWith(jsSuffix) && !entry.endsWith(tsSuffix) ) { this.logging.debug(`Skipping routes file with invalid suffix: ${entry}`) continue } this.logging.info(`Importing routes from: ${entry}`) await import(entry) } await this.registerBuiltIns() this.logging.info('Compiling routes...') this.compiledRoutes = new Collection>(await Route.compile()) this.logging.info(`Compiled ${this.compiledRoutes.length} route(s).`) this.compiledRoutes.each(route => { this.logging.verbose(`${route}`) }) await 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}`) }) } /** * Given an HTTPMethod and route path, return the Route instance that matches them, * if one exists. * @param method * @param path */ public match(method: 'ws' | HTTPMethod, path: string): Route | undefined { return this.compiledRoutes.firstWhere(route => { return route.match(method, path) }) } /** * Get the universal path to the root directory of the route definitions. */ public get path(): UniversalPath { return this.app().appPath('http', 'routes') } /** * Get the collection of compiled routes. */ public getCompiled(): Collection> { return this.compiledRoutes } /** * Resolve a UniversalPath to a file served as an asset. * @example * ```ts * this.getAssetPath('images', '123.jpg').toRemote // => http://localhost:8000/assets/images/123.jpg * ``` * @param parts */ public getAssetPath(...parts: string[]): UniversalPath { return this.getAssetBase().concat(...parts) } public getAssetBase(): UniversalPath { return this.getAppUrl().concat(this.config.get('server.builtIns.assets.prefix', '/assets')) } public getVendorPath(namespace: string, ...parts: string[]): UniversalPath { return this.getVendorBase().concat(namespace, ...parts) } public getVendorBase(): UniversalPath { return this.getAppUrl().concat(this.config.get('server.builtIns.vendor.prefix', '/vendor')) } public getNamedPath(name: string): UniversalPath { const route = this.getByName(name) if ( route ) { return this.getAppUrl().concat(route.getRoute()) } throw new ErrorWithContext(`Route does not exist with name: ${name}`, { routeName: name, }) } public hasNamedRoute(name: string): boolean { return Boolean(this.getByName(name)) } public getByName(name: string): Maybe> { return this.compiledRoutes .firstWhere(route => route.hasAlias(name)) } public getAppUrl(): UniversalPath { const forceSsl = Boolean(this.config.get('server.forceSsl', false)) if ( this.request.has() ) { // If we have a request context, use that URL, as it is the most reliable const request = this.request.get() const url = new URL(request.fullUrl) if ( forceSsl ) { url.protocol = 'https:' } return universalPath(url.origin) } const urlOverride = String(this.config.get('server.url', '')).toLowerCase() if ( urlOverride ) { // If the config has a URL override explicitly set, use that return universalPath(urlOverride) } // Otherwise, try to build one from the host and port const rawHost = String(this.config.get('server.host', 'http://localhost')).toLowerCase() const isSSL = rawHost.startsWith('https://') const port = this.config.get('server.port', 8000) return Pipeline.id() .unless( host => host.startsWith('http://') || host.startsWith('https'), host => `http://${host}`, ) .when( host => { const hasPort = host.split(':').length > 2 const defaultRaw = !isSSL && port === 80 const defaultSSL = isSSL && port === 443 return !hasPort && !defaultRaw && !defaultSSL }, host => { const parts = host.split('/') parts[2] += `:${port}` return parts.join('/') }, ) .tap(host => universalPath(host)) .apply(rawHost) } /** * 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.registerRoutes(() => { const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor') Route.group(prefix, () => { Route.group(packageName, () => { Route.get('/**') .passingRequest() .handledBy(staticServer({ basePath, directoryListing: false, })) }) }) }) } } /** 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.registerRoutes(() => { Route.group(prefix, () => { Route.get('/**') .passingRequest() .handledBy(staticServer({ directoryListing: false, basePath: ['resources', 'assets'], })) }) }) } } }