@rip-lang/server 1.3.115 → 1.3.116

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 (44) hide show
  1. package/README.md +435 -622
  2. package/api.rip +4 -4
  3. package/control/cli.rip +221 -1
  4. package/control/control.rip +9 -0
  5. package/control/lifecycle.rip +6 -1
  6. package/control/watchers.rip +10 -0
  7. package/control/workers.rip +9 -5
  8. package/default.rip +3 -1
  9. package/docs/READ_VALIDATORS.md +656 -0
  10. package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
  11. package/docs/edge/CONTRACTS.md +60 -69
  12. package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
  13. package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
  14. package/edge/config.rip +584 -52
  15. package/edge/forwarding.rip +6 -2
  16. package/edge/metrics.rip +19 -1
  17. package/edge/registry.rip +29 -3
  18. package/edge/router.rip +138 -0
  19. package/edge/runtime.rip +98 -0
  20. package/edge/static.rip +69 -0
  21. package/edge/tls.rip +23 -0
  22. package/edge/upstream.rip +272 -0
  23. package/edge/verify.rip +73 -0
  24. package/middleware.rip +3 -3
  25. package/package.json +2 -2
  26. package/server.rip +775 -393
  27. package/tests/control.rip +18 -0
  28. package/tests/edgefile.rip +165 -0
  29. package/tests/metrics.rip +16 -0
  30. package/tests/proxy.rip +22 -1
  31. package/tests/registry.rip +27 -0
  32. package/tests/router.rip +101 -0
  33. package/tests/runtime_entrypoints.rip +16 -0
  34. package/tests/servers.rip +262 -0
  35. package/tests/static.rip +64 -0
  36. package/tests/streams_clienthello.rip +108 -0
  37. package/tests/streams_index.rip +53 -0
  38. package/tests/streams_pipe.rip +70 -0
  39. package/tests/streams_router.rip +39 -0
  40. package/tests/streams_runtime.rip +38 -0
  41. package/tests/streams_upstream.rip +34 -0
  42. package/tests/upstream.rip +191 -0
  43. package/tests/verify.rip +148 -0
  44. package/tests/watchers.rip +15 -0
package/edge/config.rip CHANGED
@@ -1,75 +1,607 @@
1
1
  # ==============================================================================
2
- # edge/config.rip — declarative multi-app configuration loader
2
+ # edge/config.rip — legacy config.rip and Edgefile.rip loading/validation
3
3
  # ==============================================================================
4
4
  #
5
- # Loads config.rip from the app directory and registers apps in the registry.
6
- #
7
- # Example config.rip:
8
- #
9
- # export default
10
- # apps:
11
- # main:
12
- # entry: './index.rip'
13
- # hosts: ['example.com', 'www.example.com']
14
- # workers: 4
15
- # api:
16
- # entry: './api/index.rip'
17
- # hosts: ['api.example.com']
18
- # workers: 2
19
- # maxQueue: 1024
20
-
21
- import { existsSync } from 'node:fs'
22
- import { join, dirname, resolve } from 'node:path'
5
+ # config.rip remains the legacy app-registry config.
6
+ # Edgefile.rip is the stricter edge config surface for the pure Bun edge work.
7
+ # ==============================================================================
23
8
 
24
- export loadConfig = (configPath) ->
9
+ import { existsSync, statSync } from 'node:fs'
10
+ import { join, dirname, resolve, basename } from 'node:path'
11
+ import { normalizeStreamUpstreams, normalizeStreams, parseHostPort } from '../streams/config.rip'
12
+
13
+ EDGEFILE_NAME = 'Edgefile.rip'
14
+ LEGACY_CONFIG_NAME = 'config.rip'
15
+
16
+ isPlainObject = (value) ->
17
+ value? and typeof value is 'object' and not Array.isArray(value)
18
+
19
+ toArray = (value) ->
20
+ if Array.isArray(value) then value else []
21
+
22
+ pushError = (errors, code, path, message, hint = null) ->
23
+ errors.push({ code, path, message, hint })
24
+
25
+ validationError = (label, errors) ->
26
+ primary = errors[0]
27
+ err = new Error(primary?.message or "#{label} is invalid")
28
+ err.code = 'RIP_CONFIG_INVALID'
29
+ err.validationErrors = errors
30
+ err.label = label
31
+ err
32
+
33
+ export formatConfigErrors = (label, errors) ->
34
+ lines = ["rip-server: #{label} validation failed (#{errors.length} error#{if errors.length is 1 then '' else 's'})"]
35
+ for err in errors
36
+ line = "- [#{err.code}] #{err.path}: #{err.message}"
37
+ line += " Hint: #{err.hint}" if err.hint
38
+ lines.push(line)
39
+ lines.join('\n')
40
+
41
+ moduleObject = (configPath, label) ->
25
42
  return null unless existsSync(configPath)
