debounced 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9fa1cd41e5d203718e5f338068db77acd8b07f15369c705e096f9a2108b7614
4
- data.tar.gz: 544067be17e4efad251d656374b5d41a08c2c3ad627fc7f0ac75dbdf83be0c05
3
+ metadata.gz: 545ffc360ed1f13cb95fbec250c45bed7744f932cb8865890019594c3ec30be3
4
+ data.tar.gz: 7a2e349b770b02a29c108af1c6fed682901971afebf9fac5b272dab4570eccbf
5
5
  SHA512:
6
- metadata.gz: 7965f45c4e5055cf0c0e98123c6e88b5e7f1d9ef2ac6e0ecc1a3f09e51364b6feb2d5c3f07dc294d54a6f15af717cf6b6ccb87eae090466583f07f8ff6d162cd
7
- data.tar.gz: 7165afce55d82ff97a1747181bd219e63a94ac79f23d0ec3b3ffbc04b4c6ce31f83be4b1a3372dd20484bae19db00f4546e7e4bc02f162efaf5dc560c4569fae
6
+ metadata.gz: c64730cb742be03954ca2caaf8b3145c4c7a2e016b9f65d5a737dfda3e8d51d49f98aad35d213abf2aacb360ff9b5e83f29a5cf4cdcb98154a60c40c31a40698
7
+ data.tar.gz: 4fe5b3d2563d3cadd547c9e7beac16811ff421716151eb4bdb184b2c7c39a7527729c44b0d269a03ef37db08ca91505445bd6446e07bb43b007c1357d8b6840c
data/CHANGELOG.md CHANGED
@@ -5,34 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
9
-
10
- ### Fixed
11
-
12
- - Each process's descriptors are debounced separately. When two processes debounced the same descriptor, the later
13
- request cancelled the earlier process's callback; now each process gets its own callback, and a process's pending
14
- callbacks are cancelled when it disconnects
15
-
16
- ## [2.1.0] - 2026-09-26
17
-
18
- ### Security
19
-
20
- - Callbacks are only dispatched to public methods defined by the application, never to core Ruby methods
21
- - The server creates its socket owner-only, and the proxy refuses a socket owned by another user
8
+ ## [3.0.0] - Unreleased
22
9
 
23
10
  ### Changed
24
11
 
25
- - Requires Node.js 22 or later; Node.js 18 and 20 are past end of life
12
+ - The debounce server is now written in Ruby (`Debounced::Server`, on the `async` gem); Node.js is no longer required
13
+ - Start it with `bundle exec rake debounced:server`, as before
14
+ - Requires Ruby 3.3 or later
15
+ - The server serves any number of connected processes and debounces each process's descriptors separately, publishing each callback to the process that requested it
16
+ - On SIGTERM the server fires its pending timers on schedule, publishes new requests immediately, and exits once
17
+ nothing is pending; SIGINT still exits immediately
18
+ - `ServiceProxy#stop(timeout:)` first waits up to `timeout` seconds for the server to close the connection, so a
19
+ process can receive the callbacks still pending when it shuts down
20
+ - `Debounced.configuration.error_handler` — an optional callable, invoked with the error and the `Debounced::Callback`
21
+ when a callback raises, so a host app can report callback failures to an error tracker without the gem depending on one
26
22
 
27
23
  ### Fixed
28
24
 
29
- - A second server no longer takes over the socket of a running one, and only removes a socket it created
30
- - The server exits non-zero when it cannot listen
31
- - Multi-byte characters split across socket reads are no longer corrupted
32
- - The server serves any number of connected processes and publishes each callback to the process that requested it
33
- - The listener no longer adds an extra idle timeout before delivering a callback
25
+ - The gem now depends on and requires `concurrent-ruby` directly, rather than relying on `semantic_logger` to pull it in
26
+ transitively (`ServiceProxy` uses `Concurrent::AtomicBoolean`)
27
+ - Callbacks are only dispatched to public methods defined by the application, never to core Ruby methods
28
+ - The socket is created owner-only, and the proxy refuses a socket owned by another user
29
+ - A second server no longer takes over the socket of a running one
34
30
  - Requests larger than the socket buffer are no longer truncated
