spa
Version:
Single-page applications builder with continuous integration, granular updates and loader.
701 lines (594 loc) • 24.7 kB
text/coffeescript
fs = require('fs')
vm = require("vm")
walker = require('fs-walk-glob-rules')
globrules = require('glob-rules')
path = require('path')
clc = require('cli-color')
mkdirpSync = require('mkdirp').sync
detectiveCJS = require('detective')
detectiveAMD = require('detective-amd')
definition = require('module-definition').fromSource
amdType = require('get-amd-module-type').fromSource
yaml = require('js-yaml')
ejs = require("ejs")
_ = require('lodash')
_.string = require('underscore.string')
_.mixin(_.string.exports())
crypto = require("crypto")
resolve = require("resolve")
console = require("./console")
packagejson = ( ->
packagepath = path.resolve(__dirname, '../package.json')
return JSON.parse(fs.readFileSync(packagepath, 'utf8'))
)()
class CyclicDependenciesError extends Error
constructor: () ->
= .name
= "Can't sort modules. Loop found: \n#{@loop}"
class UnresolvedDependencyError extends Error
constructor: (, ) ->
= .name
= "Can't resolve dependency `#{@alias}` "+
"inside module `#{@path}`"
class ExternalDependencyError extends Error
constructor: (, , ) ->
= .name
= "Module at path `#{dep}` is required from `#{path}` " +
"as `#{alias}`, but it cant be found inside building scope."
class NoCopyingRuleError extends Error
constructor: () ->
= .name
= "No copying rule for module to be crypter at path `#{@path}`"
class ModuleTypeError extends Error
constructor: (, ) ->
= .name
= "Can't determine type of module at `#{@path}`: #{@error?.toString()}"
class ModuleFileOverwritingError extends Error
constructor: () ->
= .name
= "Several modules are about to be wrote into `#{@path}`. Please revisit your `copying` rules."
class HostingUrlOverwritingError extends Error
constructor: () ->
= .name
= "Several modules are about to be hosted `#{@url}`. Please revisit your `hosting` rules."
class Loop
constructor: () ->
= []
prepend: (path, alias) ->
.unshift([path, alias])
return this
toString: ->
return "" if .length == 0
p = .concat([[0]])
return (for i in [0...length-1]
"#{p[i][0]} --[#{p[i][1]}]--> #{p[i+1][0]}"
).join("\n")
class Logger
constructor: () ->
info: (args...) ->
console.info(args.join(" "))
warn: (args...) ->
console.warn(clc.bgYellow.bold(), clc.yellow(args.join(" ")))
error: (args...) ->
console.error(clc.bgRed.bold(), clc.red.bold(args.join(" ")))
class DB
constructor: () ->
if ? and fs.existsSync()
= JSON.parse(fs.readFileSync())
else
= {}
get: (key) ->
content = [key]
if content?
return JSON.parse(content)
set: (key, value) ->
[key] = JSON.stringify(value)
del: (key) ->
delete [key]
has: (key) ->
return .hasOwnProperty(key)
flush: ->
fs.writeFileSync(, JSON.stringify())
sandbox = ->
window = {}
window.window = window
result =
ArrayBuffer: Object.freeze(ArrayBuffer)
Buffer: Object.freeze(Buffer)
Uint8Array: Object.freeze(Uint8Array)
console: Object.freeze(console)
window: Object.freeze(window)
Uint32Array: Object.freeze(Uint32Array)
return result
eval_file = (p, s) ->
return vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, p), "utf8"), sandbox())
hashers =
md5: (data) -> crypto.createHash("md5").update(data).digest('hex')
ripemd160: (data) -> crypto.createHash("ripemd160").update(data).digest('hex')
sha1: (data) -> crypto.createHash("sha1").update(data).digest('hex')
sha224: (data) -> crypto.createHash("sha224").update(data).digest('hex')
sha256: (data) -> crypto.createHash("sha256").update(data).digest('hex')
sha384: (data) -> crypto.createHash("sha384").update(data).digest('hex')
sha512: (data) -> crypto.createHash("sha512").update(data).digest('hex')
sha3: eval_file("./assets/hash/sha3.js")
encoders =
"aes-ccm": eval_file("./assets/encode/aes-ccm.js")
"aes-gcm": eval_file("./assets/encode/aes-gcm.js")
"aes-ocb2": eval_file("./assets/encode/aes-ocb2.js")
class Builder
constructor: (options) ->
= ["loader", "require", "module", "exports"]
= options
.logger ?= "SPA"
if _.isString(options.logger)
=
else
= options.logger
= path.resolve(process.cwd(), options.root) + "/"
= options.extensions ? [".js"]
= options.excludes ? []
= options.paths ? {}
= for pattern, template of options.hosting ? {}
test: globrules.tester(pattern)
transform: globrules.transformer(pattern, template)
= options.hosting_map
= for pattern, template of options.copying ? {}
test: globrules.tester(pattern)
transform: globrules.transformer(pattern, template)
= options.default_loader ? "cjs"
= for pattern, type of options.loaders ? {}
test: globrules.tester(pattern)
type: type
= options.bundle
= options.manifest
= options.index
= options.pretty ? false
= options.grab ? false
= options.print_stats ? true
= options.print_roots ? true
=
appcache_template: path.join(__dirname, "assets/appcache.tmpl")
index_template: path.join(__dirname, "assets/index.tmpl")
for own name, value of options.assets
[name] = value
= options.appcache
= options.cached ? []
= options.hash_func ? "md5"
= options.randomize_urls ? true
= options.coding_func
=
# ________________________________ UTILS _________________________________ #
_create_db: (path) -> return new DB(path)
_create_logger: (name) -> return new Logger(name)
_filter: (filepath) ->
expected = path.extname(filepath)
return false unless _().any (ext) -> expected is ext
return true
calc_hash: (content) ->
return hashers[](content)
encode: (content, module) ->
encoder = encoders[.name]
return encoder(content, module, this)
_relativate: (filepath) ->
return .normalize(filepath)
_get_copying: (filepath) ->
for rule in
continue unless rule.test(filepath)
return rule.transform(filepath)
return
_host_path: (relative)->
for rule in
continue unless rule.test(relative)
return rule.transform(relative)
.error("No hosting rules for `#{relative}`")
_resolve_to_file: (filepath) ->
if fs.existsSync(filepath)
stats = fs.statSync(filepath)
if stats.isFile()
return filepath
return
_resolve_to_directory: (dirpath) ->
if fs.existsSync(dirpath)
stats = fs.statSync(dirpath)
if stats.isDirectory()
return
return
_get_type: (module, source) ->
for rule in
continue unless rule.test(module.relative)
return rule.type
try
return switch definition(module.source)
when "commonjs" then "cjs"
when "amd" then "amd"
catch error
throw new ModuleTypeError(module.path, error)
return
_resolve: (module, dep) ->
for alias, prefix of
continue unless _.startsWith(dep, alias)
if (dep[alias.length] || "/") is "/"
dep = dep.replace(alias, prefix)
break
if _.startsWith(dep, "/")
dep = path.join(, dep)
else if _.startsWith(dep, "./") or _.startsWith(dep, "../")
basedir = path.dirname(module.path)
dep = path.resolve(basedir, dep)
else
try
return resolve.sync dep,
basedir: path.dirname(module.path)
extensions:
catch
return null
return ?
?
_find_loop: (candidates) ->
for candidate in candidates
walked = []
_go_deep = (current) =>
module = [current]
relative = module.relative
deps = module.deps_paths
for alias, dep of deps
continue unless dep in candidates
continue if dep in walked
return new Loop().prepend(relative, alias) if dep is candidate
walked.push(dep)
deep = _go_deep(dep)
walked.pop()
return deep.prepend(relative, alias) if deep?
has_loop = _go_deep(candidate)
return has_loop if has_loop?
_write_file: (destination, content) ->
filepath = path.resolve(, destination)
.info("Writing #{filepath}. #{content.length} bytes.")
mkdirpSync(path.dirname(filepath))
fs.writeFileSync(filepath, content)
_stringify_json: (data) ->
return JSON.stringify(data, null, if then " ")
_inject_inline: (relative) ->
filepath = path.resolve(__dirname, "assets", relative)
return fs.readFileSync(filepath, encoding: "utf8")
# _______________________________ STAGES _________________________________ #
_clear: ->
= []
= {}
= {}
= undefined
= undefined
_enlist: () ->
= new walker.SyncWalker
root:
excludes:
for data in .walk()
continue unless
module =
path: data.path
relative: data.relative
[data.path] = module
.push(module)
return
_analyze: ->
modules = .concat()
while modules.length > 0
module = modules.shift()
source = fs.readFileSync(module.path)
module.source = source.toString('utf8')
module.deps_paths = {}
module.type =
switch module.type
when "amd"
module.amdtype = amdType(module.source)
module.source_hash =
module.source_length = source.length
deps = switch module.type
when "cjs" then detectiveCJS(module.source)
when "amd" then detectiveAMD(module.source)
else []
#3 ISSUE. Add hardcoded dependencies from config here
for dep in deps
continue if dep in
resolved =
unless resolved?
throw new UnresolvedDependencyError(module.relative, dep)
module.deps_paths[dep] = resolved
if and not [resolved]
submodule =
path: resolved
relative:
[resolved] = submodule
.push(submodule)
modules.push(submodule)
return
_host: ->
urls = {}
for module in
url =
continue unless url
if url of urls #TODO: maybe better comparison will be needed
throw new HostingUrlOverwritingError(url)
urls[url] = module
module.url = url
return
_set_ids: ->
for module in
ext = path.extname(module.path)
root = path.dirname(module.path)
id = path.basename(module.path, ext)
if id is "index"
id = path.basename(root)
root = path.dirname(root)
while id of
id = path.basename(root) + "/" + id
newroot = path.dirname(root)
break if newroot is root
root = newroot
id = id.split(/[^a-zA-Z0-9]/g).join("_")
while id of
id = "_" + id
[id] = module
module.id = id
return
_link: ->
for module in
module.deps_ids = {}
for dep, resolved of module.deps_paths
if [resolved]?
module.deps_ids[dep] = [resolved].id
else
throw new ExternalDependencyError(module.relative, dep, resolved)
_sort: ->
left = (module.path for module in )
order = []
while left.length > 0
use = []
for mpath in left
deps = [mpath].deps_paths
use.push(mpath) unless _(deps).any((dep) -> dep not in order)
if use.length == 0
throw new CyclicDependenciesError()
order.push(use...)
left = left.filter((mpath) -> mpath not in use)
= ([mpath] for mpath in order)
_encode: ->
_contents = []
if ?
paths = {}
for module in
destination =
unless destination?
throw new NoCopyingRuleError(module.relative)
if destination of paths #TODO: maybe better comparison will be needed
throw new ModuleFileOverwritingError(destination)
paths[destination] = module
source = fs.readFileSync(module.path)
output =
if
_contents.push(output)
module.hash =
module.size = output.length
else
for module in
module.hash = module.source_hash
module.size = module.source_length
if
source = fs.readFileSync(module.path)
_contents.push(source)
= _contents.join("")
return
_create_manifest: ->
modules = for module in
id: module.id
url: module.url
hash: module.hash
size: module.size
type: module.type
amdtype: module.amdtype
deps: module.deps_ids
decoding: module.decoding
=
version: packagejson.version
hash_func:
modules: modules
if
filepath = path.resolve(, )
relative =
url =
bundle =
hash:
url: url
.bundle = bundle
if ?
.decoder_func = .name
return
_create_hosting_map: ->
files = {}
for module in
files[module.url] = module.relative
map =
version: packagejson.version
files: files
if
filepath = path.resolve(, )
relative =
url =
if url?
map.bundle =
path: relative
url: url
if
filepath = path.resolve(, )
relative =
url =
if url?
map.manifest =
path: relative
url: url
if
filepath = path.resolve(, )
relative =
url =
if url?
map.index =
path: relative
url: url
if ?
filepath = path.resolve(, )
relative =
url =
if url?
map.appcache =
path: relative
url: url
return map
_create_index: ->
assets = {}
namespace =
assets: assets
for own name, value of
content = fs.readFileSync(value, encoding: "utf8")
namespace[name] = content
assets[name] = content
namespace["manifest_location"] = "manifest.json"
namespace["randomize_urls"] =
namespace["inline"] = (relative) =>
namespace["version"] = packagejson.version
namespace["hash_name"] =
namespace["decoder_name"] = if ? then .name else "identity"
namespace["passcode_required"] = ?
if ?
filepath = path.resolve(, )
relative =
url =
if url?
namespace["manifest_location"] = url
else
.warn("Manifest file hosted as `#{relative}` and will be accesible relatively")
if ?
filepath = path.resolve(, )
relative =
url =
if url?
namespace["appcache_location"] = url
else
.warn("AppCache manifest file location can't be automatically calculated for index.html")
compiled = ejs.compile(assets["index_template"])
= compiled(namespace)
return
_create_appcache: ->
assets = {}
for filename in
filepath = path.resolve(, filename)
relative =
url =
continue unless url?
content = fs.readFileSync(filepath, encoding: "utf8")
assets[url] =
if ?
filepath = path.resolve(, )
relative =
url =
if url?
filename = path.resolve(, )
assets[url] =
if Object.keys(assets).length == 0
if ?
.warn("No hosting rule for `#{@index}` file. AppCache manifest `#{@appcache}` appears to be empty")
else
.warn("There are no assets to be included into AppCache manifest `#{@appcache}`")
template = ["appcache_template"]
compiled = ejs.compile(fs.readFileSync(template, encoding: "utf8"))
content = compiled
cached: assets
return content
_print_roots: ->
all_deps = []
for module in
for dep_path, dep of module.deps_ids
all_deps.push(dep)
roots = []
for module in
continue if module.id in all_deps
roots.push(module)
return if roots.length == 0
.info("Possible roots: ")
for num, module of roots
message = _.sprintf "%(num)3s %(module.relative)s",
num: parseInt(num) + 1
module: module
.info(message)
_print_stats: ->
.info("Statistics: ")
total = 0
for num, module of
total += module.size
message = _.sprintf "%(num)3s %(module.relative)-40s %(module.size)7s %(type)-11s %(module.hash)s",
num: parseInt(num) + 1
module: module
type: if module.type == "amd" then module.type + "/" + module.amdtype else module.type
.info(message)
.info("Total #{total} bytes in #{@_modules.length} files")
build: ->
if
if ?
if ?
if ?
if ?
if ?
if
.flush()
return
hasBOM = (data) ->
return false if data.length < 3
return false unless data[0] is 0xef
return false unless data[1] is 0xbb
return false unless data[2] is 0xbf
return true
load_json = (filepath) ->
return unless filepath?
source = fs.readFileSync(filepath)
return JSON.parse(source)
load_yaml = (filepath) ->
return unless filepath?
data = fs.readFileSync(filepath)
source = data.toString("utf8", if hasBOM(data) then 3 else 0)
return yaml.safeLoad(source)
get_config_content = (filepath) ->
switch path.extname(filepath)
when ".yaml", ".yml"
return load_yaml(filepath)
when ".json"
return load_json(filepath)
# ________________________________ EXPORTS ___________________________________ #
Builder.from_config = (config_path, options) ->
basedir = path.dirname(config_path)
config = get_config_content(config_path)
for own name, value of options ? {}
config[name] = value
config.root ?= "."
config.root = path.resolve(basedir, config.root)
return new Builder(config)
exports.Builder = Builder
exports.CyclicDependenciesError = CyclicDependenciesError
exports.UnresolvedDependencyError = UnresolvedDependencyError
exports.ExternalDependencyError = ExternalDependencyError
exports.ModuleTypeError = ModuleTypeError
exports.ModuleFileOverwritingError = ModuleFileOverwritingError
exports.HostingUrlOverwritingError = HostingUrlOverwritingError
exports.Loop = Loop
exports.Logger = Logger
exports.DB = DB
exports.hashers = hashers
exports.encoders = encoders