26
- try
27
- mod = import!(configPath)
28
- await Promise.resolve()
29
- config = mod?.default or mod
30
- return null unless config and typeof config is 'object'
31
- config
32
- catch e
33
- console.error "rip-server: failed to load config.rip:", e.message
34
- null
43
+ mod = import!(configPath)
44
+ await Promise.resolve()
45
+ config = mod?.default or mod
46
+ if config?.then
47
+ throw validationError(label, [
48
+ {
49
+ code: 'E_CONFIG_ASYNC'
50
+ path: '$'
51
+ message: "#{label} must export a plain object, not a promise"
52
+ hint: "Export `default` as a synchronous object literal."
53
+ }
54
+ ])
55
+ unless isPlainObject(config)
56
+ throw validationError(label, [
57
+ {
58
+ code: 'E_CONFIG_TYPE'
59
+ path: '$'
60
+ message: "#{label} must export a plain object"
61
+ hint: "Use `export default { ... }`."
62
+ }
63
+ ])
64
+ config
35
65
 
36
- export normalizeAppConfig = (appId, appConfig, baseDir) ->
37
- entry = if appConfig.entry
66
+ normalizeHosts = (value, path, errors) ->
67
+ hosts = []
68
+ unless value?
69
+ return hosts
70
+ unless Array.isArray(value)
71
+ pushError(errors, 'E_HOSTS_TYPE', path, 'hosts must be an array of strings', "Use `hosts: ['example.com']`.")
72
+ return hosts
73
+ for host, idx in value
74
+ unless typeof host is 'string' and host.trim()
75
+ pushError(errors, 'E_HOST_TYPE', "#{path}[#{idx}]", 'host must be a non-empty string', "Use `'api.example.com'`, `'*.example.com'`, or `'*'`.")
76
+ continue
77
+ normalized = host.trim().toLowerCase()
78
+ if normalized.startsWith('*.')
79
+ base = normalized.slice(2)
80
+ labels = base.split('.').filter(Boolean)
81
+ if labels.length < 2
82
+ 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'`.")
83
+ continue
84
+ hosts.push(normalized)
85
+ hosts
86
+
87
+ normalizeEnv = (value, path, errors) ->
88
+ return {} unless value?
89
+ unless isPlainObject(value)
90
+ pushError(errors, 'E_ENV_TYPE', path, 'env must be an object', "Use `env: { FOO: 'bar' }`.")
91
+ return {}
92
+ out = {}
93
+ for key, val of value
94
+ unless ['string', 'number', 'boolean'].includes(typeof val)
95
+ pushError(errors, 'E_ENV_VALUE', "#{path}.#{key}", 'env values must be strings, numbers, or booleans', "Convert the value to a string if needed.")
96
+ continue
97
+ out[key] = String(val)
98
+ out
99
+
100
+ normalizePositiveInt = (value, fallback, path, errors, label) ->
101
+ return fallback unless value?
102
+ n = parseInt(String(value))
103
+ unless Number.isFinite(n) and n >= 0
104
+ fieldName = path.split('.').slice(-1)[0]
105
+ pushError(errors, 'E_NUMBER_TYPE', path, "#{label} must be a non-negative integer", "Set #{fieldName}: #{fallback} or another whole number.")
106
+ return fallback
107
+ n
108
+
109
+ normalizeAppConfigInto = (appId, appConfig, baseDir, path, errors) ->
110
+ unless isPlainObject(appConfig)
111
+ pushError(errors, 'E_APP_TYPE', path, 'app config must be an object', "Use #{appId}: { entry: './index.rip' }.")
112
+ appConfig = {}
113
+ entry = if typeof appConfig.entry is 'string' and appConfig.entry.trim()
38
114
  if appConfig.entry.startsWith('/') then appConfig.entry else resolve(baseDir, appConfig.entry)
115
+ else if appConfig.entry?
116
+ pushError(errors, 'E_APP_ENTRY', "#{path}.entry", 'entry must be a string path', "Use `entry: './index.rip'`.")
117
+ null
39
118
  else
40
119
  null
