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 +4 -4
- data/CHANGELOG.md +18 -20
- data/README.md +42 -12
- data/lib/debounced/callback.rb +1 -0
- data/lib/debounced/server.rb +162 -0
- data/lib/debounced/service_proxy.rb +16 -2
- data/lib/debounced/version.rb +1 -1
- data/lib/debounced.rb +1 -1
- data/lib/tasks/debounced.rake +4 -11
- metadata +67 -13
- data/lib/debounced/javascript/server.mjs +0 -9
- data/lib/debounced/javascript/service.mjs +0 -167
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 545ffc360ed1f13cb95fbec250c45bed7744f932cb8865890019594c3ec30be3
|
|
4
|
+
data.tar.gz: 7a2e349b770b02a29c108af1c6fed682901971afebf9fac5b272dab4570eccbf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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
|
-
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
- The
|
|
33
|
-
-
|
|
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
|
|
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
|
|
101
|
-
2. When you call `debounce_activity`,
|
|
102
|
-
3.
|
|
103
|
-
4. When
|
|
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
|
|
data/lib/debounced/callback.rb
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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
|
data/lib/debounced/version.rb
CHANGED
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'
|
data/lib/tasks/debounced.rake
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
namespace :debounced do
|
|
2
2
|
desc 'Start the debounce server'
|
|
3
|
-
task :
|
|
3
|
+
task server: :environment do
|
|
4
4
|
require 'debounced'
|
|
5
|
+
require 'debounced/server'
|
|
5
6
|
|
|
6
|
-
|
|
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:
|
|
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:
|
|
13
|
+
name: async
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '2.
|
|
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:
|
|
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/
|
|
162
|
+
homepage: https://github.com/Flytedesk/debounced-rb
|
|
109
163
|
licenses:
|
|
110
164
|
- MIT
|
|
111
165
|
metadata:
|
|
112
|
-
source_code_uri: https://github.com/
|
|
113
|
-
changelog_uri: https://github.com/
|
|
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.
|
|
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.
|
|
182
|
+
rubygems_version: 4.0.20
|
|
129
183
|
specification_version: 4
|
|
130
184
|
summary: Efficient event debouncing in Ruby
|
|
131
185
|
test_files: []
|
|
@@ -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
|
-
}
|