class Closure::Sources

This class is responsible for scanning source files and managing dependencies.

Constants

BASE_JS_REGEX

Google Closure Library base.js is the file with no provides, no requires, and defines goog a particular way.

ENV_FLAG

Flag env so that refresh is never run more than once per request

GOOG_REGEX_STRING

Using regular expressions may seem clunky, but the Python scripts did it this way and I've not see it fail in practice.

PROVIDE_REGEX
REQUIRE_REGEX

Attributes

dwell[RW]

@return (Float) Limits how often a full refresh is allowed to run. Blocked threads can trigger unneeded refreshes in rare scenarios. Also sent to browser in cache-control for frames performance. Caching, lazy loading, and flagging (of env) make up the remaining techniques for good performance.

Public Class Methods

new(dwell = 1.0) click to toggle source

@param (Float) dwell in seconds.

# File lib/closure/sources.rb, line 48
def initialize(dwell = 1.0)
  @dwell = dwell
  @files = {}
  @sources = []
  @semaphore = Mutex.new
  @last_been_run = nil
  reset_all_computed_instance_vars
end

Public Instance Methods

add(directory, path=nil) click to toggle source

Adds a new directory of source files. @param (String) path Where to mount on the http server. @param (String) directory Filesystem location of your sources. @return (Sources) self

# File lib/closure/sources.rb, line 71
def add(directory, path=nil)
  raise "immutable once used" if @last_been_run
  if path
    raise "path must start with /" unless path =~ %r{^/}
    path = '' if path == '/'
    raise "path must not end with /" if path =~ %r{/$}
    raise "path already exists" if @sources.find{|s|s[0]==path}
  end
  raise "directory already exists" if @sources.find{|s|s[1]==directory}
  @sources << [File.expand_path(directory), path]
  @sources.sort! {|a,b| (b[1]||'') <=> (a[1]||'')}
  self
end
base_js(env={}) click to toggle source

Determine the path_info and query_string for loading base_js. @return [String]

# File lib/closure/sources.rb, line 95
def base_js(env={})
  if (goog = @goog) and @last_been_run
    return "#{goog[:base_js]}?#{goog[:base_js_mtime].to_i}"
  end
  @semaphore.synchronize do
    refresh(env)
    raise BaseJsNotFoundError unless @goog
    @goog[:base_js]
  end
end
deps_js(env={}) click to toggle source

Determine the path_info for where deps_js is located. @return [String]

# File lib/closure/sources.rb, line 109
def deps_js(env={})
  # Because Server uses this on every call, it's best to never lock.
  # We grab a local goog so we don't need the lock if everything looks good.
  # This works because #refresh creates new @goog hashes instead of modifying.
  if (goog = @goog) and @last_been_run
    return goog[:deps_js]
  end
  @semaphore.synchronize do
    refresh(env)
    raise BaseJsNotFoundError unless @goog
    @goog[:deps_js]
  end
end
deps_response(base, env={}) click to toggle source

Builds a Rack::Response to serve a dynamic deps.js @return (Rack::Response)

# File lib/closure/sources.rb, line 126
def deps_response(base, env={})
  @semaphore.synchronize do
    refresh(env)
    base = Pathname.new(base)
    unless @deps[base]
      response = @deps[base] ||= Rack::Response.new
      response.write "// Dynamic Deps by Closure Script\n"
      @files.sort{|a,b|(a[1][:path]||'')<=>(b[1][:path]||'')}.each do |filename, dep|
        if dep[:path]
          path = Pathname.new(dep[:path]).relative_path_from(base)
          path = "#{path}?#{dep[:mtime].to_i}"
          response.write "goog.addDependency(#{path.dump}, #{dep[:provide].inspect}, #{dep[:require].inspect});\n"
        end
      end
      response.headers['Content-Type'] = 'application/javascript'
      response.headers['Cache-Control'] = "max-age=#{[1,@dwell.floor].max}, private, must-revalidate"
      response.headers['Last-Modified'] = Time.now.httpdate
    end
    mod_since = Time.httpdate(env['HTTP_IF_MODIFIED_SINCE']) rescue nil
    if mod_since == Time.httpdate(@deps[base].headers['Last-Modified'])
      Rack::Response.new [], 304 # Not Modified
    else
      @deps[base]
    end
  end
end
each() { |directory, path| ... } click to toggle source

Yields path and directory for each of the added sources. @yield (path, directory)

# File lib/closure/sources.rb, line 88
def each
  @sources.each { |directory, path| yield directory, path }
end
files_for(namespace, filenames=nil, env={}) click to toggle source

Calculate all required files for a namespace. @param (String) namespace @return (Array<String>) New Array of filenames.

# File lib/closure/sources.rb, line 157
def files_for(namespace, filenames=nil, env={})
  ns = nil
  @semaphore.synchronize do
    refresh(env)
    # Pivot the deps to a namespace hash
    # @ns is cleared when any requires or provides changes
    unless @ns
      @ns = {}
      @files.each do |filename, dep|
        dep[:provide].each do |provide|
          if @ns[provide]
            @ns = nil
            raise "Namespace #{provide.dump} provided more than once."
          end
          @ns[provide] = {
            :filename => filename,
            :require => dep[:require]
          }
        end
      end
    end
    ns = @ns
    if !filenames or filenames.empty?
      raise BaseJsNotFoundError unless @goog
      filenames ||= []
      filenames << @goog[:base_filename]
    end
  end
  # Since @ns is only unset, not modified, by another thread, we
  # can work with a local reference.  This has been finely tuned and
  # runs fast, but it's still nice to release any other threads early.
  calcdeps(ns, namespace, filenames)