35
31
  - A failed send falls back to invoking the callback instead of raising
32
+ - Callbacks are no longer delayed by an extra idle timeout in the listener
33
+ - Multi-byte characters split across socket reads are no longer corrupted
36
34
  - `ServiceProxy#stop` works before `listen`
37
35
 
38
36
  ## [1.0.7](https://github.com/Flytedesk/debounced/compare/v1.0.6...v1.0.7) (2026-04-15)
data/README.md CHANGED
@@ -23,12 +23,6 @@ Or install it yourself as:
23
23
  $ gem install debounced
24
24
  ```
25
25
 
26
- ## Dependencies
27
-
28
- This gem requires Node.js to be installed on your system, as it uses a Node.js server to handle the debouncing logic. You'll need:
29
-
30
- - Node.js >= 22.0.0
31
-
32
26
  ## Usage
33
27
 
34
28
  ### Configuration
@@ -43,10 +37,10 @@ end
43
37
 
44
38
  ### Starting the server
45
39
 
46
- Start the nodeJS debounce server with:
40
+ Start the debounce server with:
47
41
 
48
42
  ```bash
49
- $ bundle exec debounced:server
43
+ $ bundle exec rake debounced:server
50
44
  ```
51
45
 
52
46
  In your Ruby application code:
@@ -95,12 +89,48 @@ proxy.debounce_activity("my-event-123", 5, event.debounce_callback)
95
89
  # > Publishing event: #<MyEvent:0x00007f9b1b8b3b40 @test_id="Hello World">
96
90
  ```
97
91
 
92
+ ### Shutting down
93
+
94
+ On SIGTERM the server stops debouncing: pending timers still fire on schedule, requests that arrive afterwards are
95
+ published immediately, and once nothing is pending the server closes its connections and exits. Keep debounce
96
+ timeouts shorter than your platform's shutdown grace period (30 seconds on Heroku).
97
+
98
+ So that a process receives the callbacks still pending when it shuts down, stop the proxy with a timeout before
99
+ exiting:
100
+
101
+ ```ruby
102
+ at_exit { proxy.stop(timeout: 10) }
103
+ ```
104
+
105
+ It waits until the server closes the connection or the timeout passes, and returns at once when not connected.
106
+ SIGINT stops the server immediately, which is convenient in development.
107
+
98
108
  ## How It Works
99
109
 
100
- 1. The gem creates a Unix socket for communication between Ruby and Node.js
101
- 2. When you call `debounce_activity`, it sends the event to the Node.js server
102
- 3. The Node.js server restarts a timer every time an event with a given activity_descriptor is received
103
- 4. When the timeout expires, it sends the event back to Ruby to be published
110
+ 1. The debounce server listens on a Unix socket and runs one lightweight timer per activity descriptor
111
+ 2. When you call `debounce_activity`, the proxy sends the descriptor, timeout and callback to the server
112
+ 3. Each new request for the same descriptor cancels its timer and starts a new one, keeping the latest callback
113
+ 4. When a timer expires, the server sends the callback back to the proxy, which invokes it
114
+
115
+ Several processes can connect to one server, for example every Puma worker of an application. Each process's
116
+ descriptors are debounced separately, even when two processes use the same descriptor: each gets its own callback,
117
+ and a process's pending callbacks are dropped when it disconnects.
118
+ When no server is reachable, the proxy invokes callbacks immediately.
119
+
120
+ ## Performance
121
+
122
+ Measured with `bin/benchmark`, which drives a server process over its socket. Medians of three runs on an Apple M4 Max,
123
+ Ruby 4.0.4, compared with the Node.js server from `debounced` 2.1.0 on Node.js 24.18.
124
+
125
+ | Measure | Ruby server (3.0) | Node.js server (2.1) |
126
+ |---|---|---|
127
+ | Throughput: 100,000 requests across 10,000 descriptors | 224,000 requests/s | 263,000 requests/s |
128
+ | Callback lateness after its timeout, median | 0.25 ms | 0.26 ms |
129
+ | Callback lateness, 99th percentile | 1.96 ms | 1.95 ms |
130
+ | Memory, idle | 27 MB | 48 MB |
131
+ | Memory, 100,000 pending timers | 156 MB | 174 MB |
132
+
133
+ To measure another server, pass its command: `bin/benchmark node path/to/server.mjs`.
104
134
 
