UNPKG

spa

Version:

Single-page applications builder with continuous integration, granular updates and loader.

701 lines (594 loc) 24.7 kB
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: (@loop) -> @name = @constructor.name @message = "Can't sort modules. Loop found: \n#{@loop}" class UnresolvedDependencyError extends Error constructor: (@path, @alias) -> @name = @constructor.name @message = "Can't resolve dependency `#{@alias}` "+ "inside module `#{@path}`" class ExternalDependencyError extends Error constructor: (@path, @alias, @dep) -> @name = @constructor.name @message = "Module at path `#{dep}` is required from `#{path}` " + "as `#{alias}`, but it cant be found inside building scope." class NoCopyingRuleError extends Error constructor: (@path) -> @name = @constructor.name @message = "No copying rule for module to be crypter at path `#{@path}`" class ModuleTypeError extends Error constructor: (@path, @error) -> @name = @constructor.name @message = "Can't determine type of module at `#{@path}`: #{@error?.toString()}" class ModuleFileOverwritingError extends Error constructor: (@path) -> @name = @constructor.name @message = "Several modules are about to be wrote into `#{@path}`. Please revisit your `copying` rules." class HostingUrlOverwritingError extends Error constructor: (@url) -> @name = @constructor.name @message = "Several modules are about to be hosted `#{@url}`. Please revisit your `hosting` rules." class Loop constructor: () -> @_parts = [] prepend: (path, alias) -> @_parts.unshift([path, alias]) return this toString: -> return "" if @_parts.length == 0 p = @_parts.concat([@_parts[0]]) return (for i in [0..@_parts.length-1] "#{p[i][0]} --[#{p[i][1]}]--> #{p[i+1][0]}" ).join("\n") class Logger constructor: (@prefix) -> info: (args...) -> console.info(args.join(" ")) warn: (args...) -> console.warn(clc.bgYellow.bold(@prefix), clc.yellow(args.join(" "))) error: (args...) -> console.error(clc.bgRed.bold(@prefix), clc.red.bold(args.join(" "))) class DB constructor: (@filename) -> if @filename? and fs.existsSync(@filename) @_data = JSON.parse(fs.readFileSync(@filename)) else @_data = {} get: (key) -> content = @_data[key] if content? return JSON.parse(content) set: (key, value) -> @_data[key] = JSON.stringify(value) del: (key) -> delete @_data[key] has: (key) -> return @_data.hasOwnProperty(key) flush: -> fs.writeFileSync(@filename, JSON.stringify(@_data)) 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) -> @_built_ins = ["loader", "require", "module", "exports"] @options = options @options.logger ?= "SPA" if _.isString(options.logger) @logger = @_create_logger() else @logger = options.logger @root = path.resolve(process.cwd(), options.root) + "/" @extensions = options.extensions ? [".js"] @excludes = options.excludes ? [] @paths = options.paths ? {} @hosting = for pattern, template of options.hosting ? {} test: globrules.tester(pattern) transform: globrules.transformer(pattern, template) @hosting_map = options.hosting_map @copying = for pattern, template of options.copying ? {} test: globrules.tester(pattern) transform: globrules.transformer(pattern, template) @default_loader = options.default_loader ? "cjs" @loaders = for pattern, type of options.loaders ? {} test: globrules.tester(pattern) type: type @bundle = options.bundle @manifest = options.manifest @index = options.index @pretty = options.pretty ? false @grab = options.grab ? false @print_stats = options.print_stats ? true @print_roots = options.print_roots ? true @assets = appcache_template: path.join(__dirname, "assets/appcache.tmpl") index_template: path.join(__dirname, "assets/index.tmpl") for own name, value of options.assets @assets[name] = value @appcache = options.appcache @cached = options.cached ? [] @hash_func = options.hash_func ? "md5" @randomize_urls = options.randomize_urls ? true @coding_func = options.coding_func @cache = @_create_db(path.resolve(@root, options.cache_file ? ".spacache")) # ________________________________ UTILS _________________________________ # _create_db: (path) -> return new DB(path) _create_logger: (name) -> return new Logger(name) _filter: (filepath) -> expected = path.extname(filepath) return false unless _(@extensions).any (ext) -> expected is ext return true calc_hash: (content) -> return hashers[@hash_func](content) encode: (content, module) -> encoder = encoders[@coding_func.name] return encoder(content, module, this) _relativate: (filepath) -> return @walker.normalize(filepath) _get_copying: (filepath) -> for rule in @copying continue unless rule.test(filepath) return rule.transform(filepath) return _host_path: (relative)-> for rule in @hosting continue unless rule.test(relative) return rule.transform(relative) @logger.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 @_resolve_to_file(path.join(dirpath, "index.js")) return _get_type: (module, source) -> for rule in @loaders 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 @default_loader _resolve: (module, dep) -> for alias, prefix of @paths continue unless _.startsWith(dep, alias) if (dep[alias.length] || "/") is "/" dep = dep.replace(alias, prefix) break if _.startsWith(dep, "/") dep = path.join(@root, 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: @extensions catch return null return @_resolve_to_file(dep) ? @_resolve_to_file(dep + ".js") ? @_resolve_to_directory(dep) _find_loop: (candidates) -> for candidate in candidates walked = [] _go_deep = (current) => module = @_by_path[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(@root, destination) @logger.info("Writing #{filepath}. #{content.length} bytes.") mkdirpSync(path.dirname(filepath)) fs.writeFileSync(filepath, content) _stringify_json: (data) -> return JSON.stringify(data, null, if @pretty then " ") _inject_inline: (relative) -> filepath = path.resolve(__dirname, "assets", relative) return fs.readFileSync(filepath, encoding: "utf8") # _______________________________ STAGES _________________________________ # _clear: -> @_modules = [] @_by_path = {} @_by_id = {} @_manifest_content = undefined @_index_content = undefined _enlist: () -> @walker = new walker.SyncWalker root: @root excludes: @excludes for data in @walker.walk() continue unless @_filter(data.relative) module = path: data.path relative: data.relative @_by_path[data.path] = module @_modules.push(module) return _analyze: -> modules = @_modules.concat() while modules.length > 0 module = modules.shift() source = fs.readFileSync(module.path) module.source = source.toString('utf8') module.deps_paths = {} module.type = @_get_type(module) switch module.type when "amd" module.amdtype = amdType(module.source) module.source_hash = @calc_hash(source) 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 @_built_ins resolved = @_resolve(module, dep) unless resolved? throw new UnresolvedDependencyError(module.relative, dep) module.deps_paths[dep] = resolved if @grab and not @_by_path[resolved] submodule = path: resolved relative: @_relativate(resolved) @_by_path[resolved] = submodule @_modules.push(submodule) modules.push(submodule) return _host: -> urls = {} for module in @_modules url = @_host_path(module.relative) 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 @_modules 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 @_by_id 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 @_by_id id = "_" + id @_by_id[id] = module module.id = id return _link: -> for module in @_modules module.deps_ids = {} for dep, resolved of module.deps_paths if @_by_path[resolved]? module.deps_ids[dep] = @_by_path[resolved].id else throw new ExternalDependencyError(module.relative, dep, resolved) _sort: -> left = (module.path for module in @_modules) order = [] while left.length > 0 use = [] for mpath in left deps = @_by_path[mpath].deps_paths use.push(mpath) unless _(deps).any((dep) -> dep not in order) if use.length == 0 throw new CyclicDependenciesError(@_find_loop(left)) order.push(use...) left = left.filter((mpath) -> mpath not in use) @_modules = (@_by_path[mpath] for mpath in order) _encode: -> _contents = [] if @coding_func? paths = {} for module in @_modules destination = @_get_copying(module.relative) 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 = @encode(source, module) if @bundle _contents.push(output) @_write_file(destination, output) module.hash = @calc_hash(output) module.size = output.length else for module in @_modules module.hash = module.source_hash module.size = module.source_length if @bundle source = fs.readFileSync(module.path) _contents.push(source) @_bundle_content = _contents.join("") return _create_manifest: -> modules = for module in @_modules 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 @_manifest_content = version: packagejson.version hash_func: @hash_func modules: modules if @bundle filepath = path.resolve(@root, @bundle) relative = @_relativate(filepath) url = @_host_path(relative) bundle = hash: @calc_hash(new Buffer(@_bundle_content, "utf8")) url: url @_manifest_content.bundle = bundle if @coding_func? @_manifest_content.decoder_func = @coding_func.name return @_manifest_content _create_hosting_map: -> files = {} for module in @_modules files[module.url] = module.relative map = version: packagejson.version files: files if @bundle filepath = path.resolve(@root, @bundle) relative = @_relativate(filepath) url = @_host_path(relative) if url? map.bundle = path: relative url: url if @manifest filepath = path.resolve(@root, @manifest) relative = @_relativate(filepath) url = @_host_path(relative) if url? map.manifest = path: relative url: url if @index filepath = path.resolve(@root, @index) relative = @_relativate(filepath) url = @_host_path(relative) if url? map.index = path: relative url: url if @appcache? filepath = path.resolve(@root, @appcache) relative = @_relativate(filepath) url = @_host_path(relative) if url? map.appcache = path: relative url: url return map _create_index: -> assets = {} namespace = assets: assets for own name, value of @assets content = fs.readFileSync(value, encoding: "utf8") namespace[name] = content assets[name] = content namespace["manifest_location"] = "manifest.json" namespace["randomize_urls"] = @randomize_urls namespace["inline"] = (relative) => @_inject_inline(relative) namespace["version"] = packagejson.version namespace["hash_name"] = @hash_func namespace["decoder_name"] = if @coding_func? then @coding_func.name else "identity" namespace["passcode_required"] = @coding_func? if @manifest? filepath = path.resolve(@root, @manifest) relative = @_relativate(filepath) url = @_host_path(relative) if url? namespace["manifest_location"] = url else @logger.warn("Manifest file hosted as `#{relative}` and will be accesible relatively") if @appcache? filepath = path.resolve(@root, @appcache) relative = @_relativate(filepath) url = @_host_path(relative) if url? namespace["appcache_location"] = url else @logger.warn("AppCache manifest file location can't be automatically calculated for index.html") compiled = ejs.compile(assets["index_template"]) @_index_content = compiled(namespace) return @_index_content _create_appcache: -> assets = {} for filename in @cached filepath = path.resolve(@root, filename) relative = @_relativate(filepath) url = @_host_path(relative) continue unless url? content = fs.readFileSync(filepath, encoding: "utf8") assets[url] = @calc_hash(content) if @index? filepath = path.resolve(@root, @index) relative = @_relativate(filepath) url = @_host_path(relative) if url? filename = path.resolve(@root, @index) assets[url] = @calc_hash(@_index_content) if Object.keys(assets).length == 0 if @index? @logger.warn("No hosting rule for `#{@index}` file. AppCache manifest `#{@appcache}` appears to be empty") else @logger.warn("There are no assets to be included into AppCache manifest `#{@appcache}`") template = @assets["appcache_template"] compiled = ejs.compile(fs.readFileSync(template, encoding: "utf8")) content = compiled cached: assets return content _print_roots: -> all_deps = [] for module in @_modules for dep_path, dep of module.deps_ids all_deps.push(dep) roots = [] for module in @_modules continue if module.id in all_deps roots.push(module) return if roots.length == 0 @logger.info("Possible roots: ") for num, module of roots message = _.sprintf "%(num)3s %(module.relative)s", num: parseInt(num) + 1 module: module @logger.info(message) _print_stats: -> @logger.info("Statistics: ") total = 0 for num, module of @_modules 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 @logger.info(message) @logger.info("Total #{total} bytes in #{@_modules.length} files") build: -> @_clear() @_enlist() @_analyze() @_host() @_set_ids() @_link() @_print_roots() if @print_roots @_sort() @_encode() @_write_file(@manifest, @_stringify_json(@_create_manifest())) if @manifest? @_write_file(@hosting_map, @_stringify_json(@_create_hosting_map())) if @hosting_map? @_write_file(@index, @_create_index()) if @index? @_write_file(@appcache, @_create_appcache()) if @appcache? @_write_file(@bundle, @_bundle_content) if @bundle? @_print_stats() if @print_stats @cache.flush() return @_manifest_content 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