@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.
@@ -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()
@@ -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
@@ -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
@@ -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