105
135
  ## License
106
136
 
@@ -78,6 +78,7 @@ module Debounced
78
78
  rescue StandardError => e
79
79
  Debounced.configuration.logger.warn("Unable to invoke callback #{as_json}: #{e.message}")
80
80
  Debounced.configuration.logger.warn(e.backtrace.join("\n"))
81
+ Debounced.configuration.error_handler&.call(e, self)
81
82
  end
82
83
 
83
84
  private
@@ -0,0 +1,162 @@
1
+ require 'async'
2
+ require 'async/barrier'
3
+ require 'async/notification'
4
+ require 'async/queue'
5
+ require 'io/event'
6
+ require 'json'
7
+ require 'socket'
8
+
9
+ module Debounced
10
+ class Server
11
+ def initialize(socket_descriptor)
12
+ @socket_descriptor = socket_descriptor
13
+ @timers = {}
14
+ @writers = Async::Barrier.new
15
+ @queue = IO::Event::Timers.new
16
+ @timer_scheduled = Async::Notification.new
17
+ end
18
+
19
+ def listen
20
+ remove_stale_socket_file
21
+ Sync do |task|
22
+ @root = task
23
+ server = bind_owner_only
24
+ logger.info("#{self.class.name} listening on #{@socket_descriptor}")
25
+ task.async { run_timers }
26
+ task.async { drain_on_sigterm }
27
+ loop do
28
+ connection = server.accept
29
+ task.async { serve(connection) }
30
+ end
31
+ ensure
32
+ File.delete(@socket_descriptor) if server
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def bind_owner_only
39
+ previous_umask = File.umask(0o177)
40
+ UNIXServer.new(@socket_descriptor)
41
+ ensure
42
+ File.umask(previous_umask)
43
+ end
44
+
45
+ def serve(connection)
46
+ logger.info('Client connected')
47
+ outbox = Async::Queue.new
48
+ @timers[outbox] = {}
49
+ @writers.async { write_messages(connection, outbox) }
50
+ while (line = connection.gets(ServiceProxy::DELIMITER, chomp: true))
51
+ handle(line, outbox)
52
+ end
53
+ rescue IOError, SystemCallError => e
54
+ logger.warn("Client connection error: #{e.message}")
55
+ ensure
56
+ logger.info('Client disconnected')
57
+ @timers.delete(outbox)&.each_value(&:cancel!)
58
+ outbox.close
59
+ connection.close
60
+ stop_if_drained
61
+ end
62
+
63
+ def write_messages(connection, outbox)
64
+ outbox.each { |message| send_message(connection, message) }
65
+ rescue IOError, SystemCallError => e
66
+ logger.warn("Unable to write to client: #{e.message}")
67
+ end
68
+
69
+ def handle(line, outbox)
70
+ message = JSON.parse(line)
71
+ case message['type']
72
+ when 'debounceEvent' then debounce(message['data'], outbox)
73
+ when 'reset' then reset
74
+ else logger.warn("Unknown message: #{line}")
75
+ end
76
+ rescue JSON::ParserError => e
77
+ logger.warn("Unable to parse message: #{e.message}")
78
+ end
79
+
80
+ def debounce(data, outbox)
81
+ descriptor = data['descriptor']
82
+ timers = @timers.fetch(outbox)
83
+ timers.delete(descriptor)&.cancel!
84
+ if @draining
85
+ publish(descriptor, data['callback'], outbox)
86
+ return stop_if_drained
87
+ end
88
+
89
+ logger.debug { "Debouncing #{descriptor}" }
90
+ timers[descriptor] = @queue.after(data['timeout']) do
91
+ timers.delete(descriptor)
92
+ publish(descriptor, data['callback'], outbox)
93
+ stop_if_drained
94
+ end
95
+ @timer_scheduled.signal
96
+ end
97
+
98
+ def drain_on_sigterm
99
+ signals, signal_writer = IO.pipe
100
+ Signal.trap('TERM') { signal_writer.write_nonblock('.', exception: false) }
101
+ signals.read(1)
102
+ logger.info('Received SIGTERM; firing pending timers, then exiting')
103
+ @draining = true
104
+ stop_if_drained
105
+ end
106
+
107
+ def stop_if_drained
108
+ finish_draining if @draining && @timers.each_value.all?(&:empty?)
109
+ end
110
+
111
+ def finish_draining
112
+ @draining = false
113
+ @timers.each_key(&:close)
114
+ @writers.wait
115
+ @root.stop
116
+ end
117
+
118
+ def run_timers
119
+ loop do
120
+ wait_for_next_deadline
121
+ @queue.fire
122
+ end
123
+ end
124
+
125
+ def wait_for_next_deadline
126
+ interval = @queue.wait_interval
127
+ if interval.nil?
128
+ @timer_scheduled.wait
129
+ elsif interval.positive?
130
+ Async::Task.current.with_timeout(interval) { @timer_scheduled.wait }
131
+ end
132
+ rescue Async::TimeoutError
133
+ nil
134
+ end
135
+
136
+ def publish(descriptor, callback, outbox)
137
+ logger.debug { "Debounce period expired for #{descriptor}" }
138
+ outbox.push(type: 'publishEvent', callback:)
139
+ end
140
+
141
+ def reset
142
+ @timers.each_value { |timers| timers.each_value(&:cancel!).clear }
143
+ end
144
+
145
+ def send_message(connection, message)
146
+ connection.write(JSON.generate(message), ServiceProxy::DELIMITER)
147
+ end
148
+
149
+ def remove_stale_socket_file
150
+ return unless File.exist?(@socket_descriptor)
151
+
152
+ UNIXSocket.new(@socket_descriptor).close
153
+ raise SocketConflictError, "Another server is listening on #{@socket_descriptor}"
154
+ rescue Errno::ECONNREFUSED
155
+ File.delete(@socket_descriptor)
156
+ end
157
+
158
+ def logger
159
+ Debounced.configuration.logger
160
+ end
161
+ end
162
+ end
@@ -1,3 +1,4 @@
1
+ require 'concurrent/atomic/atomic_boolean'
1
2
  require 'socket'
