@rip-lang/server 1.3.125 → 1.4.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.
- package/{docs/READ_VALIDATORS.md → API.md} +41 -119
- package/CONFIG.md +408 -0
- package/README.md +246 -1109
- package/acme/crypto.rip +0 -2
- package/browse.rip +62 -0
- package/control/cli.rip +95 -36
- package/control/lifecycle.rip +67 -1
- package/control/manager.rip +250 -0
- package/control/mdns.rip +3 -0
- package/middleware.rip +1 -1
- package/package.json +14 -11
- package/server.rip +189 -673
- package/serving/config.rip +766 -0
- package/{edge → serving}/forwarding.rip +2 -2
- package/serving/logging.rip +101 -0
- package/{edge → serving}/metrics.rip +29 -1
- package/serving/proxy.rip +99 -0
- package/{edge → serving}/queue.rip +1 -1
- package/{edge → serving}/ratelimit.rip +1 -1
- package/{edge → serving}/realtime.rip +71 -2
- package/{edge → serving}/registry.rip +1 -1
- package/{edge → serving}/router.rip +3 -3
- package/{edge → serving}/runtime.rip +18 -16
- package/{edge → serving}/security.rip +1 -1
- package/serving/static.rip +393 -0
- package/{edge → serving}/tls.rip +3 -7
- package/{edge → serving}/upstream.rip +4 -4
- package/{edge → serving}/verify.rip +16 -16
- package/streams/{tls_clienthello.rip → clienthello.rip} +1 -1
- package/streams/config.rip +8 -8
- package/streams/index.rip +5 -5
- package/streams/router.rip +2 -2
- package/tests/acme.rip +1 -1
- package/tests/config.rip +215 -0
- package/tests/control.rip +1 -1
- package/tests/{runtime_entrypoints.rip → entrypoints.rip} +11 -7
- package/tests/extracted.rip +118 -0
- package/tests/helpers.rip +4 -4
- package/tests/metrics.rip +3 -3
- package/tests/proxy.rip +9 -8
- package/tests/read.rip +1 -1
- package/tests/realtime.rip +3 -3
- package/tests/registry.rip +4 -4
- package/tests/router.rip +27 -27
- package/tests/runner.rip +70 -0
- package/tests/security.rip +4 -4
- package/tests/servers.rip +102 -136
- package/tests/static.rip +2 -2
- package/tests/streams_clienthello.rip +2 -2
- package/tests/streams_index.rip +4 -4
- package/tests/streams_pipe.rip +1 -1
- package/tests/streams_router.rip +10 -10
- package/tests/streams_runtime.rip +4 -4
- package/tests/streams_upstream.rip +1 -1
- package/tests/upstream.rip +2 -2
- package/tests/verify.rip +18 -18
- package/tests/watchers.rip +4 -4
- package/default.rip +0 -435
- package/docs/edge/CONFIG_LIFECYCLE.md +0 -111
- package/docs/edge/CONTRACTS.md +0 -137
- package/docs/edge/EDGEFILE_CONTRACT.md +0 -282
- package/docs/edge/M0B_REVIEW_NOTES.md +0 -102
- package/docs/edge/SCHEDULER.md +0 -46
- package/docs/logo.png +0 -0
- package/docs/logo.svg +0 -13
- package/docs/social.png +0 -0
- package/edge/config.rip +0 -607
- package/edge/static.rip +0 -69
- package/tests/edgefile.rip +0 -165
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# serving/config.rip — serve.rip loading and validation
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { existsSync, statSync } from 'node:fs'
|
|
6
|
+
import { join, dirname, resolve, basename } from 'node:path'
|
|
7
|
+
import { normalizeStreams, parseHostPort } from '../streams/config.rip'
|
|
8
|
+
|
|
9
|
+
CONFIG_FILE = 'serve.rip'
|
|
10
|
+
VALID_REDIRECT_STATUSES = new Set([301, 302, 307, 308])
|
|
11
|
+
TOP_LEVEL_KEYS = new Set(%w[
|
|
12
|
+
version server edge proxies apps certs rules groups hosts streams
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
isPlainObject = (value) ->
|
|
16
|
+
value? and typeof value is 'object' and not Array.isArray(value) and not (value instanceof Map)
|
|
17
|
+
|
|
18
|
+
isEntriesSource = (value) ->
|
|
19
|
+
isPlainObject(value) or value instanceof Map
|
|
20
|
+
|
|
21
|
+
entriesOf = (value) ->
|
|
22
|
+
if value instanceof Map then Array.from(value.entries())
|
|
23
|
+
else if isPlainObject(value) then Object.entries(value)
|
|
24
|
+
else []
|
|
25
|
+
|
|
26
|
+
cloneRoute = (route) ->
|
|
27
|
+
Object.assign({}, route)
|
|
28
|
+
|
|
29
|
+
dedupe = (list) ->
|
|
30
|
+
Array.from(new Set(list))
|
|
31
|
+
|
|
32
|
+
pushError = (errors, code, path, message, hint = null) ->
|
|
33
|
+
errors.push({ code, path, message, hint })
|
|
34
|
+
|
|
35
|
+
validationError = (label, errors) ->
|
|
36
|
+
primary = errors[0]
|
|
37
|
+
err = new Error(primary?.message or "#{label} is invalid")
|
|
38
|
+
err.code = 'RIP_CONFIG_INVALID'
|
|
39
|
+
err.validationErrors = errors
|
|
40
|
+
err.label = label
|
|
41
|
+
err
|
|
42
|
+
|
|
43
|
+
export formatConfigErrors = (label, errors) ->
|
|
44
|
+
lines = ["rip-server: #{label} validation failed (#{errors.length} error#{if errors.length is 1 then '' else 's'})"]
|
|
45
|
+
for err in errors
|
|
46
|
+
line = "- [#{err.code}] #{err.path}: #{err.message}"
|
|
47
|
+
line += " Hint: #{err.hint}" if err.hint
|
|
48
|
+
lines.push(line)
|
|
49
|
+
lines.join('\n')
|
|
50
|
+
|
|
51
|
+
moduleObject = (configPath, label) ->
|
|
52
|
+
return null unless existsSync(configPath)
|
|
53
|
+
mod = import!(configPath)
|
|
54
|
+
await Promise.resolve()
|
|
55
|
+
config = mod?.default or mod
|
|
56
|
+
if config?.then
|
|
57
|
+
throw validationError(label, [
|
|
58
|
+
{
|
|
59
|
+
code: 'E_CONFIG_ASYNC'
|
|
60
|
+
path: '$'
|
|
61
|
+
message: "#{label} must export a plain object, not a promise"
|
|
62
|
+
hint: "Export `default` as a synchronous object literal."
|
|
63
|
+
}
|
|
64
|
+
])
|
|
65
|
+
unless isPlainObject(config)
|
|
66
|
+
throw validationError(label, [
|
|
67
|
+
{
|
|
68
|
+
code: 'E_CONFIG_TYPE'
|
|
69
|
+
path: '$'
|
|
70
|
+
message: "#{label} must export a plain object"
|
|
71
|
+
hint: "Use `export default { ... }`."
|
|
72
|
+
}
|
|
73
|
+
])
|
|
74
|
+
config
|
|
75
|
+
|
|
76
|
+
normalizeHosts = (value, path, errors) ->
|
|
77
|
+
hosts = []
|
|
78
|
+
unless value?
|
|
79
|
+
return hosts
|
|
80
|
+
unless Array.isArray(value)
|
|
81
|
+
pushError(errors, 'E_HOSTS_TYPE', path, 'hosts must be an array of strings', "Use `hosts: ['example.com']`.")
|
|
82
|
+
return hosts
|
|
83
|
+
for host, idx in value
|
|
84
|
+
unless typeof host is 'string' and host.trim()
|
|
85
|
+
pushError(errors, 'E_HOST_TYPE', "#{path}[#{idx}]", 'host must be a non-empty string', "Use `'api.example.com'`, `'*.example.com'`, or `'*'`.")
|
|
86
|
+
continue
|
|
87
|
+
normalized = host.trim().toLowerCase()
|
|
88
|
+
if normalized.startsWith('*.')
|
|
89
|
+
base = normalized.slice(2)
|
|
90
|
+
labels = base.split('.').filter(Boolean)
|
|
91
|
+
if labels.length < 2
|
|
92
|
+
pushError(errors, 'E_HOST_WILDCARD_BASE', "#{path}[#{idx}]", 'wildcard hosts must have a base domain with at least two labels', "Use `'*.example.com'`, not `'*.com'`.")
|
|
93
|
+
continue
|
|
94
|
+
hosts.push(normalized)
|
|
95
|
+
dedupe(hosts)
|
|
96
|
+
|
|
97
|
+
normalizeEnv = (value, path, errors) ->
|
|
98
|
+
return {} unless value?
|
|
99
|
+
unless isPlainObject(value)
|
|
100
|
+
pushError(errors, 'E_ENV_TYPE', path, 'env must be an object', "Use `env: { FOO: 'bar' }`.")
|
|
101
|
+
return {}
|
|
102
|
+
out = {}
|
|
103
|
+
for key, val of value
|
|
104
|
+
unless ['string', 'number', 'boolean'].includes(typeof val)
|
|
105
|
+
pushError(errors, 'E_ENV_VALUE', "#{path}.#{key}", 'env values must be strings, numbers, or booleans', "Convert the value to a string if needed.")
|
|
106
|
+
continue
|
|
107
|
+
out[key] = String(val)
|
|
108
|
+
out
|
|
109
|
+
|
|
110
|
+
normalizePositiveInt = (value, fallback, path, errors, label) ->
|
|
111
|
+
return fallback unless value?
|
|
112
|
+
n = parseInt(String(value))
|
|
113
|
+
unless Number.isFinite(n) and n >= 0
|
|
114
|
+
fieldName = path.split('.').slice(-1)[0]
|
|
115
|
+
pushError(errors, 'E_NUMBER_TYPE', path, "#{label} must be a non-negative integer", "Set #{fieldName}: #{fallback} or another whole number.")
|
|
116
|
+
return fallback
|
|
117
|
+
n
|
|
118
|
+
|
|
119
|
+
normalizeAppConfigInto = (appId, appConfig, baseDir, path, errors) ->
|
|
120
|
+
unless isPlainObject(appConfig)
|
|
121
|
+
pushError(errors, 'E_APP_TYPE', path, 'app config must be an object', "Use #{appId}: { entry: './index.rip' }.")
|
|
122
|
+
appConfig = {}
|
|
123
|
+
entry = if typeof appConfig.entry is 'string' and appConfig.entry.trim()
|
|
124
|
+
if appConfig.entry.startsWith('/') then appConfig.entry else resolve(baseDir, appConfig.entry)
|
|
125
|
+
else if appConfig.entry?
|
|
126
|
+
pushError(errors, 'E_APP_ENTRY', "#{path}.entry", 'entry must be a string path', "Use `entry: './index.rip'`.")
|
|
127
|
+
null
|
|
128
|
+
else
|
|
129
|
+
null
|
|
130
|
+
{
|
|
131
|
+
id: appId
|
|
132
|
+
entry
|
|
133
|
+
hosts: normalizeHosts(appConfig.hosts, "#{path}.hosts", errors)
|
|
134
|
+
workers: if appConfig.workers? then normalizePositiveInt(appConfig.workers, 0, "#{path}.workers", errors, 'workers') else null
|
|
135
|
+
maxQueue: normalizePositiveInt(appConfig.maxQueue, 512, "#{path}.maxQueue", errors, 'maxQueue')
|
|
136
|
+
queueTimeoutMs: normalizePositiveInt(appConfig.queueTimeoutMs, 30000, "#{path}.queueTimeoutMs", errors, 'queueTimeoutMs')
|
|
137
|
+
readTimeoutMs: normalizePositiveInt(appConfig.readTimeoutMs, 30000, "#{path}.readTimeoutMs", errors, 'readTimeoutMs')
|
|
138
|
+
env: do ->
|
|
139
|
+
env = normalizeEnv(appConfig.env, "#{path}.env", errors)
|
|
140
|
+
if typeof appConfig.root is 'string' and appConfig.root.trim()
|
|
141
|
+
env.APP_BASE_DIR = if appConfig.root.startsWith('/') then appConfig.root else resolve(baseDir, appConfig.root)
|
|
142
|
+
env
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
normalizeTimeouts = (value, path, errors) ->
|
|
146
|
+
return {} unless value?
|
|
147
|
+
unless isPlainObject(value)
|
|
148
|
+
pushError(errors, 'E_TIMEOUTS_TYPE', path, 'timeouts must be an object', "Use `timeouts: { connectMs: 2000, readMs: 30000 }`.")
|
|
149
|
+
return {}
|
|
150
|
+
{
|
|
151
|
+
connectMs: normalizePositiveInt(value.connectMs, 2000, "#{path}.connectMs", errors, 'connectMs')
|
|
152
|
+
readMs: normalizePositiveInt(value.readMs, 30000, "#{path}.readMs", errors, 'readMs')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
normalizeVerifyPolicy = (value, path, errors) ->
|
|
156
|
+
return {
|
|
157
|
+
requireHealthyProxies: true
|
|
158
|
+
requireReadyApps: true
|
|
159
|
+
includeUnroutedManagedApps: true
|
|
160
|
+
minHealthyTargetsPerProxy: 1
|
|
161
|
+
} unless value?
|
|
162
|
+
unless isPlainObject(value)
|
|
163
|
+
pushError(errors, 'E_VERIFY_TYPE', path, 'verify must be an object', "Use `verify: { requireHealthyProxies: true }`.")
|
|
164
|
+
return {
|
|
165
|
+
requireHealthyProxies: true
|
|
166
|
+
requireReadyApps: true
|
|
167
|
+
includeUnroutedManagedApps: true
|
|
168
|
+
minHealthyTargetsPerProxy: 1
|
|
169
|
+
}
|
|
170
|
+
{
|
|
171
|
+
requireHealthyProxies: if value.requireHealthyProxies? then value.requireHealthyProxies is true else true
|
|
172
|
+
requireReadyApps: if value.requireReadyApps? then value.requireReadyApps is true else true
|
|
173
|
+
includeUnroutedManagedApps: if value.includeUnroutedManagedApps? then value.includeUnroutedManagedApps is true else true
|
|
174
|
+
minHealthyTargetsPerProxy: Math.max(1, normalizePositiveInt(value.minHealthyTargetsPerProxy, 1, "#{path}.minHealthyTargetsPerProxy", errors, 'minHealthyTargetsPerProxy'))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
normalizeServerSettings = (value, errors) ->
|
|
178
|
+
unless isPlainObject(value)
|
|
179
|
+
pushError(errors, 'E_SERVER_SETTINGS', 'server', 'server settings must be an object', "Use `server: {}` or omit it to use defaults.")
|
|
180
|
+
value = {}
|
|
181
|
+
certPath = if typeof value.cert is 'string' and value.cert.trim() then resolve(process.cwd(), value.cert) else null
|
|
182
|
+
keyPath = if typeof value.key is 'string' and value.key.trim() then resolve(process.cwd(), value.key) else null
|
|
183
|
+
if (certPath and not keyPath) or (keyPath and not certPath)
|
|
184
|
+
pushError(errors, 'E_TLS_PAIR', 'server', 'manual TLS requires both cert and key', "Provide both `server.cert` and `server.key`, or remove both and rely on manual host TLS.")
|
|
185
|
+
acmeDomains = []
|
|
186
|
+
acmeEnabled = false
|
|
187
|
+
if Array.isArray(value.acme)
|
|
188
|
+
acmeEnabled = true
|
|
189
|
+
for domain, idx in value.acme
|
|
190
|
+
unless typeof domain is 'string' and domain.trim()
|
|
191
|
+
pushError(errors, 'E_ACME_DOMAIN_VALUE', "server.acme[#{idx}]", 'acme domain must be a non-empty string', "Use `'example.com'`.")
|
|
192
|
+
continue
|
|
193
|
+
if domain.includes('*')
|
|
194
|
+
pushError(errors, 'E_ACME_WILDCARD', "server.acme[#{idx}]", 'ACME HTTP-01 cannot issue wildcard certificates', "Remove the wildcard domain or provide `cert` and `key` manually via `hosts` or `certs`.")
|
|
195
|
+
continue
|
|
196
|
+
acmeDomains.push(domain.trim().toLowerCase())
|
|
197
|
+
else if value.acme is true
|
|
198
|
+
acmeEnabled = true
|
|
199
|
+
{
|
|
200
|
+
acme: acmeEnabled
|
|
201
|
+
acmeDomains
|
|
202
|
+
certDir: if typeof value.certDir is 'string' and value.certDir.trim() then resolve(process.cwd(), value.certDir) else null
|
|
203
|
+
cert: certPath
|
|
204
|
+
key: keyPath
|
|
205
|
+
hsts: value.hsts is true
|
|
206
|
+
trustedProxies: normalizeHosts(value.trustedProxies, 'server.trustedProxies', errors)
|
|
207
|
+
timeouts: normalizeTimeouts(value.timeouts, 'server.timeouts', errors)
|
|
208
|
+
verify: normalizeVerifyPolicy(value.verify, 'server.verify', errors)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
normalizeProxyConfig = (proxyId, proxyConfig, path, errors) ->
|
|
212
|
+
unless isPlainObject(proxyConfig)
|
|
213
|
+
pushError(errors, 'E_PROXY_TYPE', path, 'proxy config must be an object', "Use #{proxyId}: { hosts: ['http://127.0.0.1:3000'] }.")
|
|
214
|
+
proxyConfig = {}
|
|
215
|
+
rawHosts = proxyConfig.hosts
|
|
216
|
+
httpHosts = []
|
|
217
|
+
tcpTargets = []
|
|
218
|
+
unless Array.isArray(rawHosts) and rawHosts.length > 0
|
|
219
|
+
pushError(errors, 'E_PROXY_HOSTS', "#{path}.hosts", 'proxy must define at least one host', "Use `hosts: ['http://127.0.0.1:3000']` or `hosts: ['tcp://127.0.0.1:8443']`.")
|
|
220
|
+
else
|
|
221
|
+
for host, idx in rawHosts
|
|
222
|
+
unless typeof host is 'string' and host.trim()
|
|
223
|
+
pushError(errors, 'E_PROXY_HOST_VALUE', "#{path}.hosts[#{idx}]", 'proxy host must be a non-empty string', "Use `'http://127.0.0.1:3000'` or `'tcp://127.0.0.1:8443'`.")
|
|
224
|
+
continue
|
|
225
|
+
value = host.trim()
|
|
226
|
+
try
|
|
227
|
+
url = new URL(value)
|
|
228
|
+
switch url.protocol
|
|
229
|
+
when 'http:', 'https:'
|
|
230
|
+
httpHosts.push(value)
|
|
231
|
+
when 'tcp:'
|
|
232
|
+
port = parseInt(url.port)
|
|
233
|
+
unless Number.isFinite(port) and port > 0 and port <= 65535 and url.hostname
|
|
234
|
+
pushError(errors, 'E_PROXY_HOST_VALUE', "#{path}.hosts[#{idx}]", 'tcp proxy hosts must include a valid hostname and port', "Use `'tcp://127.0.0.1:8443'`.")
|
|
235
|
+
continue
|
|
236
|
+
tcpTargets.push
|
|
237
|
+
targetId: "#{url.hostname}:#{port}"
|
|
238
|
+
host: url.hostname
|
|
239
|
+
port: port
|
|
240
|
+
source: value
|
|
241
|
+
else
|
|
242
|
+
pushError(errors, 'E_PROXY_HOST_VALUE', "#{path}.hosts[#{idx}]", 'proxy hosts must use http://, https://, or tcp://', "Use `'http://127.0.0.1:3000'`, `'https://api.example.com'`, or `'tcp://127.0.0.1:8443'`.")
|
|
243
|
+
catch
|
|
244
|
+
pushError(errors, 'E_PROXY_HOST_VALUE', "#{path}.hosts[#{idx}]", 'proxy host must be a valid URL with http://, https://, or tcp://', "Use `'http://127.0.0.1:3000'` or `'tcp://127.0.0.1:8443'`.")
|
|
245
|
+
if httpHosts.length > 0 and tcpTargets.length > 0
|
|
246
|
+
pushError(errors, 'E_PROXY_KIND_MIX', path, 'proxy hosts must all use the same transport scheme family', 'Split HTTP(S) and TCP backends into separate proxy entries.')
|
|
247
|
+
kind = if httpHosts.length > 0 then 'http' else if tcpTargets.length > 0 then 'tcp' else null
|
|
248
|
+
if kind is 'tcp'
|
|
249
|
+
pushError(errors, 'E_PROXY_TCP_CHECK', "#{path}.check", 'TCP proxies do not support `check`', 'Remove `check` or use HTTP backend URLs.') if proxyConfig.check?
|
|
250
|
+
pushError(errors, 'E_PROXY_TCP_RETRY', "#{path}.retry", 'TCP proxies do not support `retry`', 'Remove `retry` or use HTTP backend URLs.') if proxyConfig.retry?
|
|
251
|
+
pushError(errors, 'E_PROXY_TCP_TIMEOUTS', "#{path}.timeouts", 'TCP proxies do not support `timeouts`', 'Use `connectTimeoutMs` for TCP proxies.') if proxyConfig.timeouts?
|
|
252
|
+
return {
|
|
253
|
+
id: proxyId
|
|
254
|
+
kind: 'tcp'
|
|
255
|
+
hosts: (rawHosts or []).slice()
|
|
256
|
+
targets: tcpTargets
|
|
257
|
+
connectTimeoutMs: normalizePositiveInt(proxyConfig.connectTimeoutMs, 5000, "#{path}.connectTimeoutMs", errors, 'connectTimeoutMs')
|
|
258
|
+
}
|
|
259
|
+
{
|
|
260
|
+
id: proxyId
|
|
261
|
+
kind: 'http'
|
|
262
|
+
hosts: httpHosts
|
|
263
|
+
targets: httpHosts
|
|
264
|
+
healthCheck:
|
|
265
|
+
path: if typeof proxyConfig.check?.path is 'string' and proxyConfig.check.path.startsWith('/') then proxyConfig.check.path else '/health'
|
|
266
|
+
intervalMs: normalizePositiveInt(proxyConfig.check?.intervalMs, 5000, "#{path}.check.intervalMs", errors, 'check.intervalMs')
|
|
267
|
+
timeoutMs: normalizePositiveInt(proxyConfig.check?.timeoutMs, 2000, "#{path}.check.timeoutMs", errors, 'check.timeoutMs')
|
|
268
|
+
retry:
|
|
269
|
+
attempts: normalizePositiveInt(proxyConfig.retry?.attempts, 2, "#{path}.retry.attempts", errors, 'retry.attempts')
|
|
270
|
+
retryOn: if Array.isArray(proxyConfig.retry?.retryOn) and proxyConfig.retry.retryOn.length > 0 then proxyConfig.retry.retryOn else [502, 503, 504]
|
|
271
|
+
timeouts: normalizeTimeouts(proxyConfig.timeouts, "#{path}.timeouts", errors)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
normalizeCertProfile = (certId, certConfig, baseDir, path, errors) ->
|
|
275
|
+
if typeof certConfig is 'string' and certConfig.trim()
|
|
276
|
+
stem = certConfig.trim()
|
|
277
|
+
fullStem = if stem.startsWith('/') then stem else resolve(baseDir, stem)
|
|
278
|
+
return {
|
|
279
|
+
id: certId
|
|
280
|
+
certPath: "#{fullStem}.crt"
|
|
281
|
+
keyPath: "#{fullStem}.key"
|
|
282
|
+
}
|
|
283
|
+
unless isPlainObject(certConfig)
|
|
284
|
+
pushError(errors, 'E_CERT_TYPE', path, 'cert entry must be a string stem or an object', "Use #{certId}: '/ssl/site' or #{certId}: { cert: '/ssl/site.crt', key: '/ssl/site.key' }.")
|
|
285
|
+
certConfig = {}
|
|
286
|
+
hasCert = certConfig.cert?
|
|
287
|
+
hasKey = certConfig.key?
|
|
288
|
+
if hasCert isnt hasKey
|
|
289
|
+
pushError(errors, 'E_CERT_PAIR', path, 'cert entry must define both cert and key', "Add both `cert` and `key`.")
|
|
290
|
+
certPath = if typeof certConfig.cert is 'string' and certConfig.cert.trim() then resolve(baseDir, certConfig.cert) else null
|
|
291
|
+
keyPath = if typeof certConfig.key is 'string' and certConfig.key.trim() then resolve(baseDir, certConfig.key) else null
|
|
292
|
+
{
|
|
293
|
+
id: certId
|
|
294
|
+
certPath
|
|
295
|
+
keyPath
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
normalizeCertProfiles = (value, baseDir, errors) ->
|
|
299
|
+
profiles = {}
|
|
300
|
+
return profiles unless value?
|
|
301
|
+
unless isEntriesSource(value)
|
|
302
|
+
pushError(errors, 'E_CERTS_TYPE', 'certs', 'certs must be an object or map keyed by cert ID', "Use `certs: { trusthealth: '/ssl/trusthealth.com' }`.")
|
|
303
|
+
return profiles
|
|
304
|
+
for [certId, certConfig] in entriesOf(value)
|
|
305
|
+
profiles[String(certId)] = normalizeCertProfile(String(certId), certConfig, baseDir, "certs.#{certId}", errors)
|
|
306
|
+
profiles
|
|
307
|
+
|
|
308
|
+
normalizeGroups = (value, errors) ->
|
|
309
|
+
groups = {}
|
|
310
|
+
return groups unless value?
|
|
311
|
+
unless isEntriesSource(value)
|
|
312
|
+
pushError(errors, 'E_GROUPS_TYPE', 'groups', 'groups must be an object or map keyed by group ID', "Use `groups: { publicWeb: ['example.com', 'www.example.com'] }`.")
|
|
313
|
+
return groups
|
|
314
|
+
for [groupId, groupConfig] in entriesOf(value)
|
|
315
|
+
groups[String(groupId)] = normalizeHosts(groupConfig, "groups.#{groupId}", errors)
|
|
316
|
+
groups
|
|
317
|
+
|
|
318
|
+
normalizeRules = (value, errors) ->
|
|
319
|
+
sets = {}
|
|
320
|
+
return sets unless value?
|
|
321
|
+
unless isEntriesSource(value)
|
|
322
|
+
pushError(errors, 'E_RULES_TYPE', 'rules', 'rules must be an object or map keyed by rule-set ID', "Use `rules: { web: [{ path: '/*', app: 'web' }] }`.")
|
|
323
|
+
return sets
|
|
324
|
+
for [setId, setConfig] in entriesOf(value)
|
|
325
|
+
path = "rules.#{setId}"
|
|
326
|
+
unless Array.isArray(setConfig)
|
|
327
|
+
pushError(errors, 'E_RULE_VALUE', path, 'rule set must be an array of route objects', "Use `web: [{ path: '/*', app: 'web' }]`.")
|
|
328
|
+
continue
|
|
329
|
+
routes = []
|
|
330
|
+
for routeConfig, idx in setConfig
|
|
331
|
+
unless isPlainObject(routeConfig)
|
|
332
|
+
pushError(errors, 'E_ROUTE_TYPE', "#{path}[#{idx}]", 'route must be an object', "Use `{ path: '/*', app: 'web' }`.")
|
|
333
|
+
continue
|
|
334
|
+
routes.push(cloneRoute(routeConfig))
|
|
335
|
+
sets[String(setId)] = routes
|
|
336
|
+
sets
|
|
337
|
+
|
|
338
|
+
resolveHostToken = (token, path, errors, groups) ->
|
|
339
|
+
unless typeof token is 'string' and token.trim()
|
|
340
|
+
pushError(errors, 'E_HOST_SELECTOR_TOKEN', path, 'host selector entries must be strings', "Use `'example.com'`, `'*.example.com'`, or a host-group ID.")
|
|
341
|
+
return []
|
|
342
|
+
trimmed = token.trim()
|
|
343
|
+
if groups[trimmed]? and not trimmed.includes('.') and trimmed isnt '*' and not trimmed.startsWith('*.')
|
|
344
|
+
return groups[trimmed]
|
|
345
|
+
normalizeHosts([trimmed], path, errors)
|
|
346
|
+
|
|
347
|
+
expandHostSelector = (selector, path, errors, groups) ->
|
|
348
|
+
out = []
|
|
349
|
+
if typeof selector is 'string'
|
|
350
|
+
out = resolveHostToken(selector, path, errors, groups)
|
|
351
|
+
else if Array.isArray(selector)
|
|
352
|
+
for item, idx in selector
|
|
353
|
+
out = out.concat(resolveHostToken(item, "#{path}[#{idx}]", errors, groups))
|
|
354
|
+
else
|
|
355
|
+
pushError(errors, 'E_HOST_SELECTOR', path, 'hosts selector must be a string or array of strings', "Use `hosts: 'publicWeb'` or `hosts: ['example.com', 'www.example.com']`.")
|
|
356
|
+
dedupe(out)
|
|
357
|
+
|
|
358
|
+
expandRules = (value, path, errors, rulesets) ->
|
|
359
|
+
routes = []
|
|
360
|
+
pushInlineRoute = (routeConfig, sourcePath) ->
|
|
361
|
+
unless isPlainObject(routeConfig)
|
|
362
|
+
pushError(errors, 'E_ROUTE_TYPE', sourcePath, 'route must be an object', "Use `{ path: '/*', app: 'web' }`.")
|
|
363
|
+
return
|
|
364
|
+
routes.push({ config: cloneRoute(routeConfig), sourcePath })
|
|
365
|
+
|
|
366
|
+
pushRuleSet = (ruleSetId, sourcePath) ->
|
|
367
|
+
unless typeof ruleSetId is 'string' and ruleSetId.trim()
|
|
368
|
+
pushError(errors, 'E_RULE_REF', sourcePath, 'rule-set references must be strings', "Use `rules: 'web'` or `rules: ['web', ...]`.")
|
|
369
|
+
return
|
|
370
|
+
routeSet = rulesets[ruleSetId]
|
|
371
|
+
unless routeSet?
|
|
372
|
+
pushError(errors, 'E_RULE_UNKNOWN', sourcePath, "unknown rule set #{ruleSetId}", 'Define the rule set under `rules` first.')
|
|
373
|
+
return
|
|
374
|
+
for routeConfig, idx in routeSet
|
|
375
|
+
routes.push({ config: cloneRoute(routeConfig), sourcePath: "#{sourcePath}[#{idx}]" })
|
|
376
|
+
|
|
377
|
+
return routes unless value?
|
|
378
|
+
if typeof value is 'string'
|
|
379
|
+
pushRuleSet(value, path)
|
|
380
|
+
else if Array.isArray(value)
|
|
381
|
+
for item, idx in value
|
|
382
|
+
itemPath = "#{path}[#{idx}]"
|
|
383
|
+
if typeof item is 'string'
|
|
384
|
+
pushRuleSet(item, itemPath)
|
|
385
|
+
else
|
|
386
|
+
pushInlineRoute(item, itemPath)
|
|
387
|
+
else
|
|
388
|
+
pushError(errors, 'E_SERVER_RULES_TYPE', path, 'rules must be a rule-set ID or an array of route objects/rule-set IDs', "Use `rules: 'web'` or `rules: [{ path: '/*', app: 'web' }]`.")
|
|
389
|
+
routes
|
|
390
|
+
|
|
391
|
+
normalizeHostBoundRoute = (routeConfig, idx, host, hostRoot, baseDir, path, errors, knownHttpProxies, knownApps) ->
|
|
392
|
+
unless isPlainObject(routeConfig)
|
|
393
|
+
pushError(errors, 'E_ROUTE_TYPE', path, 'route must be an object', "Use `{ path: '/*', app: 'web' }`.")
|
|
394
|
+
routeConfig = {}
|
|
395
|
+
if routeConfig.host?
|
|
396
|
+
pushError(errors, 'E_SERVER_ROUTE_HOST', "#{path}.host", 'rules inside `hosts` blocks must not specify host', "Remove the `host` field; the host comes from the block or selector.")
|
|
397
|
+
actionKeys = ['proxy', 'app', 'static', 'redirect', 'headers'].filter((key) -> routeConfig[key]?)
|
|
398
|
+
if actionKeys.length isnt 1
|
|
399
|
+
pushError(errors, 'E_ROUTE_ACTION', path, 'route must define exactly one action: proxy, app, static, redirect, or headers', "Choose one action field per route.")
|
|
400
|
+
routePath = if typeof routeConfig.path is 'string' and routeConfig.path.startsWith('/') then routeConfig.path else null
|
|
401
|
+
pushError(errors, 'E_ROUTE_PATH', "#{path}.path", 'route path must be a string starting with /', "Use `path: '/*'`.") unless routePath
|
|
402
|
+
if routeConfig.proxy? and not knownHttpProxies.has(routeConfig.proxy)
|
|
403
|
+
pushError(errors, 'E_ROUTE_PROXY_REF', "#{path}.proxy", "unknown HTTP proxy #{routeConfig.proxy}", 'Define the proxy under `proxies` with HTTP URLs first.')
|
|
404
|
+
if routeConfig.app? and not knownApps.has(routeConfig.app)
|
|
405
|
+
pushError(errors, 'E_ROUTE_APP_REF', "#{path}.app", "unknown app #{routeConfig.app}", 'Define the app under `apps` first.')
|
|
406
|
+
if routeConfig.websocket is true and not routeConfig.proxy?
|
|
407
|
+
pushError(errors, 'E_ROUTE_WEBSOCKET', "#{path}.websocket", 'websocket routes currently require a proxy action', 'Set `proxy: ...` on the route or remove `websocket: true`.')
|
|
408
|
+
if routeConfig.static?
|
|
409
|
+
pushError(errors, 'E_ROUTE_STATIC_TYPE', "#{path}.static", 'static must be a string path', "Use `static: '.'` or `static: '/path/to/dir'`.") unless typeof routeConfig.static is 'string'
|
|
410
|
+
pushError(errors, 'E_ROUTE_SPA_TYPE', "#{path}.spa", 'spa must be a boolean', "Use `spa: true` or remove it.") if routeConfig.spa? and typeof routeConfig.spa isnt 'boolean'
|
|
411
|
+
pushError(errors, 'E_ROUTE_ROOT_TYPE', "#{path}.root", 'root must be a string path', "Use `root: '/path/to/dir'`.") if routeConfig.root? and typeof routeConfig.root isnt 'string'
|
|
412
|
+
if routeConfig.redirect?
|
|
413
|
+
unless isPlainObject(routeConfig.redirect) and typeof routeConfig.redirect.to is 'string'
|
|
414
|
+
pushError(errors, 'E_REDIRECT_TO', "#{path}.redirect", 'redirect must have a `to` string', "Use `redirect: { to: 'https://example.com', status: 301 }`.")
|
|
415
|
+
if routeConfig.redirect?.status? and not VALID_REDIRECT_STATUSES.has(routeConfig.redirect.status)
|
|
416
|
+
pushError(errors, 'E_REDIRECT_STATUS', "#{path}.redirect.status", 'redirect status must be 301, 302, 307, or 308', "Use `status: 301` or `status: 302`.")
|
|
417
|
+
effectiveRoot = if typeof routeConfig.root is 'string' then resolve(baseDir, routeConfig.root) else if hostRoot then hostRoot else null
|
|
418
|
+
{
|
|
419
|
+
id: routeConfig.id or "route-#{idx + 1}"
|
|
420
|
+
host: host.toLowerCase()
|
|
421
|
+
path: routePath or '/'
|
|
422
|
+
methods: if routeConfig.methods? then routeConfig.methods else '*'
|
|
423
|
+
priority: normalizePositiveInt(routeConfig.priority, idx, "#{path}.priority", errors, 'priority')
|
|
424
|
+
proxy: routeConfig.proxy or null
|
|
425
|
+
app: routeConfig.app or null
|
|
426
|
+
static: routeConfig.static or null
|
|
427
|
+
root: effectiveRoot
|
|
428
|
+
spa: routeConfig.spa is true
|
|
429
|
+
browse: routeConfig.browse is true
|
|
430
|
+
redirect: routeConfig.redirect or null
|
|
431
|
+
headers: routeConfig.headers or null
|
|
432
|
+
websocket: routeConfig.websocket is true
|
|
433
|
+
timeouts: normalizeTimeouts(routeConfig.timeouts, "#{path}.timeouts", errors)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
resolveCertPair = (serverConfig, path, baseDir, certProfiles, errors) ->
|
|
437
|
+
if serverConfig.key? and not serverConfig.cert?
|
|
438
|
+
pushError(errors, 'E_SERVER_CERT_PAIR', path, 'host block must define cert when key is present', "Add `cert: '/ssl/site.crt'` or remove `key`.")
|
|
439
|
+
return { certPath: null, keyPath: null }
|
|
440
|
+
if serverConfig.cert? and serverConfig.key?
|
|
441
|
+
{
|
|
442
|
+
certPath: if typeof serverConfig.cert is 'string' and serverConfig.cert.trim() then resolve(baseDir, serverConfig.cert) else null
|
|
443
|
+
keyPath: if typeof serverConfig.key is 'string' and serverConfig.key.trim() then resolve(baseDir, serverConfig.key) else null
|
|
444
|
+
}
|
|
445
|
+
else if serverConfig.cert?
|
|
446
|
+
unless typeof serverConfig.cert is 'string' and serverConfig.cert.trim()
|
|
447
|
+
pushError(errors, 'E_SERVER_CERT_REF', "#{path}.cert", 'cert must reference a named cert or be paired with key', "Use `cert: 'trusthealth'` or define both `cert` and `key` file paths.")
|
|
448
|
+
return { certPath: null, keyPath: null }
|
|
449
|
+
profile = certProfiles[serverConfig.cert]
|
|
450
|
+
unless profile
|
|
451
|
+
pushError(errors, 'E_SERVER_CERT_UNKNOWN', "#{path}.cert", "unknown cert #{serverConfig.cert}", 'Define the cert under `certs` first.')
|
|
452
|
+
return { certPath: null, keyPath: null }
|
|
453
|
+
{ certPath: profile.certPath, keyPath: profile.keyPath }
|
|
454
|
+
else
|
|
455
|
+
{ certPath: null, keyPath: null }
|
|
456
|
+
|
|
457
|
+
implicitAppRoute = (host, appId) ->
|
|
458
|
+
id: 'route-1'
|
|
459
|
+
host: host.toLowerCase()
|
|
460
|
+
path: '/*'
|
|
461
|
+
methods: '*'
|
|
462
|
+
priority: 0
|
|
463
|
+
proxy: null
|
|
464
|
+
app: appId
|
|
465
|
+
static: null
|
|
466
|
+
root: null
|
|
467
|
+
spa: false
|
|
468
|
+
browse: false
|
|
469
|
+
redirect: null
|
|
470
|
+
headers: null
|
|
471
|
+
websocket: false
|
|
472
|
+
timeouts: {}
|
|
473
|
+
|
|
474
|
+
implicitStaticRoute = (host, root, spa = false, browse = false) ->
|
|
475
|
+
id: 'route-1'
|
|
476
|
+
host: host.toLowerCase()
|
|
477
|
+
path: '/*'
|
|
478
|
+
methods: '*'
|
|
479
|
+
priority: 0
|
|
480
|
+
proxy: null
|
|
481
|
+
app: null
|
|
482
|
+
static: '.'
|
|
483
|
+
root: root
|
|
484
|
+
spa: spa
|
|
485
|
+
browse: browse
|
|
486
|
+
redirect: null
|
|
487
|
+
headers: null
|
|
488
|
+
websocket: false
|
|
489
|
+
timeouts: {}
|
|
490
|
+
|
|
491
|
+
normalizeHostBlockForHost = (host, serverConfig, path, baseDir, errors, knownHttpProxies, knownTcpProxies, knownApps, rulesets, certProfiles) ->
|
|
492
|
+
unless isPlainObject(serverConfig)
|
|
493
|
+
pushError(errors, 'E_SERVER_TYPE', path, 'host block must be an object', "Use '#{host}': { rules: 'web' }.")
|
|
494
|
+
return null
|
|
495
|
+
|
|
496
|
+
hasSelector = serverConfig.hosts?
|
|
497
|
+
serverRoot = if typeof serverConfig.root is 'string' then resolve(baseDir, serverConfig.root) else null
|
|
498
|
+
serverSpa = serverConfig.spa is true
|
|
499
|
+
serverBrowse = serverConfig.browse is true
|
|
500
|
+
serverProxy = if typeof serverConfig.proxy is 'string' and serverConfig.proxy.trim() then serverConfig.proxy.trim() else null
|
|
501
|
+
serverApp = if typeof serverConfig.app is 'string' and serverConfig.app.trim() then serverConfig.app.trim() else null
|
|
502
|
+
if serverConfig.proxy? and not serverProxy
|
|
503
|
+
pushError(errors, 'E_SERVER_PROXY_REF', "#{path}.proxy", 'proxy must reference a named proxy ID', "Use `proxy: 'api'`.")
|
|
504
|
+
if serverProxy and (serverConfig.rules? or serverApp or serverRoot)
|
|
505
|
+
pushError(errors, 'E_SERVER_PROXY_CONFLICT', path, 'host block shorthand `proxy` cannot be combined with rules, app, or root', 'Use only one host-level shorthand or switch to explicit `rules`.')
|
|
506
|
+
if serverConfig.app? and not serverApp
|
|
507
|
+
pushError(errors, 'E_SERVER_APP_REF', "#{path}.app", 'app must reference a named app ID', "Define the app under `apps` and reference it with `app: 'web'`.")
|
|
508
|
+
if isPlainObject(serverConfig.app)
|
|
509
|
+
pushError(errors, 'E_SERVER_APP_INLINE', "#{path}.app", 'inline app definitions are not supported in serve.rip', 'Define the app under `apps` and reference it by ID.')
|
|
510
|
+
if serverApp and not knownApps.has(serverApp)
|
|
511
|
+
pushError(errors, 'E_SERVER_APP_REF', "#{path}.app", "unknown app #{serverApp}", 'Define the app under `apps` first.')
|
|
512
|
+
|
|
513
|
+
if serverProxy and knownTcpProxies.has(serverProxy)
|
|
514
|
+
for forbidden in ['cert', 'key', 'rules', 'app', 'root', 'spa', 'browse']
|
|
515
|
+
pushError(errors, 'E_SERVER_TCP_PROXY_ONLY', "#{path}.#{forbidden}", 'TCP proxy host blocks cannot also define TLS termination, rules, apps, or static roots', "Use only proxy: '#{serverProxy}' in this block.") if serverConfig[forbidden]?
|
|
516
|
+
return {
|
|
517
|
+
host: host.toLowerCase()
|
|
518
|
+
routes: []
|
|
519
|
+
timeouts: {}
|
|
520
|
+
certPath: null
|
|
521
|
+
keyPath: null
|
|
522
|
+
passthroughProxy: serverProxy
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
{ certPath, keyPath } = resolveCertPair(serverConfig, path, baseDir, certProfiles, errors)
|
|
526
|
+
timeouts = normalizeTimeouts(serverConfig.timeouts, "#{path}.timeouts", errors)
|
|
527
|
+
routes = []
|
|
528
|
+
if serverConfig.rules?
|
|
529
|
+
expanded = expandRules(serverConfig.rules, "#{path}.rules", errors, rulesets)
|
|
530
|
+
for resolvedRoute, idx in expanded
|
|
531
|
+
routes.push(normalizeHostBoundRoute(resolvedRoute.config, idx, host, serverRoot, baseDir, resolvedRoute.sourcePath, errors, knownHttpProxies, knownApps))
|
|
532
|
+
else if serverProxy
|
|
533
|
+
if not knownHttpProxies.has(serverProxy)
|
|
534
|
+
pushError(errors, 'E_SERVER_PROXY_REF', "#{path}.proxy", "unknown HTTP proxy #{serverProxy}", 'Define the proxy under `proxies` with HTTP URLs first.')
|
|
535
|
+
routes.push
|
|
536
|
+
id: 'route-1'
|
|
537
|
+
host: host.toLowerCase()
|
|
538
|
+
path: '/*'
|
|
539
|
+
methods: '*'
|
|
540
|
+
priority: 0
|
|
541
|
+
proxy: serverProxy
|
|
542
|
+
app: null
|
|
543
|
+
static: null
|
|
544
|
+
root: null
|
|
545
|
+
spa: false
|
|
546
|
+
browse: false
|
|
547
|
+
redirect: null
|
|
548
|
+
headers: null
|
|
549
|
+
websocket: false
|
|
550
|
+
timeouts: {}
|
|
551
|
+
else if serverApp
|
|
552
|
+
routes.push(implicitAppRoute(host, serverApp))
|
|
553
|
+
else if serverRoot
|
|
554
|
+
routes.push(implicitStaticRoute(host, serverRoot, serverSpa, serverBrowse))
|
|
555
|
+
else
|
|
556
|
+
hint = if hasSelector then "Add `rules`, `proxy`, `app`, or `root` to the shared host block." else "Add `rules`, `proxy`, `app`, or `root` to this host block."
|
|
557
|
+
pushError(errors, 'E_SERVER_RULES', path, 'host block must define rules, proxy, app, or root', hint)
|
|
558
|
+
|
|
559
|
+
{
|
|
560
|
+
host: host.toLowerCase()
|
|
561
|
+
routes: routes.filter(Boolean)
|
|
562
|
+
timeouts
|
|
563
|
+
certPath
|
|
564
|
+
keyPath
|
|
565
|
+
passthroughProxy: null
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
normalizeHostsSection = (value, baseDir, errors, knownHttpProxies, knownTcpProxies, knownApps, rulesets, groups, certProfiles) ->
|
|
569
|
+
sites = {}
|
|
570
|
+
resolvedCerts = {}
|
|
571
|
+
passthroughUpstreams = {}
|
|
572
|
+
passthroughStreams = []
|
|
573
|
+
|
|
574
|
+
unless isEntriesSource(value)
|
|
575
|
+
pushError(errors, 'E_HOSTS_SECTION', 'hosts', 'hosts must be an object or map', "Use `hosts: { 'example.com': { rules: 'web' } }` or `hosts: *{ ['example.com', 'www.example.com']: { rules: 'web' } }`.")
|
|
576
|
+
return { sites, resolvedCerts, passthroughUpstreams, passthroughStreams }
|
|
577
|
+
|
|
578
|
+
seenHosts = new Set()
|
|
579
|
+
entries = entriesOf(value)
|
|
580
|
+
for [entryKey, serverConfig], entryIdx in entries
|
|
581
|
+
entryLabel = if typeof entryKey is 'string' then entryKey else "entry-#{entryIdx + 1}"
|
|
582
|
+
entryPath = "hosts.#{entryLabel}"
|
|
583
|
+
concreteHosts = []
|
|
584
|
+
if Array.isArray(entryKey)
|
|
585
|
+
if serverConfig?.hosts?
|
|
586
|
+
pushError(errors, 'E_HOST_ENTRY_SELECTOR_DUP', "#{entryPath}.hosts", 'host blocks cannot define both an array key and a `hosts` selector', "Use the map key array or the `hosts` field, not both.")
|
|
587
|
+
concreteHosts = expandHostSelector(entryKey, "#{entryPath}[key]", errors, groups)
|
|
588
|
+
else if typeof entryKey is 'string'
|
|
589
|
+
if serverConfig?.hosts?
|
|
590
|
+
concreteHosts = expandHostSelector(serverConfig.hosts, "#{entryPath}.hosts", errors, groups)
|
|
591
|
+
else
|
|
592
|
+
concreteHosts = normalizeHosts([entryKey], entryPath, errors)
|
|
593
|
+
else
|
|
594
|
+
pushError(errors, 'E_HOST_ENTRY_KEY', entryPath, 'host entry keys must be strings or arrays of strings', "Use `'example.com': { ... }` or `*{ ['example.com', 'www.example.com']: { ... } }`.")
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
for host in concreteHosts
|
|
598
|
+
if seenHosts.has(host)
|
|
599
|
+
pushError(errors, 'E_HOST_DUPLICATE', entryPath, "host #{host} is defined more than once", 'Each concrete host may be configured only once in `serve.rip`.')
|
|
600
|
+
continue
|
|
601
|
+
seenHosts.add(host)
|
|
602
|
+
normalized = normalizeHostBlockForHost(host, serverConfig, entryPath, baseDir, errors, knownHttpProxies, knownTcpProxies, knownApps, rulesets, certProfiles)
|
|
603
|
+
continue unless normalized
|
|
604
|
+
if normalized.passthroughProxy
|
|
605
|
+
passthroughStreams.push({ id: normalized.passthroughProxy, order: passthroughStreams.length, listen: 443, sni: [normalized.host], proxy: normalized.passthroughProxy, timeouts: {} })
|
|
606
|
+
else
|
|
607
|
+
sites[normalized.host] = { host: normalized.host, timeouts: normalized.timeouts, routes: normalized.routes }
|
|
608
|
+
if normalized.certPath and normalized.keyPath
|
|
609
|
+
resolvedCerts[normalized.host] = { certPath: normalized.certPath, keyPath: normalized.keyPath }
|
|
610
|
+
|
|
611
|
+
{ sites, resolvedCerts, passthroughUpstreams, passthroughStreams }
|
|
612
|
+
|
|
613
|
+
normalizeServeConfig = (config, baseDir) ->
|
|
614
|
+
errors = []
|
|
615
|
+
for key of config
|
|
616
|
+
pushError(errors, 'E_UNKNOWN_KEY', key, "unknown top-level key #{key}", 'Allowed keys: version, server, proxies, apps, certs, rules, groups, hosts, streams.') unless TOP_LEVEL_KEYS.has(key)
|
|
617
|
+
|
|
618
|
+
if config.edge? and not config.server?
|
|
619
|
+
console.warn "rip-server: 'edge' is deprecated in serve.rip; use 'server' instead"
|
|
620
|
+
serverSettings = normalizeServerSettings(config.server or config.edge or {}, errors)
|
|
621
|
+
|
|
622
|
+
proxies = {}
|
|
623
|
+
httpProxies = {}
|
|
624
|
+
tcpProxies = {}
|
|
625
|
+
if config.proxies?
|
|
626
|
+
unless isEntriesSource(config.proxies)
|
|
627
|
+
pushError(errors, 'E_PROXIES_TYPE', 'proxies', 'proxies must be an object or map keyed by proxy ID', "Use `proxies: { api: { hosts: ['http://127.0.0.1:3000'] } }`.")
|
|
628
|
+
else
|
|
629
|
+
for [proxyId, proxyConfig] in entriesOf(config.proxies)
|
|
630
|
+
normalizedProxy = normalizeProxyConfig(String(proxyId), proxyConfig, "proxies.#{proxyId}", errors)
|
|
631
|
+
proxies[String(proxyId)] = normalizedProxy
|
|
632
|
+
if normalizedProxy.kind is 'http'
|
|
633
|
+
httpProxies[String(proxyId)] = normalizedProxy
|
|
634
|
+
else if normalizedProxy.kind is 'tcp'
|
|
635
|
+
tcpProxies[String(proxyId)] =
|
|
636
|
+
id: normalizedProxy.id
|
|
637
|
+
targets: normalizedProxy.targets
|
|
638
|
+
connectTimeoutMs: normalizedProxy.connectTimeoutMs
|
|
639
|
+
|
|
640
|
+
apps = {}
|
|
641
|
+
if config.apps?
|
|
642
|
+
unless isEntriesSource(config.apps)
|
|
643
|
+
pushError(errors, 'E_APPS_TYPE', 'apps', 'apps must be an object or map keyed by app ID', "Use `apps: { web: { entry: './web/index.rip' } }`.")
|
|
644
|
+
else
|
|
645
|
+
for [appId, appConfig] in entriesOf(config.apps)
|
|
646
|
+
apps[String(appId)] = normalizeAppConfigInto(String(appId), appConfig, baseDir, "apps.#{appId}", errors)
|
|
647
|
+
|
|
648
|
+
certProfiles = normalizeCertProfiles(config.certs, baseDir, errors)
|
|
649
|
+
groups = normalizeGroups(config.groups, errors)
|
|
650
|
+
rules = normalizeRules(config.rules, errors)
|
|
651
|
+
|
|
652
|
+
knownHttpProxies = new Set(Object.keys(httpProxies))
|
|
653
|
+
knownTcpProxies = new Set(Object.keys(tcpProxies))
|
|
654
|
+
knownApps = new Set(Object.keys(apps))
|
|
655
|
+
{ sites, resolvedCerts, passthroughUpstreams, passthroughStreams } = normalizeHostsSection(config.hosts, baseDir, errors, knownHttpProxies, knownTcpProxies, knownApps, rules, groups, certProfiles)
|
|
656
|
+
|
|
657
|
+
streamUpstreams = Object.assign({}, tcpProxies, passthroughUpstreams)
|
|
658
|
+
knownStreamProxies = new Set(Object.keys(streamUpstreams))
|
|
659
|
+
streams = normalizeStreams(config.streams, knownStreamProxies, errors)
|
|
660
|
+
streams = passthroughStreams.concat(streams)
|
|
661
|
+
|
|
662
|
+
throw validationError(CONFIG_FILE, errors) if errors.length > 0
|
|
663
|
+
|
|
664
|
+
{
|
|
665
|
+
kind: 'serve'
|
|
666
|
+
version: config.version or 1
|
|
667
|
+
server: serverSettings
|
|
668
|
+
proxies
|
|
669
|
+
upstreams: httpProxies
|
|
670
|
+
apps
|
|
671
|
+
certs: certProfiles
|
|
672
|
+
rules
|
|
673
|
+
groups
|
|
674
|
+
routes: []
|
|
675
|
+
sites
|
|
676
|
+
resolvedCerts
|
|
677
|
+
streamUpstreams
|
|
678
|
+
streams
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export createConfigError = (code, path, message, hint = null) ->
|
|
682
|
+
{ code, path, message, hint }
|
|
683
|
+
|
|
684
|
+
export normalizeAppConfig = (appId, appConfig, baseDir) ->
|
|
685
|
+
errors = []
|
|
686
|
+
normalized = normalizeAppConfigInto(appId, appConfig or {}, baseDir, "apps.#{appId}", errors)
|
|
687
|
+
throw validationError("app #{appId}", errors) if errors.length > 0
|
|
688
|
+
normalized
|
|
689
|
+
|
|
690
|
+
export normalizeConfig = (config, baseDir) ->
|
|
691
|
+
normalizeServeConfig(config, baseDir)
|
|
692
|
+
|
|
693
|
+
export normalizeEdgeConfig = (config, baseDir) -> normalizeConfig(config, baseDir)
|
|
694
|
+
|
|
695
|
+
export summarizeConfig = (configPath, normalized) ->
|
|
696
|
+
path = resolve(configPath)
|
|
697
|
+
kind = normalized.kind or 'serve'
|
|
698
|
+
hostCount = Object.keys(normalized.sites or {}).length
|
|
699
|
+
routeCount = Object.values(normalized.sites or {}).reduce((sum, site) -> sum + (site?.routes?.length or 0), 0)
|
|
700
|
+
{
|
|
701
|
+
kind
|
|
702
|
+
path
|
|
703
|
+
version: normalized.version or null
|
|
704
|
+
counts:
|
|
705
|
+
proxies: Object.keys(normalized.proxies or {}).length
|
|
706
|
+
apps: Object.keys(normalized.apps or {}).length
|
|
707
|
+
hosts: hostCount
|
|
708
|
+
routes: routeCount
|
|
709
|
+
streams: (normalized.streams or []).length
|
|
710
|
+
canonicalShape:
|
|
711
|
+
version: true
|
|
712
|
+
server: true
|
|
713
|
+
proxies: true
|
|
714
|
+
apps: true
|
|
715
|
+
certs: true
|
|
716
|
+
rules: true
|
|
717
|
+
groups: true
|
|
718
|
+
hosts: true
|
|
719
|
+
streams: true
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export loadConfig = (configPath) ->
|
|
723
|
+
return null unless existsSync(configPath)
|
|
724
|
+
raw = moduleObject!(configPath, CONFIG_FILE)
|
|
725
|
+
normalizeConfig(raw, dirname(configPath))
|
|
726
|
+
|
|
727
|
+
export checkConfigFile = (configPath) ->
|
|
728
|
+
resolved = resolve(configPath)
|
|
729
|
+
normalized = loadConfig!(resolved)
|
|
730
|
+
{ kind: 'serve', normalized, summary: summarizeConfig(resolved, normalized) }
|
|
731
|
+
|
|
732
|
+
export findConfigFile = (appEntry) ->
|
|
733
|
+
dir = if existsSync(appEntry) and statSync(appEntry).isDirectory() then appEntry else dirname(appEntry)
|
|
734
|
+
servePath = join(dir, CONFIG_FILE)
|
|
735
|
+
return servePath if existsSync(servePath)
|
|
736
|
+
null
|
|
737
|
+
|
|
738
|
+
export resolveConfigSource = (appEntry, configPath = null) ->
|
|
739
|
+
if configPath
|
|
740
|
+
resolved = if configPath.startsWith('/') then configPath else resolve(process.cwd(), configPath)
|
|
741
|
+
return { kind: 'serve', path: resolved }
|
|
742
|
+
if basename(appEntry) is CONFIG_FILE
|
|
743
|
+
return { kind: 'serve', path: resolve(appEntry) }
|
|
744
|
+
configPath = findConfigFile(appEntry)
|
|
745
|
+
return { kind: 'serve', path: configPath } if configPath
|
|
746
|
+
null
|
|
747
|
+
|
|
748
|
+
export applyConfig = (config, registry, registerAppFn, baseDir) ->
|
|
749
|
+
registered = []
|
|
750
|
+
apps = config.apps or {}
|
|
751
|
+
for appId, appConfig of apps
|
|
752
|
+
normalizedApp = normalizeAppConfigInto(appId, appConfig, baseDir, "apps.#{appId}", [])
|
|
753
|
+
registerAppFn(registry, appId,
|
|
754
|
+
entry: normalizedApp.entry
|
|
755
|
+
appBaseDir: if normalizedApp.entry then dirname(normalizedApp.entry) else baseDir
|
|
756
|
+
hosts: normalizedApp.hosts
|
|
757
|
+
workers: normalizedApp.workers
|
|
758
|
+
maxQueue: normalizedApp.maxQueue
|
|
759
|
+
queueTimeoutMs: normalizedApp.queueTimeoutMs
|
|
760
|
+
readTimeoutMs: normalizedApp.readTimeoutMs
|
|
761
|
+
env: normalizedApp.env
|
|
762
|
+
)
|
|
763
|
+
registered.push(normalizedApp)
|
|
764
|
+
registered
|
|
765
|
+
|
|
766
|
+
export applyEdgeConfig = (config, registry, registerAppFn, baseDir) -> applyConfig(config, registry, registerAppFn, baseDir)
|