@rip-lang/server 1.3.114 → 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
@@ -0,0 +1,272 @@
1
+ # ==============================================================================
2
+ # edge/upstream.rip — upstream pools, health checks, and retry helpers
3
+ # ==============================================================================
4
+
5
+ DEFAULT_HEALTH =
6
+ path: '/health'
7
+ intervalMs: 5000
8
+ timeoutMs: 2000
9
+ unhealthyThreshold: 3
10
+ healthyThreshold: 1
11
+
12
+ DEFAULT_RETRY =
13
+ attempts: 2
14
+ retryOn: [502, 503, 504]
15
+ retryMethods: ['GET', 'HEAD', 'OPTIONS']
16
+ backoffMs: 100
17
+
18
+ DEFAULT_CIRCUIT =
19
+ errorThreshold: 0.5
20
+ minRequests: 10
21
+ cooldownMs: 30000
22
+ jitterRatio: 0.3
23
+
24
+ toArray = (value, fallback = []) ->
25
+ if Array.isArray(value) then value else fallback
26
+
27
+ toPositiveInt = (value, fallback) ->
28
+ return fallback unless value?
29
+ n = parseInt(String(value))
30
+ if Number.isFinite(n) and n >= 0 then n else fallback
31
+
32
+ toPositiveFloat = (value, fallback) ->
33
+ return fallback unless value?
34
+ n = parseFloat(String(value))
35
+ if Number.isFinite(n) and n >= 0 then n else fallback
36
+
37
+ mergeHealth = (config = {}) ->
38
+ path: if typeof config.path is 'string' and config.path.startsWith('/') then config.path else DEFAULT_HEALTH.path
39
+ intervalMs: toPositiveInt(config.intervalMs, DEFAULT_HEALTH.intervalMs)
40
+ timeoutMs: toPositiveInt(config.timeoutMs, DEFAULT_HEALTH.timeoutMs)
41
+ unhealthyThreshold: Math.max(1, toPositiveInt(config.unhealthyThreshold, DEFAULT_HEALTH.unhealthyThreshold))
42
+ healthyThreshold: Math.max(1, toPositiveInt(config.healthyThreshold, DEFAULT_HEALTH.healthyThreshold))
43
+
44
+ mergeRetry = (config = {}) ->
45
+ attempts: Math.max(1, toPositiveInt(config.attempts, DEFAULT_RETRY.attempts))
46
+ retryOn: toArray(config.retryOn, DEFAULT_RETRY.retryOn).map((n) -> parseInt(String(n))).filter(Number.isFinite)
47
+ retryMethods: toArray(config.retryMethods, DEFAULT_RETRY.retryMethods).map((m) -> String(m).toUpperCase())
48
+ backoffMs: toPositiveInt(config.backoffMs, DEFAULT_RETRY.backoffMs)
49
+
50
+ mergeCircuit = (config = {}) ->
51
+ errorThreshold: Math.min(1, Math.max(0, toPositiveFloat(config.errorThreshold, DEFAULT_CIRCUIT.errorThreshold)))
52
+ minRequests: Math.max(1, toPositiveInt(config.minRequests, DEFAULT_CIRCUIT.minRequests))
53
+ cooldownMs: Math.max(1, toPositiveInt(config.cooldownMs, DEFAULT_CIRCUIT.cooldownMs))
54
+ jitterRatio: Math.min(1, Math.max(0, toPositiveFloat(config.jitterRatio, DEFAULT_CIRCUIT.jitterRatio)))
55
+
56
+ createHistory = ->
57
+ []
58
+
59
+ trimHistory = (history, minRequests) ->
60
+ history.shift() while history.length > minRequests
61
+ history
62
+
63
+ recordCircuitResult = (target, success, pool = null) ->
64
+ target.history.push(success)
65
+ trimHistory(target.history, target.circuitConfig.minRequests)
66
+
67
+ return unless target.circuitState is 'closed'
68
+ return unless target.history.length >= target.circuitConfig.minRequests
69
+ failures = target.history.filter((ok) -> not ok).length
70
+ errorRate = failures / target.history.length
71
+ openCircuit(target, pool?.randomFn or Math.random, pool?.nowFn or Date.now) if errorRate >= target.circuitConfig.errorThreshold
72
+
73
+ computeCooldownMs = (target, randomFn = Math.random) ->
74
+ base = target.circuitConfig.cooldownMs
75
+ jitter = base * target.circuitConfig.jitterRatio * randomFn()
76
+ Math.round(base + jitter)
77
+
78
+ openCircuit = (target, randomFn = Math.random, nowFn = Date.now) ->
79
+ target.circuitState = 'open'
80
+ target.circuitOpenedAt = nowFn()
81
+ target.circuitProbeInFlight = false
82
+ target.circuitCooldownMs = computeCooldownMs(target, randomFn)
83
+
84
+ export createUpstreamPool = (options = {}) ->
85
+ upstreams: new Map()
86
+ intervals: new Map()
87
+ fetchFn: options.fetchFn or fetch
88
+ nowFn: options.nowFn or Date.now
89
+ randomFn: options.randomFn or Math.random
90
+ setIntervalFn: options.setIntervalFn or setInterval
91
+ clearIntervalFn: options.clearIntervalFn or clearInterval
92
+
93
+ export normalizeUpstream = (id, config = {}) ->
94
+ timeouts =
95
+ connectMs: toPositiveInt(config.timeouts?.connectMs or config.timeout?.connectMs, 2000)
96
+ readMs: toPositiveInt(config.timeouts?.readMs or config.timeout?.readMs, 30000)
97
+ targets = toArray(config.targets).map (url, idx) ->
98
+ url: url
99
+ targetId: "#{id}:#{idx}"
100
+ healthy: true
101
+ inflight: 0
102
+ lastCheckAt: null
103
+ lastStatus: null
104
+ consecutiveFailures: 0
105
+ consecutiveSuccesses: 0
106
+ latencyEwma: 0
107
+ history: createHistory()
108
+ circuitState: 'closed'
109
+ circuitOpenedAt: null
110
+ circuitCooldownMs: null
111
+ circuitProbeInFlight: false
112
+ health: mergeHealth(config.healthCheck)
113
+ retry: mergeRetry(config.retry)
114
+ timeouts: timeouts
115
+ circuitConfig: mergeCircuit(config.circuit)
116
+ meta: config.meta or {}
117
+ {
118
+ id
119
+ healthCheck: mergeHealth(config.healthCheck)
120
+ retry: mergeRetry(config.retry)
121
+ timeouts: timeouts
122
+ circuit: mergeCircuit(config.circuit)
123
+ targets
124
+ strategy: config.strategy or 'least-inflight'
125
+ meta: config.meta or {}
126
+ }
127
+
128
+ export addUpstream = (pool, id, config = {}) ->
129
+ upstream = normalizeUpstream(id, config)
130
+ pool.upstreams.set(id, upstream)
131
+ upstream
132
+
133
+ export getUpstream = (pool, id) ->
134
+ pool.upstreams.get(id) ?? null
135
+
136
+ export removeUpstream = (pool, id) ->
137
+ stopHealthCheck(pool, id)
138
+ pool.upstreams.delete(id)
139
+
140
+ export listUpstreams = (pool) ->
141
+ Array.from(pool.upstreams.values())
142
+
143
+ export selectTarget = (upstream, nowFn = Date.now) ->
144
+ return null unless upstream?.targets?.length
145
+ candidates = upstream.targets.filter (target) ->
146
+ return false unless target.healthy
147
+ if target.circuitState is 'open'
148
+ cooldown = target.circuitCooldownMs or target.circuitConfig.cooldownMs
149
+ if target.circuitOpenedAt? and nowFn() - target.circuitOpenedAt >= cooldown
150
+ target.circuitState = 'half-open'
151
+ target.circuitProbeInFlight = false
152
+ else
153
+ return false
154
+ if target.circuitState is 'half-open'
155
+ return false if target.circuitProbeInFlight
156
+ true
157
+ return null unless candidates.length
158
+ best = candidates[0]
159
+ for candidate in candidates[1..]
160
+ better = false
161
+ if candidate.inflight < best.inflight
162
+ better = true
163
+ else if candidate.inflight is best.inflight and candidate.latencyEwma < best.latencyEwma
164
+ better = true
165
+ else if candidate.inflight is best.inflight and candidate.latencyEwma is best.latencyEwma and candidate.targetId < best.targetId
166
+ better = true
167
+ best = candidate if better
168
+ best
169
+
170
+ export markTargetBusy = (target) ->
171
+ target.inflight++
172
+ target.circuitProbeInFlight = true if target.circuitState is 'half-open'
173
+ target
174
+
175
+ export releaseTarget = (target, latencyMs = 0, success = true, pool = null) ->
176
+ target.inflight = Math.max(0, target.inflight - 1)
177
+ target.latencyEwma = if target.latencyEwma > 0 then (target.latencyEwma * 0.8) + (latencyMs * 0.2) else latencyMs
178
+ target.circuitProbeInFlight = false if target.circuitState is 'half-open'
179
+
180
+ if success
181
+ if target.circuitState is 'half-open'
182
+ target.circuitState = 'closed'
183
+ target.circuitOpenedAt = null
184
+ target.circuitCooldownMs = null
185
+ target.history.length = 0
186
+ recordCircuitResult(target, true, pool)
187
+ else
188
+ if target.circuitState is 'half-open'
189
+ openCircuit(target, pool?.randomFn or Math.random, pool?.nowFn or Date.now)
190
+ else
191
+ recordCircuitResult(target, false, pool)
192
+ target
193
+
194
+ export shouldRetry = (retryConfig, method, status, bodyStarted = false) ->
195
+ return false if bodyStarted
196
+ cfg = mergeRetry(retryConfig)
197
+ return false unless cfg.retryMethods.includes(String(method or 'GET').toUpperCase())
198
+ cfg.retryOn.includes(status)
199
+
200
+ export computeRetryDelayMs = (retryConfig, attempt, randomFn = Math.random) ->
201
+ cfg = mergeRetry(retryConfig)
202
+ base = cfg.backoffMs * Math.max(1, attempt)
203
+ jitter = cfg.backoffMs * 0.3 * randomFn()
204
+ Math.round(base + jitter)
205
+
206
+ export updateTargetHealth = (target, healthy, pool = null) ->
207
+ target.lastCheckAt = (pool?.nowFn or Date.now)()
208
+ if healthy
209
+ target.consecutiveFailures = 0
210
+ target.consecutiveSuccesses++
211
+ if not target.healthy and target.consecutiveSuccesses >= target.health.healthyThreshold
212
+ target.healthy = true
213
+ if target.circuitState in ['open', 'half-open']
214
+ target.circuitState = 'closed'
215
+ target.circuitOpenedAt = null
216
+ target.circuitCooldownMs = null
217
+ target.circuitProbeInFlight = false
218
+ target.history.length = 0
219
+ else
220
+ target.consecutiveSuccesses = 0
221
+ target.consecutiveFailures++
222
+ if target.healthy and target.consecutiveFailures >= target.health.unhealthyThreshold
223
+ target.healthy = false
224
+ openCircuit(target, pool?.randomFn or Math.random, pool?.nowFn or Date.now) if target.circuitState is 'half-open'
225
+ target
226
+
227
+ export checkTargetHealth = (pool, target, fetchFn = null) ->
228
+ runFetch = fetchFn or pool.fetchFn or fetch
229
+ url = target.url + target.health.path
230
+ try
231
+ res = runFetch(url)
232
+ res = res! if res?.then
233
+ target.lastStatus = res.status
234
+ updateTargetHealth(target, res.ok, pool)
235
+ res.ok
236
+ catch
237
+ target.lastStatus = null
238
+ updateTargetHealth(target, false, pool)
239
+ false
240
+
241
+ export startHealthCheck = (pool, upstreamId) ->
242
+ upstream = getUpstream(pool, upstreamId)
243
+ return null unless upstream
244
+ stopHealthCheck(pool, upstreamId)
245
+
246
+ targets = upstream.targets
247
+ return null unless targets.length > 0
248
+ intervalMs = upstream.healthCheck.intervalMs
249
+ offsetMs = if targets.length > 0 then Math.floor(intervalMs / targets.length) else 0
250
+
251
+ timers = targets.map (target, idx) ->
252
+ checkTargetHealth(pool, target)
253
+ pool.setIntervalFn((-> checkTargetHealth(pool, target)), intervalMs + (idx * offsetMs))
254
+
255
+ pool.intervals.set(upstreamId, timers)
256
+ timers
257
+
258
+ export startHealthChecks = (pool) ->
259
+ for upstream in listUpstreams(pool)
260
+ startHealthCheck(pool, upstream.id)
261
+ pool
262
+
263
+ export stopHealthCheck = (pool, upstreamId) ->
264
+ timers = pool.intervals.get(upstreamId)
265
+ if timers
266
+ pool.clearIntervalFn(timer) for timer in timers
267
+ pool.intervals.delete(upstreamId)
268
+
269
+ export stopHealthChecks = (pool) ->
270
+ for upstreamId in pool.intervals.keys()
271
+ stopHealthCheck(pool, upstreamId)
272
+ pool
@@ -0,0 +1,73 @@
1
+ # ==============================================================================
2
+ # edge/verify.rip — edge runtime verification helpers
3
+ # ==============================================================================
4
+
5
+ export buildVerificationResult = (ok, code = null, message = null, details = null) ->
6
+ { ok, code, message, details }
7
+
8
+ defaultPolicy =
9
+ requireHealthyUpstreams: true
10
+ requireReadyApps: true
11
+ includeUnroutedManagedApps: true
12
+ minHealthyTargetsPerUpstream: 1
13
+
14
+ mergePolicy = (policy = {}) ->
15
+ requireHealthyUpstreams: if policy.requireHealthyUpstreams? then policy.requireHealthyUpstreams is true else defaultPolicy.requireHealthyUpstreams
16
+ requireReadyApps: if policy.requireReadyApps? then policy.requireReadyApps is true else defaultPolicy.requireReadyApps
17
+ includeUnroutedManagedApps: if policy.includeUnroutedManagedApps? then policy.includeUnroutedManagedApps is true else defaultPolicy.includeUnroutedManagedApps
18
+ minHealthyTargetsPerUpstream: Math.max(1, parseInt(String(policy.minHealthyTargetsPerUpstream or defaultPolicy.minHealthyTargetsPerUpstream)) or 1)
19
+
20
+ export collectRouteRequirements = (routeTable, appRegistry = null, defaultAppId = null, policy = {}) ->
21
+ policy = mergePolicy(policy)
22
+ upstreamIds = new Set()
23
+ appIds = new Set()
24
+
25
+ for route in (routeTable?.routes or [])
26
+ upstreamIds.add(route.upstream) if route.upstream
27
+ appIds.add(route.app) if route.app
28
+
29
+ if appRegistry and policy.includeUnroutedManagedApps
30
+ for [appId, app] as appRegistry.apps
31
+ continue if appId is defaultAppId
32
+ continue unless app?.config?.entry
33
+ appIds.add(appId)
34
+
35
+ {
36
+ upstreamIds: Array.from(upstreamIds)
37
+ appIds: Array.from(appIds)
38
+ }
39
+
40
+ export verifyRouteRuntime = (runtime, appRegistry, defaultAppId, getUpstreamFn, checkTargetHealthFn, getAppStateFn, policy = {}) ->
41
+ policy = mergePolicy(policy)
42
+ requirements = collectRouteRequirements(runtime?.routeTable, appRegistry, defaultAppId, policy)
43
+
44
+ if policy.requireHealthyUpstreams
45
+ for upstreamId in requirements.upstreamIds
46
+ upstream = getUpstreamFn(runtime.upstreamPool, upstreamId)
47
+ return buildVerificationResult(false, 'upstream_missing', "Referenced upstream #{upstreamId} is missing", { upstreamId }) unless upstream
48
+
49
+ healthyTargets = []
50
+ for target in upstream.targets
51
+ healthyTargets.push(target.targetId) if checkTargetHealthFn(runtime.upstreamPool, target)
52
+
53
+ unless healthyTargets.length >= policy.minHealthyTargetsPerUpstream
54
+ return buildVerificationResult(
55
+ false
56
+ 'upstream_no_healthy_targets'
57
+ "Upstream #{upstreamId} has too few healthy targets after activation"
58
+ { upstreamId, targetCount: upstream.targets.length, healthyTargets: healthyTargets.length, requiredHealthyTargets: policy.minHealthyTargetsPerUpstream }
59
+ )
60
+
61
+ if policy.requireReadyApps
62
+ for appId in requirements.appIds
63
+ app = getAppStateFn(appRegistry, appId)
64
+ return buildVerificationResult(false, 'app_missing', "Referenced app #{appId} is missing", { appId }) unless app
65
+ unless app.sockets?.length > 0
66
+ return buildVerificationResult(
67
+ false
68
+ 'app_no_ready_workers'
69
+ "App #{appId} has no ready workers after activation"
70
+ { appId, workers: app.sockets?.length or 0 }
71
+ )
72
+
73
+ buildVerificationResult(true)
package/middleware.rip CHANGED
@@ -217,7 +217,7 @@ export compress = (opts = {}) ->
217
217
  # before ->
