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 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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exots
4
+ VERSION = '0.1.0'
5
+ 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: []