2
3
  require 'json'
3
4
 
@@ -49,7 +50,7 @@ module Debounced
49
50
  end
50
51
  end
51
52
  rescue IOError, SystemCallError, NoServerError => e
52
- logger.warn("Unable to send #{activity_descriptor} to #{server_name} (#{e.message}); skipping debounce step.")
53
+ logger.warn("Unable to send #{activity_descriptor} (#{e.message}); skipping debounce step.")
53
54
  close
54
55
  callback.call
55
56
  end
@@ -85,12 +86,25 @@ module Debounced
85
86
  @listening = false
86
87
  end
87
88
 
88
- def stop
89
+ ###
90
+ # Stop listening. With a timeout, first wait up to that many seconds for the server to close the connection, which it
91
+ # does once it has drained after SIGTERM, so the callbacks still pending arrive before the process exits.
92
+ def stop(timeout: 0)
93
+ deadline = monotonic_now + timeout
94
+ sleep 0.05 while connected? && monotonic_now < deadline
89
95
  @abort_signal&.make_true
90
96
  end
91
97
 
92
98
  private
93
99
 
100
+ def connected?
101
+ listening && @mutex.synchronize { !@socket.nil? }
102
+ end
103
+
104
+ def monotonic_now
105
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ end
107
+
94
108
  def close