218
218
  # session.userId = 123
219
219
  #
220
- # get '/profile', ->
220
+ # get '/profile' ->
221
221
  # { userId: session.userId }
222
222
  #
223
223
  # Security:
@@ -488,8 +488,8 @@ export serve = (opts = {}) ->
488
488
  prefix = opts.app or ''
489
489
  appDir = opts.dir or '.'
490
490
  routesDir = "#{appDir}/#{opts.routes or 'routes'}"
491
- rawBundle = opts.bundle or opts.components or ['components']
492
- bundles = new Map()
491
+ rawBundle = opts.bundle or opts.components or ['components']
492
+ bundles = new Map()
493
493
  if Array.isArray(rawBundle)
494
494
  bundles.set 'bundle', rawBundle.map (d) -> "#{appDir}/#{d}"
495
495
  else if typeof rawBundle is 'object'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/server",
3
- "version": "1.3.114",
3
+ "version": "1.3.116",
4
4
  "description": "Pure Rip web framework and application server",
5
5
  "type": "module",
6
6
  "main": "api.rip",
@@ -45,7 +45,7 @@
45
45
  "author": "Steve Shreeve <steve.shreeve@gmail.com>",
46
46
  "license": "MIT",
47
47
  "dependencies": {
48
- "rip-lang": ">=3.13.119"
48
+ "rip-lang": ">=3.13.121"
49
49
  },
50
50
  "files": [
51
51
  "api.rip",