debounced 1.0.8 → 2.1.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: 221e144d2900e1237f3f06e9b9ef347eb2703f7c3b5d33242272ab5d1115c582
4
- data.tar.gz: 7757b7afd63ade6894f5dd62ac9a2855197f0ba14229bed563e3d4a3662ad2d1
3
+ metadata.gz: f40aa8391039a8a5639ba8e5b77c64d38a07c0208eb158939df97770ae40b240
4
+ data.tar.gz: 05c33de396be8333e1945880ee7518b67d361d23aa01f68936a4740e6776acee
5
5
  SHA512:
6
- metadata.gz: f1e65faa3339ae66dce491120890d923213b8bbe49048525fc70ea022f6040ca9f2ab1fc78d28aa2905d0044bf8c78cffeb9811a6bfab6d01172334eb9273a0a
7
- data.tar.gz: 53c8676a30eba9f9ed493055e00a0fe9d72f760240626cc999c185d6ab57a3d2823cacc64da1d885b92425862b2e86a342934ef6ba185e109631601af05fb1ad
6
+ metadata.gz: b3b7aa2939222ef134b925b70a7d9bb4e535876bd317901cb3d022c880f5c397d2fc4b2a9666db9674ca1e1a6112761da664fdc4c2841f157c38010c4de929e9
7
+ data.tar.gz: 2dc3ae74b658a1ab57bbabd8cef240629102700d49a7980ddd7694c458af36dd7c91e7a55e472a75acdf2188eab511c2612dc6a8f215d3124b08a711e685ae2c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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
+ ### Security
11
+
12
+ - Callbacks are only dispatched to public methods defined by the application, never to core Ruby methods
13
+ - The server creates its socket owner-only, and the proxy refuses a socket owned by another user
14
+
15
+ ### Changed
16
+
17
+ - Requires Node.js 22 or later; Node.js 18 and 20 are past end of life
18
+
19
+ ### Fixed
20
+
21
+ - A second server no longer takes over the socket of a running one, and only removes a socket it created
22
+ - The server exits non-zero when it cannot listen
23
+ - Multi-byte characters split across socket reads are no longer corrupted
24
+ - The server serves any number of connected processes and publishes each callback to the process that requested it
25
+ - The listener no longer adds an extra idle timeout before delivering a callback
26
+ - Requests larger than the socket buffer are no longer truncated
27
+ - A failed send falls back to invoking the callback instead of raising
28
+ - `ServiceProxy#stop` works before `listen`
29
+
8
30
  ## [1.0.7](https://github.com/Flytedesk/debounced/compare/v1.0.6...v1.0.7) (2026-04-15)
9
31
 
10
32
 
data/README.md CHANGED
@@ -27,7 +27,7 @@ $ gem install debounced
27
27
 
28
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
29
 
30
- - Node.js >= 20.0.0
30
+ - Node.js >= 22.0.0
31
31
 
32
32
  ## Usage
33
33
 
@@ -3,54 +3,96 @@ module Debounced
3
3
  ###
4
4
  # Represents a callback to be executed by the debounce service
5
5
  class Callback
6
- attr_accessor :class_name, :method_name, :args, :kwargs
6
+ CORE_RUBY_OWNERS = [BasicObject, Kernel, Object, Module, Class].freeze
7
+
8
+ attr_accessor :class_name, :method_name, :args, :kwargs, :method_args, :method_kwargs
7
9
 
8
10
  ###
9
11
  # @param [String] class_name the name of the class that will receive the callback
10
12
  # @param [String] method_name the name of the method that will be called
11
- # @param [Array] args the positional arguments to be passed to the method (optional)
12
- # @param [Hash] kwargs the keyword arguments to be passed to the method (optional)
13
+ # @param [Array] args positional arguments passed to the static method, or to the initializer for instance methods (optional)
14
+ # @param [Hash] kwargs keyword arguments passed to the static method, or to the initializer for instance methods (optional)
15
+ # @param [Array] method_args positional arguments passed to the instance method (optional, ignored for static methods)
16
+ # @param [Hash] method_kwargs keyword arguments passed to the instance method (optional, ignored for static methods)
17
+ #
18
+ # @note if the class implements the method_name as a class method, the message will be sent to the class with args and kwargs.
19
+ # otherwise, an instance of the class will be created using args and kwargs, and the message will be sent to the instance
20
+ # with method_args and method_kwargs.
13
21
  #