41
- hosts = appConfig.hosts or []
42
- workers = appConfig.workers or null
43
- maxQueue = appConfig.maxQueue or 512
44
- queueTimeoutMs = appConfig.queueTimeoutMs or 30000
45
- readTimeoutMs = appConfig.readTimeoutMs or 30000
46
-
47
120
  {
48
121
  id: appId
49
122
  entry
50
- hosts
51
- workers
52
- maxQueue
53
- queueTimeoutMs
54
- readTimeoutMs
55
- env: appConfig.env or {}
123
+ hosts: normalizeHosts(appConfig.hosts, "#{path}.hosts", errors)
124
+ workers: if appConfig.workers? then normalizePositiveInt(appConfig.workers, 0, "#{path}.workers", errors, 'workers') else null
125
+ maxQueue: normalizePositiveInt(appConfig.maxQueue, 512, "#{path}.maxQueue", errors, 'maxQueue')
126
+ queueTimeoutMs: normalizePositiveInt(appConfig.queueTimeoutMs, 30000, "#{path}.queueTimeoutMs", errors, 'queueTimeoutMs')
127
+ readTimeoutMs: normalizePositiveInt(appConfig.readTimeoutMs, 30000, "#{path}.readTimeoutMs", errors, 'readTimeoutMs')
128
+ env: do ->
129
+ env = normalizeEnv(appConfig.env, "#{path}.env", errors)
130
+ if typeof appConfig.root is 'string' and appConfig.root.trim()
131
+ env.APP_BASE_DIR = if appConfig.root.startsWith('/') then appConfig.root else resolve(baseDir, appConfig.root)
132
+ env
133
+ }
134
+
135
+ normalizeTimeouts = (value, path, errors) ->
136
+ return {} unless value?
137
+ unless isPlainObject(value)
138
+ pushError(errors, 'E_TIMEOUTS_TYPE', path, 'timeouts must be an object', "Use `timeouts: { connectMs: 2000, readMs: 30000 }`.")
139
+ return {}
140
+ {
141
+ connectMs: normalizePositiveInt(value.connectMs, 2000, "#{path}.connectMs", errors, 'connectMs')
142
+ readMs: normalizePositiveInt(value.readMs, 30000, "#{path}.readMs", errors, 'readMs')
143
+ }
144
+
145
+ normalizeVerifyPolicy = (value, path, errors) ->
146
+ return {
147
+ requireHealthyUpstreams: true
148
+ requireReadyApps: true
149
+ includeUnroutedManagedApps: true
150
+ minHealthyTargetsPerUpstream: 1
151
+ } unless value?
152
+ unless isPlainObject(value)
153
+ pushError(errors, 'E_VERIFY_TYPE', path, 'verify must be an object', "Use `verify: { requireHealthyUpstreams: true }`.")
154
+ return {
155
+ requireHealthyUpstreams: true
156
+ requireReadyApps: true
157
+ includeUnroutedManagedApps: true
158
+ minHealthyTargetsPerUpstream: 1
159
+ }
160
+ {
161
+ requireHealthyUpstreams: if value.requireHealthyUpstreams? then value.requireHealthyUpstreams is true else true
162
+ requireReadyApps: if value.requireReadyApps? then value.requireReadyApps is true else true
163
+ includeUnroutedManagedApps: if value.includeUnroutedManagedApps? then value.includeUnroutedManagedApps is true else true
164
+ minHealthyTargetsPerUpstream: Math.max(1, normalizePositiveInt(value.minHealthyTargetsPerUpstream, 1, "#{path}.minHealthyTargetsPerUpstream", errors, 'minHealthyTargetsPerUpstream'))
165
+ }
166
+
167
+ normalizeEdgeSettings = (value, errors) ->
168
+ unless isPlainObject(value)
169
+ pushError(errors, 'E_EDGE_REQUIRED', 'edge', 'edge must be an object', "Use `edge: {}` or omit it to use defaults.")
170
+ value = {}
171
+ certPath = if typeof value.cert is 'string' and value.cert.trim() then resolve(process.cwd(), value.cert) else null
172
+ keyPath = if typeof value.key is 'string' and value.key.trim() then resolve(process.cwd(), value.key) else null
173
+ if (certPath and not keyPath) or (keyPath and not certPath)
174
+ pushError(errors, 'E_TLS_PAIR', 'edge', 'manual TLS requires both cert and key', "Provide both `edge.cert` and `edge.key`, or remove both and rely on ACME/manual defaults.")
175
+ acmeDomains = []
176
+ if value.acmeDomains?
177
+ unless Array.isArray(value.acmeDomains)
178
+ pushError(errors, 'E_ACME_DOMAINS', 'edge.acmeDomains', 'acmeDomains must be an array of exact hostnames', "Use `acmeDomains: ['example.com', 'www.example.com']`.")
179
+ else
180
+ for domain, idx in value.acmeDomains
181
+ unless typeof domain is 'string' and domain.trim()
182
+ pushError(errors, 'E_ACME_DOMAIN_VALUE', "edge.acmeDomains[#{idx}]", 'acme domain must be a non-empty string', "Use `'example.com'`.")
183
+ continue
184
+ if domain.includes('*')
185
+ pushError(errors, 'E_ACME_WILDCARD', "edge.acmeDomains[#{idx}]", 'ACME HTTP-01 cannot issue wildcard certificates', "Remove the wildcard domain or provide `edge.cert` and `edge.key` manually.")
186
+ continue
187
+ acmeDomains.push(domain.trim().toLowerCase())
188
+ {
189
+ acme: value.acme is true
190
+ acmeDomains
191
+ cert: certPath
192
+ key: keyPath
193
+ hsts: value.hsts is true
194
+ trustedProxies: normalizeHosts(value.trustedProxies, 'edge.trustedProxies', errors)
195
+ timeouts: normalizeTimeouts(value.timeouts, 'edge.timeouts', errors)
196
+ verify: normalizeVerifyPolicy(value.verify, 'edge.verify', errors)
197
+ }
198
+
199
+ normalizeUpstreamConfig = (upstreamId, upstreamConfig, path, errors) ->
200
+ unless isPlainObject(upstreamConfig)
201
+ pushError(errors, 'E_UPSTREAM_TYPE', path, 'upstream config must be an object', "Use #{upstreamId}: { targets: ['http://127.0.0.1:3000'] }.")
202
+ upstreamConfig = {}
203
+ targets = []
204
+ unless Array.isArray(upstreamConfig.targets) and upstreamConfig.targets.length > 0
205
+ pushError(errors, 'E_UPSTREAM_TARGETS', "#{path}.targets", 'upstream must define at least one target URL', "Use `targets: ['http://127.0.0.1:3000']`.")
206
+ else
207
+ for target, idx in upstreamConfig.targets
208
+ unless typeof target is 'string' and /^https?:\/\//.test(target)
209
+ pushError(errors, 'E_UPSTREAM_TARGET', "#{path}.targets[#{idx}]", 'target must be an absolute http:// or https:// URL', "Use `'http://app.incusbr0:3000'`.")
210
+ continue
211
+ targets.push(target)
212
+ {
213
+ id: upstreamId
214
+ targets
215
+ healthCheck:
216
+ path: if typeof upstreamConfig.healthCheck?.path is 'string' and upstreamConfig.healthCheck.path.startsWith('/') then upstreamConfig.healthCheck.path else '/health'
217
+ intervalMs: normalizePositiveInt(upstreamConfig.healthCheck?.intervalMs, 5000, "#{path}.healthCheck.intervalMs", errors, 'healthCheck.intervalMs')
218
+ timeoutMs: normalizePositiveInt(upstreamConfig.healthCheck?.timeoutMs, 2000, "#{path}.healthCheck.timeoutMs", errors, 'healthCheck.timeoutMs')
219
+ retry:
220
+ attempts: normalizePositiveInt(upstreamConfig.retry?.attempts, 2, "#{path}.retry.attempts", errors, 'retry.attempts')
221
+ retryOn: if Array.isArray(upstreamConfig.retry?.retryOn) and upstreamConfig.retry.retryOn.length > 0 then upstreamConfig.retry.retryOn else [502, 503, 504]
222
+ timeouts: normalizeTimeouts(upstreamConfig.timeouts or upstreamConfig.timeout, "#{path}.timeouts", errors)
223
+ }
224
+
225
+ normalizeRouteConfig = (routeConfig, idx, errors, knownUpstreams, knownApps) ->
226
+ path = "routes[#{idx}]"
227
+ unless isPlainObject(routeConfig)
228
+ pushError(errors, 'E_ROUTE_TYPE', path, 'route must be an object', "Use `{ path: '/api/*', upstream: 'app' }`.")
229
+ routeConfig = {}
230
+ actionKeys = ['upstream', 'app', 'static', 'redirect', 'headers'].filter((key) -> routeConfig[key]?)
231
+ if actionKeys.length isnt 1
232
+ pushError(errors, 'E_ROUTE_ACTION', path, 'route must define exactly one action: upstream, app, static, redirect, or headers', "Choose one action field per route.")
233
+ routePath = if typeof routeConfig.path is 'string' and routeConfig.path.startsWith('/') then routeConfig.path else null
234
+ pushError(errors, 'E_ROUTE_PATH', "#{path}.path", 'route path must be a string starting with /', "Use `path: '/api/*'`.") unless routePath
235
+ host = if routeConfig.host? then routeConfig.host else '*'
236
+ if typeof host is 'string' and host.startsWith('*.')
237
+ labels = host.slice(2).split('.').filter(Boolean)
238
+ pushError(errors, 'E_ROUTE_HOST_WILDCARD', "#{path}.host", 'wildcard route hosts must have a base domain with at least two labels', "Use `'*.example.com'`, not `'*.com'`.") if labels.length < 2
239
+ else if typeof host isnt 'string'
240
+ pushError(errors, 'E_ROUTE_HOST', "#{path}.host", 'route host must be a string', "Use `'api.example.com'` or `'*'`.")
241
+ if routeConfig.upstream? and not knownUpstreams.has(routeConfig.upstream)
242
+ pushError(errors, 'E_ROUTE_UPSTREAM_REF', "#{path}.upstream", "unknown upstream #{routeConfig.upstream}", 'Define the upstream under `upstreams` first.')
243
+ if routeConfig.app? and not knownApps.has(routeConfig.app)
244
+ pushError(errors, 'E_ROUTE_APP_REF', "#{path}.app", "unknown app #{routeConfig.app}", 'Define the app under `apps` first.')
245
+ if routeConfig.websocket is true and not routeConfig.upstream?
246
+ pushError(errors, 'E_ROUTE_WEBSOCKET', "#{path}.websocket", 'websocket routes currently require an upstream action', 'Set `upstream: ...` on the route or remove `websocket: true`.')
247
+ {
248
+ id: routeConfig.id or "route-#{idx + 1}"
249
+ host: if typeof host is 'string' then host.toLowerCase() else '*'
250
+ path: routePath or '/'
251
+ methods: if routeConfig.methods? then routeConfig.methods else '*'
252
+ priority: normalizePositiveInt(routeConfig.priority, idx, "#{path}.priority", errors, 'priority')
253
+ upstream: routeConfig.upstream or null
254
+ app: routeConfig.app or null
255
+ static: routeConfig.static or null
256
+ root: routeConfig.root or null
257
+ spa: routeConfig.spa is true
258
+ redirect: routeConfig.redirect or null
259
+ headers: routeConfig.headers or null
260
+ websocket: routeConfig.websocket is true
261
+ timeouts: normalizeTimeouts(routeConfig.timeouts, "#{path}.timeouts", errors)
262
+ }
263
+
264
+ normalizeSiteConfig = (host, siteConfig, idx, errors, knownUpstreams, knownApps) ->
265
+ path = "sites.#{host}"
266
+ unless isPlainObject(siteConfig)
267
+ pushError(errors, 'E_SITE_TYPE', path, 'site config must be an object', "Use `'api.example.com': { routes: [...] }`.")
268
+ siteConfig = {}
269
+ routes = []
270
+ if siteConfig.routes?
271
+ unless Array.isArray(siteConfig.routes)
272
+ pushError(errors, 'E_SITE_ROUTES', "#{path}.routes", 'site.routes must be an array', "Use `routes: [{ path: '/*', upstream: 'app' }]`.")
273
+ else
274
+ for routeConfig, routeIdx in siteConfig.routes
275
+ routes.push(normalizeRouteConfig(routeConfig, routeIdx, errors, knownUpstreams, knownApps))
276
+ {
277
+ host: host.toLowerCase()
278
+ timeouts: normalizeTimeouts(siteConfig.timeouts, "#{path}.timeouts", errors)
279
+ routes
56
280
  }
