hyperion-rb 1.6.1 → 2.10.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 +4570 -0
- data/README.md +212 -13
- data/ext/hyperion_h2_codec/Cargo.lock +7 -0
- data/ext/hyperion_h2_codec/Cargo.toml +33 -0
- data/ext/hyperion_h2_codec/extconf.rb +73 -0
- data/ext/hyperion_h2_codec/src/frames.rs +140 -0
- data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
- data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
- data/ext/hyperion_h2_codec/src/lib.rs +296 -0
- data/ext/hyperion_http/extconf.rb +28 -0
- data/ext/hyperion_http/h2_codec_glue.c +408 -0
- data/ext/hyperion_http/page_cache.c +1125 -0
- data/ext/hyperion_http/parser.c +473 -38
- data/ext/hyperion_http/sendfile.c +982 -0
- data/ext/hyperion_http/websocket.c +493 -0
- data/ext/hyperion_io_uring/Cargo.lock +33 -0
- data/ext/hyperion_io_uring/Cargo.toml +34 -0
- data/ext/hyperion_io_uring/extconf.rb +74 -0
- data/ext/hyperion_io_uring/src/lib.rs +316 -0
- data/lib/hyperion/adapter/rack.rb +370 -42
- data/lib/hyperion/admin_listener.rb +207 -0
- data/lib/hyperion/admin_middleware.rb +36 -7
- data/lib/hyperion/cli.rb +310 -11
- data/lib/hyperion/config.rb +440 -14
- data/lib/hyperion/connection.rb +679 -22
- data/lib/hyperion/deprecations.rb +81 -0
- data/lib/hyperion/dispatch_mode.rb +165 -0
- data/lib/hyperion/fiber_local.rb +75 -13
- data/lib/hyperion/h2_admission.rb +77 -0
- data/lib/hyperion/h2_codec.rb +452 -0
- data/lib/hyperion/http/page_cache.rb +122 -0
- data/lib/hyperion/http/sendfile.rb +696 -0
- data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
- data/lib/hyperion/http2_handler.rb +368 -9
- data/lib/hyperion/io_uring.rb +317 -0
- data/lib/hyperion/lint_wrapper_pool.rb +126 -0
- data/lib/hyperion/master.rb +96 -9
- data/lib/hyperion/metrics/path_templater.rb +68 -0
- data/lib/hyperion/metrics.rb +256 -0
- data/lib/hyperion/prometheus_exporter.rb +150 -0
- data/lib/hyperion/request.rb +13 -0
- data/lib/hyperion/response_writer.rb +477 -16
- data/lib/hyperion/runtime.rb +195 -0
- data/lib/hyperion/server/route_table.rb +179 -0
- data/lib/hyperion/server.rb +519 -55
- data/lib/hyperion/static_preload.rb +133 -0
- data/lib/hyperion/thread_pool.rb +61 -7
- data/lib/hyperion/tls.rb +343 -1
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/websocket/close_codes.rb +71 -0
- data/lib/hyperion/websocket/connection.rb +876 -0
- data/lib/hyperion/websocket/frame.rb +356 -0
- data/lib/hyperion/websocket/handshake.rb +525 -0
- data/lib/hyperion/worker.rb +111 -9
- data/lib/hyperion.rb +137 -3
- metadata +50 -1
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperion
|
|
4
|
+
# WS-3 (2.1.0) — RFC 6455 frame ser/de + XOR-unmask primitives.
|
|
5
|
+
#
|
|
6
|
+
# This module is intentionally narrow: it owns "given a buffer of socket
|
|
7
|
+
# bytes, parse one frame" and "given an opcode + payload, build a
|
|
8
|
+
# serialized frame". WS-1 (the hijacked-socket loop) and WS-2 (the
|
|
9
|
+
# handshake) compose these primitives — they don't reach into
|
|
10
|
+
# Hyperion::WebSocket::CFrame directly.
|
|
11
|
+
#
|
|
12
|
+
# Two layers:
|
|
13
|
+
#
|
|
14
|
+
# * `Hyperion::WebSocket::CFrame` — the C ext singleton (defined by
|
|
15
|
+
# ext/hyperion_http/websocket.c when the .bundle/.so loads).
|
|
16
|
+
# Methods: `unmask(payload, key)`, `parse(buf, offset = 0)`,
|
|
17
|
+
# `build(opcode, payload, fin:, mask:, mask_key:)`.
|
|
18
|
+
#
|
|
19
|
+
# * `Hyperion::WebSocket::Parser` / `::Builder` — Ruby façades over
|
|
20
|
+
# the C calls with a more idiomatic API (Frame structs, Symbol
|
|
21
|
+
# opcodes, ProtocolError on malformed frames).
|
|
22
|
+
#
|
|
23
|
+
# If the C ext is unavailable (JRuby, TruffleRuby, a build where
|
|
24
|
+
# extconf.rb fell back gracefully) the same module names are bound to
|
|
25
|
+
# a pure-Ruby fallback declared at the bottom of this file. The
|
|
26
|
+
# fallback is correct but slow (≥ 5× slower XOR than C) — it's a
|
|
27
|
+
# safety net so the gem never refuses to require.
|
|
28
|
+
module WebSocket
|
|
29
|
+
# Per-message struct returned by `Parser.parse`. `opcode` is a
|
|
30
|
+
# Symbol (`:text`, `:binary`, …); `payload` is a freshly-allocated
|
|
31
|
+
# binary String already unmasked. `rsv1` is the per-message-deflate
|
|
32
|
+
# marker (RFC 7692 §6); always false on parsed control frames (the
|
|
33
|
+
# parser would have errored out), only ever true on text/binary/
|
|
34
|
+
# continuation frames when the connection negotiated the extension.
|
|
35
|
+
Frame = Struct.new(:fin, :opcode, :payload, :rsv1, keyword_init: true) do
|
|
36
|
+
# Defaults — keep `Frame.new(fin:, opcode:, payload:)` working for
|
|
37
|
+
# the (overwhelming) majority of call sites that don't care about
|
|
38
|
+
# rsv1. New WS-2.3 callers pass `rsv1:` explicitly when building a
|
|
39
|
+
# compressed frame.
|
|
40
|
+
def initialize(fin:, opcode:, payload:, rsv1: false)
|
|
41
|
+
super(fin: fin, opcode: opcode, payload: payload, rsv1: rsv1)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Symbolic opcode table. Reverse table built lazily for the
|
|
46
|
+
# parse-side lookup. Frozen so accidental mutation can't corrupt
|
|
47
|
+
# the parse hot path.
|
|
48
|
+
OPCODES = {
|
|
49
|
+
continuation: 0x0,
|
|
50
|
+
text: 0x1,
|
|
51
|
+
binary: 0x2,
|
|
52
|
+
close: 0x8,
|
|
53
|
+
ping: 0x9,
|
|
54
|
+
pong: 0xA
|
|
55
|
+
}.freeze
|
|
56
|
+
OPCODE_NAMES = OPCODES.invert.freeze
|
|
57
|
+
|
|
58
|
+
class ProtocolError < StandardError; end
|
|
59
|
+
|
|
60
|
+
NATIVE_AVAILABLE = defined?(::Hyperion::WebSocket::CFrame) &&
|
|
61
|
+
::Hyperion::WebSocket::CFrame.respond_to?(:parse)
|
|
62
|
+
|
|
63
|
+
# 2.4-B (S5): the empty-payload Frame body. Pre-2.4-B every empty
|
|
64
|
+
# text/binary/control frame allocated `(+'').b` — two String
|
|
65
|
+
# allocations (the unfrozen `+''` and its `.b` re-encoding clone).
|
|
66
|
+
# Frames carry frozen empty payloads idempotently — a downstream
|
|
67
|
+
# caller that mutates would have been silently broken pre-2.4-B
|
|
68
|
+
# because the allocation was already non-shared. Sharing one frozen
|
|
69
|
+
# binary empty String per process is a strict win.
|
|
70
|
+
EMPTY_BIN_PAYLOAD = String.new('', encoding: Encoding::ASCII_8BIT).freeze
|
|
71
|
+
|
|
72
|
+
# 2.4-B (S4): pre-allocated Encoding identity check. `payload.b`
|
|
73
|
+
# always allocates a new String, even when payload is already
|
|
74
|
+
# ASCII-8BIT. Skipping the no-op clone saves one String per send().
|
|
75
|
+
BINARY_ENCODING = Encoding::ASCII_8BIT
|
|
76
|
+
|
|
77
|
+
module Parser
|
|
78
|
+
# Parse one frame out of `buf` starting at `offset`. Does NOT mutate
|
|
79
|
+
# or consume `buf` — the caller is responsible for advancing its
|
|
80
|
+
# cursor by `frame_total_len` (see `parse_with_cursor` below).
|
|
81
|
+
#
|
|
82
|
+
# Returns:
|
|
83
|
+
# * a Hyperion::WebSocket::Frame on success
|
|
84
|
+
# * `:incomplete` if `buf[offset..]` doesn't yet hold a full frame
|
|
85
|
+
#
|
|
86
|
+
# Raises Hyperion::WebSocket::ProtocolError on malformed frames
|
|
87
|
+
# (RSV bits set without a negotiated extension, unknown opcode,
|
|
88
|
+
# control frame > 125 bytes, fragmented control frame, 64-bit
|
|
89
|
+
# length with high bit set).
|
|
90
|
+
def self.parse(buf, offset = 0)
|
|
91
|
+
result = ::Hyperion::WebSocket::CFrame.parse(buf, offset)
|
|
92
|
+
return result if result == :incomplete
|
|
93
|
+
|
|
94
|
+
raise ProtocolError, 'malformed WebSocket frame' if result == :error
|
|
95
|
+
|
|
96
|
+
fin, opcode, payload_len, masked, mask_key, payload_offset, _frame_total_len, rsv1 = result
|
|
97
|
+
|
|
98
|
+
opcode_sym = OPCODE_NAMES[opcode] ||
|
|
99
|
+
raise(ProtocolError, "unknown opcode 0x#{opcode.to_s(16)}")
|
|
100
|
+
|
|
101
|
+
payload =
|
|
102
|
+
if payload_len.zero?
|
|
103
|
+
# 2.4-B (S5): share one frozen empty binary String across
|
|
104
|
+
# every empty-payload frame instead of allocating
|
|
105
|
+
# `(+'').b` (= 2 strings) per parse.
|
|
106
|
+
EMPTY_BIN_PAYLOAD
|
|
107
|
+
else
|
|
108
|
+
slice = buf.byteslice(payload_offset, payload_len)
|
|
109
|
+
# 2.4-B (S5): when the input @inbuf is ASCII-8BIT (which
|
|
110
|
+
# WS::Connection guarantees: see `@inbuf = String.new(
|
|
111
|
+
# capacity:, encoding: ASCII_8BIT)`), `slice.b` is a no-op
|
|
112
|
+
# that allocates a redundant String clone. Skip when the
|
|
113
|
+
# source slice already has the right encoding.
|
|
114
|
+
if masked
|
|
115
|
+
::Hyperion::WebSocket::CFrame.unmask(slice, mask_key)
|
|
116
|
+
elsif slice.encoding == BINARY_ENCODING
|
|
117
|
+
slice
|
|
118
|
+
else
|
|
119
|
+
slice.b
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Frame.new(fin: fin, opcode: opcode_sym, payload: payload, rsv1: rsv1 ? true : false)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Lower-level variant exposing the raw 7-tuple from the C parser
|
|
127
|
+
# AND the cursor advance the caller should apply. WS-1's read loop
|
|
128
|
+
# uses this form to drain multiple frames out of a single buffer
|
|
129
|
+
# in one pass without re-parsing the leading bytes.
|
|
130
|
+
#
|
|
131
|
+
# Returns `[Frame, frame_total_len]` on success, `:incomplete` if
|
|
132
|
+
# not enough bytes have arrived yet. Raises ProtocolError on
|
|
133
|
+
# malformed input.
|
|
134
|
+
def self.parse_with_cursor(buf, offset = 0)
|
|
135
|
+
result = ::Hyperion::WebSocket::CFrame.parse(buf, offset)
|
|
136
|
+
return result if result == :incomplete
|
|
137
|
+
|
|
138
|
+
raise ProtocolError, 'malformed WebSocket frame' if result == :error
|
|
139
|
+
|
|
140
|
+
fin, opcode, payload_len, masked, mask_key, payload_offset, frame_total_len, rsv1 = result
|
|
141
|
+
|
|
142
|
+
opcode_sym = OPCODE_NAMES[opcode] ||
|
|
143
|
+
raise(ProtocolError, "unknown opcode 0x#{opcode.to_s(16)}")
|
|
144
|
+
|
|
145
|
+
payload =
|
|
146
|
+
if payload_len.zero?
|
|
147
|
+
# 2.4-B (S5): share one frozen empty binary String. See
|
|
148
|
+
# parse() above for the rationale.
|
|
149
|
+
EMPTY_BIN_PAYLOAD
|
|
150
|
+
else
|
|
151
|
+
slice = buf.byteslice(payload_offset, payload_len)
|
|
152
|
+
if masked
|
|
153
|
+
::Hyperion::WebSocket::CFrame.unmask(slice, mask_key)
|
|
154
|
+
elsif slice.encoding == BINARY_ENCODING
|
|
155
|
+
slice
|
|
156
|
+
else
|
|
157
|
+
slice.b
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
[
|
|
162
|
+
Frame.new(fin: fin, opcode: opcode_sym, payload: payload, rsv1: rsv1 ? true : false),
|
|
163
|
+
frame_total_len
|
|
164
|
+
]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
module Builder
|
|
169
|
+
# Build a serialized frame ready for `socket.write`.
|
|
170
|
+
#
|
|
171
|
+
# opcode — Symbol from OPCODES.keys (`:text`, `:binary`, …)
|
|
172
|
+
# OR Integer (raw opcode bits).
|
|
173
|
+
# payload — String. Coerced to ASCII-8BIT internally so callers
|
|
174
|
+
# may pass UTF-8 text frames without manual conversion.
|
|
175
|
+
# fin — true/false. RFC 6455 §5.4 fragmentation: false on all
|
|
176
|
+
# but the final frame of a multi-frame message.
|
|
177
|
+
# mask — server frames are unmasked (default). Clients MUST
|
|
178
|
+
# pass mask: true and a 4-byte mask_key.
|
|
179
|
+
#
|
|
180
|
+
# Control frames (close/ping/pong) MUST have payload <= 125 bytes
|
|
181
|
+
# and MUST have fin: true; the C builder raises ArgumentError if
|
|
182
|
+
# those invariants are violated, which we re-raise as-is.
|
|
183
|
+
def self.build(opcode:, payload: '', fin: true, mask: false, mask_key: nil, rsv1: false)
|
|
184
|
+
opcode_int = opcode.is_a?(Symbol) ? OPCODES.fetch(opcode) : Integer(opcode)
|
|
185
|
+
# 2.4-B (S4): skip the redundant `.b` re-encoding when the
|
|
186
|
+
# caller already passes ASCII-8BIT. Servers echoing inbound
|
|
187
|
+
# WebSocket frames (chat, ActionCable, /echo benchmarks) all
|
|
188
|
+
# have binary-encoded payloads at this point — the previous
|
|
189
|
+
# unconditional `.b` allocated a String per send() that just
|
|
190
|
+
# equalled the input.
|
|
191
|
+
bin_payload =
|
|
192
|
+
if payload.is_a?(String)
|
|
193
|
+
payload.encoding == BINARY_ENCODING ? payload : payload.b
|
|
194
|
+
else
|
|
195
|
+
payload.to_s.b
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if mask && mask_key.nil?
|
|
199
|
+
# Caller didn't supply a key — generate one with SecureRandom
|
|
200
|
+
# so client-side tests / scripted clients don't have to.
|
|
201
|
+
require 'securerandom'
|
|
202
|
+
mask_key = SecureRandom.bytes(4)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
::Hyperion::WebSocket::CFrame.build(
|
|
206
|
+
opcode_int,
|
|
207
|
+
bin_payload,
|
|
208
|
+
fin: fin,
|
|
209
|
+
mask: mask,
|
|
210
|
+
mask_key: mask_key,
|
|
211
|
+
rsv1: rsv1
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Pure-Ruby fallback used when the C ext is missing. Same public
|
|
217
|
+
# surface as `CFrame` so the Parser / Builder façades above don't
|
|
218
|
+
# need to branch on `NATIVE_AVAILABLE` per call. Performance is
|
|
219
|
+
# ~5–10× worse on XOR — fine for a safety net, fine for JRuby
|
|
220
|
+
# interop, NOT recommended for the production hot path.
|
|
221
|
+
module RubyFrame
|
|
222
|
+
module_function
|
|
223
|
+
|
|
224
|
+
def unmask(payload, key)
|
|
225
|
+
raise ArgumentError, 'mask_key must be 4 bytes' if key.bytesize != 4
|
|
226
|
+
|
|
227
|
+
out = String.new(capacity: payload.bytesize, encoding: Encoding::BINARY)
|
|
228
|
+
bytes = payload.bytes
|
|
229
|
+
kbytes = key.bytes
|
|
230
|
+
bytes.each_with_index do |b, i|
|
|
231
|
+
out << (b ^ kbytes[i & 0x3]).chr
|
|
232
|
+
end
|
|
233
|
+
out
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse(buf, offset = 0)
|
|
237
|
+
return :incomplete if offset > buf.bytesize
|
|
238
|
+
|
|
239
|
+
avail = buf.bytesize - offset
|
|
240
|
+
return :incomplete if avail < 2
|
|
241
|
+
|
|
242
|
+
b0 = buf.getbyte(offset)
|
|
243
|
+
b1 = buf.getbyte(offset + 1)
|
|
244
|
+
|
|
245
|
+
fin = (b0 & 0x80) != 0
|
|
246
|
+
rsv1 = (b0 & 0x40) != 0
|
|
247
|
+
rsv2 = (b0 & 0x20) != 0
|
|
248
|
+
rsv3 = (b0 & 0x10) != 0
|
|
249
|
+
opcode = b0 & 0x0F
|
|
250
|
+
masked = (b1 & 0x80) != 0
|
|
251
|
+
len7 = b1 & 0x7F
|
|
252
|
+
|
|
253
|
+
# RFC 7692 §6: RSV1 is the permessage-deflate marker. Allow it
|
|
254
|
+
# through in the parse tuple; the Connection wrapper rejects
|
|
255
|
+
# RSV1 when no extension was negotiated. RSV2/RSV3 are reserved
|
|
256
|
+
# with no defined semantics → reject.
|
|
257
|
+
return :error if rsv2 || rsv3
|
|
258
|
+
|
|
259
|
+
return :error unless [0x0, 0x1, 0x2, 0x8, 0x9, 0xA].include?(opcode)
|
|
260
|
+
|
|
261
|
+
if opcode >= 0x8
|
|
262
|
+
return :error unless fin
|
|
263
|
+
return :error if len7 > 125
|
|
264
|
+
# RFC 7692 §6.1 — control frames MUST NOT be compressed.
|
|
265
|
+
return :error if rsv1
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
header_len = 2
|
|
269
|
+
payload_len =
|
|
270
|
+
case len7
|
|
271
|
+
when 0..125
|
|
272
|
+
len7
|
|
273
|
+
when 126
|
|
274
|
+
return :incomplete if avail < header_len + 2
|
|
275
|
+
|
|
276
|
+
v = (buf.getbyte(offset + 2) << 8) | buf.getbyte(offset + 3)
|
|
277
|
+
header_len += 2
|
|
278
|
+
v
|
|
279
|
+
else
|
|
280
|
+
return :incomplete if avail < header_len + 8
|
|
281
|
+
|
|
282
|
+
return :error if (buf.getbyte(offset + 2) & 0x80) != 0
|
|
283
|
+
|
|
284
|
+
v = 0
|
|
285
|
+
8.times { |i| v = (v << 8) | buf.getbyte(offset + 2 + i) }
|
|
286
|
+
header_len += 8
|
|
287
|
+
v
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
mask_key = nil
|
|
291
|
+
if masked
|
|
292
|
+
return :incomplete if avail < header_len + 4
|
|
293
|
+
|
|
294
|
+
mask_key = buf.byteslice(offset + header_len, 4)
|
|
295
|
+
header_len += 4
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
payload_offset = offset + header_len
|
|
299
|
+
frame_total_len = header_len + payload_len
|
|
300
|
+
return :incomplete if avail < frame_total_len
|
|
301
|
+
|
|
302
|
+
[fin, opcode, payload_len, masked, mask_key, payload_offset, frame_total_len, rsv1]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def build(opcode, payload, fin: true, mask: false, mask_key: nil, rsv1: false)
|
|
306
|
+
raise ArgumentError, "unknown opcode 0x#{opcode.to_s(16)}" unless [0x0, 0x1, 0x2, 0x8, 0x9,
|
|
307
|
+
0xA].include?(opcode)
|
|
308
|
+
|
|
309
|
+
payload_len = payload.bytesize
|
|
310
|
+
|
|
311
|
+
if opcode >= 0x8
|
|
312
|
+
raise ArgumentError, 'control frame must have fin=true' unless fin
|
|
313
|
+
raise ArgumentError, 'control frame payload exceeds 125 bytes' if payload_len > 125
|
|
314
|
+
raise ArgumentError, 'control frame must not have rsv1=true' if rsv1
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if mask
|
|
318
|
+
raise ArgumentError, 'mask: true requires a 4-byte mask_key' if mask_key.nil?
|
|
319
|
+
raise ArgumentError, 'mask_key must be 4 bytes' if mask_key.bytesize != 4
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
323
|
+
out << ((fin ? 0x80 : 0x00) | (rsv1 ? 0x40 : 0x00) | (opcode & 0x0F)).chr
|
|
324
|
+
mask_bit = mask ? 0x80 : 0x00
|
|
325
|
+
|
|
326
|
+
if payload_len < 126
|
|
327
|
+
out << (mask_bit | payload_len).chr
|
|
328
|
+
elsif payload_len <= 0xFFFF
|
|
329
|
+
out << (mask_bit | 126).chr
|
|
330
|
+
out << ((payload_len >> 8) & 0xFF).chr
|
|
331
|
+
out << (payload_len & 0xFF).chr
|
|
332
|
+
else
|
|
333
|
+
out << (mask_bit | 127).chr
|
|
334
|
+
8.times { |i| out << ((payload_len >> ((7 - i) * 8)) & 0xFF).chr }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
if mask
|
|
338
|
+
out << mask_key.b
|
|
339
|
+
out << unmask(payload.b, mask_key) # XOR is symmetric
|
|
340
|
+
else
|
|
341
|
+
out << payload.b
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
out
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# If the C ext didn't load, point CFrame at the Ruby fallback. We
|
|
349
|
+
# do this AFTER defining the façades above so they can call
|
|
350
|
+
# `CFrame.parse` etc. uniformly.
|
|
351
|
+
unless defined?(::Hyperion::WebSocket::CFrame) &&
|
|
352
|
+
::Hyperion::WebSocket::CFrame.respond_to?(:parse)
|
|
353
|
+
CFrame = RubyFrame
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|