14
- # @note if the class implements the method_name, the message will be sent to the class with the args and kwargs.
15
- # otherwise, an instance of the class will be created and the message will be sent to the instance. in this case,
16
- # the args and kwargs will be passed to the initializer.
17
- def initialize(class_name:, method_name:, args: [], kwargs: {})
22
+ # @note args and kwargs values must be JSON-native types (String, Numeric, Boolean, Array, Hash, nil).
23
+ # Symbol values, Date, Time, and other Ruby-specific types will not survive JSON round-trip serialization
24
+ # through the debounce server. Hash keys are deep-symbolized on parse, but values are preserved as-is.
25
+ def initialize(class_name:, method_name:, args: [], kwargs: {}, method_args: [], method_kwargs: {})
18
26
  @class_name = class_name.to_s
19
27
  @method_name = method_name.to_s
20
28
  @args = args
21
29
  @kwargs = kwargs
30
+ @method_args = method_args
31
+ @method_kwargs = method_kwargs
22
32
  end
23
33
 
24
34
  def self.parse(data)
25
35
  new(
26
36
  class_name: data['class_name'],
27
37
  method_name: data['method_name'],
28
- args: data['args'],
29
- kwargs: data['kwargs'].transform_keys(&:to_sym),
38
+ args: data['args'] || [],
39
+ kwargs: deep_symbolize_keys(data['kwargs'] || {}),
40
+ method_args: data['method_args'] || [],
41
+ method_kwargs: deep_symbolize_keys(data['method_kwargs'] || {}),
30
42
  )
31
43
  end
32
44
 
45
+ def self.deep_symbolize_keys(object)
46
+ case object
47
+ when Hash
48
+ object.to_h { |key, value| [key.to_sym, deep_symbolize_keys(value)] }
49
+ when Array
50
+ object.map { |item| deep_symbolize_keys(item) }
51
+ else
52
+ object
53
+ end
54
+ end
55
+ private_class_method :deep_symbolize_keys
56
+
33
57
  def as_json
34
58
  {
35
59
  class_name:,
36
60
  method_name:,
37
61
  args:,
38
62
  kwargs:,
63
+ method_args:,
64
+ method_kwargs:,
39
65
  }
40
66
  end
41
67
 
42
68
  def call
43
69
  Debounced.configuration.logger.debug("Invoking callback #{method_name}")
44
- klass = Object.const_get(class_name)
70
+ klass = callback_class
45
71
  if klass.respond_to?(method_name)
46
- klass.send(method_name, *args, **kwargs)
72
+ ensure_application_method(klass.method(method_name))
73
+ klass.public_send(method_name, *args, **kwargs)
47
74
  else
48
- instance = klass.new(*args, **kwargs)
49
- instance.send(method_name)
75
+ ensure_application_method(klass.public_instance_method(method_name))
76
+ klass.new(*args, **kwargs).public_send(method_name, *method_args, **method_kwargs)
50
77
  end
51
78
  rescue StandardError => e
52
79
  Debounced.configuration.logger.warn("Unable to invoke callback #{as_json}: #{e.message}")
53
80
  Debounced.configuration.logger.warn(e.backtrace.join("\n"))
54
81
  end
82
+
83
+ private
84
+
85
+ def callback_class
86
+ klass = Object.const_get(class_name)
87
+ return klass if klass.ancestors.include?(Debounced::Callbackable)
88
+
89
+ raise ArgumentError, "#{class_name} is not an allowed Debounced callback target. Include Debounced::Callbackable in the class."
90
+ end
91
+
92
+ def ensure_application_method(method)
93
+ return unless CORE_RUBY_OWNERS.include?(method.owner)
94
+
95
+ raise ArgumentError, "#{method_name} is a core Ruby method and not an allowed Debounced callback"
96
+ end
55
97
  end