57
281
 
282
+ topLevelKeys = new Set(['version', 'edge', 'hosts', 'upstreams', 'apps', 'streamUpstreams', 'streams'])
283
+
284
+ VALID_REDIRECT_STATUSES = new Set([301, 302, 307, 308])
285
+
286
+ normalizeServerRouteConfig = (routeConfig, idx, serverHost, serverRoot, baseDir, errors, knownUpstreams, knownApps) ->
287
+ path = "hosts.#{serverHost}.routes[#{idx}]"
288
+ unless isPlainObject(routeConfig)
289
+ pushError(errors, 'E_ROUTE_TYPE', path, 'route must be an object', "Use `{ path: '/*', static: '.' }`.")
290
+ routeConfig = {}
291
+ if routeConfig.host?
292
+ pushError(errors, 'E_SERVER_ROUTE_HOST', "#{path}.host", 'routes inside a server block must not specify host (inherited from server key)', "Remove the `host` field.")
293
+ actionKeys = ['upstream', 'app', 'static', 'redirect', 'headers'].filter((key) -> routeConfig[key]?)
294
+ if actionKeys.length isnt 1
295
+ pushError(errors, 'E_ROUTE_ACTION', path, 'route must define exactly one action: upstream, app, static, redirect, or headers', "Choose one action field per route.")
296
+ routePath = if typeof routeConfig.path is 'string' and routeConfig.path.startsWith('/') then routeConfig.path else null
297
+ pushError(errors, 'E_ROUTE_PATH', "#{path}.path", 'route path must be a string starting with /', "Use `path: '/*'`.") unless routePath
298
+ if routeConfig.upstream? and not knownUpstreams.has(routeConfig.upstream)
299
+ pushError(errors, 'E_ROUTE_UPSTREAM_REF', "#{path}.upstream", "unknown upstream #{routeConfig.upstream}", 'Define the upstream under `upstreams` first.')
300
+ if routeConfig.app? and not knownApps.has(routeConfig.app)
301
+ pushError(errors, 'E_ROUTE_APP_REF', "#{path}.app", "unknown app #{routeConfig.app}", 'Define the app under `apps` first.')
302
+ if routeConfig.websocket is true and not routeConfig.upstream?
303
+ pushError(errors, 'E_ROUTE_WEBSOCKET', "#{path}.websocket", 'websocket routes currently require an upstream action', 'Set `upstream: ...` on the route or remove `websocket: true`.')
304
+ if routeConfig.static?
305
+ 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'
306
+ 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'
307
+ 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'
308
+ if routeConfig.redirect?
309
+ unless isPlainObject(routeConfig.redirect) and typeof routeConfig.redirect.to is 'string'
310
+ pushError(errors, 'E_REDIRECT_TO', "#{path}.redirect", 'redirect must have a `to` string', "Use `redirect: { to: 'https://example.com', status: 301 }`.")
311
+ if routeConfig.redirect?.status? and not VALID_REDIRECT_STATUSES.has(routeConfig.redirect.status)
312
+ pushError(errors, 'E_REDIRECT_STATUS', "#{path}.redirect.status", 'redirect status must be 301, 302, 307, or 308', "Use `status: 301` or `status: 302`.")
313
+ effectiveRoot = if typeof routeConfig.root is 'string' then resolve(baseDir, routeConfig.root) else if serverRoot then serverRoot else null
314
+ {
315
+ id: routeConfig.id or "route-#{idx + 1}"
316
+ host: serverHost.toLowerCase()
317
+ path: routePath or '/'
318
+ methods: if routeConfig.methods? then routeConfig.methods else '*'
319
+ priority: normalizePositiveInt(routeConfig.priority, idx, "#{path}.priority", errors, 'priority')
320
+ upstream: routeConfig.upstream or null
321
+ app: routeConfig.app or null
322
+ static: routeConfig.static or null
323
+ root: effectiveRoot
324
+ spa: routeConfig.spa is true
325
+ browse: routeConfig.browse is true
326
+ redirect: routeConfig.redirect or null
327
+ headers: routeConfig.headers or null
328
+ websocket: routeConfig.websocket is true
329
+ timeouts: normalizeTimeouts(routeConfig.timeouts, "#{path}.timeouts", errors)
330
+ }
331
+
332
+ normalizeServerBlock = (host, serverConfig, baseDir, errors, knownUpstreams, knownApps) ->
333
+ path = "hosts.#{host}"
334
+ unless isPlainObject(serverConfig)
335
+ pushError(errors, 'E_SERVER_TYPE', path, 'server block must be an object', "Use '#{host}': { routes: [...] }.")
336
+ return { host: host.toLowerCase(), routes: [], timeouts: {}, certPath: null, keyPath: null, passthrough: null }
337
+ if serverConfig.passthrough?
338
+ unless typeof serverConfig.passthrough is 'string' and serverConfig.passthrough.trim()
339
+ pushError(errors, 'E_SERVER_PASSTHROUGH', "#{path}.passthrough", 'passthrough must be a host:port string', "Use passthrough: '127.0.0.1:8443'.")
340
+ return {
341
+ host: host.toLowerCase()
342
+ routes: []
343
+ timeouts: {}
344
+ certPath: null
345
+ keyPath: null
346
+ passthrough: if typeof serverConfig.passthrough is 'string' then serverConfig.passthrough.trim() else null
347
+ }
348
+ hasCert = serverConfig.cert?
349
+ hasKey = serverConfig.key?
350
+ if hasCert isnt hasKey
351
+ pushError(errors, 'E_SERVER_TLS_PAIR', path, 'server block must provide both cert and key or neither', "Add both cert and key, or remove both.")
352
+ certPath = if hasCert and typeof serverConfig.cert is 'string' then resolve(baseDir, serverConfig.cert) else null
353
+ keyPath = if hasKey and typeof serverConfig.key is 'string' then resolve(baseDir, serverConfig.key) else null
354
+ serverRoot = if typeof serverConfig.root is 'string' then resolve(baseDir, serverConfig.root) else null
355
+ serverSpa = serverConfig.spa is true
356
+ serverBrowse = serverConfig.browse is true
357
+ serverApp = null
358
+ inlineApp = null
359
+ if typeof serverConfig.app is 'string' and serverConfig.app.trim()
360
+ serverApp = serverConfig.app.trim()
361
+ if not knownApps.has(serverApp)
362
+ pushError(errors, 'E_SERVER_APP_REF', "#{path}.app", "unknown app #{serverApp}", 'Define the app under `apps` first.')
363
+ else if isPlainObject(serverConfig.app)
364
+ serverApp = "app-#{host.toLowerCase()}"
365
+ inlineAppConfig = Object.assign({}, serverConfig.app, { hosts: [host.toLowerCase()] })
366
+ inlineApp = normalizeAppConfigInto(serverApp, inlineAppConfig, baseDir, "#{path}.app", errors)
367
+ routes = []
368
+ if Array.isArray(serverConfig.routes) and serverConfig.routes.length > 0
369
+ for routeConfig, idx in serverConfig.routes
370
+ routes.push(normalizeServerRouteConfig(routeConfig, idx, host, serverRoot, baseDir, errors, knownUpstreams, knownApps))
371
+ else if serverApp
372
+ routes.push
373
+ id: 'route-1'
374
+ host: host.toLowerCase()
375
+ path: '/*'
376
+ methods: '*'
377
+ priority: 0
378
+ upstream: null
379
+ app: serverApp
380
+ static: null
381
+ root: null
382
+ spa: false
383
+ browse: false
384
+ redirect: null
385
+ headers: null
386
+ websocket: false
387
+ timeouts: {}
388
+ else if serverRoot
389
+ routes.push
390
+ id: 'route-1'
391
+ host: host.toLowerCase()
392
+ path: '/*'
393
+ methods: '*'
394
+ priority: 0
395
+ upstream: null
396
+ app: null
397
+ static: '.'
398
+ root: serverRoot
399
+ spa: serverSpa
400
+ browse: serverBrowse
401
+ redirect: null
402
+ headers: null
403
+ websocket: false
404
+ timeouts: {}
405
+ else
406
+ pushError(errors, 'E_SERVER_ROUTES', "#{path}", 'server block must define routes, root, app, or passthrough', "Add routes: [...], root: '/path', app: 'name', or passthrough: 'host:port'.")
407
+ {
408
+ host: host.toLowerCase()
409
+ routes: routes
410
+ timeouts: normalizeTimeouts(serverConfig.timeouts, "#{path}.timeouts", errors)
411
+ certPath: certPath
412
+ keyPath: keyPath
413
+ passthrough: null
414
+ inlineApp: inlineApp
415
+ }
416
+
417
+ normalizeServersConfig = (config, baseDir) ->
418
+ errors = []
419
+ for key of config
420
+ pushError(errors, 'E_UNKNOWN_KEY', key, "unknown top-level key #{key}", 'Allowed keys: version, edge, hosts, upstreams, apps, streamUpstreams, streams.') unless topLevelKeys.has(key)
421
+ edgeInput = config.edge or {}
422
+ edge = normalizeEdgeSettings(edgeInput, errors)
423
+ upstreams = {}
424
+ if config.upstreams?
425
+ unless isPlainObject(config.upstreams)
426
+ pushError(errors, 'E_UPSTREAMS_TYPE', 'upstreams', 'upstreams must be an object keyed by upstream ID', "Use upstreams: { app: { targets: ['http://127.0.0.1:3000'] } }.")
427
+ else
428
+ for upstreamId, upstreamConfig of config.upstreams
429
+ upstreams[upstreamId] = normalizeUpstreamConfig(upstreamId, upstreamConfig, "upstreams.#{upstreamId}", errors)
430
+ apps = {}
431
+ if config.apps?
432
+ unless isPlainObject(config.apps)
433
+ pushError(errors, 'E_APPS_TYPE', 'apps', 'apps must be an object keyed by app ID', "Use apps: { admin: { entry: './admin/index.rip' } }.")
434
+ else
435
+ for appId, appConfig of config.apps
436
+ apps[appId] = normalizeAppConfigInto(appId, appConfig, baseDir, "apps.#{appId}", errors)
437
+ knownUpstreams = new Set(Object.keys(upstreams))
438
+ knownApps = new Set(Object.keys(apps))
439
+ sites = {}
440
+ certs = {}
441
+ passthroughUpstreams = {}
442
+ passthroughStreams = []
443
+ unless isPlainObject(config.hosts)
444
+ pushError(errors, 'E_HOSTS_TYPE', 'hosts', 'hosts must be an object keyed by hostname', "Use hosts: { '*.example.com': { root: '/mnt/site' } }.")
445
+ else
446
+ for host, serverConfig of config.hosts
447
+ normalized = normalizeServerBlock(host, serverConfig, baseDir, errors, knownUpstreams, knownApps)
448
+ if normalized.passthrough
449
+ upstreamId = "passthrough-#{normalized.host}"
450
+ parsed = parseHostPort(normalized.passthrough)
451
+ if parsed
452
+ passthroughUpstreams[upstreamId] = { id: upstreamId, targets: [parsed], connectTimeoutMs: 5000 }
453
+ passthroughStreams.push({ id: upstreamId, order: passthroughStreams.length, listen: 443, sni: [normalized.host], upstream: upstreamId, timeouts: {} })
454
+ else
455
+ pushError(errors, 'E_SERVER_PASSTHROUGH', "hosts.#{host}.passthrough", "invalid passthrough target #{normalized.passthrough}", "Use passthrough: '127.0.0.1:8443'.")
456
+ else
457
+ sites[normalized.host] = { host: normalized.host, timeouts: normalized.timeouts, routes: normalized.routes }
458
+ if normalized.certPath and normalized.keyPath
459
+ certs[normalized.host] = { certPath: normalized.certPath, keyPath: normalized.keyPath }
460
+ if normalized.inlineApp
461
+ apps[normalized.inlineApp.id] = normalized.inlineApp
462
+ streamUpstreams = normalizeStreamUpstreams(config.streamUpstreams, errors)
463
+ for id, upstream of passthroughUpstreams
464
+ streamUpstreams[id] = upstream
465
+ knownStreamUpstreams = new Set(Object.keys(streamUpstreams))
466
+ streams = normalizeStreams(config.streams, knownStreamUpstreams, errors)
467
+ streams = passthroughStreams.concat(streams)
468
+ throw validationError(EDGEFILE_NAME, errors) if errors.length > 0
469
+ { kind: 'edge', version: config.version or 1, edge, upstreams, apps, routes: [], sites, certs, streamUpstreams, streams }
470
+
471
+ export createConfigError = (code, path, message, hint = null) ->
472
+ { code, path, message, hint }
473
+
474
+ export normalizeAppConfig = (appId, appConfig, baseDir) ->
475
+ errors = []
476
+ normalized = normalizeAppConfigInto(appId, appConfig or {}, baseDir, "apps.#{appId}", errors)
477
+ throw validationError("app #{appId}", errors) if errors.length > 0
478
+ normalized
479
+
480
+ export normalizeLegacyConfig = (config, baseDir) ->
481
+ errors = []
482
+ unless isPlainObject(config)
483
+ pushError(errors, 'E_CONFIG_TYPE', '$', 'config.rip must export an object', "Use `export default { apps: { ... } }`.")
484
+ throw validationError(LEGACY_CONFIG_NAME, errors)
485
+ for key of config
486
+ pushError(errors, 'E_LEGACY_KEY', key, "unknown top-level key #{key} in config.rip", 'Use only the `apps` key in config.rip, or migrate to Edgefile.rip.') unless key is 'apps'
487
+ apps = {}
488
+ if config.apps?
489
+ unless isPlainObject(config.apps)
490
+ pushError(errors, 'E_APPS_TYPE', 'apps', 'apps must be an object keyed by app ID', "Use `apps: { main: { entry: './index.rip' } }`.")
491
+ else
492
+ for appId, appConfig of config.apps
493
+ apps[appId] = normalizeAppConfigInto(appId, appConfig, baseDir, "apps.#{appId}", errors)
494
+ throw validationError(LEGACY_CONFIG_NAME, errors) if errors.length > 0
495
+ { kind: 'legacy', version: null, edge: {}, upstreams: {}, apps, routes: [], sites: {} }
496
+
497
+ export normalizeEdgeConfig = (config, baseDir) ->
498
+ normalizeServersConfig(config, baseDir)
499
+
500
+ export summarizeConfig = (configPath, normalized) ->
501
+ path = resolve(configPath)
502
+ kind = normalized.kind or (if basename(path) is EDGEFILE_NAME then 'edge' else 'legacy')
503
+ {
504
+ kind
505
+ path
506
+ version: normalized.version or null
507
+ counts:
508
+ upstreams: Object.keys(normalized.upstreams or {}).length
509
+ apps: Object.keys(normalized.apps or {}).length
510
+ routes: (normalized.routes or []).length
511
+ sites: Object.keys(normalized.sites or {}).length
512
+ streamUpstreams: Object.keys(normalized.streamUpstreams or {}).length
513
+ streams: (normalized.streams or []).length
514
+ canonicalShape:
515
+ version: true
516
+ edge: true
517
+ upstreams: true
518
+ apps: true
519
+ routes: true
520
+ sites: true
521
+ streamUpstreams: true
522
+ streams: true
523
+ }
524
+
525
+ export loadConfig = (configPath) ->
526
+ return null unless existsSync(configPath)
527
+ try
528
+ raw = moduleObject!(configPath, LEGACY_CONFIG_NAME)
529
+ normalizeLegacyConfig(raw, dirname(configPath))
530
+ catch e
531
+ if e.validationErrors
532
+ console.error formatConfigErrors(LEGACY_CONFIG_NAME, e.validationErrors)
533
+ else
534
+ console.error "rip-server: failed to load config.rip: #{e.message}"
535
+ null
536
+
537
+ export loadEdgeConfig = (configPath) ->
538
+ return null unless existsSync(configPath)
539
+ raw = moduleObject!(configPath, EDGEFILE_NAME)
540
+ normalizeEdgeConfig(raw, dirname(configPath))
541
+
542
+ export checkConfigFile = (configPath) ->
543
+ resolved = resolve(configPath)
544
+ kind = if basename(resolved) is EDGEFILE_NAME then 'edge' else 'legacy'
545
+ normalized = if kind is 'edge' then loadEdgeConfig!(resolved) else normalizeLegacyConfig(moduleObject!(resolved, LEGACY_CONFIG_NAME), dirname(resolved))
546
+ { kind, normalized, summary: summarizeConfig(resolved, normalized) }
547
+
58
548
  export findConfigFile = (appEntry) ->
