@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/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!