node-sitefile
Version:
Bootstrap Express webserver for misc. resources using Sitefile
570 lines (452 loc) • 15.4 kB
text/coffeescript
fs = require 'fs'
path = require 'path'
glob = require 'glob'
yaml = require 'js-yaml'
_ = require 'lodash'
chalk = require 'chalk'
semver = require 'semver'
nodelib = require 'nodelib'
Context = nodelib.Context
Router = require './Router'
liberror = require '../error'
libconf = require '../conf'
# register String:: exts
strutil = require '../strutil'
c = strutil.c
version = "0.0.5" # node-sitefile
get_local_sitefile_name = ( ctx={} ) ->
fn = null
ext = null
_.defaults ctx, basename: 'Sitefile', exts: [
'.json'
'.yml'
'.yaml'
]
for ext in ctx.exts
fn = ctx.basename + ext
if fs.existsSync fn
ctx.fn = fn
ctx.ext = ext
break
fn = null
if not fn
throw new Error "No #{ctx.basename}"
ctx.lfn = path.join process.cwd(), fn
ctx.lfn
get_local_sitefile = ( ctx={} ) ->
lfn = get_local_sitefile_name ctx
sitefile = libconf.load_file lfn
sf_version = sitefile.sitefile
if not semver.valid sf_version
throw new Error "Not valid semver: #{sf_version}"
if not ( semver.satisfies( ctx.version, sf_version ) or \
semver.gt( ctx.version, sf_version ) )
throw new Error "Version #{ctx.version} cannot satisfy "+
"sitefile #{sf_version}"
# TODO: validate Sitefile schema
sitefile.path = path.relative process.cwd(), lfn
_.defaults sitefile,
routes: {}
sitefile
load_sitefile = ( ctx ) ->
ctx.sitefile = get_local_sitefile ctx
# translate JSON path refs in sitefile to use global sitefile context
# ie. prefix path with 'sitefile/' so we can use context.resolve et al.
xform = (result, value, key) ->
if _.isArray value
for item, index in value
xform value, item, index
else if _.isObject value
if value.$ref
value.$ref = '#/sitefile' + value.$ref.substr(1)
else
for key, property of value
xform value, property, key
_.transform ctx.sitefile, xform
# Map some sitefile attributes to root
if ctx.sitefile.host
ctx.site.host = ctx.sitefile.host
if ctx.sitefile.port
ctx.site.port = ctx.sitefile.port
if ctx.sitefile.base
ctx.site.base = ctx.sitefile.base
if 'paths' of ctx.sitefile and ctx.sitefile.paths
if 'routers' of ctx.sitefile.paths and ctx.sitefile.paths.routers
if 'routers_replace' of ctx.sitefile.paths \
and ctx.sitefile.paths.routers_replace
ctx.paths.routers = ctx.sitefile.paths.routers
else
ctx.paths.routers = \
ctx.paths.routers.concat ctx.sitefile.paths.routers
log "Loaded", path: path.relative ctx.cwd, ctx.lfn
load_rc = ( ctx ) ->
try
ctx.static = libconf.load 'sitefilerc', get: suffixes: [ '' ], all: true
catch error
if error instanceof liberror.types.NoFilesException
ctx.static = null
else
throw error
ctx.static
load_config = ( ctx={} ) ->
if not ctx.config_name?
ctx.config_name = 'config/config.coffee'
# XXX config per client
#scriptconfig = 'config/config-#{ctx.proc.name}'
#configs = glob.sync path.join ctx.noderoot, scriptconfig + '.*'
#if not _.isEmpty configs
# ctx.config_name = scriptconfig
rc = path.join '../..', ctx.config_name
if fs.existsSync require.resolve rc
ctx.config_envs = require rc
ctx.config = ctx.config_envs[ctx.envname]
_.defaultsDeep ctx, ctx.config
if ctx.verbose
console.log "Loaded user config for #{ctx.envname}"
ctx.config
# Turn options dict into root context.
prepare_context = ( ctx={} ) ->
# Apply all static properties (set ctx.static too)
_.merge ctx, load_rc ctx
_.defaultsDeep ctx,
noderoot: '../../'
version: version
cwd: process.cwd()
proc:
name: path.basename process.argv[1]
envname: process.env.NODE_ENV ? 'development'
log: log
verbose: false
ctx.verbose = ctx.envname is 'development'
_.defaultsDeep ctx,
pkg_file: path.join ctx.noderoot, 'package.json'
_.defaultsDeep ctx,
pkg: require ctx.pkg_file
load_config ctx
_.defaultsDeep ctx,
site:
host: ''
port: 8081
base: '/'
netpath: null
routes:
resources: {}
directories: []
bundles: {}
paths: # TODO: configure lookup paths
routers: [
'sitefile:lib/sitefile/routers'
'sitefile:var/sitefile/routers'
]
bundles: [
'sitefile:lib/sitefile/bundles'
'sitefile:var/sitefile/bundles'
]
# Load local sitefile (set ctx.sitefile)
unless ctx.sitefile?
load_sitefile ctx
Context::get_auto_export = ( router_name ) ->
# Use router setings to determine opts per request (ie. to override from
# query)
Context::req_opts = ( request ) ->
options = _.merge {}, @route.options
if 'import-query' of @route
mq = @route['merge-query']
if 'bool' is not typeof mq
q = _.omitBy request.query, ( v, k ) -> k in mq
else
# NOTE: Merge everything from query
q = request.query
q = expand_obj_paths q
_.defaultsDeep options, q
if @route['export-query-path']
key = @route['export-query-path']
if not options[key]
options[key] = @route.spec.substr 0, @route.spec.length-1 # XXX: hack hack
options[key] = @query_path_export key, request, options[key]
if @route['export-context']
key = @route['export-context']
_.defaultsDeep options, expand_obj_paths "#{key}": @
return options
# If query key given use as rctx.res.path
Context::query_path_export = ( key, req, defpath ) ->
# Assert rctx.res.path isset
if defpath
unless @res.path
@res.path = defpath
if not @res.path
throw new Error \
"A path is required either provided by query or option '#{key}'"
if req.query?[key]
@res.path = req.query[key]
else
req.query[key] = @res.path
if not @res.path
throw new Error \
"A path is required either provided by query or option '#{key}'"
# Resolve to existing path
return Router.expand_path @res.path, @
if ctx.verbose
console.log "Creating new context for #{ctx.envname}"
new Context ctx
# Split sitefile router specs
split_spec = ( strspec, ctx={} ) ->
idx = strspec.indexOf ':'
handler_path = strspec.substr 0, idx
hspec = strspec.substr idx+1
if handler_path.indexOf '.' != -1
[ router_name, handler_name ] = handler_path.split '.'
else
router_name = handler_path
[ router_name, handler_name, hspec ]
expand_obj_paths = ( obj, toObj={} ) ->
curObj = toObj
for key of obj
k = key.split '.'
while k.length
e = k.shift()
if k.length
if e not of curObj
curObj[e] = {}
curObj = curObj[e]
else
curObj[e] = obj[key]
toObj
class Routers
constructor: ( @ctx ) ->
# XXX:
if @ctx._routers then throw new Error "_routers"
@ctx._routers = @
@data = {}
get: ( name ) ->
if name not of @data
throw new Error "No such router loaded: #{name}"
return @data[ name ].object
generator: ( name, rctx=null, ctx=null ) ->
if name.startsWith '.'
handler = name.substr 1
name = rctx.route.name
else if -1 != name.indexOf '.'
[ name, handler ] = name.split '.'
else
handler = 'default'
if name not of @data
throw new Error "No router for #{name}"
if not handler or \
handler not of @data[name].object.generate
throw new Error "No router generate handler #{handler} for #{name}"
@data[ name ].object.generate[ handler ]
find: ( name ) ->
router_cb = null
rip = null
for rip in @ctx.paths.routers
p = rip
if p.startsWith 'sitefile:'
p = path.join @ctx.sfdir, p.substr 9
if not p.startsWith '/'
p = path.join process.env.PWD, p
try
router_cb = require path.join p, name
break
catch err
continue
[ router_cb, rip ]
# Pre-load routers
load: ->
@names = _.union (
router_name for [ router_name, handler_name, handler_spec ] in (
split_spec strspec, @ctx for route, strspec of @ctx.sitefile.routes ) )
log 'Required routers', name: @names.join ', '
# parse sitefile.routes, pass 1: load & init routers
for name in @names
if name of Router.builtin
continue
[ router_cb, router_path ] = @find name
if not router_cb
warn "Failed to load #{name} router"
continue
router_obj = router_cb @ctx
if not router_obj
warn "Failed to initialize #{name} router"
continue
@data[name] =
module: router_cb
object: Router.define router_obj
log "Loaded router", name: name, c.sc, router_obj.label, path: router_path
class Sitefile
constructor: ( @ctx ) ->
# Track all dirs for generated files, router CB's and instances, names
_.defaults @, dirs: {}, bundles: {}
# XXX:
if @ctx._sitefile then throw new Error "_sitefile"
@ctx._sitefile = @
# TODO Also need to refactor, and scan for defaults across dirs rootward
@routers = new Routers @ctx
@routers.load()
# Load predefined resources (views, scripts, styles etc. w/ route maps)
@load_bundles @ctx
# Apply routes in sitefile to Express
@apply_routes @ctx
# reload ctx.{config,sitefile} whenever file changes
reload_on_change @ctx
# Preload other, non router bundles
load_bundles: ( ctx ) ->
for bundle of ctx.sitefile.bundles
if typeof(bundle) == 'string'
# Try importing, otherwise keep as name for something loaded at a later
# time
bundle_name = bundle
bundle_obj = name: bundle_name
else if typeof(bundle) == 'object'
# Use in-sitefile defined bundle
bundle_name = bundle.name
bundle_obj = bundle
@bundles[ bundle_name ] = bundle_obj
apply_routes: ( ctx ) ->
options = {
routes:
resources: {}
directories: {}
defaults: [ 'default', 'index', 'main' ]
}
ctx.prepare_from_obj options
ctx.seed options
# parse sitefile.routes, pass 2: process specs to handler intances
for route, strspec of ctx.sitefile.routes
# Skip route spec on missing routers
[ router_name, handler_name, handler_spec ] = split_spec strspec, ctx
if router_name not of Router.builtin and router_name not in @routers.names
warn "Skipping route", name: router_name, c.sc, path: handler_spec
continue
if route.startsWith '/' or route.startsWith ctx.site.base
warn "Non-relative route", "Route path should not include root '/' or
base prefix, are you sure #{route} is correct?"
# Get merged router_type definition
if router_name of Router.builtin
router_type = Router.Base
else
if router_name not of @routers.data
warn "Missing router #{router_name}"
continue
router_type = @routers.get router_name
# Get generate handler name
if not handler_name
handler_name = if router_type.defaults?.handler? \
then router_type.defaults.handler else 'default'
# XXX: hooking Sitefile dirlist into ctx
ctx.routes.directories = @dirs
# Resolve route spec to resource contexts with sitefile settings
for rctx in router_type.resolve route, router_name, \
handler_name, handler_spec, ctx
# Merge router local defaults onto global sitefile context.
# Then update rctx.route with local and global sitefile and router defaults.
if router_type.defaults?
# Add global and local router context defaults now.
if router_type.defaults.global?
if handler_name of router_type.defaults.global
_.defaultsDeep rctx.route, \
router_type.defaults.global[ handler_name ]
if router_type.defaults.local?
if route of router_type.defaults.local
_.defaultsDeep rctx.route, \
router_type.defaults.local[ handler_name ]
# Detect routable extension
rs = rctx.res
if rs.path and (ctx.site.base+rs.path).startsWith(rs.ref) and (
rs.ref+rs.extname is ctx.site.base+rs.path
)
# FIXME: policy on extensions
ctx.redir rs.ref+rs.extname, rs.ref
#ctx.log 'redir', rs.ref+rs.extname, rs.ref
# Finally let routers generate or add routes to ctx.app Express instance
if router_name of Router.builtin
Router.builtin[router_name] rctx
else
router_type.initialize router_type, rctx
# redirect dirs to default dir-index resource
@add_dir_redirs ctx
# For each given dir-name: leafs pair,
# add a redir rule to redirect to a dir leaf
add_dir_redirs: ( ctx ) ->
# Add redirections for dirs to default leafs
for url, leafs of @dirs
if leafs.length == 1
defleaf = leafs[0]
if leafs
for name in ctx.routes.defaults
if name in leafs
defleaf = name
break
if not defleaf
warn "Cannot choose default dir index for #{url}"
continue
ctx.redir url, url+'/'+defleaf
log "Dir", url: "#{url}/{->#{defleaf}}"
# XXX only reloads on src file or sitefile change
# XXX does not reload routes, code+config only
# TODO should reload sitefilerc, should reset/apply routes
reload_on_change = ( ctx ) ->
config_watch = ctx.noderoot + '/config/**/*'
paths = expand_globs [ config_watch ]
log 'Watching configs', path: paths.join ', '
for fn in paths
fs.watchFile fn, ( cur, prev ) ->
log "Reloading config", "because: #{fn} changed"
load_config ctx
log 'Watching sitefile', path: ctx.lfn
fs.watchFile ctx.lfn, ( cur, prev ) ->
log "Reloading context", "because: #{ctx.lfn} changed"
load_sitefile ctx
expand_globs = ( patterns ) ->
_.flattenDeep [ glob.sync p for p, i in patterns ]
warn = ->
v = Array.prototype.slice.call( arguments )
out = [ chalk.red(v.shift()) + c.sc ]
console.warn.apply null, log_line( v, out )
log = ->
if module.exports.log_enabled
v = Array.prototype.slice.call( arguments )
header = _.padStart v.shift(), 21
out = [ chalk.blue(header) + c.sc ]
console.log.apply null, log_line( v, out )
log_line = ( v, out=[] ) ->
while v.length
o = v.shift()
if o?
if _.isString o
if o.match /^[\<\>_:-]+$/
out.push chalk.grey o
else if o.match /[\<\>=_]+/
out.push chalk.magenta o
else
out.push o
else if o.res? or o.format? or o.path?
out.push chalk.green o.res or o.format or o.path
else if o.url?
out.push chalk.yellow o.url
else if o.name?
out.push chalk.cyan o.name
else if o.id?
out.push chalk.magenta o.id
else
throw new Error "log: unhandled " + JSON.stringify o
else
out.push JSON.stringify o
out
Router.log = log
Router.warn = warn
module.exports =
{
version: version
get_local_sitefile_name: get_local_sitefile_name
get_local_sitefile: get_local_sitefile
prepare_context: prepare_context
load_config: load_config
load_rc: load_rc
Router: Router,
Sitefile: Sitefile
reload_on_change: reload_on_change
log_enabled: true
log: log
warn: warn
}