95
109
  @mutex.synchronize do
96
110
  return unless @socket
@@ -1,3 +1,3 @@
1
1
  module Debounced
2
- VERSION = "2.1.1"
2
+ VERSION = "3.0.0"
3
3
  end
data/lib/debounced.rb CHANGED
@@ -21,7 +21,7 @@ module Debounced
21
21
  end
22
22
 
23
23
  class Configuration
24
- attr_accessor :socket_descriptor, :wait_timeout, :enable_trace_logging
24
+ attr_accessor :socket_descriptor, :wait_timeout, :enable_trace_logging, :error_handler
25
25
 
26
26
  def initialize
27
27
  @socket_descriptor = ENV['DEBOUNCED_SOCKET'] || '/tmp/app.debounceEvents'
@@ -1,16 +1,9 @@
1
1
  namespace :debounced do
2
2
  desc 'Start the debounce server'
3
- task :server => :environment do
3
+ task server: :environment do
4
4
  require 'debounced'
5
+ require 'debounced/server'
5
6
 
6
- # Configuration is available because environment is loaded
7
- socket_descriptor = Debounced.configuration.socket_descriptor
8
-
9
- # Find the gem's JavaScript files
10
- gem_lib_path = Pathname.new(Gem::Specification.find_by_name('debounced').gem_dir).join('lib')
11
- js_path = gem_lib_path.join('debounced', 'javascript', 'server.mjs')
12
-
13
- # Exec replaces the current process with the node process
14
- exec("node #{js_path} #{socket_descriptor}")
7
+ Debounced::Server.new(Debounced.configuration.socket_descriptor).listen
15
8
  end
16
- end
9
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debounced
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gary Passero
@@ -10,33 +10,87 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: json
13
+ name: async
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '2.19'
18
+ version: '2.46'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.46'
26
+ - !ruby/object:Gem::Dependency
27
+ name: concurrent-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
19
33
  type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
37
  - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: io-event
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.21'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.21'
54
+ - !ruby/object:Gem::Dependency
55
+ name: json
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
24
59
  - !ruby/object:Gem::Version
25
60
  version: '2.19'
61
+ - - "<"
62
+ - !ruby/object:Gem::Version
63
+ version: '4.0'
64
+ type: :runtime
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '2.19'
71
+ - - "<"
72
+ - !ruby/object:Gem::Version
73
+ version: '4.0'
26
74
  - !ruby/object:Gem::Dependency
27
75
  name: semantic_logger
28
76
  requirement: !ruby/object:Gem::Requirement
29
77
  requirements:
30
- - - "~>"
78
+ - - ">="
31
79
  - !ruby/object:Gem::Version
32
80
  version: '4.17'
81
+ - - "<"
82
+ - !ruby/object:Gem::Version
83
+ version: '6.0'
33
84
  type: :runtime
34
85
  prerelease: false
35
86
  version_requirements: !ruby/object:Gem::Requirement
36
87
  requirements:
37
- - - "~>"
88
+ - - ">="
38
89
  - !ruby/object:Gem::Version
39
90
  version: '4.17'
91
+ - - "<"
92
+ - !ruby/object:Gem::Version
93
+ version: '6.0'
40
94
  - !ruby/object:Gem::Dependency
41
95
  name: logger
42
96
  requirement: !ruby/object:Gem::Requirement
@@ -85,7 +139,8 @@ dependencies:
85
139
  - - ">="
86
140
  - !ruby/object:Gem::Version
87
141
  version: 1.0.0
88
- description: Leverage JavaScript micro-event loop to debounce events in Ruby applications
142
+ description: Debounce Ruby callbacks across processes through a lightweight Ruby timer
143
+ server
89
144
  email:
90
145
  - gary@flytedesk.com
91
146
  executables: []
