hinoki
Version:
sane, simple dependency injection and more
519 lines (449 loc) • 17.1 kB
text/coffeescript
((root, factory) ->
# amd
if ('function' is typeof define) and define.amd?
define(['bluebird', 'lodash', 'helfer'], factory)
# nodejs
else if exports?
module.exports = factory(
require('bluebird')
require('lodash')
require('helfer')
require('fs')
require('path')
)
# other
else
unless root.Promise?
throw new Error 'missing global variable `Promise`'
unless root._?
throw new Error 'missing global variable `_`'
unless root.helfer?
throw new Error 'missing global variable `helfer`'
root.hinoki = factory(root.Promise, root._, root.helfer)
)(this, (Promise, _, helfer, fsModule, pathModule) ->
################################################################################
# get
# polymorphic
hinoki = (arg1, arg2, arg3) ->
source = hinoki.source arg1
if arg3?
lifetimes = helfer.coerceToArray arg2
keyOrKeysOrFunction = arg3
else
lifetimes = [{}]
keyOrKeysOrFunction = arg2
cacheTarget = 0
if 'function' is typeof keyOrKeysOrFunction
keys = hinoki.getKeysToInject(keyOrKeysOrFunction)
paths = _.map keys, helfer.coerceToArray
return hinoki.getValuesAndCacheTarget(
source,
lifetimes,
paths,
cacheTarget
).promise.spread(keyOrKeysOrFunction)
if Array.isArray keyOrKeysOrFunction
keys = helfer.coerceToArray(keyOrKeysOrFunction)
paths = _.map keys, helfer.coerceToArray
return hinoki.getValuesAndCacheTarget(
source,
lifetimes,
paths,
cacheTarget
).promise
path = helfer.coerceToArray(keyOrKeysOrFunction)
return hinoki.getValueAndCacheTarget(
source,
lifetimes,
path,
cacheTarget
).promise
hinoki.isNodejs = fsModule?.statSync? and fsModule?.readdirSync?
# monomorphic
hinoki.PromiseAndCacheTarget = (promise, cacheTarget) ->
this.promise = promise
this.cacheTarget = cacheTarget
return this
# monomorphic
hinoki.getValuesAndCacheTarget = (source, lifetimes, paths, cacheTarget) ->
# result.cacheTarget is determined synchronously
nextCacheTarget = cacheTarget
# result.promise is fulfilled asynchronously
promise = Promise.all(_.map(paths, (path) ->
result = hinoki.getValueAndCacheTarget(
source
lifetimes
path
cacheTarget
)
nextCacheTarget = Math.max(nextCacheTarget, result.cacheTarget)
return result.promise
))
return new hinoki.PromiseAndCacheTarget promise, nextCacheTarget
# monomorphic
hinoki.getValueAndCacheTarget = (source, lifetimes, path, cacheTarget) ->
key = path[0]
# look if there already is a value for that key in one of the lifetimes
lifetimeIndex = helfer.findIndexWhereProperty lifetimes, key
unless lifetimeIndex is -1
valueOrPromise = lifetimes[lifetimeIndex][key]
promise =
if helfer.isThenable valueOrPromise
# if the value is already being constructed
# wait for that instead of starting a second construction.
hinoki.debug? {
event: 'lifetimeHasPromise'
path: path
promise: valueOrPromise
lifetime: lifetimes[lifetimeIndex]
lifetimeIndex: lifetimeIndex
}
valueOrPromise
else
hinoki.debug? {
event: 'lifetimeHasValue'
path: path
value: valueOrPromise
lifetime: lifetimes[lifetimeIndex]
lifetimeIndex: lifetimeIndex
}
Promise.resolve valueOrPromise
return new hinoki.PromiseAndCacheTarget promise, lifetimeIndex
# we have no value
# look if there is a factory for that key in the source
factory = source(key)
unless factory?
return new hinoki.PromiseAndCacheTarget(
Promise.reject(new hinoki.NotFoundError(path))
cacheTarget
)
unless hinoki.isFactory factory
return new hinoki.PromiseAndCacheTarget(
Promise.reject new hinoki.BadFactoryError path, factory
cacheTarget
)
hinoki.debug? {
event: 'sourceReturnedFactory'
path: path
factory: factory
}
# we've got a factory.
# lets make a value
# first lets resolve the dependencies of the factory
dependencyKeys = hinoki.baseGetKeysToInject factory, true
dependencyKeysIndex = -1
dependencyKeysLength = dependencyKeys.length
dependencyPaths = []
while ++dependencyKeysIndex < dependencyKeysLength
dependencyKey = dependencyKeys[dependencyKeysIndex]
newPath = path.slice()
newPath.unshift dependencyKey
# is this key already in the path?
# if so then it would introduce a circular dependency.
if -1 isnt path.indexOf dependencyKey
return new hinoki.PromiseAndCacheTarget(
Promise.reject(new hinoki.CircularDependencyError(newPath))
cacheTarget
)
dependencyPaths.push newPath
# no cycle - yeah!
# this code is reached synchronously from the start of the function call
# without interleaving.
if dependencyPaths.length isnt 0
result = hinoki.getValuesAndCacheTarget(
source,
lifetimes,
dependencyPaths,
cacheTarget
)
dependenciesPromise = result.promise
nextCacheTarget = result.cacheTarget
else
dependenciesPromise = Promise.resolve([])
nextCacheTarget = cacheTarget
factoryCallResultPromise = dependenciesPromise.then (dependencyValues) ->
# the dependencies are ready!
# we can finally call the factory!
return hinoki.callFactory(path, factory, dependencyValues)
# cache the promise:
# this code is reached synchronously from the start of the function call
# without interleaving.
# its important that the factoryCallResultPromise is added
# to lifetimes[maxCacheTarget] synchronously
# because as soon as control is given back to the scheduler
# another process might request the value as well.
# this way that process just reuses the factoryCallResultPromise
# instead of building it all over again.
# invariant:
# if we reach this line we are guaranteed that:
# lifetimes[nextCacheTarget][key] is undefined
# because we checked that synchronously
unless factory.__nocache
lifetimes[nextCacheTarget][key] = factoryCallResultPromise
returnPromise = factoryCallResultPromise
.then (value) ->
# cache
unless factory.__nocache
lifetimes[nextCacheTarget][key] = value
return value
.catch (error) ->
# prevent errored promises from being reused
# and allow further requests for the errored keys to succeed.
unless factory.__nocache
delete lifetimes[nextCacheTarget][key]
return Promise.reject error
return new hinoki.PromiseAndCacheTarget(returnPromise, nextCacheTarget)
# try catch prevents functions from being optimized.
# wrapping it into a function keeps the unoptimized part small.
hinoki.tryCatch = (fun, args) ->
try
return fun.apply null, args
catch error
if helfer.isError error
return error
else
return new Error error.toString()
# returns a promise
hinoki.callFactoryFunction = (path, factoryFunction, args) ->
result = hinoki.tryCatch(factoryFunction, args)
if helfer.isUndefined result
# note that a null value is allowed!
return Promise.reject new hinoki.FactoryReturnedUndefinedError path, factoryFunction
if helfer.isError(result)
return Promise.reject new hinoki.ErrorInFactory path, factoryFunction, result
if helfer.isThenable result
hinoki.debug? {
event: 'factoryReturnedPromise'
path: path
promise: result
factory: factoryFunction
}
return result
.then (value) ->
hinoki.debug? {
event: 'promiseResolved'
path: path
value: value
factory: factoryFunction
}
return value
.catch (rejection) ->
Promise.reject new hinoki.PromiseRejectedError path, factoryFunction, rejection
hinoki.debug? {
event: 'factoryReturnedValue'
path: path
value: result
factory: factoryFunction
}
return Promise.resolve result
hinoki.callFactoryObjectArray = (path, factoryObject, dependenciesObject) ->
iterator = (f, key) ->
newPath = path.slice()
newPath[0] += '[' + key + ']'
unless hinoki.isFactory f
return Promise.reject new hinoki.BadFactoryError newPath, f
if 'function' is typeof f
dependencyKeys = hinoki.getKeysToInject f
dependencies = _.map dependencyKeys, (dependencyKey) ->
dependenciesObject[dependencyKey]
return hinoki.callFactoryFunction(newPath, f, dependencies)
else if 'object' is typeof f
# supports nesting
return hinoki.callFactoryObjectArray(newPath, f, dependenciesObject)
if Array.isArray factoryObject
Promise.all(factoryObject).map(iterator)
else if 'object' is typeof factoryObject
keys = Object.keys(factoryObject)
length = keys.length
i = -1
result = {}
while ++i < length
key = keys[i]
# ignore special properties:
# special properties are those starting with `__`
# like: `__inject`, `__file`, ...
unless 0 is key.indexOf '__'
result[key] = iterator(factoryObject[key], key)
return Promise.props result
hinoki.callFactory = (path, factory, dependencyValues) ->
if 'function' is typeof factory
return hinoki.callFactoryFunction path, factory, dependencyValues
else
dependencyKeys = hinoki.getKeysToInject factory
dependenciesObject = _.zipObject dependencyKeys, dependencyValues
return hinoki.callFactoryObjectArray path, factory, dependenciesObject
################################################################################
# errors
# constructors for errors which are catchable with bluebirds `catch`
# the base error for all other hinoki errors
# not to be instantiated directly
hinoki.BaseError = ->
helfer.inherits hinoki.BaseError, Error
hinoki.NotFoundError = (path) ->
this.name = 'NotFoundError'
this.message = "neither value nor factory found for `#{path[0]}` in path `#{hinoki.pathToString path}`"
if Error.captureStackTrace?
# second argument excludes the constructor from inclusion in the stack trace
Error.captureStackTrace(this, this.constructor)
this.path = path
return
helfer.inherits hinoki.NotFoundError, hinoki.BaseError
hinoki.CircularDependencyError = (path) ->
this.name = 'CircularDependencyError'
this.message = "circular dependency `#{hinoki.pathToString path}`"
if Error.captureStackTrace?
Error.captureStackTrace(this, this.constructor)
this.path = path
return
helfer.inherits hinoki.CircularDependencyError, hinoki.BaseError
hinoki.ErrorInFactory = (path, factory, error) ->
this.name = 'ErrorInFactory'
this.message = "error in factory for `#{path[0]}`. original error `#{error.toString()}`"
if Error.captureStackTrace?
Error.captureStackTrace(this, this.constructor)
this.path = path
this.factory = factory
this.error = error
return
helfer.inherits hinoki.ErrorInFactory, hinoki.BaseError
hinoki.FactoryReturnedUndefinedError = (path, factory) ->
this.name = 'FactoryReturnedUndefinedError'
this.message = "factory for `#{path[0]}` returned undefined"
if Error.captureStackTrace?
Error.captureStackTrace(this, this.constructor)
this.path = path
this.factory = factory
return
helfer.inherits hinoki.FactoryReturnedUndefinedError, hinoki.BaseError
hinoki.PromiseRejectedError = (path, factory, error) ->
this.name = 'PromiseRejectedError'
this.message = "promise returned from factory for `#{path[0]}` was rejected. original error `#{error.toString()}`"
if Error.captureStackTrace?
Error.captureStackTrace(this, this.constructor)
this.path = path
this.factory = factory
this.error = error
return
helfer.inherits hinoki.PromiseRejectedError, hinoki.BaseError
hinoki.BadFactoryError = (path, factory) ->
this.name = 'BadFactoryError'
this.message = "factory for `#{path[0]}` has to be a function, object of factories or array of factories but is `#{typeof factory}`"
if Error.captureStackTrace?
Error.captureStackTrace(this, this.constructor)
this.path = path
this.factory = factory
return
helfer.inherits hinoki.BadFactoryError, hinoki.BaseError
################################################################################
# helper
hinoki.pathToString = (path) ->
path.join ' <- '
hinoki.getKeysToInject = (factory) ->
hinoki.baseGetKeysToInject factory, false
hinoki.baseGetKeysToInject = (factory, cache) ->
if factory.__inject?
return factory.__inject
type = typeof factory
if ('object' is type) or ('function' is type)
if ('function' is type)
keys = helfer.parseFunctionArguments factory
else
keysSet = {}
_.forEach factory, (subFactory) ->
subKeys = hinoki.baseGetKeysToInject(subFactory, cache)
_.forEach subKeys, (subKey) ->
keysSet[subKey] = true
keys = Object.keys(keysSet)
if cache
factory.__inject = keys
return keys
return []
hinoki.isFactory = (value) ->
type = typeof value
(type is 'function') or Array.isArray(value) or (type is 'object')
################################################################################
# functions for working with sources
# returns an object containing all the exported properties
# of all `*.js` and `*.coffee` files in `filepath`.
# if `filepath` is a directory recurse into every file and subdirectory.
if hinoki.isNodejs
# TODO better name
hinoki.requireSource = (filepath) ->
unless 'string' is typeof filepath
throw new Error 'argument must be a string'
hinoki.baseRequireSource filepath, {}
# TODO call this something like fromExports
hinoki.baseRequireSource = (filepath, object) ->
stat = fsModule.statSync(filepath)
if stat.isFile()
extension = pathModule.extname(filepath)
if extension isnt '.js' and extension isnt '.coffee'
return
# coffeescript is only required on demand when the project contains .coffee files
# in order to support pure javascript projects
if extension is '.coffee'
require('coffee-script/register')
exports = require(filepath)
Object.keys(exports).map (key) ->
unless hinoki.isFactory exports[key]
throw new Error('export is not a factory: ' + key + ' in :' + filepath)
if object[key]?
throw new Error('duplicate export: ' + key + ' in: ' + filepath + '. first was in: ' + object[key].__file)
object[key] = exports[key]
# add filename as metadata
object[key].__file = filepath
else if stat.isDirectory()
filenames = fsModule.readdirSync(filepath)
filenames.forEach (filename) ->
hinoki.baseRequireSource pathModule.join(filepath, filename), object
return object
hinoki.source = (arg) ->
if 'function' is typeof arg
return arg
if Array.isArray arg
coercedSources = _.map arg, hinoki.source
source = (key) ->
# try all sources in order
index = -1
length = arg.length
while ++index < length
result = coercedSources[index](key)
if result?
return result
return null
source.keys = ->
keys = []
_.each coercedSources, (source) ->
if source.keys?
keys = keys.concat(source.keys())
return keys
return source
if 'string' is typeof arg
unless hinoki.isNodejs
throw new Error 'string sources only work on Node.js because they need the filesystem module to be present'
return hinoki.source hinoki.requireSource arg
if 'object' is typeof arg
source = (key) ->
arg[key]
source.keys = ->
Object.keys(arg)
return source
throw new Error 'argument must be a function, string, object or array of these'
hinoki.decorateSourceToAlsoLookupWithPrefix = (innerSource, prefix) ->
source = (key) ->
result = innerSource(key)
if result?
return result
if 0 is key.indexOf(prefix)
return null
# factory that resolves to the same value
wrapperFactory = (wrapped) -> wrapped
wrapperFactory.__inject = [prefix + key]
return wrapperFactory
if innerSource.keys?
source.keys = innerSource.keys
return source
################################################################################
# return the hinoki object from the factory
return hinoki
)