@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.
Files changed (69) hide show
  1. package/{docs/READ_VALIDATORS.md → API.md} +41 -119
  2. package/CONFIG.md +408 -0
  3. package/README.md +246 -1109
  4. package/acme/crypto.rip +0 -2
  5. package/browse.rip +62 -0
  6. package/control/cli.rip +95 -36
  7. package/control/lifecycle.rip +67 -1
  8. package/control/manager.rip +250 -0
  9. package/control/mdns.rip +3 -0
  10. package/middleware.rip +1 -1
  11. package/package.json +14 -11
  12. package/server.rip +189 -673
  13. package/serving/config.rip +766 -0
  14. package/{edge → serving}/forwarding.rip +2 -2
  15. package/serving/logging.rip +101 -0
  16. package/{edge → serving}/metrics.rip +29 -1
  17. package/serving/proxy.rip +99 -0
  18. package/{edge → serving}/queue.rip +1 -1
  19. package/{edge → serving}/ratelimit.rip +1 -1
  20. package/{edge → serving}/realtime.rip +71 -2
  21. package/{edge → serving}/registry.rip +1 -1
  22. package/{edge → serving}/router.rip +3 -3
  23. package/{edge → serving}/runtime.rip +18 -16
  24. package/{edge → serving}/security.rip +1 -1
  25. package/serving/static.rip +393 -0
  26. package/{edge → serving}/tls.rip +3 -7
  27. package/{edge → serving}/upstream.rip +4 -4
  28. package/{edge → serving}/verify.rip +16 -16
  29. package/streams/{tls_clienthello.rip → clienthello.rip} +1 -1
  30. package/streams/config.rip +8 -8
  31. package/streams/index.rip +5 -5
  32. package/streams/router.rip +2 -2
  33. package/tests/acme.rip +1 -1
  34. package/tests/config.rip +215 -0
  35. package/tests/control.rip +1 -1
  36. package/tests/{runtime_entrypoints.rip → entrypoints.rip} +11 -7
  37. package/tests/extracted.rip +118 -0
  38. package/tests/helpers.rip +4 -4
  39. package/tests/metrics.rip +3 -3
  40. package/tests/proxy.rip +9 -8
  41. package/tests/read.rip +1 -1
  42. package/tests/realtime.rip +3 -3
  43. package/tests/registry.rip +4 -4
  44. package/tests/router.rip +27 -27
  45. package/tests/runner.rip +70 -0
  46. package/tests/security.rip +4 -4
  47. package/tests/servers.rip +102 -136
  48. package/tests/static.rip +2 -2
  49. package/tests/streams_clienthello.rip +2 -2
  50. package/tests/streams_index.rip +4 -4
  51. package/tests/streams_pipe.rip +1 -1
  52. package/tests/streams_router.rip +10 -10
  53. package/tests/streams_runtime.rip +4 -4
  54. package/tests/streams_upstream.rip +1 -1
  55. package/tests/upstream.rip +2 -2
  56. package/tests/verify.rip +18 -18
  57. package/tests/watchers.rip +4 -4
  58. package/default.rip +0 -435
  59. package/docs/edge/CONFIG_LIFECYCLE.md +0 -111
  60. package/docs/edge/CONTRACTS.md +0 -137
  61. package/docs/edge/EDGEFILE_CONTRACT.md +0 -282
  62. package/docs/edge/M0B_REVIEW_NOTES.md +0 -102
  63. package/docs/edge/SCHEDULER.md +0 -46
  64. package/docs/logo.png +0 -0
  65. package/docs/logo.svg +0 -13
  66. package/docs/social.png +0 -0
  67. package/edge/config.rip +0 -607
  68. package/edge/static.rip +0 -69
  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)