@rip-lang/server 0.5.0
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/README.md +336 -0
- package/bin/rip-server +17 -0
- package/dashboard.html +410 -0
- package/docs/logo.png +0 -0
- package/docs/logo.svg +13 -0
- package/docs/social.png +0 -0
- package/package.json +51 -0
- package/server.rip +1082 -0
package/server.rip
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/server — Pure Rip Application Server
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
#
|
|
5
|
+
# A multi-worker application server written entirely in Rip.
|
|
6
|
+
# Provides hot reloading, HTTPS, mDNS, and production-grade features.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# bun server.rip <app-path> # Start server
|
|
10
|
+
# bun server.rip <app-path>@alias # Start with mDNS alias
|
|
11
|
+
# bun server.rip stop # Stop server
|
|
12
|
+
# bun server.rip list # List registered hosts
|
|
13
|
+
# ==============================================================================
|
|
14
|
+
|
|
15
|
+
import { existsSync, statSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
|
|
16
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
17
|
+
import { homedir, cpus } from 'node:os'
|
|
18
|
+
import { X509Certificate } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
# Match capture holder for Rip's =~
|
|
21
|
+
_ = null
|
|
22
|
+
|
|
23
|
+
# ==============================================================================
|
|
24
|
+
# Utilities
|
|
25
|
+
# ==============================================================================
|
|
26
|
+
|
|
27
|
+
nowMs = -> Date.now()
|
|
28
|
+
|
|
29
|
+
getWorkerSocketPath = (prefix, id) -> "/tmp/#{prefix}.#{id}.sock"
|
|
30
|
+
getControlSocketPath = (prefix) -> "/tmp/#{prefix}.ctl.sock"
|
|
31
|
+
|
|
32
|
+
coerceInt = (value, fallback) ->
|
|
33
|
+
return fallback unless value? and value isnt ''
|
|
34
|
+
n = parseInt(String(value))
|
|
35
|
+
if Number.isFinite(n) then n else fallback
|
|
36
|
+
|
|
37
|
+
isDev = ->
|
|
38
|
+
env = (process.env.NODE_ENV or '').toLowerCase()
|
|
39
|
+
env in ['development', 'dev', '']
|
|
40
|
+
|
|
41
|
+
formatTimestamp = ->
|
|
42
|
+
now = new Date()
|
|
43
|
+
pad = (n, w = 2) -> String(n).padStart(w, '0')
|
|
44
|
+
timestamp = "#{now.getFullYear()}-#{pad(now.getMonth() + 1)}-#{pad(now.getDate())} #{pad(now.getHours())}:#{pad(now.getMinutes())}:#{pad(now.getSeconds())}.#{String(now.getMilliseconds()).padStart(3, '0')}"
|
|
45
|
+
tzMin = now.getTimezoneOffset()
|
|
46
|
+
tzSign = if tzMin <= 0 then '+' else '-'
|
|
47
|
+
tzAbs = Math.abs(tzMin)
|
|
48
|
+
timezone = "#{tzSign}#{String(Math.floor(tzAbs / 60)).padStart(2, '0')}#{String(tzAbs % 60).padStart(2, '0')}"
|
|
49
|
+
{ timestamp, timezone }
|
|
50
|
+
|
|
51
|
+
scale = (value, unit, pad = true) ->
|
|
52
|
+
if value > 0 and Number.isFinite(value)
|
|
53
|
+
span = ['T', 'G', 'M', 'k', (if pad then ' ' else ''), 'm', 'µ', 'n', 'p']
|
|
54
|
+
base = 4
|
|
55
|
+
minSlot = 0
|
|
56
|
+
maxSlot = span.length - 1
|
|
57
|
+
slot = base
|
|
58
|
+
|
|
59
|
+
while value < 0.05 and slot <= maxSlot
|
|
60
|
+
value *= 1000
|
|
61
|
+
slot++
|
|
62
|
+
while value >= 999.5 and slot >= minSlot
|
|
63
|
+
value /= 1000
|
|
64
|
+
slot--
|
|
65
|
+
|
|
66
|
+
if slot >= minSlot and slot <= maxSlot
|
|
67
|
+
tens = Math.round(value * 10) / 10
|
|
68
|
+
if tens >= 99.5
|
|
69
|
+
nums = Math.round(value).toString()
|
|
70
|
+
else if tens >= 10
|
|
71
|
+
nums = Math.round(value).toString()
|
|
72
|
+
else
|
|
73
|
+
nums = tens.toFixed(1)
|
|
74
|
+
nums = nums.padStart(3, ' ') if pad
|
|
75
|
+
return "#{nums}#{span[slot]}#{unit}"
|
|
76
|
+
|
|
77
|
+
return (if pad then ' 0 ' else '0') + unit if value is 0
|
|
78
|
+
'???' + (if pad then ' ' else '') + unit
|
|
79
|
+
|
|
80
|
+
logAccessJson = (app, req, res, totalSeconds, workerSeconds) ->
|
|
81
|
+
url = new URL(req.url)
|
|
82
|
+
len = res.headers.get('content-length')
|
|
83
|
+
type = (res.headers.get('content-type') or '').split(';')[0] or undefined
|
|
84
|
+
console.log JSON.stringify
|
|
85
|
+
t: new Date().toISOString()
|
|
86
|
+
app: app
|
|
87
|
+
method: req.method or 'GET'
|
|
88
|
+
path: url.pathname
|
|
89
|
+
status: res.status
|
|
90
|
+
totalSeconds: totalSeconds
|
|
91
|
+
workerSeconds: workerSeconds
|
|
92
|
+
type: type
|
|
93
|
+
length: if len then Number(len) else undefined
|
|
94
|
+
|
|
95
|
+
logAccessHuman = (app, req, res, totalSeconds, workerSeconds) ->
|
|
96
|
+
{ timestamp, timezone } = formatTimestamp()
|
|
97
|
+
d1 = scale(totalSeconds, 's')
|
|
98
|
+
d2 = scale(workerSeconds, 's')
|
|
99
|
+
method = req.method or 'GET'
|
|
100
|
+
url = new URL(req.url)
|
|
101
|
+
path = url.pathname
|
|
102
|
+
status = res.status
|
|
103
|
+
lenHeader = res.headers.get('content-length') or ''
|
|
104
|
+
len = if lenHeader then "#{lenHeader}B" else ''
|
|
105
|
+
contentType = (res.headers.get('content-type') or '').split(';')[0] or ''
|
|
106
|
+
type = if contentType.includes('/') then contentType.split('/')[1] else contentType
|
|
107
|
+
console.log "[#{timestamp} #{timezone} #{d1} #{d2}] #{method} #{path} → #{status} #{type} #{len}"
|
|
108
|
+
|
|
109
|
+
INTERNAL_HEADERS = new Set(['rip-worker-busy', 'rip-worker-id'])
|
|
110
|
+
|
|
111
|
+
stripInternalHeaders = (h) ->
|
|
112
|
+
out = new Headers()
|
|
113
|
+
for [k, v] from h.entries()
|
|
114
|
+
continue if INTERNAL_HEADERS.has(k.toLowerCase())
|
|
115
|
+
out.append(k, v)
|
|
116
|
+
out
|
|
117
|
+
|
|
118
|
+
# ==============================================================================
|
|
119
|
+
# Flag Parsing
|
|
120
|
+
# ==============================================================================
|
|
121
|
+
|
|
122
|
+
parseWorkersToken = (token, fallback) ->
|
|
123
|
+
return fallback unless token
|
|
124
|
+
cores = cpus().length
|
|
125
|
+
return Math.max(1, cores) if token is 'auto'
|
|
126
|
+
return Math.max(1, Math.floor(cores / 2)) if token is 'half'
|
|
127
|
+
return Math.max(1, cores * 2) if token is '2x'
|
|
128
|
+
return Math.max(1, cores * 3) if token is '3x'
|
|
129
|
+
n = parseInt(token)
|
|
130
|
+
if Number.isFinite(n) and n > 0 then n else fallback
|
|
131
|
+
|
|
132
|
+
parseRestartPolicy = (token, defReqs, defSecs, defReloads) ->
|
|
133
|
+
return { maxRequests: defReqs, maxSeconds: defSecs, maxReloads: defReloads } unless token
|
|
134
|
+
maxRequests = defReqs
|
|
135
|
+
maxSeconds = defSecs
|
|
136
|
+
maxReloads = defReloads
|
|
137
|
+
|
|
138
|
+
for part in token.split(',').map((s) -> s.trim()).filter(Boolean)
|
|
139
|
+
if part.endsWith('s')
|
|
140
|
+
secs = parseInt(part.slice(0, -1))
|
|
141
|
+
maxSeconds = secs if Number.isFinite(secs) and secs >= 0
|
|
142
|
+
else if part.endsWith('r')
|
|
143
|
+
rls = parseInt(part.slice(0, -1))
|
|
144
|
+
maxReloads = rls if Number.isFinite(rls) and rls >= 0
|
|
145
|
+
else
|
|
146
|
+
n = parseInt(part)
|
|
147
|
+
maxRequests = n if Number.isFinite(n) and n > 0
|
|
148
|
+
|
|
149
|
+
{ maxRequests, maxSeconds, maxReloads }
|
|
150
|
+
|
|
151
|
+
resolveAppEntry = (appPathInput) ->
|
|
152
|
+
abs = if isAbsolute(appPathInput) then appPathInput else resolve(process.cwd(), appPathInput)
|
|
153
|
+
|
|
154
|
+
if existsSync(abs) and statSync(abs).isDirectory()
|
|
155
|
+
baseDir = abs
|
|
156
|
+
one = join(abs, 'index.rip')
|
|
157
|
+
two = join(abs, 'index.ts')
|
|
158
|
+
if existsSync(one)
|
|
159
|
+
entryPath = one
|
|
160
|
+
else if existsSync(two)
|
|
161
|
+
entryPath = two
|
|
162
|
+
else
|
|
163
|
+
console.error "No app entry found. Probed: #{one}, #{two}"
|
|
164
|
+
process.exit(2)
|
|
165
|
+
else
|
|
166
|
+
unless existsSync(abs)
|
|
167
|
+
console.error "App path not found: #{abs}"
|
|
168
|
+
process.exit(2)
|
|
169
|
+
baseDir = dirname(abs)
|
|
170
|
+
entryPath = abs
|
|
171
|
+
|
|
172
|
+
appName = basename(baseDir)
|
|
173
|
+
{ baseDir, entryPath, appName }
|
|
174
|
+
|
|
175
|
+
parseFlags = (argv) ->
|
|
176
|
+
rawFlags = new Set()
|
|
177
|
+
appPathInput = null
|
|
178
|
+
appAliases = []
|
|
179
|
+
|
|
180
|
+
tryResolveApp = (tok) ->
|
|
181
|
+
looksLikePath = tok.includes('/') or tok.startsWith('.') or isAbsolute(tok) or tok.endsWith('.rip') or tok.endsWith('.ts')
|
|
182
|
+
return undefined unless looksLikePath
|
|
183
|
+
try
|
|
184
|
+
abs = if isAbsolute(tok) then tok else resolve(process.cwd(), tok)
|
|
185
|
+
if existsSync(abs) then tok else undefined
|
|
186
|
+
catch
|
|
187
|
+
undefined
|
|
188
|
+
|
|
189
|
+
for i in [2...argv.length]
|
|
190
|
+
tok = argv[i]
|
|
191
|
+
unless appPathInput
|
|
192
|
+
if tok.includes('@')
|
|
193
|
+
[pathPart, aliasesPart] = tok.split('@')
|
|
194
|
+
maybe = tryResolveApp(pathPart)
|
|
195
|
+
if maybe
|
|
196
|
+
appPathInput = maybe
|
|
197
|
+
appAliases = aliasesPart.split(',').map((a) -> a.trim()).filter((a) -> a)
|
|
198
|
+
continue
|
|
199
|
+
maybe = tryResolveApp(tok)
|
|
200
|
+
if maybe
|
|
201
|
+
appPathInput = maybe
|
|
202
|
+
continue
|
|
203
|
+
rawFlags.add(tok)
|
|
204
|
+
|
|
205
|
+
unless appPathInput
|
|
206
|
+
console.error 'Usage: bun server.rip [flags] <app-path>'
|
|
207
|
+
console.error ' bun server.rip [flags] <app-path>@<alias1>,<alias2>,...'
|
|
208
|
+
process.exit(2)
|
|
209
|
+
|
|
210
|
+
getKV = (prefix) ->
|
|
211
|
+
for f from rawFlags
|
|
212
|
+
return f.slice(prefix.length) if f.startsWith(prefix)
|
|
213
|
+
undefined
|
|
214
|
+
|
|
215
|
+
has = (name) -> rawFlags.has(name)
|
|
216
|
+
|
|
217
|
+
{ baseDir, entryPath, appName } = resolveAppEntry(appPathInput)
|
|
218
|
+
appAliases = [appName] if appAliases.length is 0
|
|
219
|
+
|
|
220
|
+
# Parse listener tokens
|
|
221
|
+
tokens = Array.from(rawFlags)
|
|
222
|
+
bareIntPort = null
|
|
223
|
+
hasHttpsKeyword = false
|
|
224
|
+
httpsPortToken = null
|
|
225
|
+
hasHttpKeyword = false
|
|
226
|
+
httpPortToken = null
|
|
227
|
+
|
|
228
|
+
for t in tokens
|
|
229
|
+
if /^\d+$/.test(t)
|
|
230
|
+
bareIntPort = parseInt(t)
|
|
231
|
+
else if t is 'https'
|
|
232
|
+
hasHttpsKeyword = true
|
|
233
|
+
else if t.startsWith('https:')
|
|
234
|
+
httpsPortToken = coerceInt(t.slice(6), 0)
|
|
235
|
+
else if t is 'http'
|
|
236
|
+
hasHttpKeyword = true
|
|
237
|
+
else if t.startsWith('http:')
|
|
238
|
+
httpPortToken = coerceInt(t.slice(5), 0)
|
|
239
|
+
|
|
240
|
+
httpsIntent = bareIntPort? or hasHttpsKeyword or httpsPortToken?
|
|
241
|
+
httpIntent = hasHttpKeyword or httpPortToken?
|
|
242
|
+
httpsIntent = true unless httpsIntent or httpIntent
|
|
243
|
+
|
|
244
|
+
httpPort = if httpIntent then (httpPortToken ? 0) else 0
|
|
245
|
+
httpsPortDerived = if not httpIntent then (bareIntPort or httpsPortToken or 0) else null
|
|
246
|
+
|
|
247
|
+
socketPrefixOverride = getKV('--socket-prefix=')
|
|
248
|
+
socketPrefix = socketPrefixOverride or "rip_#{appName}"
|
|
249
|
+
|
|
250
|
+
cores = cpus().length
|
|
251
|
+
workers = parseWorkersToken(getKV('w:'), Math.max(1, Math.floor(cores / 2)))
|
|
252
|
+
|
|
253
|
+
policy = parseRestartPolicy(
|
|
254
|
+
getKV('r:'),
|
|
255
|
+
coerceInt(process.env.RIP_MAX_REQUESTS, 10000),
|
|
256
|
+
coerceInt(process.env.RIP_MAX_SECONDS, 3600),
|
|
257
|
+
coerceInt(process.env.RIP_MAX_RELOADS, 10)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
reloadFlag = getKV('--reload=') or process.env.RIP_RELOAD
|
|
261
|
+
reload = if reloadFlag in ['none', 'process', 'module'] then reloadFlag else 'process'
|
|
262
|
+
|
|
263
|
+
httpsPort = do ->
|
|
264
|
+
kv = getKV('--https-port=')
|
|
265
|
+
return coerceInt(kv, 443) if kv?
|
|
266
|
+
httpsPortDerived
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
appPath: resolve(appPathInput)
|
|
270
|
+
appBaseDir: baseDir
|
|
271
|
+
appEntry: entryPath
|
|
272
|
+
appName
|
|
273
|
+
appAliases
|
|
274
|
+
workers
|
|
275
|
+
maxRequestsPerWorker: policy.maxRequests
|
|
276
|
+
maxSecondsPerWorker: policy.maxSeconds
|
|
277
|
+
maxReloadsPerWorker: policy.maxReloads
|
|
278
|
+
httpPort
|
|
279
|
+
httpsPort
|
|
280
|
+
certPath: getKV('--cert=')
|
|
281
|
+
keyPath: getKV('--key=')
|
|
282
|
+
autoTls: has('--auto-tls')
|
|
283
|
+
hsts: has('--hsts')
|
|
284
|
+
redirectHttp: not has('--no-redirect-http')
|
|
285
|
+
reload
|
|
286
|
+
socketPrefix
|
|
287
|
+
maxQueue: coerceInt(getKV('--max-queue='), coerceInt(process.env.RIP_MAX_QUEUE, 8192))
|
|
288
|
+
queueTimeoutMs: coerceInt(getKV('--queue-timeout-ms='), coerceInt(process.env.RIP_QUEUE_TIMEOUT_MS, 2000))
|
|
289
|
+
connectTimeoutMs: coerceInt(getKV('--connect-timeout-ms='), coerceInt(process.env.RIP_CONNECT_TIMEOUT_MS, 200))
|
|
290
|
+
readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 5000))
|
|
291
|
+
jsonLogging: has('--json-logging')
|
|
292
|
+
accessLog: not has('--no-access-log')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# ==============================================================================
|
|
296
|
+
# Worker Mode
|
|
297
|
+
# ==============================================================================
|
|
298
|
+
|
|
299
|
+
runWorker = ->
|
|
300
|
+
workerId = parseInt(process.env.WORKER_ID or '0')
|
|
301
|
+
maxRequests = parseInt(process.env.MAX_REQUESTS or '10000')
|
|
302
|
+
maxReloads = parseInt(process.env.MAX_RELOADS or '10')
|
|
303
|
+
maxSeconds = parseInt(process.env.MAX_SECONDS or '0')
|
|
304
|
+
appEntry = process.env.APP_ENTRY
|
|
305
|
+
socketPath = process.env.SOCKET_PATH
|
|
306
|
+
hotReloadMode = process.env.RIP_RELOAD or 'none'
|
|
307
|
+
socketPrefix = process.env.SOCKET_PREFIX
|
|
308
|
+
version = parseInt(process.env.RIP_VERSION or '1')
|
|
309
|
+
|
|
310
|
+
appReady = false
|
|
311
|
+
inflight = false
|
|
312
|
+
handled = 0
|
|
313
|
+
startedAtMs = Date.now()
|
|
314
|
+
lastMtime = 0
|
|
315
|
+
cachedHandler = null
|
|
316
|
+
hotReloadCount = 0
|
|
317
|
+
lastCheckTime = 0
|
|
318
|
+
CHECK_INTERVAL_MS = 100
|
|
319
|
+
|
|
320
|
+
checkForChanges = ->
|
|
321
|
+
return false unless hotReloadMode is 'module'
|
|
322
|
+
now = Date.now()
|
|
323
|
+
return false if now - lastCheckTime < CHECK_INTERVAL_MS
|
|
324
|
+
lastCheckTime = now
|
|
325
|
+
try
|
|
326
|
+
stats = statSync(appEntry)
|
|
327
|
+
currentMtime = stats.mtime.getTime()
|
|
328
|
+
if lastMtime is 0
|
|
329
|
+
lastMtime = currentMtime
|
|
330
|
+
return false
|
|
331
|
+
if currentMtime > lastMtime
|
|
332
|
+
lastMtime = currentMtime
|
|
333
|
+
return true
|
|
334
|
+
false
|
|
335
|
+
catch
|
|
336
|
+
false
|
|
337
|
+
|
|
338
|
+
getHandler = ->
|
|
339
|
+
hasChanged = checkForChanges()
|
|
340
|
+
if hasChanged
|
|
341
|
+
hotReloadCount++
|
|
342
|
+
console.log "[worker #{workerId}] File changed, reloading... (#{hotReloadCount}/#{maxReloads})"
|
|
343
|
+
cachedHandler = null
|
|
344
|
+
|
|
345
|
+
if hotReloadCount >= maxReloads
|
|
346
|
+
console.log "[worker #{workerId}] Reached maxReloads (#{maxReloads}), graceful exit"
|
|
347
|
+
setTimeout (-> process.exit(0)), 100
|
|
348
|
+
return -> new Response('Worker cycling', { status: 503 })
|
|
349
|
+
|
|
350
|
+
return cachedHandler if cachedHandler and not hasChanged
|
|
351
|
+
|
|
352
|
+
try
|
|
353
|
+
# Try to import the API for resetGlobals
|
|
354
|
+
api = null
|
|
355
|
+
try
|
|
356
|
+
api = await import('@rip-lang/api')
|
|
357
|
+
catch
|
|
358
|
+
null
|
|
359
|
+
|
|
360
|
+
api?.resetGlobals?()
|
|
361
|
+
|
|
362
|
+
bustQuery = if hotReloadMode is 'module' then "?bust=#{Date.now()}" else ''
|
|
363
|
+
mod = await import(appEntry + bustQuery)
|
|
364
|
+
fresh = mod.default or mod
|
|
365
|
+
|
|
366
|
+
if typeof fresh is 'function'
|
|
367
|
+
h = fresh
|
|
368
|
+
else if fresh?.fetch?
|
|
369
|
+
h = fresh.fetch.bind(fresh)
|
|
370
|
+
else
|
|
371
|
+
h = null
|
|
372
|
+
|
|
373
|
+
h = api?.startHandler?() unless h
|
|
374
|
+
cachedHandler = h or cachedHandler
|
|
375
|
+
cachedHandler or (-> new Response('not ready', { status: 503 }))
|
|
376
|
+
catch e
|
|
377
|
+
console.error "[worker #{workerId}] import failed:", e if process.env.RIP_DEBUG
|
|
378
|
+
cachedHandler or (-> new Response('not ready', { status: 503 }))
|
|
379
|
+
|
|
380
|
+
selfJoin = ->
|
|
381
|
+
try
|
|
382
|
+
payload = { op: 'join', workerId, pid: process.pid, socket: socketPath, version }
|
|
383
|
+
body = JSON.stringify(payload)
|
|
384
|
+
ctl = getControlSocketPath(socketPrefix)
|
|
385
|
+
fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
|
|
386
|
+
catch
|
|
387
|
+
null
|
|
388
|
+
|
|
389
|
+
selfQuit = ->
|
|
390
|
+
try
|
|
391
|
+
payload = { op: 'quit', workerId }
|
|
392
|
+
body = JSON.stringify(payload)
|
|
393
|
+
ctl = getControlSocketPath(socketPrefix)
|
|
394
|
+
fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
|
|
395
|
+
catch
|
|
396
|
+
null
|
|
397
|
+
|
|
398
|
+
# Preload handler
|
|
399
|
+
try
|
|
400
|
+
initial = getHandler!
|
|
401
|
+
appReady = typeof initial is 'function'
|
|
402
|
+
catch
|
|
403
|
+
null
|
|
404
|
+
|
|
405
|
+
server = Bun.serve
|
|
406
|
+
unix: socketPath
|
|
407
|
+
maxRequestBodySize: 100 * 1024 * 1024
|
|
408
|
+
fetch: (req) ->
|
|
409
|
+
url = new URL(req.url)
|
|
410
|
+
return new Response(if appReady then 'ok' else 'not-ready') if url.pathname is '/ready'
|
|
411
|
+
|
|
412
|
+
if inflight
|
|
413
|
+
return new Response 'busy',
|
|
414
|
+
status: 503
|
|
415
|
+
headers: { 'Rip-Worker-Busy': '1', 'Retry-After': '0', 'Rip-Worker-Id': String(workerId) }
|
|
416
|
+
|
|
417
|
+
handlerFn = getHandler!
|
|
418
|
+
appReady = typeof handlerFn is 'function'
|
|
419
|
+
inflight = true
|
|
420
|
+
|
|
421
|
+
try
|
|
422
|
+
return new Response('not ready', { status: 503 }) unless typeof handlerFn is 'function'
|
|
423
|
+
res = handlerFn!(req)
|
|
424
|
+
res = res!(req) if typeof res is 'function'
|
|
425
|
+
if res instanceof Response then res else new Response(String(res))
|
|
426
|
+
catch
|
|
427
|
+
new Response('error', { status: 500 })
|
|
428
|
+
finally
|
|
429
|
+
inflight = false
|
|
430
|
+
handled++
|
|
431
|
+
exceededReqs = handled >= maxRequests
|
|
432
|
+
exceededTime = maxSeconds > 0 and (Date.now() - startedAtMs) / 1000 >= maxSeconds
|
|
433
|
+
setTimeout (-> process.exit(0)), 10 if exceededReqs or exceededTime
|
|
434
|
+
|
|
435
|
+
selfJoin!
|
|
436
|
+
|
|
437
|
+
shutdown = ->
|
|
438
|
+
while inflight
|
|
439
|
+
await new Promise (r) -> setTimeout(r, 10)
|
|
440
|
+
try server.stop() catch then null
|
|
441
|
+
selfQuit!
|
|
442
|
+
process.exit(0)
|
|
443
|
+
|
|
444
|
+
process.on 'SIGTERM', shutdown
|
|
445
|
+
process.on 'SIGINT', shutdown
|
|
446
|
+
|
|
447
|
+
# ==============================================================================
|
|
448
|
+
# Manager Class
|
|
449
|
+
# ==============================================================================
|
|
450
|
+
|
|
451
|
+
class Manager
|
|
452
|
+
constructor: (@flags) ->
|
|
453
|
+
@workers = []
|
|
454
|
+
@shuttingDown = false
|
|
455
|
+
@lastCheck = 0
|
|
456
|
+
@currentMtime = 0
|
|
457
|
+
@isRolling = false
|
|
458
|
+
@lastRollAt = 0
|
|
459
|
+
@nextWorkerId = -1
|
|
460
|
+
@retiringIds = new Set()
|
|
461
|
+
@currentVersion = 1
|
|
462
|
+
|
|
463
|
+
process.on 'SIGTERM', => @shutdown!
|
|
464
|
+
process.on 'SIGINT', => @shutdown!
|
|
465
|
+
|
|
466
|
+
start: ->
|
|
467
|
+
@stop!
|
|
468
|
+
@workers = []
|
|
469
|
+
for i in [0...@flags.workers]
|
|
470
|
+
w = @spawnWorker!(@currentVersion)
|
|
471
|
+
@workers.push(w)
|
|
472
|
+
|
|
473
|
+
if @flags.reload is 'process'
|
|
474
|
+
@currentMtime = @getEntryMtime()
|
|
475
|
+
interval = setInterval =>
|
|
476
|
+
return clearInterval(interval) if @shuttingDown
|
|
477
|
+
now = Date.now()
|
|
478
|
+
return if now - @lastCheck < 100
|
|
479
|
+
@lastCheck = now
|
|
480
|
+
mt = @getEntryMtime()
|
|
481
|
+
if mt > @currentMtime
|
|
482
|
+
return if @isRolling or (now - @lastRollAt) < 200
|
|
483
|
+
@currentMtime = mt
|
|
484
|
+
@isRolling = true
|
|
485
|
+
@lastRollAt = now
|
|
486
|
+
@rollingRestart!.finally => @isRolling = false
|
|
487
|
+
, 50
|
|
488
|
+
|
|
489
|
+
stop: ->
|
|
490
|
+
for w in @workers
|
|
491
|
+
try w.process.kill() catch then null
|
|
492
|
+
try w.process.exited catch then null
|
|
493
|
+
try Bun.spawn(['rm', '-f', w.socketPath]).exited catch then null
|
|
494
|
+
@workers = []
|
|
495
|
+
|
|
496
|
+
spawnWorker: (version) ->
|
|
497
|
+
workerId = ++@nextWorkerId
|
|
498
|
+
socketPath = getWorkerSocketPath(@flags.socketPrefix, workerId)
|
|
499
|
+
try Bun.spawn(['rm', '-f', socketPath]).exited catch then null
|
|
500
|
+
|
|
501
|
+
workerEnv = Object.assign {}, process.env,
|
|
502
|
+
RIP_WORKER_MODE: '1'
|
|
503
|
+
WORKER_ID: String(workerId)
|
|
504
|
+
SOCKET_PATH: socketPath
|
|
505
|
+
SOCKET_PREFIX: @flags.socketPrefix
|
|
506
|
+
APP_ENTRY: @flags.appEntry
|
|
507
|
+
MAX_REQUESTS: String(@flags.maxRequestsPerWorker)
|
|
508
|
+
MAX_RELOADS: String(@flags.maxReloadsPerWorker)
|
|
509
|
+
MAX_SECONDS: String(@flags.maxSecondsPerWorker)
|
|
510
|
+
RIP_LOG_JSON: if @flags.jsonLogging then '1' else '0'
|
|
511
|
+
RIP_RELOAD: @flags.reload
|
|
512
|
+
RIP_VERSION: String(version or @currentVersion)
|
|
513
|
+
|
|
514
|
+
proc = Bun.spawn ['bun', '--preload', './rip-loader.js', __filename],
|
|
515
|
+
stdout: 'inherit'
|
|
516
|
+
stderr: 'inherit'
|
|
517
|
+
stdin: 'ignore'
|
|
518
|
+
cwd: process.cwd()
|
|
519
|
+
env: workerEnv
|
|
520
|
+
|
|
521
|
+
tracked = { id: workerId, process: proc, socketPath, restartCount: 0, backoffMs: 1000, startedAt: nowMs() }
|
|
522
|
+
@monitor(tracked)
|
|
523
|
+
tracked
|
|
524
|
+
|
|
525
|
+
monitor: (w) ->
|
|
526
|
+
w.process.exited
|
|
527
|
+
return if @shuttingDown
|
|
528
|
+
return if @retiringIds.has(w.id)
|
|
529
|
+
w.restartCount++
|
|
530
|
+
w.backoffMs = Math.min(w.backoffMs * 2, 30000)
|
|
531
|
+
return if w.restartCount > 10
|
|
532
|
+
await new Promise (r) -> setTimeout(r, w.backoffMs)
|
|
533
|
+
idx = @workers.findIndex((x) -> x.id is w.id)
|
|
534
|
+
@workers[idx] = @spawnWorker!() if idx >= 0
|
|
535
|
+
|
|
536
|
+
waitWorkerReady: (socketPath, timeoutMs = 5000) ->
|
|
537
|
+
start = Date.now()
|
|
538
|
+
while Date.now() - start < timeoutMs
|
|
539
|
+
try
|
|
540
|
+
res = fetch!('http://localhost/ready', { unix: socketPath, method: 'GET' })
|
|
541
|
+
if res.ok
|
|
542
|
+
txt = res.text!
|
|
543
|
+
return true if txt is 'ok'
|
|
544
|
+
catch
|
|
545
|
+
null
|
|
546
|
+
await new Promise (r) -> setTimeout(r, 30)
|
|
547
|
+
false
|
|
548
|
+
|
|
549
|
+
rollingRestart: ->
|
|
550
|
+
olds = [...@workers]
|
|
551
|
+
pairs = []
|
|
552
|
+
@currentVersion++
|
|
553
|
+
|
|
554
|
+
for oldWorker in olds
|
|
555
|
+
replacement = @spawnWorker!(@currentVersion)
|
|
556
|
+
@workers.push(replacement)
|
|
557
|
+
pairs.push({ old: oldWorker, replacement })
|
|
558
|
+
|
|
559
|
+
Promise.all!(pairs.map((p) => @waitWorkerReady(p.replacement.socketPath, 3000)))
|
|
560
|
+
|
|
561
|
+
for { old } in pairs
|
|
562
|
+
@retiringIds.add(old.id)
|
|
563
|
+
try old.process.kill() catch then null
|
|
564
|
+
|
|
565
|
+
Promise.all!(pairs.map(({ old }) -> old.process.exited.catch(-> null)))
|
|
566
|
+
|
|
567
|
+
retiring = new Set(pairs.map((p) -> p.old.id))
|
|
568
|
+
@workers = @workers.filter((w) -> not retiring.has(w.id))
|
|
569
|
+
@retiringIds.delete(id) for id from retiring
|
|
570
|
+
|
|
571
|
+
shutdown: ->
|
|
572
|
+
return if @shuttingDown
|
|
573
|
+
@shuttingDown = true
|
|
574
|
+
@stop!
|
|
575
|
+
process.exit(0)
|
|
576
|
+
|
|
577
|
+
getEntryMtime: ->
|
|
578
|
+
try statSync(@flags.appEntry).mtimeMs catch then 0
|
|
579
|
+
|
|
580
|
+
# ==============================================================================
|
|
581
|
+
# Server Class
|
|
582
|
+
# ==============================================================================
|
|
583
|
+
|
|
584
|
+
class Server
|
|
585
|
+
constructor: (@flags) ->
|
|
586
|
+
@server = null
|
|
587
|
+
@httpsServer = null
|
|
588
|
+
@control = null
|
|
589
|
+
@sockets = []
|
|
590
|
+
@availableWorkers = []
|
|
591
|
+
@inflightTotal = 0
|
|
592
|
+
@queue = []
|
|
593
|
+
@startedAt = nowMs()
|
|
594
|
+
@newestVersion = null
|
|
595
|
+
@httpsActive = false
|
|
596
|
+
@hostRegistry = new Set(['localhost', '127.0.0.1', 'rip.local'])
|
|
597
|
+
@mdnsProcesses = new Map()
|
|
598
|
+
|
|
599
|
+
for alias in @flags.appAliases
|
|
600
|
+
host = if alias.includes('.') then alias else "#{alias}.local"
|
|
601
|
+
@hostRegistry.add(host)
|
|
602
|
+
|
|
603
|
+
start: ->
|
|
604
|
+
httpOnly = @flags.httpsPort is null
|
|
605
|
+
|
|
606
|
+
startOnPort = (p, fetchFn) =>
|
|
607
|
+
port = p
|
|
608
|
+
while true
|
|
609
|
+
try
|
|
610
|
+
return Bun.serve({ port, idleTimeout: 8, fetch: fetchFn })
|
|
611
|
+
catch e
|
|
612
|
+
if e?.code is 'EADDRINUSE'
|
|
613
|
+
port++
|
|
614
|
+
continue
|
|
615
|
+
throw e
|
|
616
|
+
|
|
617
|
+
if httpOnly
|
|
618
|
+
if @flags.httpPort is 0
|
|
619
|
+
try
|
|
620
|
+
@server = Bun.serve({ port: 80, idleTimeout: 8, fetch: @fetch.bind(@) })
|
|
621
|
+
catch e
|
|
622
|
+
if e?.code in ['EADDRINUSE', 'EACCES']
|
|
623
|
+
@server = startOnPort(5700, @fetch.bind(@))
|
|
624
|
+
else
|
|
625
|
+
throw e
|
|
626
|
+
else
|
|
627
|
+
@server = startOnPort(@flags.httpPort, @fetch.bind(@))
|
|
628
|
+
@flags.httpPort = @server.port
|
|
629
|
+
else
|
|
630
|
+
tls = @loadTlsMaterial!
|
|
631
|
+
|
|
632
|
+
startOnTlsPort = (p) =>
|
|
633
|
+
port = p
|
|
634
|
+
while true
|
|
635
|
+
try
|
|
636
|
+
return Bun.serve({ port, idleTimeout: 8, tls, fetch: @fetch.bind(@) })
|
|
637
|
+
catch e
|
|
638
|
+
if e?.code is 'EADDRINUSE'
|
|
639
|
+
port++
|
|
640
|
+
continue
|
|
641
|
+
throw e
|
|
642
|
+
|
|
643
|
+
if not @flags.httpsPort or @flags.httpsPort is 0
|
|
644
|
+
try
|
|
645
|
+
@httpsServer = Bun.serve({ port: 443, idleTimeout: 8, tls, fetch: @fetch.bind(@) })
|
|
646
|
+
catch e
|
|
647
|
+
if e?.code in ['EADDRINUSE', 'EACCES']
|
|
648
|
+
@httpsServer = startOnTlsPort(5700)
|
|
649
|
+
else
|
|
650
|
+
throw e
|
|
651
|
+
else
|
|
652
|
+
@httpsServer = startOnTlsPort(@flags.httpsPort)
|
|
653
|
+
|
|
654
|
+
httpsPort = @httpsServer.port
|
|
655
|
+
@flags.httpsPort = httpsPort
|
|
656
|
+
@httpsActive = true
|
|
657
|
+
|
|
658
|
+
if @flags.redirectHttp
|
|
659
|
+
try
|
|
660
|
+
@server = Bun.serve
|
|
661
|
+
port: 80
|
|
662
|
+
idleTimeout: 8
|
|
663
|
+
fetch: (req) ->
|
|
664
|
+
url = new URL(req.url)
|
|
665
|
+
loc = "https://#{url.hostname}:#{httpsPort}#{url.pathname}#{url.search}"
|
|
666
|
+
new Response(null, { status: 301, headers: { Location: loc } })
|
|
667
|
+
catch
|
|
668
|
+
console.warn 'Warn: could not bind port 80 for HTTP→HTTPS redirect'
|
|
669
|
+
|
|
670
|
+
@flags.httpPort = if @server then @server.port else 0
|
|
671
|
+
|
|
672
|
+
@startControl!
|
|
673
|
+
|
|
674
|
+
stop: ->
|
|
675
|
+
try @server?.stop() catch then null
|
|
676
|
+
try @httpsServer?.stop() catch then null
|
|
677
|
+
try @control?.stop() catch then null
|
|
678
|
+
|
|
679
|
+
for [host, proc] from @mdnsProcesses
|
|
680
|
+
try
|
|
681
|
+
proc.kill()
|
|
682
|
+
console.log "rip-server: stopped advertising #{host} via mDNS"
|
|
683
|
+
catch
|
|
684
|
+
null
|
|
685
|
+
@mdnsProcesses.clear()
|
|
686
|
+
|
|
687
|
+
fetch: (req) ->
|
|
688
|
+
url = new URL(req.url)
|
|
689
|
+
host = url.hostname.toLowerCase()
|
|
690
|
+
|
|
691
|
+
# Dashboard for rip.local
|
|
692
|
+
if host is 'rip.local' and url.pathname in ['/', '']
|
|
693
|
+
headers = new Headers({ 'content-type': 'text/html; charset=utf-8' })
|
|
694
|
+
@maybeAddSecurityHeaders(headers)
|
|
695
|
+
return new Response(@getDashboardHTML(), { headers })
|
|
696
|
+
|
|
697
|
+
return @status() if url.pathname is '/status'
|
|
698
|
+
|
|
699
|
+
if url.pathname is '/server'
|
|
700
|
+
headers = new Headers({ 'content-type': 'text/plain' })
|
|
701
|
+
@maybeAddSecurityHeaders(headers)
|
|
702
|
+
return new Response('ok', { headers })
|
|
703
|
+
|
|
704
|
+
# Host-based routing guard
|
|
705
|
+
if @hostRegistry.size > 0 and not @hostRegistry.has(host)
|
|
706
|
+
return new Response('Host not found', { status: 404 })
|
|
707
|
+
|
|
708
|
+
# Fast path: try available worker
|
|
709
|
+
if @inflightTotal < Math.max(1, @sockets.length)
|
|
710
|
+
sock = @getNextAvailableSocket()
|
|
711
|
+
if sock
|
|
712
|
+
@inflightTotal++
|
|
713
|
+
try
|
|
714
|
+
return @forwardToWorker!(req, sock)
|
|
715
|
+
finally
|
|
716
|
+
@inflightTotal--
|
|
717
|
+
setImmediate => @drainQueue()
|
|
718
|
+
|
|
719
|
+
if @queue.length >= @flags.maxQueue
|
|
720
|
+
return new Response('Server busy', { status: 503, headers: { 'Retry-After': '1' } })
|
|
721
|
+
|
|
722
|
+
new Promise (resolve, reject) =>
|
|
723
|
+
@queue.push({ req, resolve, reject, enqueuedAt: nowMs() })
|
|
724
|
+
|
|
725
|
+
status: ->
|
|
726
|
+
uptime = Math.floor((nowMs() - @startedAt) / 1000)
|
|
727
|
+
healthy = @sockets.length > 0
|
|
728
|
+
body = JSON.stringify
|
|
729
|
+
status: if healthy then 'healthy' else 'degraded'
|
|
730
|
+
app: @flags.appName
|
|
731
|
+
workers: @sockets.length
|
|
732
|
+
ports: { http: @flags.httpPort or undefined, https: @flags.httpsPort or undefined }
|
|
733
|
+
uptime
|
|
734
|
+
hosts: Array.from(@hostRegistry.values())
|
|
735
|
+
headers = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-cache' })
|
|
736
|
+
@maybeAddSecurityHeaders(headers)
|
|
737
|
+
new Response(body, { headers })
|
|
738
|
+
|
|
739
|
+
getNextAvailableSocket: ->
|
|
740
|
+
while @availableWorkers.length > 0
|
|
741
|
+
worker = @availableWorkers.pop()
|
|
742
|
+
return worker if worker.inflight is 0 and @isCurrentVersion(worker)
|
|
743
|
+
null
|
|
744
|
+
|
|
745
|
+
isCurrentVersion: (worker) ->
|
|
746
|
+
@newestVersion is null or worker.version is null or worker.version >= @newestVersion
|
|
747
|
+
|
|
748
|
+
releaseWorker: (worker) ->
|
|
749
|
+
worker.inflight = 0
|
|
750
|
+
@availableWorkers.push(worker) if @isCurrentVersion(worker)
|
|
751
|
+
|
|
752
|
+
forwardToWorker: (req, socket) ->
|
|
753
|
+
start = performance.now()
|
|
754
|
+
res = null
|
|
755
|
+
workerSeconds = 0
|
|
756
|
+
released = false
|
|
757
|
+
|
|
758
|
+
try
|
|
759
|
+
socket.inflight = 1
|
|
760
|
+
t0 = performance.now()
|
|
761
|
+
res = @forwardOnce!(req, socket.socket)
|
|
762
|
+
workerSeconds = (performance.now() - t0) / 1000
|
|
763
|
+
|
|
764
|
+
if res.status is 503 and res.headers.get('Rip-Worker-Busy') is '1'
|
|
765
|
+
retry = @getNextAvailableSocket()
|
|
766
|
+
if retry and retry isnt socket
|
|
767
|
+
@releaseWorker(socket)
|
|
768
|
+
released = true
|
|
769
|
+
retry.inflight = 1
|
|
770
|
+
t1 = performance.now()
|
|
771
|
+
res = @forwardOnce!(req, retry.socket)
|
|
772
|
+
workerSeconds = (performance.now() - t1) / 1000
|
|
773
|
+
headers = stripInternalHeaders(res.headers)
|
|
774
|
+
headers.delete('date')
|
|
775
|
+
if @flags.jsonLogging
|
|
776
|
+
logAccessJson(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
|
|
777
|
+
else if @flags.accessLog
|
|
778
|
+
logAccessHuman(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
|
|
779
|
+
@releaseWorker(retry)
|
|
780
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
781
|
+
catch
|
|
782
|
+
@sockets = @sockets.filter((x) -> x.socket isnt socket.socket)
|
|
783
|
+
@availableWorkers = @availableWorkers.filter((x) -> x.socket isnt socket.socket)
|
|
784
|
+
released = true
|
|
785
|
+
return new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } })
|
|
786
|
+
finally
|
|
787
|
+
@releaseWorker(socket) unless released
|
|
788
|
+
|
|
789
|
+
return new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } }) unless res
|
|
790
|
+
|
|
791
|
+
headers = stripInternalHeaders(res.headers)
|
|
792
|
+
headers.delete('date')
|
|
793
|
+
@maybeAddSecurityHeaders(headers)
|
|
794
|
+
|
|
795
|
+
if @flags.jsonLogging
|
|
796
|
+
logAccessJson(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
|
|
797
|
+
else if @flags.accessLog
|
|
798
|
+
logAccessHuman(@flags.appName, req, res, (performance.now() - start) / 1000, workerSeconds)
|
|
799
|
+
|
|
800
|
+
new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
801
|
+
|
|
802
|
+
forwardOnce: (req, socketPath) ->
|
|
803
|
+
inUrl = new URL(req.url)
|
|
804
|
+
forwardUrl = "http://localhost#{inUrl.pathname}#{inUrl.search}"
|
|
805
|
+
controller = new AbortController()
|
|
806
|
+
timer = setTimeout (-> controller.abort()), @flags.connectTimeoutMs
|
|
807
|
+
|
|
808
|
+
try
|
|
809
|
+
upstream = fetch!(forwardUrl, { method: req.method, headers: req.headers, body: req.body, unix: socketPath, signal: controller.signal })
|
|
810
|
+
clearTimeout(timer)
|
|
811
|
+
readGuard = new Promise (_, rej) ->
|
|
812
|
+
setTimeout (-> rej(new Response('Upstream timeout', { status: 504 }))), @flags.readTimeoutMs
|
|
813
|
+
Promise.race!([Promise.resolve(upstream), readGuard])
|
|
814
|
+
finally
|
|
815
|
+
clearTimeout(timer)
|
|
816
|
+
|
|
817
|
+
drainQueue: ->
|
|
818
|
+
while @inflightTotal < Math.max(1, @sockets.length) and @availableWorkers.length > 0
|
|
819
|
+
job = @queue.shift()
|
|
820
|
+
break unless job
|
|
821
|
+
if nowMs() - job.enqueuedAt > @flags.queueTimeoutMs
|
|
822
|
+
job.resolve(new Response('Queue timeout', { status: 504 }))
|
|
823
|
+
continue
|
|
824
|
+
@inflightTotal++
|
|
825
|
+
worker = @getNextAvailableSocket()
|
|
826
|
+
unless worker
|
|
827
|
+
@inflightTotal--
|
|
828
|
+
break
|
|
829
|
+
@forwardToWorker(job.req, worker)
|
|
830
|
+
.then((r) -> job.resolve(r))
|
|
831
|
+
.catch((e) -> job.resolve(if e instanceof Response then e else new Response('Internal error', { status: 500 })))
|
|
832
|
+
.finally =>
|
|
833
|
+
@inflightTotal--
|
|
834
|
+
setImmediate => @drainQueue()
|
|
835
|
+
|
|
836
|
+
startControl: ->
|
|
837
|
+
ctlPath = getControlSocketPath(@flags.socketPrefix)
|
|
838
|
+
try unlinkSync(ctlPath) catch then null
|
|
839
|
+
@control = Bun.serve({ unix: ctlPath, fetch: @controlFetch.bind(@) })
|
|
840
|
+
|
|
841
|
+
@startMdnsAdvertisement('rip.local')
|
|
842
|
+
for alias in @flags.appAliases
|
|
843
|
+
host = if alias.includes('.') then alias else "#{alias}.local"
|
|
844
|
+
@startMdnsAdvertisement(host)
|
|
845
|
+
|
|
846
|
+
controlFetch: (req) ->
|
|
847
|
+
url = new URL(req.url)
|
|
848
|
+
|
|
849
|
+
if req.method is 'POST' and url.pathname is '/worker'
|
|
850
|
+
try
|
|
851
|
+
j = req.json!
|
|
852
|
+
if j?.op is 'join' and typeof j.socket is 'string' and typeof j.workerId is 'number'
|
|
853
|
+
version = if typeof j.version is 'number' then j.version else null
|
|
854
|
+
exists = @sockets.find((x) -> x.socket is j.socket)
|
|
855
|
+
unless exists
|
|
856
|
+
worker = { socket: j.socket, inflight: 0, version, workerId: j.workerId }
|
|
857
|
+
@sockets.push(worker)
|
|
858
|
+
@availableWorkers.push(worker)
|
|
859
|
+
@newestVersion = if @newestVersion is null then version else Math.max(@newestVersion, version) if version?
|
|
860
|
+
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
|
|
861
|
+
|
|
862
|
+
if j?.op is 'quit' and typeof j.workerId is 'number'
|
|
863
|
+
@sockets = @sockets.filter((x) -> x.workerId isnt j.workerId)
|
|
864
|
+
@availableWorkers = @availableWorkers.filter((x) -> x.workerId isnt j.workerId)
|
|
865
|
+
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
|
|
866
|
+
catch
|
|
867
|
+
null
|
|
868
|
+
return new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
|
|
869
|
+
|
|
870
|
+
if url.pathname is '/registry' and req.method is 'GET'
|
|
871
|
+
return new Response(JSON.stringify({ ok: true, hosts: Array.from(@hostRegistry.values()) }), { headers: { 'content-type': 'application/json' } })
|
|
872
|
+
|
|
873
|
+
new Response('not-found', { status: 404 })
|
|
874
|
+
|
|
875
|
+
maybeAddSecurityHeaders: (headers) ->
|
|
876
|
+
if @httpsActive and @flags.hsts
|
|
877
|
+
headers.set('strict-transport-security', 'max-age=31536000; includeSubDomains') unless headers.has('strict-transport-security')
|
|
878
|
+
|
|
879
|
+
loadTlsMaterial: ->
|
|
880
|
+
# Explicit cert/key paths
|
|
881
|
+
if @flags.certPath and @flags.keyPath
|
|
882
|
+
try
|
|
883
|
+
cert = readFileSync(@flags.certPath, 'utf8')
|
|
884
|
+
key = readFileSync(@flags.keyPath, 'utf8')
|
|
885
|
+
@printCertSummary(cert)
|
|
886
|
+
return { cert, key }
|
|
887
|
+
catch
|
|
888
|
+
console.error 'Failed to read TLS cert/key from provided paths. Use http or fix paths.'
|
|
889
|
+
process.exit(2)
|
|
890
|
+
|
|
891
|
+
# mkcert path under ~/.rip/certs
|
|
892
|
+
if @flags.autoTls
|
|
893
|
+
dir = join(homedir(), '.rip', 'certs')
|
|
894
|
+
try mkdirSync(dir, { recursive: true }) catch then null
|
|
895
|
+
certPath = join(dir, 'localhost.pem')
|
|
896
|
+
keyPath = join(dir, 'localhost-key.pem')
|
|
897
|
+
unless existsSync(certPath) and existsSync(keyPath)
|
|
898
|
+
try
|
|
899
|
+
gen = Bun.spawn(['mkcert', '-install'])
|
|
900
|
+
try gen.exited catch then null
|
|
901
|
+
p = Bun.spawn(['mkcert', '-key-file', keyPath, '-cert-file', certPath, 'localhost', '127.0.0.1', '::1'])
|
|
902
|
+
p.exited
|
|
903
|
+
catch
|
|
904
|
+
null # fall through to self-signed
|
|
905
|
+
if existsSync(certPath) and existsSync(keyPath)
|
|
906
|
+
cert = readFileSync(certPath, 'utf8')
|
|
907
|
+
key = readFileSync(keyPath, 'utf8')
|
|
908
|
+
@printCertSummary(cert)
|
|
909
|
+
return { cert, key }
|
|
910
|
+
|
|
911
|
+
# Self-signed via openssl
|
|
912
|
+
dir = join(homedir(), '.rip', 'certs')
|
|
913
|
+
try mkdirSync(dir, { recursive: true }) catch then null
|
|
914
|
+
certPath = join(dir, 'selfsigned-localhost.pem')
|
|
915
|
+
keyPath = join(dir, 'selfsigned-localhost-key.pem')
|
|
916
|
+
unless existsSync(certPath) and existsSync(keyPath)
|
|
917
|
+
try
|
|
918
|
+
p = Bun.spawn(['openssl', 'req', '-x509', '-nodes', '-newkey', 'rsa:2048', '-keyout', keyPath, '-out', certPath, '-subj', '/CN=localhost', '-days', '1'])
|
|
919
|
+
p.exited
|
|
920
|
+
catch
|
|
921
|
+
console.error 'TLS required but could not provision a certificate (mkcert/openssl missing). Use http or provide --cert/--key.'
|
|
922
|
+
process.exit(2)
|
|
923
|
+
try
|
|
924
|
+
cert = readFileSync(certPath, 'utf8')
|
|
925
|
+
key = readFileSync(keyPath, 'utf8')
|
|
926
|
+
@printCertSummary(cert)
|
|
927
|
+
return { cert, key }
|
|
928
|
+
catch
|
|
929
|
+
console.error 'Failed to read generated self-signed cert/key from ~/.rip/certs'
|
|
930
|
+
process.exit(2)
|
|
931
|
+
|
|
932
|
+
printCertSummary: (certPem) ->
|
|
933
|
+
try
|
|
934
|
+
x = new X509Certificate(certPem)
|
|
935
|
+
subject = x.subject.split(/,/)[0]?.trim() or x.subject
|
|
936
|
+
issuer = x.issuer.split(/,/)[0]?.trim() or x.issuer
|
|
937
|
+
exp = new Date(x.validTo)
|
|
938
|
+
console.log "rip-server: tls cert #{subject} issued by #{issuer} expires #{exp.toISOString()}"
|
|
939
|
+
catch
|
|
940
|
+
null
|
|
941
|
+
|
|
942
|
+
getLanIP: ->
|
|
943
|
+
try
|
|
944
|
+
output = Bun.spawnSync(['ifconfig'], { stdout: 'pipe' }).stdout.toString()
|
|
945
|
+
matches = output.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/g)
|
|
946
|
+
if matches
|
|
947
|
+
for match in matches
|
|
948
|
+
ip = match.split(/\s+/)[1]
|
|
949
|
+
return ip if ip and ip isnt '127.0.0.1' and not ip.startsWith('169.254.')
|
|
950
|
+
catch
|
|
951
|
+
null
|
|
952
|
+
null
|
|
953
|
+
|
|
954
|
+
startMdnsAdvertisement: (host) ->
|
|
955
|
+
return unless host.endsWith('.local')
|
|
956
|
+
return if @mdnsProcesses.has(host)
|
|
957
|
+
|
|
958
|
+
lanIP = @getLanIP()
|
|
959
|
+
unless lanIP
|
|
960
|
+
console.log "rip-server: unable to detect LAN IP for mDNS advertisement of #{host}"
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
port = @flags.httpsPort or @flags.httpPort or 80
|
|
964
|
+
protocol = if @flags.httpsPort then 'https' else 'http'
|
|
965
|
+
serviceName = host.replace('.local', '')
|
|
966
|
+
|
|
967
|
+
try
|
|
968
|
+
proc = Bun.spawn [
|
|
969
|
+
'dns-sd', '-P'
|
|
970
|
+
serviceName
|
|
971
|
+
'_http._tcp'
|
|
972
|
+
'local'
|
|
973
|
+
String(port)
|
|
974
|
+
host
|
|
975
|
+
lanIP
|
|
976
|
+
],
|
|
977
|
+
stdout: 'ignore'
|
|
978
|
+
stderr: 'ignore'
|
|
979
|
+
|
|
980
|
+
@mdnsProcesses.set(host, proc)
|
|
981
|
+
console.log "rip-server: #{protocol}://#{host}:#{port}"
|
|
982
|
+
catch e
|
|
983
|
+
console.error "rip-server: failed to advertise #{host} via mDNS:", e.message
|
|
984
|
+
|
|
985
|
+
getDashboardHTML: ->
|
|
986
|
+
try
|
|
987
|
+
readFileSync(join(__dirname, 'dashboard.html'), 'utf8')
|
|
988
|
+
catch
|
|
989
|
+
'<!DOCTYPE html><html><body><h1>Rip Server</h1><p>Dashboard not found</p></body></html>'
|
|
990
|
+
|
|
991
|
+
# ==============================================================================
|
|
992
|
+
# Main Entry
|
|
993
|
+
# ==============================================================================
|
|
994
|
+
|
|
995
|
+
main = ->
|
|
996
|
+
# Subcommand: stop
|
|
997
|
+
if 'stop' in process.argv
|
|
998
|
+
try
|
|
999
|
+
Bun.spawn(['pkill', '-f', __filename]).exited
|
|
1000
|
+
Bun.spawn(['pkill', '-f', 'dns-sd -P.*_http._tcp']).exited
|
|
1001
|
+
catch
|
|
1002
|
+
null
|
|
1003
|
+
console.log 'rip-server: stop requested'
|
|
1004
|
+
return
|
|
1005
|
+
|
|
1006
|
+
# Subcommand: list
|
|
1007
|
+
if 'list' in process.argv
|
|
1008
|
+
getKV = (prefix) ->
|
|
1009
|
+
for tok in process.argv
|
|
1010
|
+
return tok.slice(prefix.length) if tok.startsWith(prefix)
|
|
1011
|
+
undefined
|
|
1012
|
+
|
|
1013
|
+
findAppPathToken = ->
|
|
1014
|
+
for i in [2...process.argv.length]
|
|
1015
|
+
tok = process.argv[i]
|
|
1016
|
+
pathPart = if tok.includes('@') then tok.split('@')[0] else tok
|
|
1017
|
+
looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
|
|
1018
|
+
try
|
|
1019
|
+
return pathPart if looksLikePath and existsSync(if isAbsolute(pathPart) then pathPart else resolve(process.cwd(), pathPart))
|
|
1020
|
+
catch
|
|
1021
|
+
null
|
|
1022
|
+
undefined
|
|
1023
|
+
|
|
1024
|
+
computeSocketPrefix = ->
|
|
1025
|
+
override = getKV('--socket-prefix=')
|
|
1026
|
+
return override if override
|
|
1027
|
+
appTok = findAppPathToken()
|
|
1028
|
+
if appTok
|
|
1029
|
+
try
|
|
1030
|
+
{ appName } = resolveAppEntry(appTok)
|
|
1031
|
+
return "rip_#{appName}"
|
|
1032
|
+
catch
|
|
1033
|
+
null
|
|
1034
|
+
'rip_server'
|
|
1035
|
+
|
|
1036
|
+
controlUnix = getControlSocketPath(computeSocketPrefix())
|
|
1037
|
+
try
|
|
1038
|
+
res = fetch!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
|
|
1039
|
+
throw new Error("list failed: #{res.status}") unless res.ok
|
|
1040
|
+
j = res.json!
|
|
1041
|
+
hosts = if Array.isArray(j?.hosts) then j.hosts else []
|
|
1042
|
+
console.log if hosts.length then hosts.join('\n') else '(no hosts)'
|
|
1043
|
+
catch e
|
|
1044
|
+
console.error "list command failed: #{e?.message or e}"
|
|
1045
|
+
process.exit(1)
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
# Normal startup
|
|
1049
|
+
flags = parseFlags(process.argv)
|
|
1050
|
+
svr = new Server(flags)
|
|
1051
|
+
mgr = new Manager(flags)
|
|
1052
|
+
|
|
1053
|
+
cleanup = ->
|
|
1054
|
+
console.log 'rip-server: shutting down...'
|
|
1055
|
+
svr.stop()
|
|
1056
|
+
mgr.stop!
|
|
1057
|
+
process.exit(0)
|
|
1058
|
+
|
|
1059
|
+
process.on 'SIGTERM', cleanup
|
|
1060
|
+
process.on 'SIGINT', cleanup
|
|
1061
|
+
process.on 'uncaughtException', (err) ->
|
|
1062
|
+
console.error 'rip-server: uncaught exception:', err
|
|
1063
|
+
cleanup()
|
|
1064
|
+
process.on 'unhandledRejection', (err) ->
|
|
1065
|
+
console.error 'rip-server: unhandled rejection:', err
|
|
1066
|
+
cleanup()
|
|
1067
|
+
|
|
1068
|
+
svr.start!
|
|
1069
|
+
mgr.start!
|
|
1070
|
+
|
|
1071
|
+
httpOnly = flags.httpsPort is null
|
|
1072
|
+
url = if httpOnly then "http://localhost:#{flags.httpPort}/server" else "https://localhost:#{flags.httpsPort}/server"
|
|
1073
|
+
console.log "rip-server: app=#{flags.appName} workers=#{flags.workers} url=#{url}"
|
|
1074
|
+
|
|
1075
|
+
# ==============================================================================
|
|
1076
|
+
# Entry Point
|
|
1077
|
+
# ==============================================================================
|
|
1078
|
+
|
|
1079
|
+
if process.env.RIP_WORKER_MODE
|
|
1080
|
+
runWorker()
|
|
1081
|
+
else
|
|
1082
|
+
main!
|