@rip-lang/server 1.3.115 → 1.3.117

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
@@ -80,6 +80,8 @@ export proxyToUpstream = (req, upstreamBase, options = {}) ->
80
80
  headers.set('X-Forwarded-For', clientIp)
81
81
  headers.set('X-Forwarded-Proto', if inUrl.protocol is 'https:' then 'https' else 'http')
82
82
  headers.set('X-Forwarded-Host', inUrl.host)
83
+ via = req.headers.get('via')
84
+ headers.set('Via', if via then "#{via}, 1.1 rip" else '1.1 rip')
83
85
 
84
86
  timer = null
85
87
  try
@@ -97,6 +99,8 @@ export proxyToUpstream = (req, upstreamBase, options = {}) ->
97
99
  resHeaders = new Headers()
98
100
  for [key, val] as res.headers.entries()
99
101
  resHeaders.set(key, val) unless HOP_HEADERS.has(key.toLowerCase())
102
+ upstreamVia = res.headers.get('via')
103
+ resHeaders.set('Via', if upstreamVia then "#{upstreamVia}, 1.1 rip" else '1.1 rip')
100
104
  new Response(res.body, { status: res.status, statusText: res.statusText, headers: resHeaders })
101
105
  catch err
102
106
  clearTimeout(timer)
@@ -106,8 +110,8 @@ export proxyToUpstream = (req, upstreamBase, options = {}) ->
106
110
 
107
111
  # --- WebSocket passthrough to external upstream ---
108
112
 
109
- export createWsPassthrough = (clientWs, upstreamUrl) ->
110
- upstream = new WebSocket(upstreamUrl)
113
+ export createWsPassthrough = (clientWs, upstreamUrl, protocols = []) ->
114
+ upstream = new WebSocket(upstreamUrl, protocols)
111
115
 
112
116
  upstream.addEventListener 'open', ->
113
117
  null # upstream connected
package/edge/metrics.rip CHANGED
@@ -51,10 +51,13 @@ export createMetrics = ->
51
51
  { p50: pick(0.50), p95: pick(0.95), p99: pick(0.99) }
52
52
 
53
53
  # Full snapshot of all metrics
54
- snapshot: (startedAt, appRegistry) ->
54
+ snapshot: (startedAt, appRegistry, upstreamPool = null) ->
55
55
  totalInflight = 0
56
56
  totalQueueDepth = 0
57
57
  apps = []
58
+ upstreams = []
59
+ upstreamHealthy = 0
60
+ upstreamUnhealthy = 0
58
61
  if appRegistry
59
62
  for [appId, app] as appRegistry.apps
60
63
  totalInflight += app.inflightTotal
@@ -64,10 +67,23 @@ export createMetrics = ->
64
67
  workers: app.sockets.length
65
68
  inflight: app.inflightTotal
66
69
  queueDepth: app.queue.length
70
+ if upstreamPool
71
+ for [upstreamId, upstream] as upstreamPool.upstreams
72
+ targets = upstream.targets or []
73
+ healthyTargets = targets.filter((t) -> t.healthy and t.circuitState isnt 'open').length
74
+ unhealthyTargets = targets.length - healthyTargets
75
+ upstreamHealthy += healthyTargets
76
+ upstreamUnhealthy += unhealthyTargets
77
+ upstreams.push
78
+ id: upstreamId
79
+ targets: targets.length
80
+ healthyTargets: healthyTargets
81
+ unhealthyTargets: unhealthyTargets
67
82
  uptimeSeconds = if startedAt then Math.floor((Date.now() - startedAt) / 1000) else 0
68
83
 
69
84
  uptime: uptimeSeconds
70
85
  apps: apps
86
+ upstreams: upstreams
71
87
  counters:
72
88
  requests: m.requests
73
89
  responses: { '2xx': m.responses2xx, '4xx': m.responses4xx, '5xx': m.responses5xx }
@@ -80,6 +96,8 @@ export createMetrics = ->
80
96
  workersActive: apps.reduce(((sum, a) -> sum + a.workers), 0)
81
97
  inflight: totalInflight
82
98
  queueDepth: totalQueueDepth
99
+ upstreamTargetsHealthy: upstreamHealthy
100
+ upstreamTargetsUnhealthy: upstreamUnhealthy
83
101
  latency: m.percentiles()
84
102
 
85
103
  m
package/edge/registry.rip CHANGED
@@ -18,12 +18,25 @@ createAppState = (appId, config) ->
18
18
  export createAppRegistry = ->
19
19
  apps: new Map()
20
20
  hostIndex: new Map()
