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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +1 -1
- data/lib/debounced/callback.rb +22 -7
- data/lib/debounced/javascript/service.mjs +67 -57
- data/lib/debounced/service_proxy.rb +15 -9
- data/lib/debounced/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9fa1cd41e5d203718e5f338068db77acd8b07f15369c705e096f9a2108b7614
|
|
4
|
+
data.tar.gz: 544067be17e4efad251d656374b5d41a08c2c3ad627fc7f0ac75dbdf83be0c05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/debounced/callback.rb
CHANGED
|
@@ -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 =
|
|
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.
|
|
72
|
+
ensure_application_method(klass.method(method_name))
|
|
73
|
+
klass.public_send(method_name, *args, **kwargs)
|
|
74
74
|
else
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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.
|
|
64
|
-
socket.on('
|
|
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
|
|
68
|
-
connectionBuffer += data
|
|
58
|
+
log('DebounceService data received', data);
|
|
59
|
+
connectionBuffer += data;
|
|
69
60
|
const messages = connectionBuffer.split('\f');
|
|
70
|
-
connectionBuffer =
|
|
71
|
-
|
|
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',
|
|
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
|
|
76
|
+
log('DebounceEventService removing socket file ', this._socketDescriptor);
|
|
86
77
|
fs.unlinkSync(this._socketDescriptor);
|
|
87
78
|
}
|
|
88
79
|
}
|
|
89
80
|
|
|
90
81
|
listen() {
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
if (!fs.existsSync(this._socketDescriptor)) {
|
|
83
|
+
this.bind();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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(
|
|
143
|
+
this.sendMessage(socket, message);
|
|
135
144
|
}
|
|
136
145
|
|
|
137
|
-
debounceEvent({ descriptor, timeout, callback }) {
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
delete
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
89
|
+
@abort_signal&.make_true
|
|
87
90
|
end
|
|
88
91
|
|
|
89
92
|
private
|
|
90
93
|
|
|
91
94
|
def close
|
|
92
|
-
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
return unless @socket
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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 }
|
data/lib/debounced/version.rb
CHANGED