@rip-lang/server 1.3.117 → 1.3.118
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.
- package/package.json +3 -2
- package/streams/config.rip +120 -0
- package/streams/index.rip +288 -0
- package/streams/pipe.rip +56 -0
- package/streams/router.rip +57 -0
- package/streams/runtime.rip +51 -0
- package/streams/tls_clienthello.rip +89 -0
- package/streams/upstream.rip +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/server",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.118",
|
|
4
4
|
"description": "Pure Rip web framework and application server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "api.rip",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"rip-lang": ">=3.13.
|
|
48
|
+
"rip-lang": ">=3.13.123"
|
|
49
49
|
},
|
|
50
50
|
"files": [
|
|
51
51
|
"api.rip",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"control/",
|
|
60
60
|
"docs/",
|
|
61
61
|
"edge/",
|
|
62
|
+
"streams/",
|
|
62
63
|
"tests/",
|
|
63
64
|
"README.md"
|
|
64
65
|
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/config.rip — stream config normalization and validation
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
isPlainObject = (value) ->
|
|
6
|
+
value? and typeof value is 'object' and not Array.isArray(value)
|
|
7
|
+
|
|
8
|
+
pushError = (errors, code, path, message, hint = null) ->
|
|
9
|
+
errors.push({ code, path, message, hint })
|
|
10
|
+
|
|
11
|
+
normalizePositiveInt = (value, fallback, path, errors, label) ->
|
|
12
|
+
return fallback unless value?
|
|
13
|
+
n = parseInt(String(value))
|
|
14
|
+
unless Number.isFinite(n) and n >= 0
|
|
15
|
+
pushError(errors, 'E_STREAM_NUMBER', path, "#{label} must be a non-negative integer", "Set #{path.split('.').slice(-1)[0]}: #{fallback} or another whole number.")
|
|
16
|
+
return fallback
|
|
17
|
+
n
|
|
18
|
+
|
|
19
|
+
normalizeSniPatterns = (value, path, errors) ->
|
|
20
|
+
patterns = []
|
|
21
|
+
unless Array.isArray(value) and value.length > 0
|
|
22
|
+
pushError(errors, 'E_STREAM_SNI', path, 'sni must be a non-empty array of host patterns', "Use `sni: ['incus.example.com']`.")
|
|
23
|
+
return patterns
|
|
24
|
+
for host, idx in value
|
|
25
|
+
unless typeof host is 'string' and host.trim()
|
|
26
|
+
pushError(errors, 'E_STREAM_SNI_VALUE', "#{path}[#{idx}]", 'sni entry must be a non-empty string', "Use `'incus.example.com'` or `'*.example.com'`.")
|
|
27
|
+
continue
|
|
28
|
+
normalized = host.trim().toLowerCase()
|
|
29
|
+
if normalized.startsWith('*.')
|
|
30
|
+
base = normalized.slice(2)
|
|
31
|
+
labels = base.split('.').filter(Boolean)
|
|
32
|
+
if labels.length < 2
|
|
33
|
+
pushError(errors, 'E_STREAM_SNI_WILDCARD', "#{path}[#{idx}]", 'wildcard SNI patterns must have a base domain with at least two labels', "Use `'*.example.com'`, not `'*.com'`.")
|
|
34
|
+
continue
|
|
35
|
+
else if normalized.includes(':')
|
|
36
|
+
pushError(errors, 'E_STREAM_SNI_PORT', "#{path}[#{idx}]", 'SNI patterns must not include a port', "Use `'incus.example.com'`, not `'incus.example.com:8443'`.")
|
|
37
|
+
continue
|
|
38
|
+
patterns.push(normalized)
|
|
39
|
+
patterns
|
|
40
|
+
|
|
41
|
+
export parseHostPort = (value) ->
|
|
42
|
+
return null unless typeof value is 'string'
|
|
43
|
+
idx = value.lastIndexOf(':')
|
|
44
|
+
return null unless idx > 0 and idx < value.length - 1
|
|
45
|
+
host = value.slice(0, idx)
|
|
46
|
+
port = parseInt(value.slice(idx + 1))
|
|
47
|
+
return null unless host and Number.isFinite(port) and port > 0 and port <= 65535
|
|
48
|
+
{ host, port }
|
|
49
|
+
|
|
50
|
+
normalizeStreamTarget = (target, idx, path, errors) ->
|
|
51
|
+
parsed = parseHostPort(target)
|
|
52
|
+
unless parsed
|
|
53
|
+
pushError(errors, 'E_STREAM_TARGET', "#{path}[#{idx}]", 'stream target must be in host:port form', "Use `'127.0.0.1:8443'`.")
|
|
54
|
+
return null
|
|
55
|
+
{
|
|
56
|
+
targetId: "#{parsed.host}:#{parsed.port}"
|
|
57
|
+
host: parsed.host
|
|
58
|
+
port: parsed.port
|
|
59
|
+
source: target
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export normalizeStreamUpstreams = (rawUpstreams, errors, path = 'streamUpstreams') ->
|
|
63
|
+
upstreams = {}
|
|
64
|
+
return upstreams unless rawUpstreams?
|
|
65
|
+
unless isPlainObject(rawUpstreams)
|
|
66
|
+
pushError(errors, 'E_STREAM_UPSTREAMS_TYPE', path, 'streamUpstreams must be an object keyed by upstream ID', "Use `streamUpstreams: { incus: { targets: ['127.0.0.1:8443'] } }`.")
|
|
67
|
+
return upstreams
|
|
68
|
+
for upstreamId, rawConfig of rawUpstreams
|
|
69
|
+
unless isPlainObject(rawConfig)
|
|
70
|
+
pushError(errors, 'E_STREAM_UPSTREAM_TYPE', "#{path}.#{upstreamId}", 'stream upstream config must be an object', "Use #{upstreamId}: { targets: ['127.0.0.1:8443'] }.")
|
|
71
|
+
continue
|
|
72
|
+
targets = []
|
|
73
|
+
unless Array.isArray(rawConfig.targets) and rawConfig.targets.length > 0
|
|
74
|
+
pushError(errors, 'E_STREAM_UPSTREAM_TARGETS', "#{path}.#{upstreamId}.targets", 'stream upstream must define at least one target', "Use `targets: ['127.0.0.1:8443']`.")
|
|
75
|
+
else
|
|
76
|
+
for target, idx in rawConfig.targets
|
|
77
|
+
normalized = normalizeStreamTarget(target, idx, "#{path}.#{upstreamId}.targets", errors)
|
|
78
|
+
targets.push(normalized) if normalized
|
|
79
|
+
upstreams[upstreamId] =
|
|
80
|
+
id: upstreamId
|
|
81
|
+
targets: targets
|
|
82
|
+
connectTimeoutMs: normalizePositiveInt(rawConfig.connectTimeoutMs, 5000, "#{path}.#{upstreamId}.connectTimeoutMs", errors, 'connectTimeoutMs')
|
|
83
|
+
upstreams
|
|
84
|
+
|
|
85
|
+
normalizeStreamTimeouts = (value, path, errors) ->
|
|
86
|
+
return {} unless value?
|
|
87
|
+
unless isPlainObject(value)
|
|
88
|
+
pushError(errors, 'E_STREAM_TIMEOUTS', path, 'stream timeouts must be an object', "Use `timeouts: { handshakeMs: 5000, idleMs: 300000, connectMs: 5000 }`.")
|
|
89
|
+
return {}
|
|
90
|
+
{
|
|
91
|
+
handshakeMs: normalizePositiveInt(value.handshakeMs, 5000, "#{path}.handshakeMs", errors, 'handshakeMs')
|
|
92
|
+
idleMs: normalizePositiveInt(value.idleMs, 300000, "#{path}.idleMs", errors, 'idleMs')
|
|
93
|
+
connectMs: normalizePositiveInt(value.connectMs, 5000, "#{path}.connectMs", errors, 'connectMs')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export normalizeStreams = (rawStreams, knownUpstreams, errors, path = 'streams') ->
|
|
97
|
+
streams = []
|
|
98
|
+
return streams unless rawStreams?
|
|
99
|
+
unless Array.isArray(rawStreams)
|
|
100
|
+
pushError(errors, 'E_STREAMS_TYPE', path, 'streams must be an array', "Use `streams: [{ listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }]`.")
|
|
101
|
+
return streams
|
|
102
|
+
for route, idx in rawStreams
|
|
103
|
+
itemPath = "#{path}[#{idx}]"
|
|
104
|
+
unless isPlainObject(route)
|
|
105
|
+
pushError(errors, 'E_STREAM_ROUTE_TYPE', itemPath, 'stream route must be an object', "Use `{ listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }`.")
|
|
106
|
+
continue
|
|
107
|
+
listen = normalizePositiveInt(route.listen, 0, "#{itemPath}.listen", errors, 'listen')
|
|
108
|
+
pushError(errors, 'E_STREAM_LISTEN_RANGE', "#{itemPath}.listen", 'listen port must be between 1 and 65535', 'Choose a valid TCP port.') unless listen > 0 and listen <= 65535
|
|
109
|
+
unless typeof route.upstream is 'string' and route.upstream
|
|
110
|
+
pushError(errors, 'E_STREAM_UPSTREAM_REF', "#{itemPath}.upstream", 'stream route must reference an upstream', "Use `upstream: 'incus'`.")
|
|
111
|
+
else unless knownUpstreams.has(route.upstream)
|
|
112
|
+
pushError(errors, 'E_STREAM_UPSTREAM_REF', "#{itemPath}.upstream", "unknown stream upstream #{route.upstream}", 'Define the upstream under `streamUpstreams` first.')
|
|
113
|
+
streams.push
|
|
114
|
+
id: route.id or "stream-#{idx + 1}"
|
|
115
|
+
order: idx
|
|
116
|
+
listen: listen
|
|
117
|
+
sni: normalizeSniPatterns(route.sni, "#{itemPath}.sni", errors)
|
|
118
|
+
upstream: route.upstream or null
|
|
119
|
+
timeouts: normalizeStreamTimeouts(route.timeouts, "#{itemPath}.timeouts", errors)
|
|
120
|
+
streams
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/index.rip — stream runtime facade and listener orchestration
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { parseSNI } from './tls_clienthello.rip'
|
|
6
|
+
import { compileStreamTable, matchStreamRoute, describeStreamRoute } from './router.rip'
|
|
7
|
+
import { createStreamRuntime, buildStreamConfigInfo, describeStreamRuntime } from './runtime.rip'
|
|
8
|
+
import { createStreamUpstreamPool, addStreamUpstream, getStreamUpstream, selectStreamTarget, openStreamConnection, releaseStreamConnection } from './upstream.rip'
|
|
9
|
+
import { createPipeState, writeChunk, flushPending, markPaused, markResumed } from './pipe.rip'
|
|
10
|
+
|
|
11
|
+
MAX_CLIENT_HELLO_BYTES = 16384
|
|
12
|
+
MAX_CONNECT_BUFFER_BYTES = 262144
|
|
13
|
+
|
|
14
|
+
concatBytes = (a, b) ->
|
|
15
|
+
out = new Uint8Array(a.byteLength + b.byteLength)
|
|
16
|
+
out.set(a, 0)
|
|
17
|
+
out.set(b, a.byteLength)
|
|
18
|
+
out
|
|
19
|
+
|
|
20
|
+
export resolveHandshakeTarget = (runtime, listenPort, sni, httpFallback = {}) ->
|
|
21
|
+
route = matchStreamRoute(runtime.routeTable, listenPort, sni)
|
|
22
|
+
if route
|
|
23
|
+
upstream = getStreamUpstream(runtime.upstreamPool, route.upstream)
|
|
24
|
+
unless upstream
|
|
25
|
+
return {
|
|
26
|
+
kind: 'reject'
|
|
27
|
+
code: 'unknown_upstream'
|
|
28
|
+
}
|
|
29
|
+
target = selectStreamTarget(upstream)
|
|
30
|
+
unless target
|
|
31
|
+
return {
|
|
32
|
+
kind: 'reject'
|
|
33
|
+
code: 'no_available_target'
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
kind: 'stream'
|
|
37
|
+
route: route
|
|
38
|
+
upstream: upstream
|
|
39
|
+
target: target
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fallback = httpFallback?[listenPort] or null
|
|
43
|
+
return {
|
|
44
|
+
kind: 'fallback'
|
|
45
|
+
target: fallback
|
|
46
|
+
} if fallback
|
|
47
|
+
{
|
|
48
|
+
kind: 'reject'
|
|
49
|
+
code: 'no_matching_route'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export buildStreamRuntime = (normalized) ->
|
|
53
|
+
routeTable = compileStreamTable(normalized.streams or [])
|
|
54
|
+
upstreamPool = createStreamUpstreamPool()
|
|
55
|
+
for upstreamId, upstreamConfig of (normalized.streamUpstreams or {})
|
|
56
|
+
addStreamUpstream(upstreamPool, upstreamId, upstreamConfig)
|
|
57
|
+
configInfo = buildStreamConfigInfo(normalized)
|
|
58
|
+
createStreamRuntime(configInfo, upstreamPool, routeTable)
|
|
59
|
+
|
|
60
|
+
export streamDiagnostics = (runtime) ->
|
|
61
|
+
return null unless runtime
|
|
62
|
+
summary = describeStreamRuntime(runtime)
|
|
63
|
+
summary.upstreams = Array.from(runtime.upstreamPool.upstreams.values()).map (upstream) ->
|
|
64
|
+
id: upstream.id
|
|
65
|
+
activeConnections: upstream.targets.reduce(((sum, t) -> sum + t.activeConnections), 0)
|
|
66
|
+
totalConnections: upstream.targets.reduce(((sum, t) -> sum + t.totalConnections), 0)
|
|
67
|
+
targets: upstream.targets.map (t) ->
|
|
68
|
+
id: t.targetId
|
|
69
|
+
activeConnections: t.activeConnections
|
|
70
|
+
totalConnections: t.totalConnections
|
|
71
|
+
summary.routes = runtime.configInfo.activeStreamRouteDescriptions or []
|
|
72
|
+
summary
|
|
73
|
+
|
|
74
|
+
cleanupState = (state, options = {}) ->
|
|
75
|
+
return if state.cleanedUp
|
|
76
|
+
state.cleanedUp = true
|
|
77
|
+
clearTimeout(state.handshakeTimer) if state.handshakeTimer
|
|
78
|
+
clearTimeout(state.connectTimer) if state.connectTimer
|
|
79
|
+
clearTimeout(state.idleTimer) if state.idleTimer
|
|
80
|
+
try state.client.close() if state.client?.readyState > 0
|
|
81
|
+
try state.upstream.close() if state.upstream?.readyState > 0
|
|
82
|
+
releaseStreamConnection(state.target) if state.target and state.connectionOpened
|
|
83
|
+
options.onConnectionClose?(state.runtime)
|
|
84
|
+
|
|
85
|
+
finishHalfClose = (state) ->
|
|
86
|
+
if state.clientEnded and not state.upstreamFinSent and state.toUpstream.pending.byteLength is 0
|
|
87
|
+
state.upstreamFinSent = true
|
|
88
|
+
try state.upstream?.end()
|
|
89
|
+
if state.upstreamEnded and not state.clientFinSent and state.toClient.pending.byteLength is 0
|
|
90
|
+
state.clientFinSent = true
|
|
91
|
+
try state.client?.end()
|
|
92
|
+
|
|
93
|
+
resetIdleTimer = (state, options) ->
|
|
94
|
+
clearTimeout(state.idleTimer) if state.idleTimer
|
|
95
|
+
idleMs = state.timeouts.idleMs or 300000
|
|
96
|
+
state.idleTimer = setTimeout((-> cleanupState(state, options)), idleMs)
|
|
97
|
+
|
|
98
|
+
createUpstreamHandlers = (state, options) ->
|
|
99
|
+
open: (upstream) ->
|
|
100
|
+
if state.cleanedUp
|
|
101
|
+
try upstream.close()
|
|
102
|
+
return
|
|
103
|
+
try
|
|
104
|
+
state.upstream = upstream
|
|
105
|
+
clearTimeout(state.connectTimer) if state.connectTimer
|
|
106
|
+
state.phase = 'streaming'
|
|
107
|
+
res = writeChunk(upstream, state.toUpstream, state.buffer)
|
|
108
|
+
return cleanupState(state, options) if res.closed
|
|
109
|
+
state.buffer = new Uint8Array(0)
|
|
110
|
+
if res.needDrain
|
|
111
|
+
state.clientPaused = true
|
|
112
|
+
markPaused(state.client)
|
|
113
|
+
resetIdleTimer(state, options)
|
|
114
|
+
finishHalfClose(state) unless res.needDrain
|
|
115
|
+
catch
|
|
116
|
+
cleanupState(state, options)
|
|
117
|
+
|
|
118
|
+
data: (upstream, data) ->
|
|
119
|
+
return if state.cleanedUp
|
|
120
|
+
resetIdleTimer(state, options)
|
|
121
|
+
res = writeChunk(state.client, state.toClient, new Uint8Array(data))
|
|
122
|
+
return cleanupState(state, options) if res.closed
|
|
123
|
+
if res.needDrain
|
|
124
|
+
state.upstreamPaused = true
|
|
125
|
+
markPaused(upstream)
|
|
126
|
+
|
|
127
|
+
drain: (upstream) ->
|
|
128
|
+
return if state.cleanedUp
|
|
129
|
+
res = flushPending(upstream, state.toUpstream)
|
|
130
|
+
return cleanupState(state, options) if res.closed
|
|
131
|
+
unless res.needDrain
|
|
132
|
+
if state.clientPaused
|
|
133
|
+
state.clientPaused = false
|
|
134
|
+
markResumed(state.client)
|
|
135
|
+
finishHalfClose(state)
|
|
136
|
+
|
|
137
|
+
end: (upstream) ->
|
|
138
|
+
return if state.cleanedUp
|
|
139
|
+
state.upstreamEnded = true
|
|
140
|
+
finishHalfClose(state)
|
|
141
|
+
|
|
142
|
+
close: (upstream, err) ->
|
|
143
|
+
cleanupState(state, options)
|
|
144
|
+
|
|
145
|
+
error: (upstream, err) ->
|
|
146
|
+
cleanupState(state, options)
|
|
147
|
+
|
|
148
|
+
timeout: (upstream) ->
|
|
149
|
+
cleanupState(state, options)
|
|
150
|
+
|
|
151
|
+
connectError: (upstream, err) ->
|
|
152
|
+
cleanupState(state, options)
|
|
153
|
+
|
|
154
|
+
createClientHandlers = (runtime, listenPort, listenerTimeouts, options) ->
|
|
155
|
+
listenerOptions = Object.assign({}, options)
|
|
156
|
+
socket:
|
|
157
|
+
open: (client) ->
|
|
158
|
+
timeouts = Object.assign({ handshakeMs: 5000, idleMs: 300000, connectMs: 5000 }, listenerTimeouts or {}, options.timeouts or {})
|
|
159
|
+
state =
|
|
160
|
+
runtime: runtime
|
|
161
|
+
client: client
|
|
162
|
+
upstream: null
|
|
163
|
+
listenPort: listenPort
|
|
164
|
+
phase: 'handshake'
|
|
165
|
+
buffer: new Uint8Array(0)
|
|
166
|
+
route: null
|
|
167
|
+
target: null
|
|
168
|
+
cleanedUp: false
|
|
169
|
+
clientPaused: false
|
|
170
|
+
upstreamPaused: false
|
|
171
|
+
toUpstream: createPipeState()
|
|
172
|
+
toClient: createPipeState()
|
|
173
|
+
clientEnded: false
|
|
174
|
+
upstreamEnded: false
|
|
175
|
+
clientFinSent: false
|
|
176
|
+
upstreamFinSent: false
|
|
177
|
+
connectionOpened: false
|
|
178
|
+
handshakeTimer: null
|
|
179
|
+
connectTimer: null
|
|
180
|
+
idleTimer: null
|
|
181
|
+
timeouts: timeouts
|
|
182
|
+
client.data = state
|
|
183
|
+
state.handshakeTimer = setTimeout((-> cleanupState(state, listenerOptions)), timeouts.handshakeMs)
|
|
184
|
+
options.onConnectionOpen?(runtime)
|
|
185
|
+
|
|
186
|
+
data: (client, data) ->
|
|
187
|
+
state = client.data
|
|
188
|
+
return if state.cleanedUp
|
|
189
|
+
resetIdleTimer(state, listenerOptions) if state.phase is 'streaming'
|
|
190
|
+
|
|
191
|
+
if state.phase is 'handshake'
|
|
192
|
+
next = new Uint8Array(data)
|
|
193
|
+
state.buffer = if state.buffer.byteLength > 0 then concatBytes(state.buffer, next) else next
|
|
194
|
+
return cleanupState(state, listenerOptions) if state.buffer.byteLength > MAX_CLIENT_HELLO_BYTES
|
|
195
|
+
|
|
196
|
+
parsed = parseSNI(state.buffer)
|
|
197
|
+
return if parsed.code is 'need_more_bytes'
|
|
198
|
+
hasFallback = Boolean(options.httpFallback?[state.listenPort])
|
|
199
|
+
return cleanupState(state, listenerOptions) unless parsed.ok or (parsed.code is 'no_sni' and hasFallback)
|
|
200
|
+
|
|
201
|
+
requestedSni = if parsed.ok then parsed.sni else null
|
|
202
|
+
result = resolveHandshakeTarget(runtime, state.listenPort, requestedSni, options.httpFallback or {})
|
|
203
|
+
return cleanupState(state, listenerOptions) if result.kind is 'reject'
|
|
204
|
+
|
|
205
|
+
state.route = result.route or null
|
|
206
|
+
state.target = if result.kind is 'stream' then result.target else null
|
|
207
|
+
state.timeouts = if result.kind is 'stream'
|
|
208
|
+
Object.assign({}, state.timeouts, result.route.timeouts or {}, connectMs: result.route.timeouts?.connectMs or result.upstream.connectTimeoutMs or state.timeouts.connectMs)
|
|
209
|
+
else
|
|
210
|
+
Object.assign({}, state.timeouts)
|
|
211
|
+
|
|
212
|
+
connectHost = if result.kind is 'stream' then result.target.host else result.target.hostname
|
|
213
|
+
connectPort = result.target.port
|
|
214
|
+
openStreamConnection(result.target) if result.kind is 'stream'
|
|
215
|
+
state.connectionOpened = result.kind is 'stream'
|
|
216
|
+
state.phase = 'connecting'
|
|
217
|
+
clearTimeout(state.handshakeTimer) if state.handshakeTimer
|
|
218
|
+
state.connectTimer = setTimeout((-> cleanupState(state, listenerOptions)), state.timeouts.connectMs or 5000)
|
|
219
|
+
Bun.connect(
|
|
220
|
+
hostname: connectHost
|
|
221
|
+
port: connectPort
|
|
222
|
+
allowHalfOpen: true
|
|
223
|
+
socket: createUpstreamHandlers(state, listenerOptions)
|
|
224
|
+
).catch(-> cleanupState(state, listenerOptions))
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if state.phase is 'connecting'
|
|
228
|
+
next = new Uint8Array(data)
|
|
229
|
+
state.buffer = if state.buffer.byteLength > 0 then concatBytes(state.buffer, next) else next
|
|
230
|
+
return cleanupState(state, listenerOptions) if state.buffer.byteLength > MAX_CONNECT_BUFFER_BYTES
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if state.phase is 'streaming'
|
|
234
|
+
res = writeChunk(state.upstream, state.toUpstream, new Uint8Array(data))
|
|
235
|
+
return cleanupState(state, listenerOptions) if res.closed
|
|
236
|
+
if res.needDrain
|
|
237
|
+
state.clientPaused = true
|
|
238
|
+
markPaused(client)
|
|
239
|
+
|
|
240
|
+
drain: (client) ->
|
|
241
|
+
state = client.data
|
|
242
|
+
return if state.cleanedUp
|
|
243
|
+
res = flushPending(client, state.toClient)
|
|
244
|
+
return cleanupState(state, listenerOptions) if res.closed
|
|
245
|
+
unless res.needDrain
|
|
246
|
+
if state.upstreamPaused
|
|
247
|
+
state.upstreamPaused = false
|
|
248
|
+
markResumed(state.upstream)
|
|
249
|
+
finishHalfClose(state)
|
|
250
|
+
|
|
251
|
+
end: (client) ->
|
|
252
|
+
state = client.data
|
|
253
|
+
return if state.cleanedUp
|
|
254
|
+
state.clientEnded = true
|
|
255
|
+
finishHalfClose(state)
|
|
256
|
+
|
|
257
|
+
close: (client, err) ->
|
|
258
|
+
cleanupState(client.data, listenerOptions)
|
|
259
|
+
|
|
260
|
+
error: (client, err) ->
|
|
261
|
+
cleanupState(client.data, listenerOptions)
|
|
262
|
+
|
|
263
|
+
export startStreamListeners = (runtime, options = {}) ->
|
|
264
|
+
routesByPort = new Map()
|
|
265
|
+
for route in (runtime.routeTable.routes or [])
|
|
266
|
+
list = routesByPort.get(route.listen) or []
|
|
267
|
+
list.push(route)
|
|
268
|
+
routesByPort.set(route.listen, list)
|
|
269
|
+
ports = Array.from(routesByPort.keys())
|
|
270
|
+
for port in ports
|
|
271
|
+
portRoutes = routesByPort.get(port) or []
|
|
272
|
+
listenerTimeouts =
|
|
273
|
+
handshakeMs: Math.max(...portRoutes.map((route) -> route.timeouts?.handshakeMs or 5000), 5000)
|
|
274
|
+
idleMs: Math.max(...portRoutes.map((route) -> route.timeouts?.idleMs or 300000), 300000)
|
|
275
|
+
connectMs: Math.max(...portRoutes.map((route) -> route.timeouts?.connectMs or 5000), 5000)
|
|
276
|
+
listener = Bun.listen
|
|
277
|
+
hostname: options.hostname or '0.0.0.0'
|
|
278
|
+
port: port
|
|
279
|
+
allowHalfOpen: true
|
|
280
|
+
socket: createClientHandlers(runtime, port, listenerTimeouts, options).socket
|
|
281
|
+
runtime.listeners.set(port, listener)
|
|
282
|
+
runtime
|
|
283
|
+
|
|
284
|
+
export stopStreamListeners = (runtime, closeActiveConnections = false) ->
|
|
285
|
+
for [port, listener] as runtime.listeners
|
|
286
|
+
try listener.stop(closeActiveConnections)
|
|
287
|
+
runtime.listeners.clear()
|
|
288
|
+
runtime
|
package/streams/pipe.rip
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/pipe.rip — backpressure-safe socket write helpers
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
toBytes = (data) ->
|
|
6
|
+
if data instanceof Uint8Array then data else new Uint8Array(data)
|
|
7
|
+
|
|
8
|
+
concatBytes = (a, b) ->
|
|
9
|
+
a = toBytes(a)
|
|
10
|
+
b = toBytes(b)
|
|
11
|
+
out = new Uint8Array(a.byteLength + b.byteLength)
|
|
12
|
+
out.set(a, 0)
|
|
13
|
+
out.set(b, a.byteLength)
|
|
14
|
+
out
|
|
15
|
+
|
|
16
|
+
export createPipeState = ->
|
|
17
|
+
pending: new Uint8Array(0)
|
|
18
|
+
waitingForDrain: false
|
|
19
|
+
|
|
20
|
+
queuePending = (state, data, wrote) ->
|
|
21
|
+
bytes = toBytes(data)
|
|
22
|
+
remainder = bytes.subarray(Math.max(0, wrote))
|
|
23
|
+
state.pending = if state.pending.byteLength > 0 then concatBytes(state.pending, remainder) else new Uint8Array(remainder)
|
|
24
|
+
state.waitingForDrain = state.pending.byteLength > 0
|
|
25
|
+
state
|
|
26
|
+
|
|
27
|
+
export writeChunk = (socket, state, data) ->
|
|
28
|
+
bytes = toBytes(data)
|
|
29
|
+
if state.pending.byteLength > 0
|
|
30
|
+
state.pending = concatBytes(state.pending, bytes)
|
|
31
|
+
state.waitingForDrain = true
|
|
32
|
+
return { ok: true, closed: false, wrote: 0, buffered: state.pending.byteLength, needDrain: true }
|
|
33
|
+
wrote = socket.write(bytes)
|
|
34
|
+
return { ok: false, closed: true, wrote: -1, buffered: state.pending.byteLength } if wrote < 0
|
|
35
|
+
if wrote < bytes.byteLength
|
|
36
|
+
queuePending(state, bytes, wrote)
|
|
37
|
+
return { ok: true, closed: false, wrote: wrote, buffered: state.pending.byteLength, needDrain: true }
|
|
38
|
+
{ ok: true, closed: false, wrote: wrote, buffered: state.pending.byteLength, needDrain: false }
|
|
39
|
+
|
|
40
|
+
export flushPending = (socket, state) ->
|
|
41
|
+
return { ok: true, closed: false, wrote: 0, buffered: 0, needDrain: false } unless state.pending.byteLength > 0
|
|
42
|
+
wrote = socket.write(state.pending)
|
|
43
|
+
return { ok: false, closed: true, wrote: -1, buffered: state.pending.byteLength } if wrote < 0
|
|
44
|
+
if wrote < state.pending.byteLength
|
|
45
|
+
state.pending = new Uint8Array(state.pending.subarray(wrote))
|
|
46
|
+
state.waitingForDrain = true
|
|
47
|
+
return { ok: true, closed: false, wrote: wrote, buffered: state.pending.byteLength, needDrain: true }
|
|
48
|
+
state.pending = new Uint8Array(0)
|
|
49
|
+
state.waitingForDrain = false
|
|
50
|
+
{ ok: true, closed: false, wrote: wrote, buffered: 0, needDrain: false }
|
|
51
|
+
|
|
52
|
+
export markPaused = (socket) ->
|
|
53
|
+
socket.pause?()
|
|
54
|
+
|
|
55
|
+
export markResumed = (socket) ->
|
|
56
|
+
socket.resume?()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/router.rip — stream route compilation and matching
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
sniKind = (pattern) ->
|
|
6
|
+
return 'wildcard' if typeof pattern is 'string' and pattern.startsWith('*.')
|
|
7
|
+
'exact'
|
|
8
|
+
|
|
9
|
+
wildcardMatch = (pattern, hostname) ->
|
|
10
|
+
return false unless typeof pattern is 'string' and pattern.startsWith('*.')
|
|
11
|
+
suffix = pattern.slice(1)
|
|
12
|
+
return false unless hostname.endsWith(suffix)
|
|
13
|
+
prefix = hostname.slice(0, hostname.length - suffix.length)
|
|
14
|
+
prefix.length > 0 and not prefix.includes('.')
|
|
15
|
+
|
|
16
|
+
sniMatches = (pattern, hostname) ->
|
|
17
|
+
if sniKind(pattern) is 'exact'
|
|
18
|
+
hostname is pattern
|
|
19
|
+
else
|
|
20
|
+
wildcardMatch(pattern, hostname)
|
|
21
|
+
|
|
22
|
+
sniScore = (pattern) ->
|
|
23
|
+
if sniKind(pattern) is 'exact' then 20 else 10
|
|
24
|
+
|
|
25
|
+
export compileStreamTable = (routes = []) ->
|
|
26
|
+
compiled = []
|
|
27
|
+
for route in routes
|
|
28
|
+
for pattern in (route.sni or [])
|
|
29
|
+
compiled.push
|
|
30
|
+
id: route.id
|
|
31
|
+
order: route.order or 0
|
|
32
|
+
listen: route.listen
|
|
33
|
+
upstream: route.upstream
|
|
34
|
+
sni: pattern
|
|
35
|
+
sniKind: sniKind(pattern)
|
|
36
|
+
timeouts: route.timeouts or {}
|
|
37
|
+
{ routes: compiled }
|
|
38
|
+
|
|
39
|
+
export matchStreamRoute = (table, listenPort, sni) ->
|
|
40
|
+
host = String(sni or '').toLowerCase()
|
|
41
|
+
best = null
|
|
42
|
+
for route in (table?.routes or [])
|
|
43
|
+
continue unless route.listen is listenPort
|
|
44
|
+
continue unless sniMatches(route.sni, host)
|
|
45
|
+
better = false
|
|
46
|
+
if not best
|
|
47
|
+
better = true
|
|
48
|
+
else if sniScore(route.sni) > sniScore(best.sni)
|
|
49
|
+
better = true
|
|
50
|
+
else if sniScore(route.sni) is sniScore(best.sni) and route.order < best.order
|
|
51
|
+
better = true
|
|
52
|
+
best = route if better
|
|
53
|
+
best
|
|
54
|
+
|
|
55
|
+
export describeStreamRoute = (route) ->
|
|
56
|
+
return null unless route
|
|
57
|
+
"#{route.listen} #{route.sni} -> #{route.upstream}"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/runtime.rip — stream runtime lifecycle helpers
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { createStreamUpstreamPool } from './upstream.rip'
|
|
6
|
+
import { compileStreamTable, describeStreamRoute } from './router.rip'
|
|
7
|
+
|
|
8
|
+
export createStreamRuntime = (configInfo = null, upstreamPool = null, routeTable = null) ->
|
|
9
|
+
upstreamPool = upstreamPool or createStreamUpstreamPool()
|
|
10
|
+
routeTable = routeTable or compileStreamTable()
|
|
11
|
+
configInfo = configInfo or {
|
|
12
|
+
streamCounts:
|
|
13
|
+
streamUpstreams: 0
|
|
14
|
+
streams: 0
|
|
15
|
+
activeStreamRouteDescriptions: []
|
|
16
|
+
}
|
|
17
|
+
{
|
|
18
|
+
id: "stream-#{Date.now()}-#{Math.random().toString(16).slice(2, 8)}"
|
|
19
|
+
upstreamPool: upstreamPool
|
|
20
|
+
routeTable: routeTable
|
|
21
|
+
listeners: new Map()
|
|
22
|
+
inflight: 0
|
|
23
|
+
retiredAt: null
|
|
24
|
+
configInfo: configInfo
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export describeStreamRuntime = (runtime) ->
|
|
28
|
+
return null unless runtime
|
|
29
|
+
{
|
|
30
|
+
id: runtime.id
|
|
31
|
+
inflight: runtime.inflight
|
|
32
|
+
retiredAt: runtime.retiredAt
|
|
33
|
+
listeners: Array.from(runtime.listeners.keys())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export streamUsesListenPort = (runtimeOrRoutes, listenPort) ->
|
|
37
|
+
return false unless typeof listenPort is 'number'
|
|
38
|
+
routes = if runtimeOrRoutes?.routeTable?.routes
|
|
39
|
+
runtimeOrRoutes.routeTable.routes
|
|
40
|
+
else
|
|
41
|
+
runtimeOrRoutes or []
|
|
42
|
+
(routes or []).some((route) -> route?.listen is listenPort)
|
|
43
|
+
|
|
44
|
+
export buildStreamConfigInfo = (normalized) ->
|
|
45
|
+
routeTable = compileStreamTable(normalized.streams or [])
|
|
46
|
+
{
|
|
47
|
+
streamCounts:
|
|
48
|
+
streamUpstreams: Object.keys(normalized.streamUpstreams or {}).length
|
|
49
|
+
streams: (normalized.streams or []).length
|
|
50
|
+
activeStreamRouteDescriptions: (routeTable.routes or []).map(describeStreamRoute).filter(Boolean)
|
|
51
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/tls_clienthello.rip — minimal ClientHello SNI extractor
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
MAX_CLIENT_HELLO_BYTES = 16384
|
|
6
|
+
|
|
7
|
+
u16 = (buf, idx) -> (buf[idx] << 8) | buf[idx + 1]
|
|
8
|
+
u24 = (buf, idx) -> (buf[idx] << 16) | (buf[idx + 1] << 8) | buf[idx + 2]
|
|
9
|
+
|
|
10
|
+
fail = (code, message, needMoreBytes = false) ->
|
|
11
|
+
{ ok: false, code, message, needMoreBytes }
|
|
12
|
+
|
|
13
|
+
export parseSNI = (input) ->
|
|
14
|
+
buf = if input instanceof Uint8Array then input else new Uint8Array(input or [])
|
|
15
|
+
len = buf.length
|
|
16
|
+
|
|
17
|
+
return fail('need_more_bytes', 'need at least 5 bytes for TLS record header', true) if len < 5
|
|
18
|
+
return fail('bad_record_type', 'expected TLS handshake record') unless buf[0] is 0x16
|
|
19
|
+
|
|
20
|
+
recordLen = u16(buf, 3)
|
|
21
|
+
totalLen = 5 + recordLen
|
|
22
|
+
return fail('client_hello_too_large', "ClientHello exceeds #{MAX_CLIENT_HELLO_BYTES} byte cap") if totalLen > MAX_CLIENT_HELLO_BYTES
|
|
23
|
+
return fail('need_more_bytes', 'partial TLS record', true) if len < totalLen
|
|
24
|
+
|
|
25
|
+
return fail('need_more_bytes', 'need handshake header', true) if totalLen < 9
|
|
26
|
+
return fail('bad_handshake_type', 'expected TLS ClientHello handshake') unless buf[5] is 0x01
|
|
27
|
+
|
|
28
|
+
helloLen = u24(buf, 6)
|
|
29
|
+
helloEnd = 9 + helloLen
|
|
30
|
+
return fail('need_more_bytes', 'partial ClientHello', true) if len < helloEnd
|
|
31
|
+
|
|
32
|
+
cursor = 9
|
|
33
|
+
return fail('need_more_bytes', 'need client version and random', true) if cursor + 34 > helloEnd
|
|
34
|
+
cursor += 34
|
|
35
|
+
|
|
36
|
+
return fail('need_more_bytes', 'need session ID length', true) if cursor + 1 > helloEnd
|
|
37
|
+
sessionLen = buf[cursor]
|
|
38
|
+
cursor++
|
|
39
|
+
return fail('bad_session_id_length', 'session ID exceeds ClientHello bounds') if cursor + sessionLen > helloEnd
|
|
40
|
+
cursor += sessionLen
|
|
41
|
+
|
|
42
|
+
return fail('need_more_bytes', 'need cipher suites length', true) if cursor + 2 > helloEnd
|
|
43
|
+
cipherLen = u16(buf, cursor)
|
|
44
|
+
cursor += 2
|
|
45
|
+
return fail('bad_cipher_length', 'cipher suite list exceeds ClientHello bounds') if cursor + cipherLen > helloEnd
|
|
46
|
+
cursor += cipherLen
|
|
47
|
+
|
|
48
|
+
return fail('need_more_bytes', 'need compression methods length', true) if cursor + 1 > helloEnd
|
|
49
|
+
compressionLen = buf[cursor]
|
|
50
|
+
cursor++
|
|
51
|
+
return fail('bad_compression_length', 'compression methods exceed ClientHello bounds') if cursor + compressionLen > helloEnd
|
|
52
|
+
cursor += compressionLen
|
|
53
|
+
|
|
54
|
+
return fail('no_sni', 'ClientHello has no extensions') if cursor is helloEnd
|
|
55
|
+
return fail('need_more_bytes', 'need extensions length', true) if cursor + 2 > helloEnd
|
|
56
|
+
extensionsLen = u16(buf, cursor)
|
|
57
|
+
cursor += 2
|
|
58
|
+
extensionsEnd = cursor + extensionsLen
|
|
59
|
+
return fail('bad_extensions_length', 'extensions exceed ClientHello bounds') if extensionsEnd > helloEnd
|
|
60
|
+
|
|
61
|
+
decoder = new TextDecoder()
|
|
62
|
+
|
|
63
|
+
while cursor < extensionsEnd
|
|
64
|
+
return fail('bad_extension_header', 'incomplete extension header') if cursor + 4 > extensionsEnd
|
|
65
|
+
extType = u16(buf, cursor)
|
|
66
|
+
extLen = u16(buf, cursor + 2)
|
|
67
|
+
cursor += 4
|
|
68
|
+
return fail('bad_extension_length', 'extension data exceeds extensions bounds') if cursor + extLen > extensionsEnd
|
|
69
|
+
|
|
70
|
+
if extType is 0x0000
|
|
71
|
+
extEnd = cursor + extLen
|
|
72
|
+
return fail('bad_sni_length', 'SNI extension too short') if cursor + 2 > extEnd
|
|
73
|
+
listLen = u16(buf, cursor)
|
|
74
|
+
cursor += 2
|
|
75
|
+
return fail('bad_sni_list_length', 'SNI list exceeds extension bounds') if cursor + listLen > extEnd
|
|
76
|
+
return fail('bad_sni_entry', 'SNI entry is incomplete') if cursor + 3 > extEnd
|
|
77
|
+
nameType = buf[cursor]
|
|
78
|
+
cursor++
|
|
79
|
+
return fail('bad_sni_type', 'only host_name SNI entries are supported') unless nameType is 0x00
|
|
80
|
+
nameLen = u16(buf, cursor)
|
|
81
|
+
cursor += 2
|
|
82
|
+
return fail('bad_sni_name_length', 'SNI hostname exceeds extension bounds') if cursor + nameLen > extEnd
|
|
83
|
+
sni = decoder.decode(buf.subarray(cursor, cursor + nameLen)).toLowerCase()
|
|
84
|
+
return fail('no_sni', 'SNI hostname is empty') unless sni
|
|
85
|
+
return { ok: true, sni, bytesConsumed: totalLen }
|
|
86
|
+
|
|
87
|
+
cursor += extLen
|
|
88
|
+
|
|
89
|
+
fail('no_sni', 'ClientHello does not include SNI')
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# streams/upstream.rip — stream upstream selection and accounting
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
export createStreamUpstreamPool = ->
|
|
6
|
+
upstreams: new Map()
|
|
7
|
+
|
|
8
|
+
export addStreamUpstream = (pool, id, config) ->
|
|
9
|
+
targets = (config.targets or []).map (target, idx) ->
|
|
10
|
+
targetId: target.targetId or "#{target.host}:#{target.port}"
|
|
11
|
+
host: target.host
|
|
12
|
+
port: target.port
|
|
13
|
+
source: target.source or "#{target.host}:#{target.port}"
|
|
14
|
+
activeConnections: 0
|
|
15
|
+
totalConnections: 0
|
|
16
|
+
upstream =
|
|
17
|
+
id: id
|
|
18
|
+
connectTimeoutMs: config.connectTimeoutMs or 5000
|
|
19
|
+
targets: targets
|
|
20
|
+
pool.upstreams.set(id, upstream)
|
|
21
|
+
upstream
|
|
22
|
+
|
|
23
|
+
export getStreamUpstream = (pool, id) ->
|
|
24
|
+
pool.upstreams.get(id) ?? null
|
|
25
|
+
|
|
26
|
+
export listStreamUpstreams = (pool) ->
|
|
27
|
+
Array.from(pool.upstreams.values())
|
|
28
|
+
|
|
29
|
+
export selectStreamTarget = (upstream) ->
|
|
30
|
+
return null unless upstream?.targets?.length
|
|
31
|
+
best = upstream.targets[0]
|
|
32
|
+
for target in upstream.targets[1..]
|
|
33
|
+
better = false
|
|
34
|
+
if target.activeConnections < best.activeConnections
|
|
35
|
+
better = true
|
|
36
|
+
else if target.activeConnections is best.activeConnections and target.targetId < best.targetId
|
|
37
|
+
better = true
|
|
38
|
+
best = target if better
|
|
39
|
+
best
|
|
40
|
+
|
|
41
|
+
export openStreamConnection = (target) ->
|
|
42
|
+
target.activeConnections++
|
|
43
|
+
target.totalConnections++
|
|
44
|
+
target
|
|
45
|
+
|
|
46
|
+
export releaseStreamConnection = (target) ->
|
|
47
|
+
target.activeConnections = Math.max(0, target.activeConnections - 1)
|
|
48
|
+
target
|