@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
package/edge/config.rip DELETED
@@ -1,607 +0,0 @@
1
- # ==============================================================================
2
- # edge/config.rip — legacy config.rip and Edgefile.rip loading/validation
3
- # ==============================================================================
4
- #
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
- # ==============================================================================
8
-
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) ->
42
- return null unless existsSync(configPath)
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
65
-
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()
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
118
- else
119
- null
120
- {
121
- id: appId
122
- entry
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
280
- }
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
-
548
- export findConfigFile = (appEntry) ->
549
- dir = if existsSync(appEntry) and statSync(appEntry).isDirectory() then appEntry else dirname(appEntry)
550
- configPath = join(dir, LEGACY_CONFIG_NAME)
551
- if existsSync(configPath) then configPath else null
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
-
572
- export applyConfig = (config, registry, registerAppFn, baseDir) ->
573
- normalized = if config?.kind then config else normalizeLegacyConfig(config or {}, baseDir)
574
- apps = normalized.apps or {}
575
- registered = []
576
- for appId, appConfig of apps
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}", [])
596
- registerAppFn(registry, appId,
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
605
- )
606
- registered.push(normalizedApp)
607
- registered
package/edge/static.rip DELETED
@@ -1,69 +0,0 @@
1
- # ==============================================================================
2
- # edge/static.rip — static file serving and redirect route handlers
3
- # ==============================================================================
4
-
5
- import { resolve, join } from 'node:path'
6
- import { statSync } from 'node:fs'
7
- import { mimeType } from '../api.rip'
8
-
9
- stripRoutePrefix = (pathname, routePath) ->
10
- return pathname if routePath is '/' or routePath is '/*'
11
- prefix = if routePath.endsWith('/*') then routePath.slice(0, -2) else routePath
12
- if pathname.startsWith(prefix)
13
- rest = pathname.slice(prefix.length)
14
- return if rest is '' then '/' else rest
15
- pathname
16
-
17
- isSafeWithinRoot = (root, resolved) ->
18
- rootSlash = if root.endsWith('/') then root else root + '/'
19
- resolved is root or resolved.startsWith(rootSlash)
20
-
21
- acceptsHtml = (req) ->
22
- accept = req.headers?.get?('accept') or ''
23
- accept.includes('text/html')
24
-
25
- export serveStaticRoute = (req, url, route) ->
26
- method = req.method or 'GET'
27
- return new Response(null, { status: 405 }) unless method is 'GET' or method is 'HEAD'
28
-
29
- base = route.root or route.static
30
- return new Response('Not Found', { status: 404 }) unless base
31
-
32
- staticDir = if route.static and route.static isnt '.'
33
- if route.static.startsWith('/') then route.static else resolve(base, route.static)
34
- else
35
- base
36
-
37
- pathname = try decodeURIComponent(url.pathname) catch then url.pathname
38
- relative = stripRoutePrefix(pathname, route.path or '/*')
39
- filePath = resolve(staticDir, '.' + relative)
40
-
41
- return new Response('Forbidden', { status: 403 }) unless isSafeWithinRoot(staticDir, filePath)
42
-
43
- try
44
- stat = statSync(filePath)
45
- if stat.isDirectory()
46
- indexPath = join(filePath, 'index.html')
47
- try
48
- indexStat = statSync(indexPath)
49
- if indexStat.isFile()
50
- file = Bun.file(indexPath)
51
- return new Response(file, { headers: { 'content-type': 'text/html; charset=UTF-8' } })
52
- if stat.isFile()
53
- file = Bun.file(filePath)
54
- return new Response(file, { headers: { 'content-type': mimeType(filePath) } })
55
-
56
- if route.spa and acceptsHtml(req)
57
- spaPath = join(staticDir, 'index.html')
58
- try
59
- spaStat = statSync(spaPath)
60
- if spaStat.isFile()
61
- file = Bun.file(spaPath)
62
- return new Response(file, { headers: { 'content-type': 'text/html; charset=UTF-8' } })
63
-
64
- new Response('Not Found', { status: 404 })
65
-
66
- export buildRedirectResponse = (req, url, route) ->
67
- target = route.redirect?.to or '/'
68
- status = route.redirect?.status or 302
69
- Response.redirect(target, status)