protocol-sp 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: b3a9f50418b5ea8885c5e54d7d8245bf47fea17bd6f2f32e5ed84707fec6868a
4
+ data.tar.gz: 3c9dfd7d357ef8dea0b325c99c5b326a39e25ee4eb292e3a7ec655f80de3c213
5
+ SHA512:
6
+ metadata.gz: 9964fdb2780489536856044c5686af01b2b63b685589c43c96b9c48929b9e5afc7ee4cba5ea56ab0ccb9b3f0d72909e53ab6d394b08e076aa107d2ca60a3e0f7
7
+ data.tar.gz: ea786df241e7777e6715bdc97eeb73e52270f238c0e43b45fbd7efd2a533efd3db604eab27156526d6230ca1fa5804fb559d82db19488257ef5e9aae4e94caf4
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-04-09
4
+
5
+ Initial release.
6
+
7
+ - `Protocol::SP::Codec::Frame` — length-prefixed framing. SP/TCP uses an 8-byte big-endian length; SP/IPC prepends a 1-byte message type to match nng's wire format.
8
+ - `Protocol::SP::Codec::Greeting` — 8-byte SP/TCP greeting.
9
+ - `Protocol::SP::Protocols` — protocol identifier constants.
10
+ - `Protocol::SP::Connection` — mutex-protected handshake, `#send_message`, `#write_message`, `#write_messages` (batched), `#receive_message`. `framing:` selects `:tcp` or `:ipc`.
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2026, Patrik Wenger
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # protocol-sp
2
+
3
+ [![CI](https://github.com/paddor/protocol-sp/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/protocol-sp/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/protocol-sp?color=e9573f)](https://rubygems.org/gems/protocol-sp)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
7
+
8
+ Pure Ruby codec and connection for the [Scalability Protocols](https://nanomsg.org/documentation-zeromq.html) (SP) wire format used by [nanomsg](https://nanomsg.org) and [nng](https://nng.nanomsg.org). Zero runtime dependencies. Sister gem to [protocol-zmtp](https://github.com/paddor/protocol-zmtp).
9
+
10
+ ## What's in the box
11
+
12
+ - `Protocol::SP::Codec::Frame` — length-prefixed framing. SP/TCP uses an 8-byte big-endian length; SP/IPC prepends a 1-byte message type (0x00 control, 0x01 user) to match nng's wire format.
13
+ - `Protocol::SP::Codec::Greeting` — 8-byte handshake: `00 'S' 'P' 00 <peer-proto:u16-BE> 00 00`.
14
+ - `Protocol::SP::Protocols` — protocol identifier constants (PUSH=0x50, PULL=0x51, PUB=0x20, SUB=0x21, REQ=0x30, REP=0x31, PAIR_V0=0x10, PAIR_V1=0x11, BUS=0x70, SURVEYOR=0x62, RESPONDENT=0x63).
15
+ - `Protocol::SP::Connection` — mutex-protected `#handshake!`, `#send_message`, `#write_message` (no flush), `#write_messages` (batched under a single mutex acquisition), `#receive_message` over any IO-like object. `framing:` selects `:tcp` or `:ipc`.
16
+
17
+ ## Notes
18
+
19
+ - SP messages are single-frame (no multipart, unlike ZMTP).
20
+ - No security mechanisms in the SP wire protocol — encryption is layered via TLS/WebSocket transports, not the SP framing.
21
+ - No commands at the wire level — protocol-specific control bytes (e.g. REQ request IDs, SUB topics) live inside the message body.
22
+ - Zero-alloc frame headers on the unencrypted hot send path via `Array#pack(buffer:)`.
23
+
24
+ ## Usage
25
+
26
+ `protocol-sp` is a low-level codec. For a full socket API (PUSH/PULL, REQ/REP, PAIR, transports, reconnect), see [nnq](https://github.com/paddor/nnq).
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ module Codec
6
+ # SP/TCP frame encode/decode.
7
+ #
8
+ # Wire format (per nng `src/sp/transport/tcp/tcp.c`):
9
+ # 8 bytes body length, big-endian unsigned 64-bit
10
+ # N bytes body
11
+ #
12
+ # SP messages are single-frame — there is no MORE flag and no
13
+ # multipart concept at the transport level.
14
+ #
15
+ class Frame
16
+ HEADER_SIZE = 8
17
+
18
+
19
+ # @return [String] frame body (binary)
20
+ attr_reader :body
21
+
22
+ # @param body [String] frame body
23
+ def initialize(body)
24
+ @body = body.b
25
+ end
26
+
27
+
28
+ # Encodes to wire bytes.
29
+ #
30
+ # @return [String] binary wire representation (length + body)
31
+ def to_wire
32
+ [@body.bytesize].pack("Q>") + @body
33
+ end
34
+
35
+
36
+ # Encodes a body into wire bytes without allocating a Frame.
37
+ #
38
+ # @param body [String]
39
+ # @return [String] frozen binary wire representation
40
+ def self.encode(body)
41
+ ([body.bytesize].pack("Q>") + body.b).freeze
42
+ end
43
+
44
+
45
+ # Reads one frame from an IO-like object.
46
+ #
47
+ # @param io [#read_exactly] must support read_exactly(n)
48
+ # @param max_message_size [Integer, nil] maximum body size, nil = unlimited
49
+ # @return [Frame]
50
+ # @raise [Error] on oversized frame
51
+ # @raise [EOFError] if the connection is closed
52
+ def self.read_from(io, max_message_size: nil)
53
+ size = io.read_exactly(HEADER_SIZE).unpack1("Q>")
54
+
55
+ if max_message_size && size > max_message_size
56
+ raise Error, "frame size #{size} exceeds max_message_size #{max_message_size}"
57
+ end
58
+
59
+ body = size > 0 ? io.read_exactly(size) : "".b
60
+ new(body)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ module Codec
6
+ # SP/TCP greeting encode/decode.
7
+ #
8
+ # The greeting is exactly 8 bytes (per nng `src/sp/transport/tcp/tcp.c`,
9
+ # `tcptran_pipe_nego_cb` and `tcptran_pipe_send_start`):
10
+ #
11
+ # Offset Bytes Field
12
+ # 0 1 0x00
13
+ # 1 1 'S' (0x53)
14
+ # 2 1 'P' (0x50)
15
+ # 3 1 0x00
16
+ # 4-5 2 protocol id (u16, big-endian)
17
+ # 6-7 2 reserved (must be 0x00 0x00)
18
+ #
19
+ module Greeting
20
+ SIZE = 8
21
+ SIGNATURE = "\x00SP\x00".b.freeze
22
+
23
+
24
+ # Encodes an SP/TCP greeting.
25
+ #
26
+ # @param protocol [Integer] our protocol id (e.g. Protocols::PUSH_V0)
27
+ # @return [String] 8-byte binary greeting
28
+ def self.encode(protocol:)
29
+ SIGNATURE + [protocol].pack("n") + "\x00\x00".b
30
+ end
31
+
32
+
33
+ # Decodes an SP/TCP greeting.
34
+ #
35
+ # @param data [String] 8-byte binary greeting
36
+ # @return [Integer] peer protocol id
37
+ # @raise [Error] on invalid greeting
38
+ def self.decode(data)
39
+ raise Error, "greeting too short (#{data.bytesize} bytes)" if data.bytesize < SIZE
40
+
41
+ data = data.b
42
+ unless data.byteslice(0, 4) == SIGNATURE
43
+ raise Error, "invalid SP greeting signature"
44
+ end
45
+ unless data.getbyte(6) == 0 && data.getbyte(7) == 0
46
+ raise Error, "invalid SP greeting reserved bytes"
47
+ end
48
+
49
+ data.byteslice(4, 2).unpack1("n")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ module Codec
6
+ end
7
+ end
8
+ end
9
+
10
+ require_relative "codec/frame"
11
+ require_relative "codec/greeting"
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ # Manages one SP peer connection over any transport IO.
6
+ #
7
+ # The SP wire protocol has no commands, no security mechanisms, and no
8
+ # multipart messages — `#handshake!` is just an exchange of two 8-byte
9
+ # greetings, and `#send_message` / `#receive_message` work on single
10
+ # binary bodies framed by an 8-byte big-endian length.
11
+ #
12
+ class Connection
13
+ # SP/IPC data messages are prefixed with a 1-byte message type.
14
+ # 0x01 = user message. 0x00 is reserved in nng for control frames
15
+ # (keepalive) that we don't emit, but we still accept and skip on
16
+ # read for forward-compatibility with nng peers.
17
+ IPC_MSG_TYPE = 0x01
18
+
19
+
20
+ # @return [Integer] peer's protocol id (set after handshake)
21
+ attr_reader :peer_protocol
22
+
23
+ # @return [Object] transport IO (#read_exactly, #write, #flush, #close)
24
+ attr_reader :io
25
+
26
+ # @return [Float, nil] monotonic timestamp of last received frame
27
+ attr_reader :last_received_at
28
+
29
+ # @return [Symbol] :tcp or :ipc
30
+ attr_reader :framing
31
+
32
+ # @param io [#read_exactly, #write, #flush, #close] transport IO
33
+ # @param protocol [Integer] our protocol id (e.g. Protocols::PUSH_V0)
34
+ # @param max_message_size [Integer, nil] max body size, nil = unlimited
35
+ # @param framing [Symbol] :tcp (default) uses 8-byte length headers;
36
+ # :ipc prepends a 1-byte message-type marker to each frame
37
+ # (nng's SP/IPC wire format)
38
+ def initialize(io, protocol:, max_message_size: nil, framing: :tcp)
39
+ @io = io
40
+ @protocol = protocol
41
+ @peer_protocol = nil
42
+ @max_message_size = max_message_size
43
+ @framing = framing
44
+ @mutex = Mutex.new
45
+ @last_received_at = nil
46
+ # Reusable scratch buffer for frame headers — written into by
47
+ # Array#pack(buffer:), then flushed to @io. Capacity 9 covers
48
+ # both :tcp (8B) and :ipc (1+8B) framings.
49
+ @header_buf = String.new(capacity: 9, encoding: Encoding::BINARY)
50
+ end
51
+
52
+
53
+ # Performs the SP/TCP greeting exchange.
54
+ #
55
+ # @return [void]
56
+ # @raise [Error] on greeting mismatch or peer-incompatibility
57
+ def handshake!
58
+ @io.write(Codec::Greeting.encode(protocol: @protocol))
59
+ @io.flush
60
+
61
+ peer = Codec::Greeting.decode(@io.read_exactly(Codec::Greeting::SIZE))
62
+ @peer_protocol = peer
63
+
64
+ valid = Protocols::VALID_PEERS[@protocol]
65
+ unless valid&.include?(peer)
66
+ raise Error, "incompatible SP protocols: 0x#{@protocol.to_s(16)} cannot speak to 0x#{peer.to_s(16)}"
67
+ end
68
+ end
69
+
70
+
71
+ # Sends one message (write + flush).
72
+ #
73
+ # @param body [String] message body (single frame)
74
+ # @return [void]
75
+ def send_message(body)
76
+ @mutex.synchronize do
77
+ write_header_nolock(body.bytesize)
78
+ @io.write(body)
79
+ @io.flush
80
+ end
81
+ end
82
+
83
+
84
+ # Writes one message to the buffer without flushing.
85
+ # Call {#flush} after batching writes.
86
+ #
87
+ # Two writes — header then body — into the buffered IO; avoids
88
+ # the per-message intermediate String allocation that
89
+ # {Codec::Frame.encode} would otherwise produce.
90
+ #
91
+ # @param body [String]
92
+ # @return [void]
93
+ def write_message(body)
94
+ @mutex.synchronize do
95
+ write_header_nolock(body.bytesize)
96
+ @io.write(body)
97
+ end
98
+ end
99
+
100
+
101
+ # Writes a batch of messages to the buffer under a single mutex
102
+ # acquisition. Used by work-stealing send pumps that dequeue up
103
+ # to N messages at once — avoids N lock/unlock pairs per batch.
104
+ # Call {#flush} after to push the buffer to the socket.
105
+ #
106
+ # @param bodies [Array<String>]
107
+ # @return [void]
108
+ def write_messages(bodies)
109
+ @mutex.synchronize do
110
+ i = 0
111
+ n = bodies.size
112
+ while i < n
113
+ body = bodies[i]
114
+ write_header_nolock(body.bytesize)
115
+ @io.write(body)
116
+ i += 1
117
+ end
118
+ end
119
+ end
120
+
121
+
122
+ # Writes the frame header into the already-held @mutex. Hotpath:
123
+ # keep the branch monomorphic per-connection and avoid fresh pack
124
+ # allocations by packing into a per-connection scratch buffer.
125
+ #
126
+ # @param size [Integer] body size
127
+ # @return [void]
128
+ private def write_header_nolock(size)
129
+ buf = @header_buf
130
+ buf.clear
131
+ if @framing == :ipc
132
+ [IPC_MSG_TYPE, size].pack("CQ>", buffer: buf)
133
+ else
134
+ [size].pack("Q>", buffer: buf)
135
+ end
136
+ @io.write(buf)
137
+ end
138
+
139
+
140
+ # Writes pre-encoded wire bytes without flushing. Used for fan-out:
141
+ # encode once with `Codec::Frame.encode`, write to many connections.
142
+ #
143
+ # @param wire_bytes [String]
144
+ # @return [void]
145
+ def write_wire(wire_bytes)
146
+ @mutex.synchronize do
147
+ @io.write(wire_bytes)
148
+ end
149
+ end
150
+
151
+
152
+ # Flushes the write buffer to the underlying IO.
153
+ #
154
+ # @return [void]
155
+ def flush
156
+ @mutex.synchronize do
157
+ @io.flush
158
+ end
159
+ end
160
+
161
+
162
+ # Receives one message body.
163
+ #
164
+ # @return [String] binary body (NOT frozen — let callers freeze if
165
+ # they want, the freeze cost shows up in hot loops)
166
+ # @raise [EOFError] if connection is closed
167
+ def receive_message
168
+ if @framing == :ipc
169
+ loop do
170
+ # One read_exactly(9) is cheaper than separate 1+8 reads:
171
+ # halves the io-stream dispatch overhead per message.
172
+ header = @io.read_exactly(9)
173
+ type, size = header.unpack("CQ>")
174
+ if @max_message_size && size > @max_message_size
175
+ raise Error, "frame size #{size} exceeds max_message_size #{@max_message_size}"
176
+ end
177
+ body = size > 0 ? @io.read_exactly(size) : "".b
178
+ touch_heartbeat
179
+ # Skip nng IPC control frames (0x00 — keepalive/etc.); only
180
+ # deliver user messages (0x01) to the caller.
181
+ return body if type == IPC_MSG_TYPE
182
+ end
183
+ else
184
+ frame = Codec::Frame.read_from(@io, max_message_size: @max_message_size)
185
+ touch_heartbeat
186
+ frame.body
187
+ end
188
+ rescue Error
189
+ close
190
+ raise
191
+ end
192
+
193
+
194
+ # Records that a frame was received (for inactivity tracking).
195
+ #
196
+ # @return [void]
197
+ def touch_heartbeat
198
+ @last_received_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
199
+ end
200
+
201
+
202
+ # Returns true if no frame has been received within +timeout+ seconds.
203
+ #
204
+ # @param timeout [Numeric] seconds
205
+ # @return [Boolean]
206
+ def heartbeat_expired?(timeout)
207
+ return false unless @last_received_at
208
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_received_at) > timeout
209
+ end
210
+
211
+
212
+ # Closes the connection.
213
+ #
214
+ # @return [void]
215
+ def close
216
+ @io.close
217
+ rescue IOError
218
+ # already closed
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ # Raised on SP protocol violations.
6
+ class Error < RuntimeError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ # Wire-level protocol identifiers (16-bit, big-endian on the wire).
6
+ #
7
+ # Encoding: `(major << 4) | minor`, matching nng's `NNI_PROTO(major,
8
+ # minor)` macro in `src/core/protocol.h`. Sourced from
9
+ # `src/sp/protocol/*/`.
10
+ #
11
+ module Protocols
12
+ PAIR_V0 = 0x10 # (1, 0)
13
+ PAIR_V1 = 0x11 # (1, 1)
14
+
15
+ PUB_V0 = 0x20 # (2, 0)
16
+ SUB_V0 = 0x21 # (2, 1)
17
+
18
+ REQ_V0 = 0x30 # (3, 0)
19
+ REP_V0 = 0x31 # (3, 1)
20
+
21
+ PUSH_V0 = 0x50 # (5, 0)
22
+ PULL_V0 = 0x51 # (5, 1)
23
+
24
+ SURVEYOR_V0 = 0x62 # (6, 2)
25
+ RESPONDENT_V0 = 0x63 # (6, 3)
26
+
27
+ BUS_V0 = 0x70 # (7, 0)
28
+
29
+
30
+ # Compatibility table: which peer ID is acceptable for each self ID.
31
+ VALID_PEERS = {
32
+ PAIR_V0 => [PAIR_V0],
33
+ PAIR_V1 => [PAIR_V1],
34
+ PUB_V0 => [SUB_V0],
35
+ SUB_V0 => [PUB_V0],
36
+ REQ_V0 => [REP_V0],
37
+ REP_V0 => [REQ_V0],
38
+ PUSH_V0 => [PULL_V0],
39
+ PULL_V0 => [PUSH_V0],
40
+ SURVEYOR_V0 => [RESPONDENT_V0],
41
+ RESPONDENT_V0 => [SURVEYOR_V0],
42
+ BUS_V0 => [BUS_V0],
43
+ }.freeze
44
+
45
+
46
+ NAMES = {
47
+ PAIR_V0 => "pair",
48
+ PAIR_V1 => "pair1",
49
+ PUB_V0 => "pub",
50
+ SUB_V0 => "sub",
51
+ REQ_V0 => "req",
52
+ REP_V0 => "rep",
53
+ PUSH_V0 => "push",
54
+ PULL_V0 => "pull",
55
+ SURVEYOR_V0 => "surveyor",
56
+ RESPONDENT_V0 => "respondent",
57
+ BUS_V0 => "bus",
58
+ }.freeze
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ # Scalability Protocols (nanomsg/nng) wire codec and connection.
5
+ module SP
6
+ end
7
+ end
8
+
9
+ require_relative "sp/version"
10
+ require_relative "sp/error"
11
+ require_relative "sp/protocols"
12
+ require_relative "sp/codec"
13
+ require_relative "sp/connection"
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: protocol-sp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Pure Ruby implementation of the Scalability Protocols wire format used
13
+ by nanomsg and nng. Includes the SP/TCP framing codec, 8-byte greeting, protocol
14
+ identifiers, and connection management. No runtime dependencies.
15
+ email:
16
+ - paddor@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - lib/protocol/sp.rb
25
+ - lib/protocol/sp/codec.rb
26
+ - lib/protocol/sp/codec/frame.rb
27
+ - lib/protocol/sp/codec/greeting.rb
28
+ - lib/protocol/sp/connection.rb
29
+ - lib/protocol/sp/error.rb
30
+ - lib/protocol/sp/protocols.rb
31
+ - lib/protocol/sp/version.rb
32
+ homepage: https://github.com/paddor/protocol-sp
33
+ licenses:
34
+ - ISC
35
+ metadata: {}
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.3'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 4.0.6
51
+ specification_version: 4
52
+ summary: Scalability Protocols (nanomsg/nng) wire codec and connection
53
+ test_files: []