You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/service/Routing.ts

247 lines
8.9 KiB

import {Inject, Singleton} from '../di'
import {Awaitable, Collection, ErrorWithContext, Maybe, 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'
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'
import {staticServer} from '../http/servers/static'
/**
* 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!: EventBus
protected compiledRoutes: Collection<Route> = new Collection<Route>()
public async up(): Promise<void> {
this.app().registerFactory(new ViewEngineFactory())
const engine = <ViewEngine> this.make(ViewEngine)
this.logging.verbose('Registering @extollo view engine namespace.')
engine.registerNamespace('extollo', lib().concat('resources', 'views'))
for await ( const entry of this.path.walk() ) {
if ( !entry.endsWith('.routes.js') ) {
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<Route>(await Route.compile())
this.logging.info(`Compiled ${this.compiledRoutes.length} route(s).`)
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<void>): Promise<void> {
await callback()
if ( this.status === UnitStatus.Started ) {
await this.recompile()
}
}
/**
* Recompile registered routes into resolved handler stacks.
*/
public async recompile(): Promise<void> {
this.logging.debug('Recompiling routes...')
this.compiledRoutes = this.compiledRoutes.concat(new Collection<Route>(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: 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<Route> {
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<Route> {
return this.compiledRoutes
.firstWhere(route => route.aliases.includes(name))
}
public getAppUrl(): UniversalPath {
const rawHost = String(this.config.get('server.url', 'http://localhost')).toLowerCase()
const isSSL = rawHost.startsWith('https://')
const port = this.config.get('server.port', 8000)
return Pipe.wrap<string>(rawHost)
.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<UniversalPath>(host => universalPath(host))
.get()
}
/**
* 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<void> {
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('/**', staticServer({
basePath,
directoryListing: false,
}))
})
})
})
}
}
/** Register built-in servers and routes. */
protected async registerBuiltIns(): Promise<void> {
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('/**', staticServer({
directoryListing: false,
basePath: ['resources', 'assets'],
}))
})
})
}
}
}