end
invalidate(env) click to toggle source

Certain Script operations, such as building Templates, will need to invalidate the cache.

# File lib/closure/sources.rb, line 222
def invalidate(env)
  env.delete ENV_FLAG
  @last_been_run = Time.at 0
end
namespaces_for(filename, env={}) click to toggle source

Return all provided and required namespaces for a file. @param (String) filename @return (String)

# File lib/closure/sources.rb, line 210
def namespaces_for(filename, env={})
  @semaphore.synchronize do
    refresh(env)
    file = @files[filename]
    raise "#{filename.dump} not found" unless file
    file[:provide] + file[:require]
  end
end
src_for(filename, env={}) click to toggle source

Calculate the file server path for a filename @param (String) filename @return (String)

# File lib/closure/sources.rb, line 195
def src_for(filename, env={})
  @semaphore.synchronize do
    refresh(env)
    file = @files[filename]
    unless file and file.has_key? :path
      raise "#{filename.dump} is not available from file server"
    end
    "#{file[:path]}?#{file[:mtime].to_i}"
  end
end

Protected Instance Methods

calcdeps(ns, namespace, filenames, prev = []) click to toggle source

Namespace recursion with two-way circular stop

# File lib/closure/sources.rb, line 232
def calcdeps(ns, namespace, filenames, prev = [])
  unless source = ns[namespace]
    msg = "#{prev.last[:filename]}: " rescue ''
    msg += "Namespace #{namespace.dump} not found."
    raise msg
  end
  if prev.include? source
    return
  else
    prev.push source
  end
  return if filenames.include?(source[:filename])
  source[:require].each do |required|
    calcdeps ns, required, filenames, prev
  end
  filenames.push source[:filename]
end
multiple_base_js_failure() click to toggle source

We can't trust anything if we see more than one goog

# File lib/closure/sources.rb, line 333
def multiple_base_js_failure
  reset_all_computed_instance_vars
  raise MultipleBaseJsError
end
refresh(env) click to toggle source

Lasciate ogne speranza, voi ch'intrate.

# File lib/closure/sources.rb, line 252
def refresh(env)
  return if env[ENV_FLAG]
  env[ENV_FLAG] = true
  # Having been run within the dwell period is good enough
  return if @last_been_run and Time.now - @last_been_run < @dwell
  # verbose loggers
  added_files = []
  changed_files = []
  deleted_files = []
  # Prepare to find a moving base_js
  previous_goog_base_filename = @goog ? @goog[:base_filename] : nil
  goog = nil
  # Mark everything for deletion.
  @files.each {|f, dep| dep[:not_found] = true}
  # Scan filesystem for changes.
  @sources.each do |dir, path|
    dir_range = (dir.length..-1)
    Dir.glob(File.join(dir,'**','*.js')).each do |filename|
      file = (@files[filename] ||= {})
      file.delete(:not_found)
      mtime = File.mtime(filename)
      if previous_goog_base_filename == filename
        if file[:mtime] == mtime
          multiple_base_js_failure if goog
          goog = @goog 
        end
        previous_goog_base_filename = nil
      end
      if path and !file.has_key?(:path)
        raise unless filename.index(dir) == 0 # glob sanity
        file[:path] = "#{path}#{filename.slice(dir_range)}"
        added_files << filename unless file[:excluded]
      end
      if !path or file[:excluded]
        file[:excluded] = true
        file[:provide] = file[:require] = []          
      elsif file[:mtime] != mtime
        @deps = {}
        old_file_provide = file[:provide]
        old_file_require = file[:require]
        file_text = File.read filename
        file[:provide] = file_text.scan(PROVIDE_REGEX).flatten.uniq
        file[:require] = file_text.scan(REQUIRE_REGEX).flatten.uniq
        if old_file_provide != file[:provide] or old_file_require != file[:require]
          # We're changed only if the provides or requires changes.
          # Other edits to the files don't actually alter the dependencies.
          changed_files << filename
        end
        # Record goog as we pass by
        if file[:provide].empty? and file[:require].empty?
          if File.basename(filename) == 'base.js'
            if file_text =~ BASE_JS_REGEX
              multiple_base_js_failure if goog
              goog = {:base_filename => filename}
              if file[:path]
                goog[:base_js] = file[:path]
                goog[:base_js_mtime] = mtime
                goog[:deps_js] = file[:path].gsub(/base.js$/, 'deps.js')
              end
            end
          end
        end
      end
      file[:mtime] = mtime
    end
  end
  # Sweep to delete not-found files.
  @files.select{|f, dep| dep[:not_found]}.each do |filename, o|
    deleted_files << filename
    @files.delete(filename)
  end
  # Decide if deps has changed.
  if 0 < added_files.length + changed_files.length + deleted_files.length
    @ns = nil
  end
  # Finish
  @goog = goog
  @last_been_run = Time.now
end
reset_all_computed_instance_vars() click to toggle source
# File lib/closure/sources.rb, line 338
def reset_all_computed_instance_vars
  @deps = {}
  @ns = nil
  @goog = nil
end