require 'rack/utils'
module Jsus
#
# Jsus rack middleware.
#
# Usage
# -----
#
# `use Jsus::Middleware` in your rack application and all the requests
# to /javascripts/jsus/* will be redirected to the middleware.
#
# Example requests
# ----------------
#
#
`GET /javascripts/jsus/require/Mootools.Core+Mootools.More`
# merges packages named Mootools.Core and Mootools.More with all the
# dependencies and outputs the result.
#
# `GET /javascripts/jsus/require/Mootools.More~Mootools.Core`
# returns package Mootools.More with all the dependencies MINUS any of
# Mootools.Core dependencies.
#
# `GET /javascripts/jsus/require/Mootools.Core:Class+Mootools.More:Fx`
# same thing but for source files providing Mootools.Core/Class and
# Mootools.More/Fx
#
# `GET /javascripts/jsus/include/Mootools.Core`
# generates js file with remote javascript fetching via ajax
#
# @see .settings=
# @see https://github.com/jsus/jsus-sinatra-app Sinatra example (Github)
# @see https://github.com/jsus/jsus-rails-app Rails example (Github)
#
class Middleware
include Rack
class << self
# Default settings for Middleware
DEFAULT_SETTINGS = {
:packages_dir => ".",
:cache => false,
:cache_path => nil,
:prefix => "jsus",
:cache_pool => true,
:includes_root => ".",
:log_method => nil, # [:alert, :html, :console]
:postproc => [] # ["mooltie8", "moocompat12"]
}.freeze
# @return [Hash] Middleware current settings
# @api public
def settings
@@settings ||= DEFAULT_SETTINGS.dup
end # settings
# *Merges* given new settings into current settings.
#
# @param [Hash] new_settings
# @option new_settings [String, Array] :packages_dir directory (or array
# of directories) containing source files.
# @option new_settings [Boolean] :cache enable file caching (every request
# is written into corresponding file). Note, that it's write-only cache,
# you will have to configure your webserver to serve these files.
# @option new_settings [String] :cache_path path to cache directory
# @option new_settings [String, nil] :prefix path prefix to use for
# request. You can change default "jsus" to anything else or disable it
# altogether.
# @option new_settings [Boolean] :cache_pool whether to cache pool between
# requests. Cached pool means that updates to your source files will not
# be visible until you restart webserver.
# @option new_settings [String] :includes_root when generating "includes"
# lists, this is the point in filesystem used as relative root.
# @api public
def settings=(new_settings)
settings.merge!(new_settings)
end # settings=
# Generates and caches a pool of source files and packages.
#
# @return [Jsus::Pool]
# @api public
def pool
@@pool ||= Jsus::Pool.new(settings[:packages_dir])
end # pool
# @return [Boolean] whether caching is enabled
# @api public
def cache?
settings[:cache]
end # cache?
# @return [Jsus::Util::FileCache] file cache to store results of requests.
# @api public
def cache
@@cache ||= cache? ? Util::FileCache.new(settings[:cache_path]) : nil
end # cache
end # class <= 2
request_options[:path] = path
if components[0] == "require"
generate_requires(components[1])
elsif components[0] == "compressed"
request_options[:compress] = true
generate_requires(components[1])
elsif components[0] == "include"
generate_includes(components[1])
else
not_found!
end
end # _call
# Rack calling point
#
# @param [Hash] env rack env
# @return [#each] rack response
# @api public
def call(env)
dup._call(env)
end # call
protected
# Current request options
# @return [Hash]
def request_options
@options ||= {}
end # request_options
# Rack response of not found
# @return [#each] 404 response
# @api semipublic
def not_found!
[404, {"Content-Type" => "text/plain"}, ["Jsus doesn't know anything about this entity"]]
end # not_found!
# Respond with given text
# @param [String] text text to respond with
# @return [#each] 200 response
# @api semipublic
def respond_with(text)
response = formatted_errors + postproc(text)
cache_response!(response) if cache?
[200, {"Content-Type" => "text/javascript"}, [response]]
end # respond_with
# Generates response for /require/ requests.
#
# @param [String] path_string path component to required sources
# @return [#each] rack response
# @api semipublic
def generate_requires(path_string)
files = path_string_to_files(path_string)
if !files.empty?
response = Container.new(*files).map {|f| f.content }.join("\n")
response = Jsus::Util::Compressor.new(response).result if request_options[:compress]
respond_with(response)
else
not_found!
end
end # generate_requires
# Generates response for /include/ requests.
#
# @param [String] path_string path component to included sources
# @return [#each] rack response
# @api semipublic
def generate_includes(path_string)
files = path_string_to_files(path_string)
if !files.empty?
paths = Container.new(*files).required_files(self.class.settings[:includes_root])
respond_with(Jsus::Util::CodeGenerator.generate_includes(paths))
else
not_found!
end
end # generate_includes
# Returns list of exlcuded and included source files for given path strings.
#
# @param [String] path_string string with + and ~
# @return [Hash] hash with source files to include and to exclude
# @api semipublic
def path_string_to_files(path_string)
path_args = parse_path_string(path_string.sub(/.js$/, ""))
files = []
path_args[:include].each {|tag| files += get_associated_files(tag).to_a }
path_args[:exclude].each {|tag| files -= get_associated_files(tag).to_a }
files
end # path_string_to_files
# Post-processes output (removes different compatibility tags)
#
# @param [String] source source to post-process
# @return [String] post-processed source
def postproc(source)
Array(self.class.settings[:postproc]).inject(source) do |result, processor|
case processor.strip
when /^moocompat12$/i
result.gsub(/\/\/<1.2compat>.*?\/\/<\/1.2compat>/m, '').
gsub(/\/\*<1.2compat>\*\/.*?\/\*<\/1.2compat>\*\//m, '')
when /^mooltie8$/i
result.gsub(/\/\/.*?\/\/<\/ltIE8>/m, '').
gsub(/\/\*\*\/.*?\/\*<\/ltIE8>\*\//m, '')
else
Jsus.logger.error "Unknown post-processor: #{processor}"
result
end
end
end
# Parses human-readable string with + and ~ operations into a more usable hash.
# @note + is a space after url decoding
#
# @example
# parse_path_string("Package:A~Package:C Package:B~Other:D")
# => {:include => ["Package/A", "Package/B"],
# :exclude => ["Package/C", "Other/D"]}
# @api semipublic
def parse_path_string(path_string)
path_string = " " + path_string unless path_string[0,1] =~ /\+\-/
included = []
excluded = []
path_string.scan(/([ ~])([^ ~]*)/) do |op, arg|
arg = arg.gsub(":", "/")
if op == " "
included << arg
else
excluded << arg
end
end
{:include => included, :exclude => excluded}
end # parse_path_string
# Returns a list of associated files for given source file or source package.
# @param [String] source_file_or_package canonical source file or source
# package name or wildcard. E.g. "Mootools.Core", "Mootools.Core/*",
# "Mootools.Core/Class", "**/*"
# @return [Array] list of source files for given input
# @api semipublic
def get_associated_files(source_file_or_package)
if package = pool.packages.detect {|pkg| pkg.name == source_file_or_package}
package.include_dependencies!
package.linked_external_dependencies.to_a + package.source_files.to_a
elsif source_file = pool.lookup(source_file_or_package)
pool.lookup_dependencies(source_file).to_a << source_file
else
# Try using arg as mask
mask = source_file_or_package.to_s
if !(mask =~ /^\s*$/) && !(source_files = pool.provides_tree.glob(mask).compact).empty?
source_files.map {|source| get_associated_files(source) }.flatten
else
# No dice
[]
end
end
end # get_associated_files
# Check whether given path is handled by jsus middleware.
#
# @param [String] path path
# @return [Boolean]
# @api semipublic
def handled_by_jsus?(path)
path =~ path_prefix_regex
end # handled_by_jsus?
# @return [String] Jsus request path prefix
# @api semipublic
def path_prefix
@path_prefix ||= self.class.settings[:prefix] ? "/javascripts/#{self.class.settings[:prefix]}/" : "/javascripts/"
end # path_prefix
# @return [Regexp] Jsus request path regexp
# @api semipublic
def path_prefix_regex
@path_prefix_regex ||= %r{^#{path_prefix}}
end # path_prefix_regex
# @return [Jsus::Pool] Jsus session pool
# @api semipublic
def pool
if cache_pool?
self.class.pool
else
@pool ||= Jsus::Pool.new(self.class.settings[:packages_dir])
end
end # pool
# @return [Boolean] whether request is going to be cached
# @api semipublic
def cache?
self.class.cache?
end # cache?
# @return [Jsus::Util::FileCache] file cache to store response
# @api semipublic
def cache
self.class.cache
end # cache
# Saves response into the filesystem
# @param [String] response text to store
# @return [String] filename
def cache_response!(response)
cache.write(escape_path_for_cache_key(request_options[:path]), response)
end # cache_response!
# @return [Boolean] whether pool is shared between requests
# @api semipublic
def cache_pool?
self.class.settings[:cache_pool]
end # cache_pool?
# You might or might not need to do some last minute conversions for your cache
# key. Default behaviour is merely a nginx hack, you may have to use your own
# function for your web-server.
# @param [String] path request path minus the prefix
# @return [String] normalized cache key for given request path
# @api semipublic
def escape_path_for_cache_key(path)
path.gsub(" ", "+")
end # escape_path_for_cache_key
# Outputs errors in one or multiple ways.
# Set middleware setting :log_method to array with a combination of any of the following:
# :alert -- generates javascript alert with warning text
# :console -- generates console logging entry
# :html -- injects error / warning messages directly into html body
# @return [String] javascript code containing errors output for various methods
# @api semipublic
def formatted_errors
Array(self.class.settings[:log_method]).inject("") do |result, log_method|
result << errors.map do |severity, error|
case log_method
when :alert then "alert(#{error.inspect});"
when :console then "console.log(#{error.inspect});"
when :html then "document.body.innerHTML = '' + #{error.inspect} + '
' + document.body.innerHTML;"
end
end.compact.join("\n") + "\n"
end
end # formatted_errors
def errors
Jsus.logger.buffer
end # errors
end # class Middleware
end # module Jsus