21
+ wildcardIndex: new Map()
22
+
23
+ wildcardKey = (host) ->
24
+ return null unless typeof host is 'string' and host.startsWith('*.')
25
+ base = host.slice(2)
26
+ labels = base.split('.').filter(Boolean)
27
+ return null if labels.length < 2
28
+ ".#{base}"
21
29
 
22
30
  export registerApp = (registry, appId, config) ->
23
31
  state = createAppState(appId, config)
24
32
  registry.apps.set(appId, state)
25
33
  for host in (config.hosts or [])
26
- registry.hostIndex.set(host.toLowerCase(), appId)
34
+ normalized = host.toLowerCase()
35
+ wildcard = wildcardKey(normalized)
36
+ if wildcard
37
+ registry.wildcardIndex.set(wildcard, appId)
38
+ else
39
+ registry.hostIndex.set(normalized, appId)
27
40
  state
28
41
 
29
42
  export removeApp = (registry, appId) ->
@@ -31,11 +44,24 @@ export removeApp = (registry, appId) ->
31
44
  return unless state
32
45
  for host in state.hosts
33
46
  h = host.toLowerCase()
34
- registry.hostIndex.delete(h) if registry.hostIndex.get(h) is appId
47
+ wildcard = wildcardKey(h)
48
+ if wildcard
49
+ registry.wildcardIndex.delete(wildcard) if registry.wildcardIndex.get(wildcard) is appId
50
+ else
51
+ registry.hostIndex.delete(h) if registry.hostIndex.get(h) is appId
35
52
  registry.apps.delete(appId)
36
53
 
37
54
  export resolveHost = (registry, hostname) ->
38
- registry.hostIndex.get(hostname.toLowerCase()) ?? null
55
+ host = hostname.toLowerCase()
56
+ exact = registry.hostIndex.get(host)
57
+ return exact if exact?
58
+
59
+ dot = host.indexOf('.')
60
+ if dot > 0
61
+ wildcard = registry.wildcardIndex.get(host.slice(dot))
62
+ return wildcard if wildcard?
63
+
64
+ registry.hostIndex.get('*') ?? null
39
65
 
40
66
  export getAppState = (registry, appId) ->
41
67
  registry.apps.get(appId) ?? null
