@rip-lang/server 1.3.111 → 1.3.112
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.
- package/acme/client.rip +138 -0
- package/acme/crypto.rip +130 -0
- package/acme/manager.rip +107 -0
- package/acme/store.rip +67 -0
- package/control/cli.rip +123 -0
- package/control/control.rip +75 -0
- package/control/lifecycle.rip +71 -0
- package/control/mdns.rip +53 -0
- package/control/watchers.rip +68 -0
- package/control/worker.rip +136 -0
- package/control/workers.rip +53 -0
- package/edge/config.rip +75 -0
- package/edge/forwarding.rip +149 -0
- package/edge/metrics.rip +85 -0
- package/edge/queue.rip +53 -0
- package/edge/ratelimit.rip +63 -0
- package/edge/realtime.rip +200 -0
- package/edge/registry.rip +44 -0
- package/edge/security.rip +38 -0
- package/edge/tls.rip +49 -0
- package/package.json +6 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/lifecycle.rip — startup, shutdown, reporting, and event logging
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
# --- Structured event logging ---
|
|
6
|
+
|
|
7
|
+
_jsonMode = false
|
|
8
|
+
|
|
9
|
+
export setEventJsonMode = (enabled) -> _jsonMode = enabled
|
|
10
|
+
|
|
11
|
+
export logEvent = (event, data = {}) ->
|
|
12
|
+
if _jsonMode
|
|
13
|
+
entry = Object.assign { t: new Date().toISOString(), event }, data
|
|
14
|
+
p JSON.stringify(entry)
|
|
15
|
+
else
|
|
16
|
+
parts = ["rip-server: #{event}"]
|
|
17
|
+
for key, val of data
|
|
18
|
+
parts.push "#{key}=#{val}" if val?
|
|
19
|
+
p parts.join(' ')
|
|
20
|
+
|
|
21
|
+
# --- Startup reporting ---
|
|
22
|
+
|
|
23
|
+
export computeHideUrls = (appEntry, joinFn, dirnameFn, existsSyncFn) ->
|
|
24
|
+
setupFile = joinFn(dirnameFn(appEntry), 'setup.rip')
|
|
25
|
+
existsSyncFn(setupFile)
|
|
26
|
+
|
|
27
|
+
export logStartupSummary = (server, flags, buildRipdevUrlFn, formatPortFn) ->
|
|
28
|
+
httpOnly = flags.httpsPort is null
|
|
29
|
+
protocol = if httpOnly then 'http' else 'https'
|
|
30
|
+
port = flags.httpsPort or flags.httpPort or 80
|
|
31
|
+
url = buildRipdevUrlFn(flags.appName, protocol, port, formatPortFn)
|
|
32
|
+
p "rip-server: rip=#{server.ripVersion} server=#{server.serverVersion} app=#{flags.appName} workers=#{flags.workers} url=#{url}/server"
|
|
33
|
+
|
|
34
|
+
FORCED_EXIT_MS = 35000
|
|
35
|
+
|
|
36
|
+
export createCleanup = (pidFile, server, manager, unlinkSyncFn, fetchFn, processObj) ->
|
|
37
|
+
called = false
|
|
38
|
+
->
|
|
39
|
+
return if called
|
|
40
|
+
called = true
|
|
41
|
+
p 'rip-server: shutting down...'
|
|
42
|
+
|
|
43
|
+
# Safety net: force exit if graceful shutdown stalls
|
|
44
|
+
setTimeout (-> processObj.exit(1)), FORCED_EXIT_MS
|
|
45
|
+
|
|
46
|
+
# Stop accepting new work before anything else
|
|
47
|
+
manager.shuttingDown = true
|
|
48
|
+
|
|
49
|
+
# Stop ACME renewal loop
|
|
50
|
+
try server.acmeManager?.stopRenewalLoop()
|
|
51
|
+
|
|
52
|
+
# Drain connections and stop listeners
|
|
53
|
+
server.stop()
|
|
54
|
+
manager.stop!
|
|
55
|
+
|
|
56
|
+
# Shutdown DB if we started it
|
|
57
|
+
try fetchFn!(manager.dbUrl + '/shutdown', { method: 'POST' }) if manager.dbUrl
|
|
58
|
+
|
|
59
|
+
# PID file cleanup last — only after everything has stopped
|
|
60
|
+
try unlinkSyncFn(pidFile)
|
|
61
|
+
processObj.exit(0)
|
|
62
|
+
|
|
63
|
+
export installShutdownHandlers = (cleanup, processObj) ->
|
|
64
|
+
processObj.on 'SIGTERM', cleanup
|
|
65
|
+
processObj.on 'SIGINT', cleanup
|
|
66
|
+
processObj.on 'uncaughtException', (err) ->
|
|
67
|
+
console.error 'rip-server: uncaught exception:', err
|
|
68
|
+
cleanup()
|
|
69
|
+
processObj.on 'unhandledRejection', (err) ->
|
|
70
|
+
console.error 'rip-server: unhandled rejection:', err
|
|
71
|
+
cleanup()
|
package/control/mdns.rip
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/mdns.rip — mDNS and LAN detection helpers
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
export getLanIP = (networkInterfacesFn) ->
|
|
6
|
+
try
|
|
7
|
+
nets = networkInterfacesFn()
|
|
8
|
+
for name, addrs of nets
|
|
9
|
+
for addr in addrs
|
|
10
|
+
continue if addr.internal or addr.family isnt 'IPv4'
|
|
11
|
+
continue if addr.address.startsWith('169.254.') # Link-local
|
|
12
|
+
return addr.address
|
|
13
|
+
null
|
|
14
|
+
|
|
15
|
+
export stopMdnsAdvertisements = (mdnsProcesses) ->
|
|
16
|
+
for [host, proc] as mdnsProcesses
|
|
17
|
+
try
|
|
18
|
+
proc.kill()
|
|
19
|
+
p "rip-server: stopped advertising #{host} via mDNS"
|
|
20
|
+
mdnsProcesses.clear()
|
|
21
|
+
|
|
22
|
+
export startMdnsAdvertisement = (host, mdnsProcesses, getLanIP, flags, formatPort, publishUrl) ->
|
|
23
|
+
return unless host.endsWith('.local')
|
|
24
|
+
return if mdnsProcesses.has(host)
|
|
25
|
+
|
|
26
|
+
lanIP = getLanIP()
|
|
27
|
+
unless lanIP
|
|
28
|
+
p "rip-server: unable to detect LAN IP for mDNS advertisement of #{host}"
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
port = flags.httpsPort or flags.httpPort or 80
|
|
32
|
+
protocol = if flags.httpsPort then 'https' else 'http'
|
|
33
|
+
serviceType = if flags.httpsPort then '_https._tcp' else '_http._tcp'
|
|
34
|
+
serviceName = host.replace('.local', '')
|
|
35
|
+
|
|
36
|
+
try
|
|
37
|
+
proc = Bun.spawn [
|
|
38
|
+
'dns-sd', '-P'
|
|
39
|
+
serviceName
|
|
40
|
+
serviceType
|
|
41
|
+
'local'
|
|
42
|
+
String(port)
|
|
43
|
+
host
|
|
44
|
+
lanIP
|
|
45
|
+
],
|
|
46
|
+
stdout: 'ignore'
|
|
47
|
+
stderr: 'ignore'
|
|
48
|
+
|
|
49
|
+
mdnsProcesses.set(host, proc)
|
|
50
|
+
url = "#{protocol}://#{host}#{formatPort(protocol, port)}"
|
|
51
|
+
publishUrl(url)
|
|
52
|
+
catch e
|
|
53
|
+
console.error "rip-server: failed to advertise #{host} via mDNS:", e.message
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/watchers.rip — SSE watch groups and app directory watchers
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { watch } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
# --- SSE watch groups ---
|
|
8
|
+
|
|
9
|
+
export registerWatchGroup = (watchGroups, prefix) ->
|
|
10
|
+
return if watchGroups.has(prefix)
|
|
11
|
+
watchGroups.set prefix, { sseClients: new Set() }
|
|
12
|
+
|
|
13
|
+
export handleWatchGroup = (watchGroups, prefix) ->
|
|
14
|
+
group = watchGroups.get(prefix)
|
|
15
|
+
return new Response('not-found', { status: 404 }) unless group
|
|
16
|
+
encoder = new TextEncoder()
|
|
17
|
+
client = null
|
|
18
|
+
new Response new ReadableStream(
|
|
19
|
+
start: (controller) ->
|
|
20
|
+
send = (type) ->
|
|
21
|
+
try controller.enqueue encoder.encode("event: #{if type is 'connected' then 'connected' else 'reload'}\ndata: #{type}\n\n")
|
|
22
|
+
client = { send }
|
|
23
|
+
group.sseClients.add(client)
|
|
24
|
+
send('connected')
|
|
25
|
+
cancel: ->
|
|
26
|
+
group.sseClients.delete(client) if client
|
|
27
|
+
),
|
|
28
|
+
headers:
|
|
29
|
+
'Content-Type': 'text/event-stream'
|
|
30
|
+
'Cache-Control': 'no-cache'
|
|
31
|
+
'Connection': 'keep-alive'
|
|
32
|
+
|
|
33
|
+
export broadcastWatchChange = (watchGroups, prefix, type = 'page') ->
|
|
34
|
+
group = watchGroups.get(prefix)
|
|
35
|
+
return unless group
|
|
36
|
+
dead = []
|
|
37
|
+
for client as group.sseClients
|
|
38
|
+
try client.send(type)
|
|
39
|
+
catch then dead.push(client)
|
|
40
|
+
group.sseClients.delete(c) for c in dead
|
|
41
|
+
|
|
42
|
+
# --- App directory watchers ---
|
|
43
|
+
|
|
44
|
+
export registerAppWatchDirs = (appWatchers, prefix, dirs, server, cwdFn) ->
|
|
45
|
+
return if appWatchers.has(prefix)
|
|
46
|
+
timer = null
|
|
47
|
+
pending = null
|
|
48
|
+
watchers = []
|
|
49
|
+
broadcast = (type = 'page') =>
|
|
50
|
+
pending = if type is 'page' or pending is 'page' then 'page' else 'styles'
|
|
51
|
+
clearTimeout(timer) if timer
|
|
52
|
+
timer = setTimeout =>
|
|
53
|
+
timer = null
|
|
54
|
+
server?.broadcastChange(prefix, pending)
|
|
55
|
+
pending = null
|
|
56
|
+
, 300
|
|
57
|
+
for dir in dirs
|
|
58
|
+
try
|
|
59
|
+
w = watch dir, { recursive: true }, (event, filename) ->
|
|
60
|
+
if filename?.endsWith('.rip') or filename?.endsWith('.html')
|
|
61
|
+
broadcast('page')
|
|
62
|
+
else if filename?.endsWith('.css')
|
|
63
|
+
broadcast('styles')
|
|
64
|
+
watchers.push(w)
|
|
65
|
+
catch e
|
|
66
|
+
rel = dir.replace(cwdFn() + '/', '')
|
|
67
|
+
warn "rip-server: watch skipped (#{e.code or 'error'}): #{rel}"
|
|
68
|
+
appWatchers.set prefix, { watchers, timer }
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/worker.rip — setup and worker child process runtime
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { getControlSocketPath } from './control.rip'
|
|
6
|
+
|
|
7
|
+
SHUTDOWN_TIMEOUT_MS = 30000 # Max time to wait for in-flight requests on shutdown
|
|
8
|
+
|
|
9
|
+
export runSetupMode = ->
|
|
10
|
+
setupFile = process.env.RIP_SETUP_FILE
|
|
11
|
+
try
|
|
12
|
+
mod = import!(setupFile)
|
|
13
|
+
await Promise.resolve()
|
|
14
|
+
fn = mod?.setup or mod?.default
|
|
15
|
+
if typeof fn is 'function'
|
|
16
|
+
await fn()
|
|
17
|
+
catch e
|
|
18
|
+
console.error "rip-server: setup failed:", e
|
|
19
|
+
exit 1
|
|
20
|
+
|
|
21
|
+
export runWorkerMode = ->
|
|
22
|
+
workerId = parseInt(process.env.WORKER_ID or '0')
|
|
23
|
+
maxRequests = parseInt(process.env.MAX_REQUESTS or '10000')
|
|
24
|
+
maxSeconds = parseInt(process.env.MAX_SECONDS or '0')
|
|
25
|
+
appEntry = process.env.APP_ENTRY
|
|
26
|
+
appId = process.env.APP_ID or 'default'
|
|
27
|
+
socketPath = process.env.SOCKET_PATH
|
|
28
|
+
socketPrefix = process.env.SOCKET_PREFIX
|
|
29
|
+
version = parseInt(process.env.RIP_VERSION or '1')
|
|
30
|
+
|
|
31
|
+
startedAtMs = Date.now()
|
|
32
|
+
# Use object to avoid Rip closure scoping issues with mutable variables
|
|
33
|
+
workerState =
|
|
34
|
+
appReady: false
|
|
35
|
+
inflight: false
|
|
36
|
+
handled: 0
|
|
37
|
+
handler: null
|
|
38
|
+
|
|
39
|
+
getHandler = ->
|
|
40
|
+
return workerState.handler if workerState.handler
|
|
41
|
+
|
|
42
|
+
try
|
|
43
|
+
mod = import!(appEntry)
|
|
44
|
+
|
|
45
|
+
# Ensure module has fully executed by yielding to microtask queue
|
|
46
|
+
await Promise.resolve()
|
|
47
|
+
|
|
48
|
+
fresh = mod.default or mod
|
|
49
|
+
|
|
50
|
+
if typeof fresh is 'function'
|
|
51
|
+
h = fresh
|
|
52
|
+
else if fresh?.fetch?
|
|
53
|
+
h = fresh.fetch.bind(fresh)
|
|
54
|
+
else
|
|
55
|
+
h = globalThis.__ripHandler # Handler set by start() in rip-server mode
|
|
56
|
+
|
|
57
|
+
unless h
|
|
58
|
+
try
|
|
59
|
+
api = import!('@rip-lang/server')
|
|
60
|
+
h = api?.startHandler?.()
|
|
61
|
+
|
|
62
|
+
workerState.handler = h if h
|
|
63
|
+
workerState.handler or (-> new Response('not ready', { status: 503 }))
|
|
64
|
+
catch e
|
|
65
|
+
console.error "[worker #{workerId}] import failed:", e
|
|
66
|
+
workerState.handler or (-> new Response('not ready', { status: 503 }))
|
|
67
|
+
|
|
68
|
+
selfJoin = ->
|
|
69
|
+
try
|
|
70
|
+
payload = { op: 'join', workerId, pid: process.pid, socket: socketPath, version, appId }
|
|
71
|
+
body = JSON.stringify(payload)
|
|
72
|
+
ctl = getControlSocketPath(socketPrefix)
|
|
73
|
+
fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
|
|
74
|
+
|
|
75
|
+
selfQuit = ->
|
|
76
|
+
try
|
|
77
|
+
payload = { op: 'quit', workerId, appId }
|
|
78
|
+
body = JSON.stringify(payload)
|
|
79
|
+
ctl = getControlSocketPath(socketPrefix)
|
|
80
|
+
fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
|
|
81
|
+
|
|
82
|
+
# Preload handler
|
|
83
|
+
try
|
|
84
|
+
initial = getHandler!
|
|
85
|
+
workerState.appReady = typeof initial is 'function'
|
|
86
|
+
|
|
87
|
+
server = Bun.serve
|
|
88
|
+
unix: socketPath
|
|
89
|
+
maxRequestBodySize: 100 * 1024 * 1024
|
|
90
|
+
fetch: (req) ->
|
|
91
|
+
url = new URL(req.url)
|
|
92
|
+
return new Response(if workerState.appReady then 'ok' else 'not-ready') if url.pathname is '/ready'
|
|
93
|
+
|
|
94
|
+
if workerState.inflight
|
|
95
|
+
return new Response 'busy',
|
|
96
|
+
status: 503
|
|
97
|
+
headers: { 'Rip-Worker-Busy': '1', 'Retry-After': '0', 'Rip-Worker-Id': String(workerId) }
|
|
98
|
+
|
|
99
|
+
handlerFn = getHandler!
|
|
100
|
+
workerState.appReady = typeof handlerFn is 'function'
|
|
101
|
+
workerState.inflight = true
|
|
102
|
+
|
|
103
|
+
try
|
|
104
|
+
return new Response('not ready', { status: 503 }) unless typeof handlerFn is 'function'
|
|
105
|
+
res = handlerFn!(req)
|
|
106
|
+
res = res!(req) if typeof res is 'function'
|
|
107
|
+
if res instanceof Response then res else new Response(String(res))
|
|
108
|
+
catch err
|
|
109
|
+
console.error "[worker #{workerId}] ERROR:", err
|
|
110
|
+
new Response('error', { status: 500 })
|
|
111
|
+
finally
|
|
112
|
+
workerState.inflight = false
|
|
113
|
+
workerState.handled++
|
|
114
|
+
exceededReqs = workerState.handled >= maxRequests
|
|
115
|
+
exceededTime = maxSeconds > 0 and (Date.now() - startedAtMs) / 1000 >= maxSeconds
|
|
116
|
+
setTimeout (-> exit 0), 10 if exceededReqs or exceededTime
|
|
117
|
+
|
|
118
|
+
selfJoin!
|
|
119
|
+
|
|
120
|
+
shutdown = ->
|
|
121
|
+
# Wait for in-flight request to complete (with timeout)
|
|
122
|
+
start = Date.now()
|
|
123
|
+
while workerState.inflight and Date.now() - start < SHUTDOWN_TIMEOUT_MS
|
|
124
|
+
await new Promise (r) -> setTimeout(r, 10)
|
|
125
|
+
try server.stop()
|
|
126
|
+
selfQuit!
|
|
127
|
+
exit 0
|
|
128
|
+
|
|
129
|
+
process.on 'SIGTERM', shutdown
|
|
130
|
+
process.on 'SIGINT', shutdown
|
|
131
|
+
process.on 'uncaughtException', (err) ->
|
|
132
|
+
console.error "[worker #{workerId}] uncaught exception:", err
|
|
133
|
+
shutdown()
|
|
134
|
+
process.on 'unhandledRejection', (err) ->
|
|
135
|
+
console.error "[worker #{workerId}] unhandled rejection:", err
|
|
136
|
+
shutdown()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/workers.rip — manager-side worker spawn, health, and control
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { unlinkSync } from 'node:fs'
|
|
6
|
+
import { getWorkerSocketPath, getControlSocketPath } from './control.rip'
|
|
7
|
+
|
|
8
|
+
# --- Spawn ---
|
|
9
|
+
|
|
10
|
+
export spawnTrackedWorker = (flags, workerId, version, nowMsFn, importMetaPath, appId) ->
|
|
11
|
+
socketPath = getWorkerSocketPath(flags.socketPrefix, workerId)
|
|
12
|
+
try unlinkSync(socketPath)
|
|
13
|
+
|
|
14
|
+
workerEnv = Object.assign {}, process.env,
|
|
15
|
+
RIP_WORKER_MODE: '1'
|
|
16
|
+
WORKER_ID: String(workerId)
|
|
17
|
+
SOCKET_PATH: socketPath
|
|
18
|
+
SOCKET_PREFIX: flags.socketPrefix
|
|
19
|
+
APP_ID: String(appId or flags.appName or 'default')
|
|
20
|
+
APP_ENTRY: flags.appEntry
|
|
21
|
+
APP_BASE_DIR: flags.appBaseDir
|
|
22
|
+
MAX_REQUESTS: String(flags.maxRequestsPerWorker)
|
|
23
|
+
MAX_SECONDS: String(flags.maxSecondsPerWorker)
|
|
24
|
+
RIP_LOG_JSON: if flags.jsonLogging then '1' else '0'
|
|
25
|
+
RIP_VERSION: String(version)
|
|
26
|
+
|
|
27
|
+
proc = Bun.spawn ['rip', importMetaPath],
|
|
28
|
+
stdout: 'inherit'
|
|
29
|
+
stderr: 'inherit'
|
|
30
|
+
stdin: 'ignore'
|
|
31
|
+
cwd: process.cwd()
|
|
32
|
+
env: workerEnv
|
|
33
|
+
|
|
34
|
+
{ id: workerId, process: proc, socketPath, restartCount: 0, backoffMs: 1000, startedAt: nowMsFn() }
|
|
35
|
+
|
|
36
|
+
# --- Health and control notifications ---
|
|
37
|
+
|
|
38
|
+
export postWorkerQuit = (socketPrefix, workerId) ->
|
|
39
|
+
try
|
|
40
|
+
ctl = getControlSocketPath(socketPrefix)
|
|
41
|
+
body = JSON.stringify({ op: 'quit', workerId })
|
|
42
|
+
fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
|
|
43
|
+
|
|
44
|
+
export waitForWorkerReady = (socketPath, timeoutMs = 5000) ->
|
|
45
|
+
start = Date.now()
|
|
46
|
+
while Date.now() - start < timeoutMs
|
|
47
|
+
try
|
|
48
|
+
res = fetch!('http://localhost/ready', { unix: socketPath, method: 'GET' })
|
|
49
|
+
if res.ok
|
|
50
|
+
txt = res.text!
|
|
51
|
+
return true if txt is 'ok'
|
|
52
|
+
await new Promise (r) -> setTimeout(r, 30)
|
|
53
|
+
false
|
package/edge/config.rip
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# edge/config.rip — declarative multi-app configuration loader
|
|
3
|
+
# ==============================================================================
|
|
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'
|
|
23
|
+
|
|
24
|
+
export loadConfig = (configPath) ->
|
|
25
|
+
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
|
|
35
|
+
|
|
36
|
+
export normalizeAppConfig = (appId, appConfig, baseDir) ->
|
|
37
|
+
entry = if appConfig.entry
|
|
38
|
+
if appConfig.entry.startsWith('/') then appConfig.entry else resolve(baseDir, appConfig.entry)
|
|
39
|
+
else
|
|
40
|
+
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
|
+
{
|
|
48
|
+
id: appId
|
|
49
|
+
entry
|
|
50
|
+
hosts
|
|
51
|
+
workers
|
|
52
|
+
maxQueue
|
|
53
|
+
queueTimeoutMs
|
|
54
|
+
readTimeoutMs
|
|
55
|
+
env: appConfig.env or {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export findConfigFile = (appEntry) ->
|
|
59
|
+
dir = dirname(appEntry)
|
|
60
|
+
configPath = join(dir, 'config.rip')
|
|
61
|
+
if existsSync(configPath) then configPath else null
|
|
62
|
+
|
|
63
|
+
export applyConfig = (config, registry, registerAppFn, baseDir) ->
|
|
64
|
+
apps = config.apps or {}
|
|
65
|
+
registered = []
|
|
66
|
+
for appId, appConfig of apps
|
|
67
|
+
normalized = normalizeAppConfig(appId, appConfig, baseDir)
|
|
68
|
+
registerAppFn(registry, appId,
|
|
69
|
+
hosts: normalized.hosts
|
|
70
|
+
maxQueue: normalized.maxQueue
|
|
71
|
+
queueTimeoutMs: normalized.queueTimeoutMs
|
|
72
|
+
readTimeoutMs: normalized.readTimeoutMs
|
|
73
|
+
)
|
|
74
|
+
registered.push(normalized)
|
|
75
|
+
registered
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# edge/forwarding.rip — helpers, responses, forwarding, and proxy
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
# --- Edge utilities ---
|
|
8
|
+
|
|
9
|
+
export generateRequestId = ->
|
|
10
|
+
"req-#{randomBytes(6).toString('hex')}"
|
|
11
|
+
|
|
12
|
+
export formatPort = (protocol, port) ->
|
|
13
|
+
if (protocol is 'https' and port is 443) or (protocol is 'http' and port is 80)
|
|
14
|
+
''
|
|
15
|
+
else
|
|
16
|
+
":#{port}"
|
|
17
|
+
|
|
18
|
+
export maybeAddSecurityHeaders = (httpsActive, hstsEnabled, headers) ->
|
|
19
|
+
if httpsActive and hstsEnabled
|
|
20
|
+
headers.set('strict-transport-security', 'max-age=31536000; includeSubDomains') unless headers.has('strict-transport-security')
|
|
21
|
+
|
|
22
|
+
export buildStatusBody = (startedAt, workerCount, hostIndex, serverVersion, appName, httpPort, httpsPort, nowMsFn) ->
|
|
23
|
+
uptime = Math.floor((nowMsFn() - startedAt) / 1000)
|
|
24
|
+
healthy = workerCount > 0
|
|
25
|
+
hosts = Array.from(hostIndex.keys())
|
|
26
|
+
JSON.stringify
|
|
27
|
+
status: if healthy then 'healthy' else 'degraded'
|
|
28
|
+
version: serverVersion
|
|
29
|
+
app: appName
|
|
30
|
+
workers: workerCount
|
|
31
|
+
ports: { http: httpPort or undefined, https: httpsPort or undefined }
|
|
32
|
+
uptime: uptime
|
|
33
|
+
hosts: hosts
|
|
34
|
+
|
|
35
|
+
export buildRipdevUrl = (appName, protocol, port, formatPortFn) ->
|
|
36
|
+
"#{protocol}://#{appName}.ripdev.io#{formatPortFn(protocol, port)}"
|
|
37
|
+
|
|
38
|
+
# --- Responses ---
|
|
39
|
+
|
|
40
|
+
export watchUnavailableResponse = ->
|
|
41
|
+
new Response('', { status: 503, headers: { 'Retry-After': '2' } })
|
|
42
|
+
|
|
43
|
+
export serverBusyResponse = ->
|
|
44
|
+
new Response('Server busy', { status: 503, headers: { 'Retry-After': '1' } })
|
|
45
|
+
|
|
46
|
+
export serviceUnavailableResponse = ->
|
|
47
|
+
new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } })
|
|
48
|
+
|
|
49
|
+
export gatewayTimeoutResponse = ->
|
|
50
|
+
new Response('Gateway timeout', { status: 504 })
|
|
51
|
+
|
|
52
|
+
export queueTimeoutResponse = ->
|
|
53
|
+
new Response('Queue timeout', { status: 504 })
|
|
54
|
+
|
|
55
|
+
export buildUpstreamResponse = (res, req, totalSeconds, workerSeconds, maybeAddSecurityHeaders, logAccess, stripInternalHeaders) ->
|
|
56
|
+
silent = res.headers.get('rip-no-log') is '1'
|
|
57
|
+
headers = stripInternalHeaders(res.headers)
|
|
58
|
+
headers.delete('date')
|
|
59
|
+
maybeAddSecurityHeaders(headers)
|
|
60
|
+
logAccess(req, res, totalSeconds, workerSeconds) unless silent
|
|
61
|
+
new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
62
|
+
|
|
63
|
+
# --- Reverse proxy to external upstream ---
|
|
64
|
+
|
|
65
|
+
HOP_HEADERS = new Set([
|
|
66
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization'
|
|
67
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade'
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
export proxyToUpstream = (req, upstreamBase, options = {}) ->
|
|
71
|
+
inUrl = new URL(req.url)
|
|
72
|
+
target = "#{upstreamBase}#{inUrl.pathname}#{inUrl.search}"
|
|
73
|
+
timeoutMs = options.timeoutMs or 30000
|
|
74
|
+
clientIp = options.clientIp or '127.0.0.1'
|
|
75
|
+
|
|
76
|
+
# Forward headers, set X-Forwarded-* (overwrite, not append — prevents spoofing)
|
|
77
|
+
headers = new Headers()
|
|
78
|
+
for [key, val] as req.headers.entries()
|
|
79
|
+
headers.set(key, val) unless HOP_HEADERS.has(key.toLowerCase())
|
|
80
|
+
headers.set('X-Forwarded-For', clientIp)
|
|
81
|
+
headers.set('X-Forwarded-Proto', if inUrl.protocol is 'https:' then 'https' else 'http')
|
|
82
|
+
headers.set('X-Forwarded-Host', inUrl.host)
|
|
83
|
+
|
|
84
|
+
timer = null
|
|
85
|
+
try
|
|
86
|
+
fetchPromise = fetch target,
|
|
87
|
+
method: req.method
|
|
88
|
+
headers: headers
|
|
89
|
+
body: req.body
|
|
90
|
+
redirect: 'manual'
|
|
91
|
+
readGuard = new Promise (_, rej) ->
|
|
92
|
+
timer = setTimeout (-> rej(new Error('Upstream timeout'))), timeoutMs
|
|
93
|
+
res = Promise.race!([fetchPromise, readGuard])
|
|
94
|
+
clearTimeout(timer)
|
|
95
|
+
|
|
96
|
+
# Strip hop-by-hop headers from response
|
|
97
|
+
resHeaders = new Headers()
|
|
98
|
+
for [key, val] as res.headers.entries()
|
|
99
|
+
resHeaders.set(key, val) unless HOP_HEADERS.has(key.toLowerCase())
|
|
100
|
+
new Response(res.body, { status: res.status, statusText: res.statusText, headers: resHeaders })
|
|
101
|
+
catch err
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
if err.message is 'Upstream timeout'
|
|
104
|
+
return new Response('Gateway timeout', { status: 504 })
|
|
105
|
+
new Response("Bad gateway: #{err.message}", { status: 502 })
|
|
106
|
+
|
|
107
|
+
# --- WebSocket passthrough to external upstream ---
|
|
108
|
+
|
|
109
|
+
export createWsPassthrough = (clientWs, upstreamUrl) ->
|
|
110
|
+
upstream = new WebSocket(upstreamUrl)
|
|
111
|
+
|
|
112
|
+
upstream.addEventListener 'open', ->
|
|
113
|
+
null # upstream connected
|
|
114
|
+
|
|
115
|
+
upstream.addEventListener 'message', (event) ->
|
|
116
|
+
try clientWs.send(event.data)
|
|
117
|
+
|
|
118
|
+
upstream.addEventListener 'close', ->
|
|
119
|
+
try clientWs.close()
|
|
120
|
+
|
|
121
|
+
upstream.addEventListener 'error', (err) ->
|
|
122
|
+
console.error "ws-passthrough: upstream error:", err.message or err
|
|
123
|
+
try clientWs.close()
|
|
124
|
+
|
|
125
|
+
# Return object for client-side event wiring
|
|
126
|
+
sendToUpstream: (data) ->
|
|
127
|
+
try upstream.send(data) if upstream.readyState is WebSocket.OPEN
|
|
128
|
+
close: ->
|
|
129
|
+
try upstream.close() if upstream.readyState is WebSocket.OPEN
|
|
130
|
+
|
|
131
|
+
# --- Worker forwarding ---
|
|
132
|
+
|
|
133
|
+
export forwardOnceWithTimeout = (req, socketPath, readTimeoutMs, gatewayTimeoutResponse) ->
|
|
134
|
+
inUrl = new URL(req.url)
|
|
135
|
+
forwardUrl = "http://localhost#{inUrl.pathname}#{inUrl.search}"
|
|
136
|
+
timer = null
|
|
137
|
+
|
|
138
|
+
try
|
|
139
|
+
fetchPromise = fetch(forwardUrl, { method: req.method, headers: req.headers, body: req.body, unix: socketPath, decompress: false })
|
|
140
|
+
readGuard = new Promise (_, rej) ->
|
|
141
|
+
timer = setTimeout (-> rej(new Error('Upstream timeout'))), readTimeoutMs
|
|
142
|
+
res = Promise.race!([fetchPromise, readGuard])
|
|
143
|
+
clearTimeout(timer)
|
|
144
|
+
res
|
|
145
|
+
catch err
|
|
146
|
+
clearTimeout(timer)
|
|
147
|
+
if err.message is 'Upstream timeout'
|
|
148
|
+
return gatewayTimeoutResponse()
|
|
149
|
+
throw err
|
package/edge/metrics.rip
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# edge/metrics.rip — lightweight in-process metrics collector
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
LATENCY_WINDOW = 1000
|
|
6
|
+
|
|
7
|
+
export createMetrics = ->
|
|
8
|
+
latencies = []
|
|
9
|
+
latencyIdx = 0
|
|
10
|
+
|
|
11
|
+
m =
|
|
12
|
+
# Counters (monotonically increasing)
|
|
13
|
+
requests: 0
|
|
14
|
+
responses2xx: 0
|
|
15
|
+
responses4xx: 0
|
|
16
|
+
responses5xx: 0
|
|
17
|
+
forwarded: 0
|
|
18
|
+
queued: 0
|
|
19
|
+
queueTimeouts: 0
|
|
20
|
+
queueShed: 0
|
|
21
|
+
workerRestarts: 0
|
|
22
|
+
acmeRenewals: 0
|
|
23
|
+
acmeFailures: 0
|
|
24
|
+
wsConnections: 0
|
|
25
|
+
wsMessages: 0
|
|
26
|
+
wsDeliveries: 0
|
|
27
|
+
|
|
28
|
+
# Record a request latency (seconds)
|
|
29
|
+
recordLatency: (seconds) ->
|
|
30
|
+
if latencies.length < LATENCY_WINDOW
|
|
31
|
+
latencies.push(seconds)
|
|
32
|
+
else
|
|
33
|
+
latencies[latencyIdx] = seconds
|
|
34
|
+
latencyIdx = (latencyIdx + 1) % LATENCY_WINDOW
|
|
35
|
+
|
|
36
|
+
# Record a response by status code class
|
|
37
|
+
recordStatus: (status) ->
|
|
38
|
+
if status >= 200 and status < 300
|
|
39
|
+
m.responses2xx++
|
|
40
|
+
else if status >= 400 and status < 500
|
|
41
|
+
m.responses4xx++
|
|
42
|
+
else if status >= 500
|
|
43
|
+
m.responses5xx++
|
|
44
|
+
|
|
45
|
+
# Compute latency percentiles from the rolling window
|
|
46
|
+
percentiles: ->
|
|
47
|
+
return { p50: 0, p95: 0, p99: 0 } if latencies.length is 0
|
|
48
|
+
sorted = [...latencies].sort((a, b) -> a - b)
|
|
49
|
+
n = sorted.length
|
|
50
|
+
pick = (p) -> sorted[Math.min(Math.floor(n * p), n - 1)]
|
|
51
|
+
{ p50: pick(0.50), p95: pick(0.95), p99: pick(0.99) }
|
|
52
|
+
|
|
53
|
+
# Full snapshot of all metrics
|
|
54
|
+
snapshot: (startedAt, appRegistry) ->
|
|
55
|
+
totalInflight = 0
|
|
56
|
+
totalQueueDepth = 0
|
|
57
|
+
apps = []
|
|
58
|
+
if appRegistry
|
|
59
|
+
for [appId, app] as appRegistry.apps
|
|
60
|
+
totalInflight += app.inflightTotal
|
|
61
|
+
totalQueueDepth += app.queue.length
|
|
62
|
+
apps.push
|
|
63
|
+
id: appId
|
|
64
|
+
workers: app.sockets.length
|
|
65
|
+
inflight: app.inflightTotal
|
|
66
|
+
queueDepth: app.queue.length
|
|
67
|
+
uptimeSeconds = if startedAt then Math.floor((Date.now() - startedAt) / 1000) else 0
|
|
68
|
+
|
|
69
|
+
uptime: uptimeSeconds
|
|
70
|
+
apps: apps
|
|
71
|
+
counters:
|
|
72
|
+
requests: m.requests
|
|
73
|
+
responses: { '2xx': m.responses2xx, '4xx': m.responses4xx, '5xx': m.responses5xx }
|
|
74
|
+
forwarded: m.forwarded
|
|
75
|
+
queue: { queued: m.queued, timeouts: m.queueTimeouts, shed: m.queueShed }
|
|
76
|
+
workers: { restarts: m.workerRestarts }
|
|
77
|
+
acme: { renewals: m.acmeRenewals, failures: m.acmeFailures }
|
|
78
|
+
websocket: { connections: m.wsConnections, messages: m.wsMessages, deliveries: m.wsDeliveries }
|
|
79
|
+
gauges:
|
|
80
|
+
workersActive: apps.reduce(((sum, a) -> sum + a.workers), 0)
|
|
81
|
+
inflight: totalInflight
|
|
82
|
+
queueDepth: totalQueueDepth
|
|
83
|
+
latency: m.percentiles()
|
|
84
|
+
|
|
85
|
+
m
|