exots 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/AGENTS.md +21 -0
- data/LICENSE +15 -0
- data/README.md +80 -0
- data/Rakefile +18 -0
- data/lib/exots/client.rb +135 -0
- data/lib/exots/context.rb +84 -0
- data/lib/exots/runner.rb +21 -0
- data/lib/exots/version.rb +5 -0
- data/lib/exots.rb +12 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e7ccbf2566f4dd5ff3c05e550df972b800bd465b383bbd6464dbed34206a7a0a
|
|
4
|
+
data.tar.gz: 2e9e5bcb9e60c831de10e5c789fe285081938cc020a57d5f1fafef60f3e6deeb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2e672b26d285707b81cf8c6bf420547a46834438cdef1e7b9f67ab072cfa1d3a72e879d1a42c4a1f14493af92e6a74c57241cef4588f1fd23926a4cd2cdcdd9a
|
|
7
|
+
data.tar.gz: df569b4df17b4787063bbfa694939eaf606cc39f3efc166f9a8e9aafb707e75a897e86b118417fc7f55df50ae414a04dfbffdb8cebdd36b5d422f881280d5d58
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# gem/AGENTS.md
|
|
2
|
+
|
|
3
|
+
This directory contains the **Ruby Client** implementation for Exots.
|
|
4
|
+
|
|
5
|
+
## Tech Stack
|
|
6
|
+
- **Language:** Ruby
|
|
7
|
+
- **Package Manager:** `bundler`
|
|
8
|
+
- **Testing:** `rspec`
|
|
9
|
+
|
|
10
|
+
## Key Commands
|
|
11
|
+
- **Install:** `bundle install`
|
|
12
|
+
- **Test:** `bundle exec rspec`
|
|
13
|
+
- **Pack:** `bundle exec rake pack` (Automatically includes root `LICENSE` and `README.md`)
|
|
14
|
+
|
|
15
|
+
## Development Workflow
|
|
16
|
+
1. Make changes to the Ruby code in `lib/`.
|
|
17
|
+
2. Run tests: `bundle exec rspec`.
|
|
18
|
+
3. If adding new dependencies, update `exots.gemspec` and run `bundle install`.
|
|
19
|
+
4. To release/distribute, run `bundle exec rake pack`.
|
|
20
|
+
|
|
21
|
+
Note: Do not manually edit `gem/LICENSE` or `gem/README.md`. These are automatically copied from the project root during the build process.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, sunteya
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Exots
|
|
2
|
+
|
|
3
|
+
**Exots** (Exo-Typescript) is a high-performance Inter-Process Communication (IPC) bridge designed to allow Ruby applications to seamlessly invoke functions written in TypeScript/JavaScript.
|
|
4
|
+
|
|
5
|
+
It acts as a **process manager** that spawns a Node.js (or Bun/Deno) runtime and communicates via **JSON-RPC 2.0** over **HTTP** on **Unix Domain Sockets (UDS)**. This approach ensures high performance and security by avoiding local TCP ports and leveraging file-system-level access control.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
The system operates on a Host/Plugin model:
|
|
10
|
+
|
|
11
|
+
1. **Host (Ruby)**: The `exots` gem manages the lifecycle of the JavaScript process. It creates a temporary communication socket and injects its path into the child process.
|
|
12
|
+
2. **Plugin (TypeScript/Node.js)**: The `exots` npm package wraps your functions and exposes them via an HTTP server listening on the injected socket path.
|
|
13
|
+
|
|
14
|
+
## Components
|
|
15
|
+
|
|
16
|
+
### NPM Package (`exots`)
|
|
17
|
+
- **Role**: RPC Server
|
|
18
|
+
- **Transport**: HTTP over Unix Domain Socket
|
|
19
|
+
- **Protocol**: JSON-RPC 2.0
|
|
20
|
+
- **Configuration**: Accepts a map of functions and listens on a socket path provided via environment variables.
|
|
21
|
+
|
|
22
|
+
### Ruby Gem (`exots`)
|
|
23
|
+
- **Role**: Process Manager & RPC Client
|
|
24
|
+
- **Features**:
|
|
25
|
+
- Spawns and manages the Node.js/Bun process.
|
|
26
|
+
- Automatic socket path generation and handshake.
|
|
27
|
+
- Zero-dependency HTTP client over Unix Sockets.
|
|
28
|
+
- Maps JSON-RPC errors to Ruby exceptions.
|
|
29
|
+
|
|
30
|
+
## Usage Example
|
|
31
|
+
|
|
32
|
+
### 1. TypeScript Side (`plugin.js`)
|
|
33
|
+
Create a script that exports your functions.
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
import { Server } from 'exots'
|
|
37
|
+
|
|
38
|
+
// 1. Initialize the server with exposed functions
|
|
39
|
+
const server = new Server({
|
|
40
|
+
add: ({ a, b }) => a + b,
|
|
41
|
+
render: async ({ title }) => {
|
|
42
|
+
// Perform complex operations, e.g., SSR
|
|
43
|
+
return `<div>${title}</div>`
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// 2. Start the server (automatically handles arguments and environment variables)
|
|
48
|
+
server.run(process.argv)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Ruby Side
|
|
52
|
+
Use the client to spawn the process and call functions.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
require 'exots'
|
|
56
|
+
|
|
57
|
+
# 1. Initialize the client
|
|
58
|
+
client = Exots::Client.new(
|
|
59
|
+
script_path: "plugin.js",
|
|
60
|
+
socket_path: Rails.root.join("tmp/sockets/exots.sock")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
# 2. Start the process and connect
|
|
65
|
+
context = client.start
|
|
66
|
+
|
|
67
|
+
# 3. Call functions transparently
|
|
68
|
+
sum = context.call("add", a: 5, b: 3)
|
|
69
|
+
puts "Sum: #{sum}"
|
|
70
|
+
# => Sum: 8
|
|
71
|
+
|
|
72
|
+
html = context.call("render", title: "Hello World")
|
|
73
|
+
puts "HTML: #{html}"
|
|
74
|
+
# => HTML: <div>Hello World</div>
|
|
75
|
+
|
|
76
|
+
ensure
|
|
77
|
+
# 4. Clean shutdown
|
|
78
|
+
client.stop
|
|
79
|
+
end
|
|
80
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "bundler/gem_tasks"
|
|
2
|
+
|
|
3
|
+
task :pack => :build
|
|
4
|
+
|
|
5
|
+
task :build => :copy_assets
|
|
6
|
+
|
|
7
|
+
task :copy_assets do
|
|
8
|
+
FileUtils.cp "../LICENSE", "."
|
|
9
|
+
FileUtils.cp "../README.md", "."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task :clean_assets do
|
|
13
|
+
FileUtils.rm ["LICENSE", "README.md"], force: true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Ensure cleanup happens even if build fails, or explicitly call clean_assets
|
|
17
|
+
# For now, let's just make build depend on copy. Users can run `git clean -fdx` or similar if they want super clean,
|
|
18
|
+
# but we also added them to .gitignore so they won't pollute git status.
|
data/lib/exots/client.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module Exots
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :pid, :socket_path, :pid_path
|
|
11
|
+
|
|
12
|
+
def initialize(script_path:, socket_path:, pid_path: nil, runner: Runner::Node, timeout: 5.0, auto_stop: true)
|
|
13
|
+
@script_path = script_path
|
|
14
|
+
@socket_path = socket_path
|
|
15
|
+
@pid_path = pid_path
|
|
16
|
+
@runner = runner
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@running = false
|
|
19
|
+
@context = nil
|
|
20
|
+
@owner = false
|
|
21
|
+
@owner_pid = nil
|
|
22
|
+
|
|
23
|
+
at_exit { stop } if auto_stop
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(method, params = {})
|
|
27
|
+
start unless @running
|
|
28
|
+
@context.call(method, params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start
|
|
32
|
+
return @context if @running
|
|
33
|
+
|
|
34
|
+
lock_path = "#{@socket_path}.lock"
|
|
35
|
+
# Ensure lock directory exists
|
|
36
|
+
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
37
|
+
|
|
38
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
39
|
+
f.flock(File::LOCK_EX)
|
|
40
|
+
|
|
41
|
+
if server_alive?
|
|
42
|
+
@running = true
|
|
43
|
+
@owner = false
|
|
44
|
+
else
|
|
45
|
+
File.unlink(@socket_path) if File.exist?(@socket_path)
|
|
46
|
+
spawn_process
|
|
47
|
+
wait_for_socket
|
|
48
|
+
@running = true
|
|
49
|
+
@owner = true
|
|
50
|
+
@owner_pid = Process.pid
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@context = Context.new(@socket_path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop
|
|
58
|
+
if @owner && @pid && Process.pid == @owner_pid && process_running?
|
|
59
|
+
Process.kill('TERM', @pid)
|
|
60
|
+
begin
|
|
61
|
+
Timeout.timeout(2) { Process.wait(@pid) }
|
|
62
|
+
rescue Timeout::Error
|
|
63
|
+
Process.kill('KILL', @pid)
|
|
64
|
+
Process.wait(@pid)
|
|
65
|
+
rescue Errno::ECHILD
|
|
66
|
+
# Already gone
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
rescue Errno::ESRCH
|
|
70
|
+
# Process already gone
|
|
71
|
+
ensure
|
|
72
|
+
if @owner && Process.pid == @owner_pid
|
|
73
|
+
cleanup_files
|
|
74
|
+
File.unlink("#{@socket_path}.lock") if File.exist?("#{@socket_path}.lock")
|
|
75
|
+
end
|
|
76
|
+
@running = false
|
|
77
|
+
@pid = nil
|
|
78
|
+
@context = nil
|
|
79
|
+
@owner = false
|
|
80
|
+
@owner_pid = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def server_alive?
|
|
86
|
+
return false unless File.exist?(@socket_path)
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
UNIXSocket.new(@socket_path).close
|
|
90
|
+
true
|
|
91
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def spawn_process
|
|
97
|
+
env = {
|
|
98
|
+
'EXOTS_SOCKET' => @socket_path
|
|
99
|
+
}
|
|
100
|
+
env['EXOTS_PID'] = @pid_path if @pid_path
|
|
101
|
+
|
|
102
|
+
# Use exec form to avoid shell overhead
|
|
103
|
+
args = @runner.command(@script_path, @socket_path, @pid_path)
|
|
104
|
+
@pid = Process.spawn(env, *args)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def wait_for_socket
|
|
108
|
+
start_time = Time.now
|
|
109
|
+
|
|
110
|
+
until server_alive?
|
|
111
|
+
raise ProcessError, "Timeout waiting for socket at #{@socket_path}" if Time.now - start_time > @timeout
|
|
112
|
+
|
|
113
|
+
unless process_running?
|
|
114
|
+
# Try to read stderr/stdout if we could, but for now just report exit
|
|
115
|
+
raise ProcessError, 'Process exited unexpectedly'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
sleep 0.1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def process_running?
|
|
123
|
+
return false unless @pid
|
|
124
|
+
|
|
125
|
+
Process.getpgid(@pid)
|
|
126
|
+
true
|
|
127
|
+
rescue Errno::ESRCH
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def cleanup_files
|
|
132
|
+
# FileUtils.rm_rf(@tmp_dir) if @tmp_dir && File.directory?(@tmp_dir)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Exots
|
|
8
|
+
class Context
|
|
9
|
+
attr_reader :socket_path
|
|
10
|
+
|
|
11
|
+
def initialize(socket_path)
|
|
12
|
+
@socket_path = socket_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(method, *args)
|
|
16
|
+
id = SecureRandom.uuid
|
|
17
|
+
|
|
18
|
+
payload = {
|
|
19
|
+
jsonrpc: '2.0',
|
|
20
|
+
method: method,
|
|
21
|
+
params: args,
|
|
22
|
+
id: id
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
response = send_request(payload)
|
|
26
|
+
handle_response(response, id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def send_request(payload)
|
|
32
|
+
body = JSON.generate(payload)
|
|
33
|
+
|
|
34
|
+
UNIXSocket.open(@socket_path) do |sock|
|
|
35
|
+
sock.write("POST / HTTP/1.1\r\n")
|
|
36
|
+
sock.write("Host: localhost\r\n")
|
|
37
|
+
sock.write("Content-Type: application/json\r\n")
|
|
38
|
+
sock.write("Content-Length: #{body.bytesize}\r\n")
|
|
39
|
+
sock.write("\r\n")
|
|
40
|
+
sock.write(body)
|
|
41
|
+
|
|
42
|
+
parse_response(sock)
|
|
43
|
+
end
|
|
44
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
45
|
+
raise Error, "Failed to connect to socket at #{@socket_path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_response(sock)
|
|
49
|
+
status_line = sock.gets
|
|
50
|
+
return nil unless status_line
|
|
51
|
+
|
|
52
|
+
status = status_line.split(' ')[1].to_i
|
|
53
|
+
headers = {}
|
|
54
|
+
|
|
55
|
+
while (line = sock.gets) && line != "\r\n"
|
|
56
|
+
key, value = line.split(':', 2)
|
|
57
|
+
headers[key.strip.downcase] = value.strip if key
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if status == 204
|
|
61
|
+
return { 'result' => nil } # Notification or empty response
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
raise Error, "HTTP Error: #{status}" unless status >= 200 && status < 300
|
|
65
|
+
|
|
66
|
+
content_length = headers['content-length']&.to_i
|
|
67
|
+
return nil unless content_length && content_length > 0
|
|
68
|
+
|
|
69
|
+
body = sock.read(content_length)
|
|
70
|
+
JSON.parse(body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle_response(response, _id)
|
|
74
|
+
return nil if response.nil?
|
|
75
|
+
|
|
76
|
+
if response.key?('error')
|
|
77
|
+
error = response['error']
|
|
78
|
+
raise RPCError, "#{error['message']} (code: #{error['code']})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
response['result']
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/exots/runner.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exots
|
|
4
|
+
class Runner
|
|
5
|
+
attr_reader :bin, :args
|
|
6
|
+
|
|
7
|
+
def initialize(bin, args: [])
|
|
8
|
+
@bin = bin
|
|
9
|
+
@args = args
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def command(script, socket_path, pid_path = nil)
|
|
13
|
+
cmd_args = [@bin] + @args + [script, '--socket', socket_path]
|
|
14
|
+
cmd_args += ['--pid', pid_path] if pid_path
|
|
15
|
+
cmd_args
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Node = new('node')
|
|
19
|
+
Bun = new('bun')
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/exots.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'exots/version'
|
|
4
|
+
require_relative 'exots/runner'
|
|
5
|
+
require_relative 'exots/client'
|
|
6
|
+
require_relative 'exots/context'
|
|
7
|
+
|
|
8
|
+
module Exots
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class ProcessError < Error; end
|
|
11
|
+
class RPCError < Error; end
|
|
12
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: exots
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- sunteya
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-02-05 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: Exots provides a seamless bridge to spawn Node.js/Bun processes and execute
|
|
27
|
+
functions via high-performance Unix Domain Sockets.
|
|
28
|
+
email:
|
|
29
|
+
- sunteya@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- AGENTS.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- lib/exots.rb
|
|
39
|
+
- lib/exots/client.rb
|
|
40
|
+
- lib/exots/context.rb
|
|
41
|
+
- lib/exots/runner.rb
|
|
42
|
+
- lib/exots/version.rb
|
|
43
|
+
homepage: https://github.com/sunteya/exots
|
|
44
|
+
licenses:
|
|
45
|
+
- ISC
|
|
46
|
+
metadata:
|
|
47
|
+
source_code_uri: https://github.com/sunteya/exots
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 3.0.0
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.6.2
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: A Ruby client for executing JavaScript functions via an IPC bridge.
|
|
65
|
+
test_files: []
|