@@ -0,0 +1,138 @@
1
+ # ==============================================================================
2
+ # edge/router.rip — edge route compilation and matching
3
+ # ==============================================================================
4
+
5
+ normalizeMethods = (methods) ->
6
+ return '*' unless methods?
7
+ return '*' if methods is '*'
8
+ if Array.isArray(methods)
9
+ list = methods.map((m) -> String(m).toUpperCase()).filter(Boolean)
10
+ return '*' unless list.length
11
+ return Array.from(new Set(list))
12
+ [String(methods).toUpperCase()]
13
+
14
+ hostKind = (host) ->
15
+ return 'catchall' if host is '*'
16
+ return 'wildcard' if typeof host is 'string' and host.startsWith('*.')
17
+ 'exact'
18
+
19
+ pathKind = (path) ->
20
+ if typeof path is 'string' and path.endsWith('*') then 'prefix' else 'exact'
21
+
22
+ pathBase = (path) ->
23
+ if pathKind(path) is 'prefix' then path.slice(0, -1) else path
24
+
25
+ wildcardMatch = (pattern, hostname) ->
26
+ return false unless typeof pattern is 'string' and pattern.startsWith('*.')
27
+ suffix = pattern.slice(1) # keep the leading dot
28
+ return false unless hostname.endsWith(suffix)
29
+ prefix = hostname.slice(0, hostname.length - suffix.length)
30
+ prefix.length > 0 and not prefix.includes('.')
31
+
32
+ routeMatchesHost = (route, hostname) ->
33
+ switch route.hostKind
34
+ when 'catchall' then true
35
+ when 'exact' then hostname is route.host
36
+ when 'wildcard' then wildcardMatch(route.host, hostname)
37
+ else false
38
+
39
+ routeMatchesPath = (route, pathname) ->
40
+ if route.pathKind is 'exact'
41
+ pathname is route.path
42
+ else
43
+ pathname.startsWith(route.pathBase)
44
+
45
+ routeMatchesMethod = (route, method) ->
46
+ route.methods is '*' or route.methods.includes(String(method).toUpperCase())
47
+
48
+ pathScore = (route) ->
49
+ route.pathBase.length * 10 + (if route.pathKind is 'exact' then 5 else 0)
50
+
51
+ hostScore = (route) ->
52
+ switch route.hostKind
53
+ when 'exact' then 30
54
+ when 'wildcard' then 20
55
+ else 10
56
+
57
+ matchTuple = (route) ->
58
+ [
59
+ hostScore(route)
60
+ pathScore(route)
61
+ -route.priority
62
+ -route.order
63
+ ]
64
+
65
+ betterMatch = (candidate, current) ->
66
+ return true unless current
67
+ a = matchTuple(candidate)
68
+ b = matchTuple(current)
69
+ for idx in [0...a.length]
70
+ return true if a[idx] > b[idx]
71
+ return false if a[idx] < b[idx]
72
+ false
73
+
74
+ compileRoute = (route, order, inheritedHost = null) ->
75
+ host = (route.host or inheritedHost or '*').toLowerCase()
76
+ path = route.path or '/'
77
+ methods = normalizeMethods(route.methods)
78
+ {
79
+ id: route.id or "route-#{order + 1}"
80
+ order
81
+ host
82
+ hostKind: hostKind(host)
83
+ path
84
+ pathKind: pathKind(path)
85
+ pathBase: pathBase(path)
86
+ methods
87
+ priority: route.priority or order
88
+ upstream: route.upstream or null
89
+ app: route.app or null
90
+ static: route.static or null
91
+ root: route.root or null
92
+ spa: route.spa is true
93
+ browse: route.browse is true
94
+ redirect: route.redirect or null
95
+ headers: route.headers or null
96
+ websocket: route.websocket is true
97
+ timeouts: route.timeouts or {}
98
+ }
99
+
100
+ export compileRouteTable = (routes = [], sites = {}) ->
101
+ compiled = []
102
+ order = 0
103
+
104
+ for route in routes
105
+ compiled.push(compileRoute(route, order))
106
+ order++
107
+
108
+ for siteHost, siteConfig of sites
109
+ for route in (siteConfig.routes or [])
110
+ compiled.push(compileRoute(route, order, siteHost))
111
+ order++
112
+
113
+ { routes: compiled }
114
+
115
+ export matchRoute = (table, hostname, pathname, method = 'GET') ->
116
+ host = String(hostname or '').toLowerCase()
117
+ path = String(pathname or '/')
118
+ verb = String(method or 'GET').toUpperCase()
119
+ match = null
120
+
121
+ for route in (table?.routes or [])
122
+ continue unless routeMatchesHost(route, host)
123
+ continue unless routeMatchesPath(route, path)
124
+ continue unless routeMatchesMethod(route, verb)
125
+ match = route if betterMatch(route, match)
126
+
127
+ match
128
+
129
+ export describeRoute = (route) ->
130
+ return null unless route
131
+ action = if route.upstream? then "proxy:#{route.upstream}"
132
+ else if route.app? then "app:#{route.app}"
133
+ else if route.static? then "static"
134
+ else if route.redirect? then "redirect"
135
+ else if route.headers? then "headers"
136
+ else 'unknown'
137
+ suffix = if route.websocket then ' [ws]' else ''
138
+ "#{route.host} #{route.path} #{action}#{suffix}"
@@ -0,0 +1,98 @@
1
+ # ==============================================================================
2
+ # edge/runtime.rip — edge runtime lifecycle helpers
3
+ # ==============================================================================
4
+
5
+ import { createUpstreamPool } from './upstream.rip'
6
+ import { compileRouteTable, describeRoute } from './router.rip'
7
+ import { resolveConfigSource, loadConfig, loadEdgeConfig, summarizeConfig, formatConfigErrors, checkConfigFile } from './config.rip'
8
+
9
+ export createEdgeRuntime = (configInfo = null, upstreamPool = null, routeTable = null) ->
10
+ upstreamPool = upstreamPool or createUpstreamPool()
11
+ routeTable = routeTable or compileRouteTable()
12
+ configInfo = configInfo or {
13
+ kind: 'none'
14
+ path: null
15
+ version: null
16
+ counts: { apps: 0, upstreams: 0, routes: 0, sites: 0 }
17
+ lastResult: 'none'
18
+ loadedAt: null
19
+ note: null
20
+ lastError: null
21
+ lastErrorCode: null
22
+ lastErrorDetails: null
23
+ activeRouteDescriptions: []
24
+ }
25
+ {
26
+ id: "edge-#{Date.now()}-#{Math.random().toString(16).slice(2, 8)}"
27
+ upstreamPool
28
+ routeTable
29
+ configInfo
30
+ verifyPolicy: configInfo?.verifyPolicy or null
31
+ inflight: 0
32
+ wsConnections: 0
33
+ retiredAt: null
34
+ }
35
+
36
+ export createReloadHistoryEntry = (id, source, oldVersion, newVersion, result, reason = null, code = null, details = null) ->
37
+ id: id
38
+ source: source
39
+ oldVersion: oldVersion
40
+ newVersion: newVersion
41
+ result: result
42
+ reason: reason
43
+ code: code
44
+ details: details
45
+ at: new Date().toISOString()
46
+
47
+ export restoreRegistrySnapshot = (registry, snapshot) ->
48
+ registry.apps = new Map(snapshot.apps)
49
+ registry.hostIndex = new Map(snapshot.hostIndex)
50
+ registry.wildcardIndex = new Map(snapshot.wildcardIndex)
51
+
52
+ export configNote = (loaded) ->
53
+ return null unless loaded?.source?.kind is 'edge'
54
+ if loaded.summary.counts.upstreams > 0 or loaded.summary.counts.routes > 0 or loaded.summary.counts.sites > 0
55
+ 'Edgefile upstream routes, websocket routes, wildcard hosts, managed app-route expansion, and per-app server-side reload parity are live.'
56
+ else
57
+ null
58
+
59
+ export toUpstreamWsUrl = (baseUrl, pathName, search) ->
60
+ origin = new URL(baseUrl)
61
+ protocol = if origin.protocol is 'https:' then 'wss:' else 'ws:'
62
+ "#{protocol}//#{origin.host}#{pathName}#{search}"
63
+
64
+ export loadRuntimeConfig = (flags) ->
65
+ source = resolveConfigSource(flags.appEntry, flags.edgefilePath)
66
+ return null unless source?.path
67
+ normalized = if source.kind is 'edge' then loadEdgeConfig!(source.path) else loadConfig!(source.path)
68
+ return null unless normalized
69
+ {
70
+ source
71
+ normalized
72
+ summary: summarizeConfig(source.path, normalized)
73
+ }
74
+
75
+ export printCheckConfigResult = (loaded) ->
76
+ label = if loaded.source.kind is 'edge' then 'Edgefile.rip' else 'config.rip'
77
+ p "rip-server: #{label} OK"
78
+ p " path: #{loaded.summary.path}"
79
+ p " apps: #{loaded.summary.counts.apps} upstreams: #{loaded.summary.counts.upstreams} routes: #{loaded.summary.counts.routes} sites: #{loaded.summary.counts.sites}"
80
+ p " version: #{loaded.summary.version}" if loaded.summary.version?
81
+ note = configNote(loaded)
82
+ p " note: #{note}" if note
83
+
84
+ export runCheckConfig = (flags) ->
85
+ source = resolveConfigSource(flags.appEntry, flags.edgefilePath)
86
+ unless source?.path
87
+ console.error 'rip-server: no Edgefile.rip or config.rip found to validate'
88
+ exit 1
89
+ try
90
+ checked = checkConfigFile!(source.path)
91
+ printCheckConfigResult({ source, normalized: checked.normalized, summary: checked.summary })
92
+ catch e
93
+ if e.validationErrors
94
+ label = if source.kind is 'edge' then 'Edgefile.rip' else 'config.rip'
95
+ console.error formatConfigErrors(label, e.validationErrors)
96
+ else
97
+ console.error "rip-server: config check failed: #{e.message or e}"
98
+ exit 1
@@ -0,0 +1,69 @@
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)
package/edge/tls.rip CHANGED
@@ -6,6 +6,29 @@ import { readFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
  import { X509Certificate } from 'node:crypto'
8
8
 
9
+ hostSpecificity = (serverName) ->
10
+ return 0 unless typeof serverName is 'string'
11
+ if serverName.startsWith('*.')
12
+ labels = serverName.slice(2).split('.').length
13
+ return 100 + labels
14
+ serverName.split('.').length + 200
15
+
16
+ export buildTlsArray = (defaultMaterial, certMap) ->
17
+ entries = []
18
+ for serverName, certInfo of (certMap or {})
19
+ try
20
+ cert = readFileSync(certInfo.certPath, 'utf8')
21
+ key = readFileSync(certInfo.keyPath, 'utf8')
22
+ entries.push({ serverName, cert, key, specificity: hostSpecificity(serverName) })
23
+ catch e
24
+ console.error "rip-server: failed to load cert for #{serverName}: #{e.message}"
25
+ entries.sort((a, b) -> b.specificity - a.specificity)
26
+ result = entries.map((e) -> { serverName: e.serverName, cert: e.cert, key: e.key })
27
+ if defaultMaterial?.cert and defaultMaterial?.key
28
+ fallbackName = '*.localhost'
29
+ result.push({ serverName: fallbackName, cert: defaultMaterial.cert, key: defaultMaterial.key })
30
+ result
31
+
9
32
  export printCertSummary = (certPem) ->
10
33
  try
11
34
  x = new X509Certificate(certPem)