59
- dir = dirname(appEntry)
60
- configPath = join(dir, 'config.rip')
549
+ dir = if existsSync(appEntry) and statSync(appEntry).isDirectory() then appEntry else dirname(appEntry)
550
+ configPath = join(dir, LEGACY_CONFIG_NAME)
61
551
  if existsSync(configPath) then configPath else null
62
552
 
553
+ export findEdgeFile = (appEntry) ->
554
+ dir = if existsSync(appEntry) and statSync(appEntry).isDirectory() then appEntry else dirname(appEntry)
555
+ edgePath = join(dir, EDGEFILE_NAME)
556
+ if existsSync(edgePath) then edgePath else null
557
+
558
+ export resolveConfigSource = (appEntry, edgefilePath = null) ->
559
+ if edgefilePath
560
+ resolved = if edgefilePath.startsWith('/') then edgefilePath else resolve(process.cwd(), edgefilePath)
561
+ return { kind: 'edge', path: resolved }
562
+ if basename(appEntry) is EDGEFILE_NAME
563
+ return { kind: 'edge', path: resolve(appEntry) }
564
+ if basename(appEntry) is LEGACY_CONFIG_NAME
565
+ return { kind: 'legacy', path: resolve(appEntry) }
566
+ edgePath = findEdgeFile(appEntry)
567
+ return { kind: 'edge', path: edgePath } if edgePath
568
+ legacyPath = findConfigFile(appEntry)
569
+ return { kind: 'legacy', path: legacyPath } if legacyPath
570
+ null
571
+
63
572
  export applyConfig = (config, registry, registerAppFn, baseDir) ->
