• watchit.coffee

  • ¶
    {EventEmitter} = require 'events'
    fs             = require 'fs'
    path           = require 'path'
  • ¶

    Options: retain means that if something is later created at the same location as the target, the new entity will be watched. debounce means that changes that occur within 1 second of each other will be treated as a single change. This also allows "echo" events that occur under OS X to be ignored. include means that if the target is a directory, files contained in that directory will be treated like targets. (Otherwise, directory events will be forwarded directly from fs.watch.) recurse means that if the target is a directory, all of its subdirectories will also be counted as targets. persistent is identical to fs.watch's persistent option. If disabled, the process may exit while files are being watched. ignore could contain RegExp pattern or function against which added files will be tested.

    defaults =
      retain: false
      debounce: false
      include: false
      recurse: false
      persistent: true
      ignored: null
  • ¶

    Main function

    watchit = (target, options, callback) ->
  • ¶

    The options argument and the callback are both optional

      if typeof options is 'function'
        callback = options
        options = {}
    
      options = extend {}, defaults, options ? {}
  • ¶

    Generic function that will check if some file is ignored.

      ignored = (file) ->
        if options.ignored
          if typeof options.ignored.test is 'function'
            options.ignored.test(file)
          else
            options.ignored(file)
        else
          no
  • ¶

    emitter will be returned from the function; it emits "change", "create", and "unlink" events. It also emits "success" and "failure" events the first time a target is found or not found, respectively.

      emitter = options.emitter = options.emitter ? new WatchitEmitter(callback)
  • ¶

    emitter also keeps track of the mtime on each target. If a target is already being watched on the same emitter, we return null rather than watch the same target multiple times.

      emitter.targets ?= {}
      return null if emitter.targets[target]
  • ¶

    The emitter can also be used to stop the watching process. Because the same emitter is used for directory children (if include or recurse is enabled), a single "close" event can shut down several fswatchers.

      fswatcher = null
      emitter.close = -> emitter.emit 'close', target
      emitter.on 'close', -> fswatcher?.close()
  • ¶

    Start watching

      do watchTarget = ->
        emitter.targets[target] = {}
        fs.stat target, (err, stats) ->
          fail = (err) ->
            if options.retain
              notifyWhenExists target, ->
                emitter.emit 'create', target
                watchTarget()
            else
              emitter.emit 'failure', target, err
    
          return fail err if err
    
          emitter.targets[target].stats = stats
          try
            if stats.isDirectory()
              fswatcher = watchTargetDir()
              scanTargetDir true if options.include or options.recurse
            else
              fswatcher = watchTargetFile()
          catch e
            return fail e
    
          emitter.emit 'success', target
          fswatcher.on 'error', (err) -> throw err
  • ¶

    If the target is lost and retain is enabled, we watchTarget again

      retainTarget = watchTarget
  • ¶

    If the target is lost, we close the FSWatcher

      unwatchTarget = ->
        fswatcher.close()
        delete emitter.targets[target]
    
      watchTargetFile = ->
        fs.watch target, {persistent: options.persistent}, (event) ->
          if event is 'rename'
  • ¶

    Has the target been unlinked, or merely replaced?

            fs.stat target, (err) ->
              if err
                unwatchTarget()
                retainTarget() if options.retain
  • ¶

    TODO: Distinguish renames from unlinks, somehow

                emitter.emit 'unlink', target
              else
                emitter.emit 'change', target
          else if event is 'change'
            if options.debounce
              conditionalTimeout target, 1000, ->
                fs.stat target, (err, stats) ->
                  return if err or target not of emitter.targets
                  prevStats = emitter.targets[target].stats
                  return if stats.mtime.getTime() is prevStats.mtime.getTime()
                  emitter.targets[target].stats = stats
                  emitter.emit 'change', target
            else
              emitter.emit 'change', target
    
      watchTargetDir = ->
        fs.watch target, {persistent: options.persistent}, (event, filename) ->
          if event is 'rename'
  • ¶

    Is this happening to the target, or one of its children?

            fs.stat target, (err) ->
              if err
                unwatchTarget()
                retainTarget() if options.retain
                emitter.emit 'unlink', target
              else
                emitter.emit 'rename', target unless options.include
                scanTargetDir() if options.include or options.recurse
          else
            throw new Error "Unexpected directory event: #{event}"
    
      scanTargetDir = (initial) ->
        fs.readdir target, (err, items) ->
          return if err
          for item in items
            do (item) ->
              return if ignored item
              itemPath = path.join(target, item)
              fs.stat itemPath, (err, stats) ->
                return if err
                isDir = stats.isDirectory()
                if (isDir and options.recurse) or (!isDir and options.include)
  • ¶

    watchit returns null if target is already watched

                  if watchit itemPath, extend({emitter}, options)
                    emitter.emit 'create', itemPath unless initial
    
      emitter
  • ¶

    Helpers

    class WatchitEmitter extends EventEmitter
      constructor: (@callback) ->
      emit: (event, filename, etc...) ->
        return if event is 'newListener'
        super event, filename, etc...
        super 'all', event, filename, etc...
        @callback? event, filename, etc...
    
    extend = (obj, sources...) ->
      for source in sources
        for prop of source
          obj[prop] = source[prop] if prop of source
      obj
  • ¶

    Set a timeout, unless a timeout with the same key already exists.

    pendingTimeouts = {}
    conditionalTimeout = (key, time, callback) ->
      return if key of pendingTimeouts
      pendingTimeouts[key] = 1
      setTimeout (->
        delete pendingTimeouts[key]
        callback()
      ), time
  • ¶

    To be notified when target does not exist, we must watch its parent.

    notifyWhenExists = (target, callback) ->
      throw new Error 'notifyWhenExists requires a callback' unless callback
      parentDir = path.join target, '..'
  • ¶

    And if parentDir does not exist, we must watch its parent. And so on...

      levelUp = ->
        notifyWhenExists parentDir, ->
          notifyWhenExists target, callback
    
      fs.exists target, (exists) ->
        return callback() if exists
    
        try
          fswatcher = fs.watch parentDir, {persistent: options.persistent}, ->
            fs.readdir parentDir, (err, items) ->
              if err
  • ¶

    parentDir no longer exists

                fswatcher.close()
                levelUp()
                return
              for item in items
                if path.join(parentDir, item) is target
  • ¶

    Our target now exists

                  fswatcher.close()
                  return callback()
            return
        catch e
          levelUp()
  • ¶

    While Watchit's main export is its titular function, functions which can be tested independently are attached.

    module.exports = watchit
    module.exports.conditionalTimeout = conditionalTimeout
    module.exports.notifyWhenExists = notifyWhenExists