56
98
  end
@@ -0,0 +1,4 @@
1
+ module Debounced
2
+ module Callbackable
3
+ end
4
+ end
@@ -10,7 +10,7 @@ export default class DebounceService {
10
10
  constructor(socketDescriptor) {
11
11
  this._socketDescriptor = socketDescriptor;
12
12
  this._timers = {};
13
- this._client = null;
13
+ this._clients = new Set();
14
14
  this.publishEvent = this.publishEvent.bind(this);
15
15
  this.debounceEvent = this.debounceEvent.bind(this);
16
16
  this.reset = this.reset.bind(this);
@@ -18,28 +18,25 @@ export default class DebounceService {
18
18
  this.handleError = this.handleError.bind(this);
19
19
  this.sendMessage = this.sendMessage.bind(this);
20
20
  this.onClientConnected = this.onClientConnected.bind(this);
21
- this.onClientDisconnected = this.onClientDisconnected.bind(this);
22
- this.onConnectionError = this.onConnectionError.bind(this);
23
21
  this.handleMessage = this.handleMessage.bind(this);
24
22
  this.configureServer();
25
23
  }
26
24
 
27
25
  onConnectionError(err) {
28
26
  log('DebounceService client connection error');
29
- this._client = null
30
27
  this.handleError(err);
31
28
  }
32
29
 
33
- onClientDisconnected() {
30
+ onClientDisconnected(socket) {
34
31
  log('DebounceService client disconnected');
35
- this._client = null
32
+ this._clients.delete(socket);
36
33
  }
37
34
 
38
- handleMessage(message) {
35
+ handleMessage(message, socket) {
39
36
  try {
40
37
  const object = JSON.parse(message);
41
38
  if (object.type === 'debounceEvent') {
42
- this.debounceEvent(object.data);
39
+ this.debounceEvent(object.data, socket);
43
40
  } else if (object.type === 'reset') {
44
41
  this.reset();
45
42
  } else {
@@ -51,68 +48,80 @@ export default class DebounceService {
51
48
  }
52
49
 
53
50
  onClientConnected(socket) {
54
- if (this._client) {
55
- log('DebounceService rejecting connection: client already connected');
56
- this.sendMessage(socket, JSON.stringify({ type: 'rejectClient'}));
57
- socket.destroy();
58
- return;
59
- }
60
-
61
51
  log('DebounceService client connected');
62
52
  let connectionBuffer = '';
63
- this._client = socket;
64
- socket.on('end', this.onClientDisconnected)
65
- socket.on('error', this.onConnectionError)
53
+ this._clients.add(socket);
54
+ socket.on('close', () => this.onClientDisconnected(socket));
55
+ socket.on('error', this.onConnectionError);
56
+ socket.setEncoding('utf8');
66
57
  socket.on('data', data => {
67
- log('DebounceService data received', data.toString());
68
- connectionBuffer += data.toString();
58
+ log('DebounceService data received', data);
59
+ connectionBuffer += data;
69
60
  const messages = connectionBuffer.split('\f');
70
- connectionBuffer = ''
71
- if (!connectionBuffer.endsWith('\f')) {
72
- connectionBuffer = messages.pop();
73
- }
74
- messages.forEach(this.handleMessage);
61
+ connectionBuffer = messages.pop();
62
+ messages.forEach(message => this.handleMessage(message, socket));
75
63
  })
76
64
  }
77
65
 
78
66
  configureServer() {
79
67
  this.server = net.createServer(this.onClientConnected)
80
- this.server.on('error', this.handleError);
68
+ this.server.on('error', (err) => {
69
+ this.handleError(err);
70
+ process.exit(1);
71
+ });
81
72
  }
82
73
 
83
74
  cleanupDescriptor() {
84
75
  if (fs.existsSync(this._socketDescriptor)) {
85
- log('DebounceEventService removing stale socket file ', this._socketDescriptor);
76
+ log('DebounceEventService removing socket file ', this._socketDescriptor);
86
77
  fs.unlinkSync(this._socketDescriptor);
87
78
  }
88
79
  }
89
80
 
90
81
  listen() {
91
- // Remove the existing socket file if it exists
92
- this.cleanupDescriptor()
82
+ if (!fs.existsSync(this._socketDescriptor)) {
83
+ this.bind();
84
+ return;
85
+ }
93
86
 
94
- process.on('exit', (code) => {
95
- log(`Process exiting with code: ${code}`);
96
- this.server.close();
97
- this.cleanupDescriptor()
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();
98
101
  });
102
+ }
99
103
 
100
- process.on('SIGTERM', () => {
101
- log('Process received SIGTERM');
102
- this.server.close();
103
- this.cleanupDescriptor()
104
- process.exit(0);
104
+ bind() {
105
+ const previousUmask = process.umask(0o177);
106
+ this.server.listen(this._socketDescriptor, () => {
107
+ log('DebounceService listening on', this._socketDescriptor);
108
+ this.cleanupOnExit();
105
109
  });
110
+ process.umask(previousUmask);
111
+ }
106
112
 
107
- process.on('SIGINT', () => {
108
- log('Process received SIGINT');
113
+ cleanupOnExit() {
114
+ process.on('exit', (code) => {
115
+ log(`Process exiting with code: ${code}`);
109
116
  this.server.close();
110
- this.cleanupDescriptor()
111
- process.exit(0);
117
+ this.cleanupDescriptor();
112
118
  });
113
119
 
114
- this.server.listen(this._socketDescriptor, () => {
115
- log('DebounceService listening on', this._socketDescriptor);
120
+ ['SIGTERM', 'SIGINT'].forEach((signal) => {
121
+ process.on(signal, () => {
122
+ log(`Process received ${signal}`);
123
+ process.exit(0);
124
+ });
116
125
  });
117
126
  }
118
127
 
@@ -125,16 +134,21 @@ export default class DebounceService {
125
134
  }
126
135
  }
127
136
 
128
- publishEvent(descriptor, callback) {
137
+ publishEvent(descriptor, callback, socket) {
138
+ if (!this._clients.has(socket)) {
139
+ log(`Debounce period expired for ${descriptor} - client disconnected, dropping`);
140
+ return;
141
+ }
142
+
129
143
  log(`Debounce period expired for ${descriptor} - sending to client`);
130
144
  const message = JSON.stringify({
131
145
  type: 'publishEvent',
132
146
  callback: callback
133
147
  });
134
- this.sendMessage(this._client, message);
148
+ this.sendMessage(socket, message);
135
149
  }
136
150
 
137
- debounceEvent({ descriptor, timeout, callback }) {
151
+ debounceEvent({ descriptor, timeout, callback }, socket) {
138
152
  if (this._timers[descriptor]) {
139
153
  clearTimeout(this._timers[descriptor]);
140
154
  }
@@ -142,7 +156,7 @@ export default class DebounceService {
142
156
  log("Debouncing", descriptor);
143
157
  this._timers[descriptor] = setTimeout(() => {
144
158
  delete this._timers[descriptor];
145
- this.publishEvent(descriptor, callback);
159
+ this.publishEvent(descriptor, callback, socket);
146
160
  }, timeout * 1000);
147
161
  }
148
162
 
@@ -48,6 +48,10 @@ module Debounced
48
48
  transmit(build_request(activity_descriptor, timeout, callback))
49
49
  end
50
50
  end
51
+ rescue IOError, SystemCallError, NoServerError => e
52
+ logger.warn("Unable to send #{activity_descriptor} to #{server_name} (#{e.message}); skipping debounce step.")
53
+ close
54
+ callback.call
51
55
  end
52
56
 
53
57
  ###
@@ -65,7 +69,6 @@ module Debounced
65
69
  next unless message
66
70
 
67
71
  payload = deserialize_message(message)
68
- raise SocketConflictError if payload['type'] == 'rejectClient'
69
72
 
70
73
  instantiate_callback(payload['callback']).call
71
74
  rescue Debounced::NoServerError => e
@@ -75,7 +78,7 @@ module Debounced
75
78
 
76
79
  close
77
80
  end
78
- rescue SocketConflictError, StandardError => e
81
+ rescue StandardError => e
79
82
  logger.warn("Unable to listen for messages from #{server_name}: #{e.message}")
80
83
  logger.warn(e.backtrace.join("\n"))
81
84
  ensure
@@ -83,17 +86,19 @@ module Debounced
83
86
  end
84
87
 
85
88
  def stop
86
- @abort_signal.make_true
89
+ @abort_signal&.make_true
87
90
  end
88
91
 
89
92
  private
90
93
 
91
94
  def close
92
- return unless @socket
95
+ @mutex.synchronize do
96
+ return unless @socket
93
97
 
94
- logger.debug("Closing connection to #{server_name}")
95
- @socket.close
96
- @socket = nil
98
+ logger.debug("Closing connection to #{server_name}")
99
+ @socket.close
100
+ @socket = nil
101
+ end
97
102
  end
98
103
 
99
104
  def receive_message_from_server
@@ -108,7 +113,6 @@ module Debounced
108
113
  message
109
114
  rescue IO::TimeoutError
110
115
  logger_trace { "Timeout waiting for data" }
111
- sleep wait_timeout
112
116
  nil
113
117
  rescue Errno::EPIPE, IOError, Errno::ECONNRESET
114
118
  close
@@ -127,7 +131,8 @@ module Debounced
127
131
  end
128
132
 
129
133
  def transmit(message)
130
- socket.send serialize_message(message), 0
134
+ connection = socket or raise NoServerError, "#{server_name} at #{socket_descriptor} not running"
135
+ connection.write(serialize_message(message))
131
136
  end
132
137
 
133
138
  def server_name
@@ -154,6 +159,7 @@ module Debounced
154
159
  def socket
155
160
  @mutex.synchronize do
156
161
  return @socket if @socket
162
+ return unless File.owned?(socket_descriptor)
157
163
 
158
164
  logger_trace { "Connecting to #{server_name} at #{socket_descriptor}" }
159
165
  @socket = UNIXSocket.new(socket_descriptor).tap { |s| s.timeout = wait_timeout }
@@ -1,3 +1,3 @@
1
1
  module Debounced
2
- VERSION = "1.0.8"
2
+ VERSION = "2.1.0"
3
3
  end
data/lib/debounced.rb CHANGED
@@ -3,6 +3,7 @@ require 'debounced/railtie' if defined?(Rails)
3
3
  require 'debounced/no_server_error'
4
4
  require 'debounced/socket_conflict_error'
5
5
  require 'debounced/service_proxy'
6
+ require 'debounced/callbackable'
6
7
  require 'debounced/callback'
7
8
  require 'semantic_logger'
8
9
 
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: 1.0.8
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gary Passero
@@ -96,6 +96,7 @@ files:
96
96
  - README.md
97
97
  - lib/debounced.rb
98
98
  - lib/debounced/callback.rb
99
+ - lib/debounced/callbackable.rb
99
100
  - lib/debounced/javascript/server.mjs
100
101
  - lib/debounced/javascript/service.mjs
101
102
  - lib/debounced/no_server_error.rb
@@ -117,14 +118,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
117
118
  requirements:
118
119
  - - ">="
119
120
  - !ruby/object:Gem::Version
120
- version: 3.0.0
121
+ version: 3.1.0
121
122
  required_rubygems_version: !ruby/object:Gem::Requirement
122
123
  requirements:
123
124
  - - ">="
124
125
  - !ruby/object:Gem::Version
125
126
  version: '0'
126
127
  requirements: []
127
- rubygems_version: 3.6.9
128
+ rubygems_version: 4.0.10
128
129
  specification_version: 4
129
130
  summary: Efficient event debouncing in Ruby
130
131
  test_files: []