64
- apps = config.apps or {}
573
+ normalized = if config?.kind then config else normalizeLegacyConfig(config or {}, baseDir)
574
+ apps = normalized.apps or {}
65
575
  registered = []
66
576
  for appId, appConfig of apps
67
- normalized = normalizeAppConfig(appId, appConfig, baseDir)
577
+ normalizedApp = normalizeAppConfigInto(appId, appConfig, baseDir, "apps.#{appId}", [])
578
+ registerAppFn(registry, appId,
579
+ entry: normalizedApp.entry
580
+ appBaseDir: if normalizedApp.entry then dirname(normalizedApp.entry) else baseDir
581
+ hosts: normalizedApp.hosts
582
+ workers: normalizedApp.workers
583
+ maxQueue: normalizedApp.maxQueue
584
+ queueTimeoutMs: normalizedApp.queueTimeoutMs
585
+ readTimeoutMs: normalizedApp.readTimeoutMs
586
+ env: normalizedApp.env
587
+ )
588
+ registered.push(normalizedApp)
589
+ registered
590
+
591
+ export applyEdgeConfig = (config, registry, registerAppFn, baseDir) ->
592
+ registered = []
593
+ apps = config.apps or {}
594
+ for appId, appConfig of apps
595
+ normalizedApp = normalizeAppConfigInto(appId, appConfig, baseDir, "apps.#{appId}", [])
68
596
  registerAppFn(registry, appId,
69
- hosts: normalized.hosts
70
- maxQueue: normalized.maxQueue
71
- queueTimeoutMs: normalized.queueTimeoutMs
72
- readTimeoutMs: normalized.readTimeoutMs
597
+ entry: normalizedApp.entry
598
+ appBaseDir: if normalizedApp.entry then dirname(normalizedApp.entry) else baseDir
599
+ hosts: normalizedApp.hosts
600
+ workers: normalizedApp.workers
601
+ maxQueue: normalizedApp.maxQueue
602
+ queueTimeoutMs: normalizedApp.queueTimeoutMs
603
+ readTimeoutMs: normalizedApp.readTimeoutMs
604
+ env: normalizedApp.env
73
605
  )
74
- registered.push(normalized)
606
+ registered.push(normalizedApp)
75
607
  registered