@@ -97,20 +152,19 @@ files:
97
152
  - lib/debounced.rb
98
153
  - lib/debounced/callback.rb
99
154
  - lib/debounced/callbackable.rb
100
- - lib/debounced/javascript/server.mjs
101
- - lib/debounced/javascript/service.mjs
102
155
  - lib/debounced/no_server_error.rb
103
156
  - lib/debounced/railtie.rb
157
+ - lib/debounced/server.rb
104
158
  - lib/debounced/service_proxy.rb
105
159
  - lib/debounced/socket_conflict_error.rb
106
160
  - lib/debounced/version.rb
107
161
  - lib/tasks/debounced.rake
108
- homepage: https://github.com/flytedesk/debounced
162
+ homepage: https://github.com/Flytedesk/debounced-rb
109
163
  licenses:
110
164
  - MIT
111
165
  metadata:
112
- source_code_uri: https://github.com/flytedesk/debounced
113
- changelog_uri: https://github.com/flytedesk/debounced/blob/main/CHANGELOG.md
166
+ source_code_uri: https://github.com/Flytedesk/debounced-rb
167
+ changelog_uri: https://github.com/Flytedesk/debounced-rb/blob/main/CHANGELOG.md
114
168
  rdoc_options: []
115
169
  require_paths:
116
170
  - lib
@@ -118,14 +172,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
118
172
  requirements:
119
173
  - - ">="
120
174
  - !ruby/object:Gem::Version
121
- version: 3.1.0
175
+ version: 3.3.0
122
176
  required_rubygems_version: !ruby/object:Gem::Requirement
123
177
  requirements:
124
178
  - - ">="
125
179
  - !ruby/object:Gem::Version
126
180
  version: '0'
127
181
  requirements: []
128
- rubygems_version: 4.0.10
182
+ rubygems_version: 4.0.20
129
183
  specification_version: 4
130
184
  summary: Efficient event debouncing in Ruby
131
185
  test_files: []
