debounced 2.0.0 → 2.1.1

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: 23ca0ee42ff088cb85dd495dc84f5cffb38b2a6af402bab7b23424f962e8f002
4
- data.tar.gz: 0e218d90fb5ad2d908cd20d3557dd6bf4aa7267f9461810c34d9f30b3c62d0ae
3
+ metadata.gz: c9fa1cd41e5d203718e5f338068db77acd8b07f15369c705e096f9a2108b7614
4
+ data.tar.gz: 544067be17e4efad251d656374b5d41a08c2c3ad627fc7f0ac75dbdf83be0c05
5
5
  SHA512:
6
- metadata.gz: 2f16ed706132085f5109692d233544893edb540b1500a498f5597caef5fa856e8e1bdc06c3eae9a46251e8104537908f7b7f24aca8356c16331465fd1c2de250
7
- data.tar.gz: '02438414f936b2d72c5dd185288892a9e12501e734977ae3d2103ca458c22e628c8ff83ea809f68c3c21084888ebc2fd2a7c5a1752fd42d9bdba5ab29feb7444'
6
+ metadata.gz: 7965f45c4e5055cf0c0e98123c6e88b5e7f1d9ef2ac6e0ecc1a3f09e51364b6feb2d5c3f07dc294d54a6f15af717cf6b6ccb87eae090466583f07f8ff6d162cd
7
+ data.tar.gz: 7165afce55d82ff97a1747181bd219e63a94ac79f23d0ec3b3ffbc04b4c6ce31f83be4b1a3372dd20484bae19db00f4546e7e4bc02f162efaf5dc560c4569fae
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ 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
22
+
23
+ ### Changed
24
+
25
+ - Requires Node.js 22 or later; Node.js 18 and 20 are past end of life
26
+
27
+ ### Fixed
28
+
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
34
+ - Requests larger than the socket buffer are no longer truncated
35
+ - A failed send falls back to invoking the callback instead of raising
36
+ - `ServiceProxy#stop` works before `listen`
37
+
8
38
  ## [1.0.7](https://github.com/Flytedesk/debounced/compare/v1.0.6...v1.0.7) (2026-04-15)
9
39
 
10
40
 
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,6 +3,8 @@ module Debounced
3
3
  ###
4
4
  # Represents a callback to be executed by the debounce service
5
5
  class Callback
6
+ CORE_RUBY_OWNERS = [BasicObject, Kernel, Object, Module, Class].freeze
7
+
6
8
  attr_accessor :class_name, :method_name, :args, :kwargs, :method_args, :method_kwargs
7
9
 
8
10
  ###
@@ -65,19 +67,32 @@ module Debounced
65
67
 
66
68
  def call
67
69
  Debounced.configuration.logger.debug("Invoking callback #{method_name}")
68
- klass = Object.const_get(class_name)
69
- unless klass.ancestors.include?(Debounced::Callbackable)
70
- raise ArgumentError, "#{class_name} is not an allowed Debounced callback target. Include Debounced::Callbackable in the class."
71
- end
70
+ klass = callback_class
72
71
  if klass.respond_to?(method_name)
73
- klass.send(method_name, *args, **kwargs)
72
+ ensure_application_method(klass.method(method_name))
73
+ klass.public_send(method_name, *args, **kwargs)
74
74
  else
75
- instance = klass.new(*args, **kwargs)
76
- instance.send(method_name, *method_args, **method_kwargs)
75
+ ensure_application_method(klass.public_instance_method(method_name))
76
+ klass.new(*args, **kwargs).public_send(method_name, *method_args, **method_kwargs)
77
77
  end
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
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
82
97
  end
83
98
  end
@@ -9,8 +9,7 @@ function log(message, ...args) {
9
9
  export default class DebounceService {
10
10
  constructor(socketDescriptor) {
11
11
  this._socketDescriptor = socketDescriptor;
12
- this._timers = {};
13
- this._client = null;
12
+ this._timers = new Map();
14
13
  this.publishEvent = this.publishEvent.bind(this);
15
14
  this.debounceEvent = this.debounceEvent.bind(this);
16
15
  this.reset = this.reset.bind(this);
@@ -18,28 +17,26 @@ export default class DebounceService {
18
17
  this.handleError = this.handleError.bind(this);
19
18
  this.sendMessage = this.sendMessage.bind(this);
20
19
  this.onClientConnected = this.onClientConnected.bind(this);
21
- this.onClientDisconnected = this.onClientDisconnected.bind(this);
22
- this.onConnectionError = this.onConnectionError.bind(this);
23
20
  this.handleMessage = this.handleMessage.bind(this);
24
21
  this.configureServer();
25
22
  }
26
23
 
27
24
  onConnectionError(err) {
28
25
  log('DebounceService client connection error');
29
- this._client = null
30
26
  this.handleError(err);
31
27
  }
32
28
 
33
- onClientDisconnected() {
29
+ onClientDisconnected(socket) {
34
30
  log('DebounceService client disconnected');
35
- this._client = null
31
+ this._timers.get(socket).forEach(timerID => clearTimeout(timerID));
32
+ this._timers.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._timers.set(socket, new Map());
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);
98
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
+ }
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,30 +134,31 @@ export default class DebounceService {
125
134
  }
126
135
  }
127
136
 
128
- publishEvent(descriptor, callback) {
137
+ publishEvent(descriptor, callback, socket) {
129
138
  log(`Debounce period expired for ${descriptor} - sending to client`);
130
139
  const message = JSON.stringify({
131
140
  type: 'publishEvent',
132
141
  callback: callback
133
142
  });
134
- this.sendMessage(this._client, message);
143
+ this.sendMessage(socket, message);
135
144
  }
136
145
 
137
- debounceEvent({ descriptor, timeout, callback }) {
138
- if (this._timers[descriptor]) {
139
- clearTimeout(this._timers[descriptor]);
140
- }
146
+ debounceEvent({ descriptor, timeout, callback }, socket) {
147
+ const timers = this._timers.get(socket);
148
+ clearTimeout(timers.get(descriptor));
141
149
 
142
150
  log("Debouncing", descriptor);
143
- this._timers[descriptor] = setTimeout(() => {
144
- delete this._timers[descriptor];
145
- this.publishEvent(descriptor, callback);
146
- }, timeout * 1000);
151
+ timers.set(descriptor, setTimeout(() => {
152
+ timers.delete(descriptor);
153
+ this.publishEvent(descriptor, callback, socket);
154
+ }, timeout * 1000));
147
155
  }
148
156
 
149
157
  reset() {
150
- Object.values(this._timers).forEach(timerID => clearTimeout(timerID));
151
- this._timers = {};
158
+ this._timers.forEach(timers => {
159
+ timers.forEach(timerID => clearTimeout(timerID));
160
+ timers.clear();
161
+ });
152
162
  }
153
163
 
154
164
  handleError(err) {
@@ -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 = "2.0.0"
2
+ VERSION = "2.1.1"
3
3
  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.0.0
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gary Passero