debounced 0.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/README.md +95 -0
- data/exe/debounced-server +23 -0
- data/lib/assets/javascript/debounced/event_service.mjs +68 -0
- data/lib/debounced/event_service_proxy.rb +174 -0
- data/lib/debounced/version.rb +3 -0
- data/lib/debounced.rb +24 -0
- metadata +131 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e4b64c7302b08f7d4134cfad294a0dc7b39203ef2f860691ce09838a8e56cc1c
|
4
|
+
data.tar.gz: 78006a905bcab3220c6968d66b244670d96a40095f212261421e46965b612799
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 954ab3aaa0b3bc092a3119c53414be10a44ce576b5f195f18ccab33786514468d9e3febe4407130bd22a87ed4de50f8897f8f7bb7c7f57da145ae6d1ba4ce8e4
|
7
|
+
data.tar.gz: a47d2fbe9fd86cf486ad79088039c9503f6c2d56cfea2d1d1e982b960635232f0bcfe44a9ced45537ad305c5ed595cd7053da068bf5a048f9dad75cc9e2e9146
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [0.1.0] - 2025-03-16
|
9
|
+
|
10
|
+
### Added
|
11
|
+
- Initial release
|
12
|
+
- Core functionality extracted from supply-side-platform
|
13
|
+
- Ruby wrapper for Node.js event debouncing service
|
14
|
+
- Configuration options for socket descriptor
|
15
|
+
- Rails integration via Railtie
|
data/README.md
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
# Debounced
|
2
|
+
|
3
|
+
A Ruby gem that provides a NodeJS-based event debouncing service for Ruby applications. It uses the JavaScript micro event loop to efficiently debounce events.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'debounced'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
$ gem install debounced
|
23
|
+
```
|
24
|
+
|
25
|
+
## Dependencies
|
26
|
+
|
27
|
+
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:
|
28
|
+
|
29
|
+
- Node.js >= 14.0.0
|
30
|
+
- npm (to install the required node packages)
|
31
|
+
|
32
|
+
After installing the gem, run:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
$ cd $(bundle show debounced)
|
36
|
+
$ npm install
|
37
|
+
```
|
38
|
+
|
39
|
+
## Usage
|
40
|
+
|
41
|
+
### Configuration
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
# config/initializers/debounced.rb
|
45
|
+
Debounced.configure do |config|
|
46
|
+
config.socket_descriptor = '/tmp/my_app.debounceEvents'
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
### Starting the server
|
51
|
+
|
52
|
+
You can start the debounce server with:
|
53
|
+
|
54
|
+
```bash
|
55
|
+
$ bundle exec debounced-server
|
56
|
+
```
|
57
|
+
|
58
|
+
Or in your application code:
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
require 'debounced'
|
62
|
+
|
63
|
+
# Start the listener thread
|
64
|
+
proxy = Debounced::EventServiceProxy.new
|
65
|
+
listener_thread = proxy.listen
|
66
|
+
|
67
|
+
# Debounce an event
|
68
|
+
class MyEvent
|
69
|
+
attr_reader :attributes
|
70
|
+
|
71
|
+
def initialize(data)
|
72
|
+
@attributes = data
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.publish(data)
|
76
|
+
# Publish logic here
|
77
|
+
puts "Publishing event with data: #{data.inspect}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
event = MyEvent.new({ id: 1, message: "Hello World" })
|
82
|
+
proxy.debounce_event("my-event-123", event, 5) # Debounce for 5 seconds
|
83
|
+
```
|
84
|
+
|
85
|
+
## How It Works
|
86
|
+
|
87
|
+
1. The gem creates a Unix socket for communication between Ruby and Node.js
|
88
|
+
2. When you call `debounce_event`, it sends the event to the Node.js server
|
89
|
+
3. The Node.js server keeps track of events with the same descriptor
|
90
|
+
4. If another event with the same descriptor arrives before the timeout, it resets the timer
|
91
|
+
5. When the timeout expires, it sends the event back to Ruby to be published
|
92
|
+
|
93
|
+
## License
|
94
|
+
|
95
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,23 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import { fileURLToPath } from 'url';
|
4
|
+
import { dirname, join } from 'path';
|
5
|
+
import DebounceEventService from '../lib/assets/javascript/debounced/event_service.mjs';
|
6
|
+
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
8
|
+
const __dirname = dirname(__filename);
|
9
|
+
|
10
|
+
// Default socket path
|
11
|
+
let socketDescriptor = process.env.DEBOUNCED_SOCKET || '/tmp/app.debounceEvents';
|
12
|
+
|
13
|
+
// If test environment is specified
|
14
|
+
if (process.env.NODE_ENV === 'test') {
|
15
|
+
socketDescriptor = process.env.DEBOUNCED_TEST_SOCKET || '/tmp/app.debounceEvents.test';
|
16
|
+
}
|
17
|
+
|
18
|
+
// Allow socket path to be specified as command line argument
|
19
|
+
if (process.argv.length > 2) {
|
20
|
+
socketDescriptor = process.argv[2];
|
21
|
+
}
|
22
|
+
|
23
|
+
new DebounceEventService(socketDescriptor).listen();
|
@@ -0,0 +1,68 @@
|
|
1
|
+
import ipc from 'node-ipc';
|
2
|
+
import fs from 'fs';
|
3
|
+
|
4
|
+
const logLevel = process.env.LOG_LEVEL || 'info';
|
5
|
+
|
6
|
+
export default class DebounceEventService {
|
7
|
+
constructor(socketDescriptor) {
|
8
|
+
this._socketDescriptor = socketDescriptor;
|
9
|
+
this._timers = {};
|
10
|
+
this.publishEvent = this.publishEvent.bind(this);
|
11
|
+
this.debounceEvent = this.debounceEvent.bind(this);
|
12
|
+
this.reset = this.reset.bind(this);
|
13
|
+
this.listen = this.listen.bind(this);
|
14
|
+
this.handleError = this.handleError.bind(this);
|
15
|
+
this.configureServer();
|
16
|
+
this.registerMessageTypes();
|
17
|
+
}
|
18
|
+
|
19
|
+
registerMessageTypes() {
|
20
|
+
ipc.server.on('debounceEvent', this.debounceEvent);
|
21
|
+
ipc.server.on('reset', this.reset);
|
22
|
+
ipc.server.on('error', this.handleError);
|
23
|
+
}
|
24
|
+
|
25
|
+
configureServer() {
|
26
|
+
// Remove the existing socket file if it exists
|
27
|
+
if (fs.existsSync(this._socketDescriptor)) {
|
28
|
+
console.log('DebounceEventService removing stale socket file ', this._socketDescriptor);
|
29
|
+
fs.unlinkSync(this._socketDescriptor);
|
30
|
+
}
|
31
|
+
|
32
|
+
ipc.config.delimiter = '\f'; // incoming messages are terminated by a form feed character
|
33
|
+
ipc.config.encoding = 'utf8';
|
34
|
+
ipc.config.silent = (logLevel !== 'debug');
|
35
|
+
|
36
|
+
ipc.serve(this._socketDescriptor);
|
37
|
+
}
|
38
|
+
|
39
|
+
listen() {
|
40
|
+
console.log('DebounceEventService listening on', this._socketDescriptor);
|
41
|
+
ipc.server.start();
|
42
|
+
}
|
43
|
+
|
44
|
+
publishEvent(descriptor, data) {
|
45
|
+
console.log(`Debounce period expired for ${descriptor}, publishing ${data.klass} event`);
|
46
|
+
ipc.server.broadcast("publishEvent", data);
|
47
|
+
}
|
48
|
+
|
49
|
+
debounceEvent({ descriptor, attributes, klass, timeout }) {
|
50
|
+
if (this._timers[descriptor]) {
|
51
|
+
clearTimeout(this._timers[descriptor]);
|
52
|
+
}
|
53
|
+
|
54
|
+
this._timers[descriptor] = setTimeout(() => {
|
55
|
+
delete this._timers[descriptor];
|
56
|
+
this.publishEvent(descriptor, { attributes, klass });
|
57
|
+
}, timeout * 1000);
|
58
|
+
}
|
59
|
+
|
60
|
+
reset() {
|
61
|
+
Object.values(this._timers).forEach(timerID => clearTimeout(timerID));
|
62
|
+
this._timers = {};
|
63
|
+
}
|
64
|
+
|
65
|
+
handleError(err) {
|
66
|
+
console.log('\n\n######\nERROR: ', err);
|
67
|
+
}
|
68
|
+
}
|
@@ -0,0 +1,174 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'active_support/json'
|
3
|
+
|
4
|
+
module Debounced
|
5
|
+
class EventServiceProxy
|
6
|
+
DELIMITER = "\f".freeze
|
7
|
+
WAIT_TIMEOUT = 3
|
8
|
+
|
9
|
+
def listen(abort_signal = nil)
|
10
|
+
Thread.new do
|
11
|
+
if defined?(Rails)
|
12
|
+
Rails.application.executor.wrap do
|
13
|
+
receive(abort_signal)
|
14
|
+
end
|
15
|
+
else
|
16
|
+
receive(abort_signal)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def reset
|
22
|
+
if socket.nil?
|
23
|
+
log_debug("No connection to #{server_name}; unable to reset server.")
|
24
|
+
else
|
25
|
+
log_debug("Resetting #{server_name}")
|
26
|
+
transmit({ type: 'reset' })
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def debounce_event(descriptor, event, timeout)
|
31
|
+
if socket.nil?
|
32
|
+
log_debug("No connection to #{server_name}; publishing event immediately.")
|
33
|
+
event.publish if event.respond_to?(:publish)
|
34
|
+
else
|
35
|
+
log_debug("Sending #{event_name(event)} event to #{server_name}")
|
36
|
+
transmit(debounce_request(descriptor, event, timeout))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def receive(abort_signal = nil)
|
41
|
+
log_debug("Listening for messages from #{server_name}...")
|
42
|
+
|
43
|
+
loop do
|
44
|
+
break if abort_signal&.set?
|
45
|
+
|
46
|
+
if socket.nil?
|
47
|
+
log_debug("Waiting for #{server_name}...")
|
48
|
+
sleep(WAIT_TIMEOUT)
|
49
|
+
next
|
50
|
+
end
|
51
|
+
|
52
|
+
message = socket.gets(DELIMITER, chomp: true)
|
53
|
+
|
54
|
+
# gets => nil when server crashed.... try to reconnect
|
55
|
+
if message.nil?
|
56
|
+
close
|
57
|
+
next
|
58
|
+
end
|
59
|
+
|
60
|
+
payload = deserialize_payload(message)
|
61
|
+
next unless payload['type'] == 'publishEvent'
|
62
|
+
|
63
|
+
publish_event(payload['data'])
|
64
|
+
rescue IO::TimeoutError
|
65
|
+
# Ignored
|
66
|
+
end
|
67
|
+
rescue StandardError => e
|
68
|
+
log_warn("Unable to listen for messages from #{server_name}: #{e.message}")
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def close
|
74
|
+
return unless @socket
|
75
|
+
|
76
|
+
log_info("Closing connection to #{server_name}")
|
77
|
+
@socket.close
|
78
|
+
@socket = nil
|
79
|
+
end
|
80
|
+
|
81
|
+
def publish_event(data)
|
82
|
+
event_klass = data['klass'].constantize
|
83
|
+
event_data = data['attributes']
|
84
|
+
event_klass.publish(event_data)
|
85
|
+
end
|
86
|
+
|
87
|
+
def debounce_request(descriptor, event, timeout)
|
88
|
+
{
|
89
|
+
type: 'debounceEvent',
|
90
|
+
data: {
|
91
|
+
timeout: timeout,
|
92
|
+
descriptor: descriptor,
|
93
|
+
klass: event.class.name,
|
94
|
+
attributes: event_attributes(event)
|
95
|
+
}
|
96
|
+
}
|
97
|
+
end
|
98
|
+
|
99
|
+
def event_attributes(event)
|
100
|
+
if event.respond_to?(:attributes)
|
101
|
+
event.attributes
|
102
|
+
else
|
103
|
+
event.instance_variables.each_with_object({}) do |var, hash|
|
104
|
+
hash[var.to_s.delete('@')] = event.instance_variable_get(var)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def event_name(event)
|
110
|
+
if event.respond_to?(:event_name)
|
111
|
+
event.event_name
|
112
|
+
else
|
113
|
+
event.class.name
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def transmit(request)
|
118
|
+
socket.send serialize_payload(request), 0
|
119
|
+
end
|
120
|
+
|
121
|
+
def server_name
|
122
|
+
'DebounceEventServer'
|
123
|
+
end
|
124
|
+
|
125
|
+
def serialize_payload(payload)
|
126
|
+
"#{ActiveSupport::JSON.encode(payload)}#{DELIMITER}" # inject EOM delimiter (form feed character)
|
127
|
+
end
|
128
|
+
|
129
|
+
def deserialize_payload(payload)
|
130
|
+
ActiveSupport::JSON.decode(payload)
|
131
|
+
end
|
132
|
+
|
133
|
+
def socket_descriptor
|
134
|
+
Debounced.configuration.socket_descriptor
|
135
|
+
end
|
136
|
+
|
137
|
+
def socket
|
138
|
+
@socket ||= UNIXSocket.new(socket_descriptor)
|
139
|
+
.tap do |socket|
|
140
|
+
socket.timeout = WAIT_TIMEOUT
|
141
|
+
end
|
142
|
+
###
|
143
|
+
# Errno::ENOENT is raised if the socket file does not exist.
|
144
|
+
# Errno::ECONNREFUSED is raised if the socket file exists but no process is listening on it.
|
145
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
146
|
+
log_debug("#{server_name} is not running")
|
147
|
+
nil
|
148
|
+
end
|
149
|
+
|
150
|
+
def log_debug(message)
|
151
|
+
if defined?(Rails)
|
152
|
+
Rails.logger.debug { message }
|
153
|
+
else
|
154
|
+
puts "[DEBUG] #{message}" if ENV['DEBUG']
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def log_info(message)
|
159
|
+
if defined?(Rails)
|
160
|
+
Rails.logger.info(message)
|
161
|
+
else
|
162
|
+
puts "[INFO] #{message}"
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def log_warn(message)
|
167
|
+
if defined?(Rails)
|
168
|
+
Rails.logger.warn(message)
|
169
|
+
else
|
170
|
+
puts "[WARNING] #{message}"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
data/lib/debounced.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require "debounced/version"
|
2
|
+
require "debounced/railtie" if defined?(Rails)
|
3
|
+
require "debounced/event_service_proxy"
|
4
|
+
|
5
|
+
module Debounced
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
class << self
|
9
|
+
attr_accessor :configuration
|
10
|
+
|
11
|
+
def configure
|
12
|
+
self.configuration ||= Configuration.new
|
13
|
+
yield(configuration) if block_given?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class Configuration
|
18
|
+
attr_accessor :socket_descriptor
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@socket_descriptor = ENV['DEBOUNCED_SOCKET'] || '/tmp/app.debounceEvents'
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
metadata
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: debounced
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Gary Passero
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-03-16 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.0
|
20
|
+
- - "~>"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '6.0'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 6.0.0
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '6.0'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: rspec
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '3.0'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '3.0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rubocop
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '1.21'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '1.21'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: rubocop-rails
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '2.12'
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '2.12'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: rubocop-rspec
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '2.4'
|
82
|
+
type: :development
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - "~>"
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '2.4'
|
89
|
+
description: A Ruby wrapper for a NodeJS server that uses the JavaScript micro event
|
90
|
+
loop to debounce events in Ruby applications
|
91
|
+
email:
|
92
|
+
- gary@flytedesk.com
|
93
|
+
executables:
|
94
|
+
- debounced-server
|
95
|
+
extensions: []
|
96
|
+
extra_rdoc_files: []
|
97
|
+
files:
|
98
|
+
- CHANGELOG.md
|
99
|
+
- README.md
|
100
|
+
- exe/debounced-server
|
101
|
+
- lib/assets/javascript/debounced/event_service.mjs
|
102
|
+
- lib/debounced.rb
|
103
|
+
- lib/debounced/event_service_proxy.rb
|
104
|
+
- lib/debounced/version.rb
|
105
|
+
homepage: https://github.com/flytedesk/debounced
|
106
|
+
licenses:
|
107
|
+
- MIT
|
108
|
+
metadata:
|
109
|
+
homepage_uri: https://github.com/flytedesk/debounced
|
110
|
+
source_code_uri: https://github.com/flytedesk/debounced
|
111
|
+
changelog_uri: https://github.com/flytedesk/debounced/blob/main/CHANGELOG.md
|
112
|
+
post_install_message:
|
113
|
+
rdoc_options: []
|
114
|
+
require_paths:
|
115
|
+
- lib
|
116
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: 2.6.0
|
121
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
requirements: []
|
127
|
+
rubygems_version: 3.0.3.1
|
128
|
+
signing_key:
|
129
|
+
specification_version: 4
|
130
|
+
summary: Efficient event debouncing in Ruby
|
131
|
+
test_files: []
|