@@ -1,9 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import DebounceEventService from './service.mjs'
4
- let socketDescriptor = '/tmp/app.debounceEvents';
5
- if (process.argv.length > 2) {
6
- socketDescriptor = process.argv[2];
7
- }
8
-
9
- new DebounceEventService(socketDescriptor).listen();
@@ -1,167 +0,0 @@
1
- import net from 'net';
2
- import fs from 'fs';
3
-
4
- function log(message, ...args) {
5
- const timestamp = new Date().toISOString();
6
- console.log(`${timestamp} - ${message}`, ...args);
7
- }
8
-
9
- export default class DebounceService {
10
- constructor(socketDescriptor) {
11
- this._socketDescriptor = socketDescriptor;
12
- this._timers = new Map();
13
- this.publishEvent = this.publishEvent.bind(this);
14
- this.debounceEvent = this.debounceEvent.bind(this);
15
- this.reset = this.reset.bind(this);
16
- this.listen = this.listen.bind(this);
17
- this.handleError = this.handleError.bind(this);
18
- this.sendMessage = this.sendMessage.bind(this);
19
- this.onClientConnected = this.onClientConnected.bind(this);
20
- this.handleMessage = this.handleMessage.bind(this);
21
- this.configureServer();
22
- }
23
-
24
- onConnectionError(err) {
25
- log('DebounceService client connection error');
26
- this.handleError(err);
27
- }
28
-
29
- onClientDisconnected(socket) {
30
- log('DebounceService client disconnected');
31
- this._timers.get(socket).forEach(timerID => clearTimeout(timerID));
32
- this._timers.delete(socket);
33
- }
34
-
35
- handleMessage(message, socket) {
36
- try {
37
- const object = JSON.parse(message);
38
- if (object.type === 'debounceEvent') {
39
- this.debounceEvent(object.data, socket);
40
- } else if (object.type === 'reset') {
41
- this.reset();
42
- } else {
43
- log('DebounceService unknown message', message);
44
- }
45
- } catch (e) {
46
- log('unable to parse message', e);
47
- }
48
- }
49
-
50
- onClientConnected(socket) {
51
- log('DebounceService client connected');
52
- let connectionBuffer = '';
53
- this._timers.set(socket, new Map());
54
- socket.on('close', () => this.onClientDisconnected(socket));
55
- socket.on('error', this.onConnectionError);
56
- socket.setEncoding('utf8');
57
- socket.on('data', data => {
58
- log('DebounceService data received', data);
59
- connectionBuffer += data;
60
- const messages = connectionBuffer.split('\f');
61
- connectionBuffer = messages.pop();
62
- messages.forEach(message => this.handleMessage(message, socket));
63
- })
64
- }
65
-
66
- configureServer() {
67
- this.server = net.createServer(this.onClientConnected)
68
- this.server.on('error', (err) => {
69
- this.handleError(err);
70
- process.exit(1);
71
- });
72
- }
73
-
74
- cleanupDescriptor() {
75
- if (fs.existsSync(this._socketDescriptor)) {
76
- log('DebounceEventService removing socket file ', this._socketDescriptor);
77
- fs.unlinkSync(this._socketDescriptor);
78
- }
79
- }
80
-
81
- listen() {
82
- if (!fs.existsSync(this._socketDescriptor)) {
83
- this.bind();
84
- return;
85
- }
86
-
87
- const probe = net.connect(this._socketDescriptor);
88
- probe.on('connect', () => {
89
- probe.destroy();
90
- log('DebounceEventService another server is listening on', this._socketDescriptor);
91
- process.exit(1);
92
- });
93
- probe.on('error', (err) => {
94
- if (err.code !== 'ECONNREFUSED') {
95
- this.handleError(err);
96
- process.exit(1);
97
- }
98
- log('DebounceEventService removing stale socket file ', this._socketDescriptor);
99
- fs.unlinkSync(this._socketDescriptor);
100
- this.bind();
101
- });
102
- }
103
-
104
- bind() {
105
- const previousUmask = process.umask(0o177);
106
- this.server.listen(this._socketDescriptor, () => {
107
- log('DebounceService listening on', this._socketDescriptor);
108
- this.cleanupOnExit();
109
- });
110
- process.umask(previousUmask);
111
- }
112
-
113
- cleanupOnExit() {
114
- process.on('exit', (code) => {
115
- log(`Process exiting with code: ${code}`);
116
- this.server.close();
117
- this.cleanupDescriptor();
118
- });
119
-
120
- ['SIGTERM', 'SIGINT'].forEach((signal) => {
121
- process.on(signal, () => {
122
- log(`Process received ${signal}`);
123
- process.exit(0);
124
- });
125
- });
126
- }
127
-
128
- sendMessage(socket, message) {
129
- try {
130
- socket.write(message);
131
- socket.write("\f");
132
- } catch (err) {
133
- this.handleError(err);
134
- }
135
- }
136
-
137
- publishEvent(descriptor, callback, socket) {
138
- log(`Debounce period expired for ${descriptor} - sending to client`);
139
- const message = JSON.stringify({
140
- type: 'publishEvent',
141
- callback: callback
142
- });
143
- this.sendMessage(socket, message);
144
- }
145
-
146
- debounceEvent({ descriptor, timeout, callback }, socket) {
147
- const timers = this._timers.get(socket);
148
- clearTimeout(timers.get(descriptor));
149
-
150
- log("Debouncing", descriptor);
151
- timers.set(descriptor, setTimeout(() => {
152
- timers.delete(descriptor);
153
- this.publishEvent(descriptor, callback, socket);
154
- }, timeout * 1000));
155
- }
156
-
157
- reset() {
158
- this._timers.forEach(timers => {
159
- timers.forEach(timerID => clearTimeout(timerID));
160
- timers.clear();
161
- });
162
- }
163
-
164
- handleError(err) {
165
- log('\n\n######\nERROR: ', err);
166
- }
167
- }