iopa
Version:
API-first, Internet of Things (IoT) stack for Typescript, official implementation of the Internet Open Protocols Alliance (IOPA) reference pattern
501 lines (441 loc) • 14.7 kB
text/typescript
/* eslint-disable no-restricted-syntax */
/*
* Internet Open Protocol Abstraction (IOPA)
* Copyright (c) 2016-2022 Internet Open Protocols Alliance
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
FC,
IRef,
IopaApp,
IPlugin,
IContextCore,
IAppProperties,
IAppCapabilityLegacy,
IFactory,
IContextIopa,
IMap,
HandlerResult,
AppHandler,
IRouterApp,
IConfig,
Next,
DefaultHandler
} from '@iopa/types'
import { URN_APP, VERSION } from '../iopa/constants'
import guid from '../util/guid'
import FactoryCore from '../iopa/factory-core'
import FactoryEdge from '../iopa/factory-edge'
import logger from '../iopa/logging'
import IopaMap from '../iopa/map'
import { ExtendableEventTarget, TraceEvent } from '../util/events'
import Middleware from './middleware'
type MiddlewareFC<C = any> = FC<C> & { id: string }
/**
* AppBuilder Class to Compile/Build all Middleware in the Pipeline into single IOPA AppFunc
**/
export default class AppBuilder<P = any, C = any> implements IopaApp<P, C> {
public properties: IMap<IAppProperties<C>>
private _middleware: {
invoke: Array<MiddlewareFC>
dispatch: Array<MiddlewareFC>
}
public middlewareProxy: typeof Middleware = Middleware
private _factory: IFactory<C, IContextIopa<C>>
private _abortController: AbortController
public constructor(
appProperties?: Partial<IAppProperties<C>>,
appOptions: Partial<IAppCapabilityLegacy> = {
'server.Version': VERSION,
'server.Base': 'core'
}
) {
this._abortController = new AbortController()
process.on('exit', this._abort)
process.on('SIGINT', this._abort)
process.on('uncaughtException', this._exception)
this._abortController.signal.addEventListener('abort', this._dispose)
const defaults: IAppProperties<C> = {
'iopa.Id': guid(),
'server.Capabilities': {
[URN_APP]: appOptions
} as unknown as C,
'server.Events': new ExtendableEventTarget(),
'server.Logger': logger,
'server.NotFound': DefaultApp,
'server.ErrorHandler': ErrorHandler,
'server.IsBuilt': false,
'server.Pipeline': undefined,
'server.AbortController': this._abortController,
'server.AbortSignal': this._abortController.signal
} as any
if (typeof appProperties === 'string') {
appProperties = { 'iopa.Id': appProperties as string } as any
}
if (appProperties) {
Object.entries(appProperties).forEach(([key, value]) => {
if (key === 'server.Capabilities') {
const capabilities = defaults['server.Capabilities']
Object.entries(value!).forEach(([key2, value2]) => {
capabilities[key2] = value2
})
} else {
defaults[key] = value
}
})
}
this._factory =
defaults['server.Factory'] ?? appOptions['server.Base'] === 'request'
? new FactoryEdge()
: new FactoryCore()
this.properties = new IopaMap<IAppProperties<C>>(defaults)
this._middleware = { invoke: [], dispatch: [] }
}
private _dispose: () => void = () => {
this._abortController.signal.removeEventListener('abort', this._dispose)
process.off('exit', this._abort)
process.off('SIGINT', this._abort)
process.off('uncaughtException', this._exception)
}
private _abort: () => void = () => {
this._abortController.abort()
}
private _exception: (e: any) => void = (e) => {
console.log('Uncaught Exception...')
console.log(e.stack)
this._abortController.abort()
process.exit(99)
}
public capability<T>(keyOrRef: IRef<T>): T | undefined
public capability<K extends keyof C>(keyOrRef: K): C[K] {
if (typeof keyOrRef === 'string') {
return this._capability(keyOrRef as keyof C) as any
} else {
return this._capability(
(keyOrRef as unknown as IRef<any>).id as unknown as keyof C
) as any
}
}
private _capability<K extends keyof C>(keyOrRef: K): C[K] {
return this.properties.get('server.Capabilities')[keyOrRef]
}
public setCapability<K extends keyof C>(keyOrRef: K, value: C[K])
public setCapability<T, I extends T>(keyOrRef: IRef<T>, value: I): void {
if (typeof keyOrRef === 'string') {
return this._setCapability(keyOrRef as keyof C, value)
} else {
return this._setCapability(
(keyOrRef as unknown as IRef<any>).id as unknown as keyof C,
value
)
}
}
private _setCapability<K extends keyof C>(keyOrRef: K, value: any): void {
this.properties.get('server.Capabilities')[keyOrRef] = value
}
public createContext(
urlOrRequest?: string | Request,
options?: any
): IContextIopa<C> {
return this._factory.createContext(urlOrRequest, options)
}
public fork(when: (context: any) => boolean): IopaApp<P, C> {
const subApp = new AppBuilder<P, C>(this.properties.toJSON())
this.use(async function forkFunction(
context: IContextCore<C>,
next: () => Promise<void>
) {
if (!subApp.properties.get('server.IsBuilt')) {
subApp.build()
}
if (when(context)) {
await subApp.invoke(context)
return Promise.resolve(null)
// do not call next on main pipeline after forked pipeline is invoked
}
return next()
},
'forkFuncton')
return subApp
}
/**
* Add Middleware Function to AppBuilder pipeline
**/
public use(mw: any, id: string, options: unknown): this
public use(mw: any, id: string): this
public use(mw: any): this
public use(method: 'invoke' | 'dispatch', mw?: any): this
public use(arg0: any, arg1?: any, options?: any): this {
let id, mw, method
/** Fix comnmon es6 module interop issues */
if (typeof arg0 === 'object' && 'default' in arg0) {
// eslint-disable-next-line dot-notation
arg0 = arg0['default']
} else if (typeof arg1 === 'object' && 'default' in arg1) {
// eslint-disable-next-line dot-notation
arg1 = arg1['default']
}
if (typeof arg0 === 'function' && typeof arg1 === 'string') {
/** resilience wrapper style */
id = arg1
mw = arg0
method = 'invoke'
} else if (typeof arg0 === 'function' && !arg1) {
id = arg0.name || arg0.toString().split(/\r?\n/)[0]
mw = arg0
method = 'invoke'
} else if (arg0 === undefined) {
throw new Error(
`app.use called with undefined / empty middleware for ${mw}`
)
} else {
id = arg1.name || arg1.toString().split(/\r?\n/)[0]
method = arg0
mw = arg1
}
if (!this._middleware[method]) {
throw new Error(
`Unknown AppBuilder Category ${JSON.stringify(
method,
null,
2
)} for ${id}`
)
}
const params = _getParams(mw)
if (params === 'app' || mw.length === 1) {
const Mw = mw
let mwInstance: any
try {
mwInstance = new Mw(this, options)
} catch (ex) {
// this is a js lambda function not a class Instance
mwInstance = Mw(this, options) || {}
}
if (typeof mwInstance.invoke === 'function') {
const fn = mwInstance.invoke.bind(mwInstance)
fn.id = id
this._middleware.invoke.push(fn)
}
if (typeof mwInstance.dispatch === 'function') {
const fn = mwInstance.dispatch.bind(mwInstance)
fn.id = id
this._middleware.dispatch.push(fn)
}
} else {
if (options) {
throw new Error(
`Cannot instantiate AppFunc ${id} with options, use a class constructor instead`
)
}
const fn: any = this.middlewareProxy(this, mw)
fn.id = id
this._middleware[method].push(fn)
}
return this
}
public dispose(): void {
/** noop */
}
/**
* Compile/Build all Middleware in the Pipeline into single IOPA AppFunc
**/
public build(): FC<C> & {
properties: IMap<IAppProperties<C>>
dispatch: MiddlewareFC<C>
} {
const middleware = this._middleware.invoke.concat(
this.properties.get('server.NotFound') as any
)
const pipeline: FC<C> & {
properties: IMap<IAppProperties<C>>
dispatch: FC<C>
} = this._compose(middleware) as any
if (this._middleware.dispatch.length > 0) {
pipeline.dispatch = this._compose(this._middleware.dispatch.reverse())
} else {
pipeline.dispatch = (context) => {
return Promise.resolve()
}
}
pipeline.properties = this.properties
this.properties.set('server.IsBuilt', true)
this.properties.set('server.Pipeline', pipeline)
return pipeline as any
}
private _buildDynamic(handler: DefaultHandler): FC<C> {
const middleware = this._middleware.invoke.concat(handler as any)
return this._compose(middleware) as any
}
/** Call Dispatch Pipeline to process given context */
public dispatch(context: IContextCore<C>): any {
return this.properties.get('server.Pipeline').dispatch.call(this, context)
}
/** Call App Invoke Pipeline to process given context */
public invoke(context: IContextCore<C>): Promise<void> {
context.log = this.properties
.get('server.Logger')
.child({ tid: context.get('iopa.Id') })
if (!this.properties.get('server.IsBuilt')) {
this.build()
}
return this.properties.get('server.Pipeline').call(this, context)
}
/** invoke a new child request on the iopa pipeline with added default handler */
public childInvoke(
context: IContextIopa<C>,
handler: DefaultHandler & { id?: string },
id?: string
): Promise<void> {
handler.id = id || 'childInvoke'
const pipeline = this._buildDynamic(handler)
context.log = this.properties
.get('server.Logger')
.child({ tid: context.get('iopa.Id') })
return pipeline.call(this, context)
}
/** Compile/Build all Middleware in the Pipeline into single IOPA AppFunc */
private _compose(
middleware: Array<MiddlewareFC>
): (context: IContextCore<C>) => Promise<void> {
let i: number
let next: (context: IContextCore<C>) => Promise<void>
let curr: FC<C>
i = middleware.length
next = () => {
return Promise.resolve()
}
// eslint-disable-next-line no-plusplus
while (i--) {
curr = middleware[i]
if (!curr) {
console.error(middleware, i)
throw new Error('Missing middleware')
}
next = ((
fn: AppHandler & { id: string },
prev: FC<C>,
context: IContextCore<C>
) => {
context.set('server.CurrentMiddleware', fn.id)
const _next: Next & { invoke: FC<C> } = () => {
context.dispatchEvent(
new TraceEvent('trace-next', {
label: fn.id
})
)
return prev.call(this, context).then(() => {
context.dispatchEvent(
new TraceEvent('trace-next-resume', {
label: fn.id
})
)
})
}
_next.invoke = prev
context.dispatchEvent(
new TraceEvent('trace-start', {
label: fn.id
})
)
const _oldLogger = context.log
context.log = context.log.child({
middleware: fn.id
})
return Promise.resolve(fn.call(this, context, _next)).then(
(res: HandlerResult) => {
context.log = _oldLogger
if (res || res === false || res === 0 || res === '') {
;(context as IContextIopa).respondWith(res)
}
context.dispatchEvent(
new TraceEvent('trace-end', {
label: fn.id
})
)
}
)
}).bind(this, curr, next)
}
const rootPipeline = next
return (context: IContextCore<C>) => {
const capabilities = this.properties.get('server.Capabilities')
context.set('server.Capabilities', capabilities)
context.set('server.Events', this.properties.get('server.Events'))
context.app = this as any
return rootPipeline.call(this, context)
}
}
public usePlugin(plugin: IPlugin<P, C>): this {
throw new Error('Not Implemented')
}
}
export class RouterApp extends AppBuilder implements IRouterApp {
public head: (path: string, handler: AppHandler) => this
public post: (path: string, handler: AppHandler) => this
public get: (path: string, handler: AppHandler) => this
public options: (path: string, handler: AppHandler) => this
public put: (path: string, handler: AppHandler) => this
public delete: (path: string, handler: AppHandler) => this
public patch: (path: string, handler: AppHandler) => this
public all: (path: string, handler: AppHandler) => this
public config: IConfig
public constructor(
appProperties: Partial<IAppProperties<any>> = undefined,
appOptions: Partial<IAppCapabilityLegacy> = {}
) {
super(appProperties, {
'server.Base': 'request',
...appOptions
})
}
public registerFeatureFlag(flagOptions: { name: string }): this {
throw new Error('Method not implemented.')
}
}
/**
* Not Found app used at end of pipeline if not handled by any other middleware
* Default is no-op, replace 404 responder as appropriate
**/
function DefaultApp<C>(
context: IContextCore<C>
): HandlerResult | PromiseLike<HandlerResult> {
return Promise.resolve()
}
DefaultApp.id = 'DefaultApp'
/**
* Not Found app used at end of pipeline if not handled by any other middleware
* Default is throw, replace 500 responder as appropriate
**/
function ErrorHandler<C>(
ex: Error,
context: IContextCore<C>
): HandlerResult | PromiseLike<HandlerResult> {
throw ex
}
ErrorHandler.id = 'ErrorHandler'
const STRIP_COMMENTS: RegExp = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm
/**
* Gets the parameter names of a javascript function as a comma separated string
**/
function _getParams(func: Function): string {
const fnStr = func.toString().replace(STRIP_COMMENTS, '')
let result = fnStr
.slice(fnStr.indexOf('(') + 1, fnStr.indexOf(')'))
.match(/([^\s,]+)/g)
if (result === null) {
result = []
}
return result.join()
}