@rip-lang/server 1.3.98 → 1.3.100

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 CHANGED
@@ -15,7 +15,29 @@
15
15
  import { existsSync, statSync, readFileSync, writeFileSync, unlinkSync, watch, utimesSync } from 'node:fs'
16
16
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
17
17
  import { cpus, networkInterfaces } from 'node:os'
18
- import { X509Certificate } from 'node:crypto'
18
+ import { isCurrentVersion as schedulerIsCurrentVersion, getNextAvailableSocket as schedulerGetNextAvailableSocket, releaseWorker as schedulerReleaseWorker, shouldRetryBodylessBusy } from './edge/queue.rip'
19
+ import { formatPort, maybeAddSecurityHeaders as edgeMaybeAddSecurityHeaders, buildStatusBody as edgeBuildStatusBody, buildRipdevUrl as edgeBuildRipdevUrl, generateRequestId } from './edge/forwarding.rip'
20
+ import { loadTlsMaterial as edgeLoadTlsMaterial, printCertSummary as edgePrintCertSummary } from './edge/tls.rip'
21
+ import { createAppRegistry, registerApp, resolveHost, getAppState } from './edge/registry.rip'
22
+ import { watchUnavailableResponse, serverBusyResponse, serviceUnavailableResponse, gatewayTimeoutResponse, queueTimeoutResponse, buildUpstreamResponse, forwardOnceWithTimeout } from './edge/forwarding.rip'
23
+ import { processQueuedJob, drainQueueOnce } from './edge/queue.rip'
24
+ import { getControlSocketPath, getPidFilePath } from './control/control.rip'
25
+ import { handleWorkerControl, handleWatchControl, handleRegistryControl } from './control/control.rip'
26
+ import { getLanIP as controlGetLanIP, startMdnsAdvertisement as controlStartMdnsAdvertisement, stopMdnsAdvertisements as controlStopMdnsAdvertisements } from './control/mdns.rip'
27
+ import { registerWatchGroup, handleWatchGroup, broadcastWatchChange, registerAppWatchDirs } from './control/watchers.rip'
28
+ import { computeHideUrls, logStartupSummary, createCleanup, installShutdownHandlers } from './control/lifecycle.rip'
29
+ import { computeSocketPrefix as cliComputeSocketPrefix, runVersionOutput, runHelpOutput, runStopSubcommand, runListSubcommand } from './control/cli.rip'
30
+ import { runSetupMode, runWorkerMode } from './control/worker.rip'
31
+ import { spawnTrackedWorker, postWorkerQuit, waitForWorkerReady } from './control/workers.rip'
32
+ import { createChallengeStore, handleChallengeRequest } from './acme/store.rip'
33
+ import { createAcmeManager } from './acme/manager.rip'
34
+ import { loadCert as acmeLoadCert, defaultCertDir } from './acme/store.rip'
35
+ import { createMetrics } from './edge/metrics.rip'
36
+ import { setEventJsonMode, logEvent } from './control/lifecycle.rip'
37
+ import { createHub, generateClientId, addClient, removeClient, processResponse, handlePublish, getRealtimeStats } from './edge/realtime.rip'
38
+ import { findConfigFile, loadConfig, applyConfig } from './edge/config.rip'
39
+ import { createRateLimiter, rateLimitResponse } from './edge/ratelimit.rip'
40
+ import { validateRequest } from './edge/security.rip'
19
41
 
20
42
  # Match capture holder for Rip's =~
21
43
  _ = null
@@ -24,20 +46,15 @@ _ = null
24
46
  # Constants
25
47
  # ==============================================================================
26
48
 
27
- MAX_BACKOFF_MS = 30000 # Max delay between worker restart attempts
28
- MAX_RESTART_COUNT = 10 # Max consecutive worker crashes before giving up
29
- SHUTDOWN_TIMEOUT_MS = 30000 # Max time to wait for in-flight requests on shutdown
49
+ MAX_BACKOFF_MS = 30000 # Max delay between worker restart attempts
50
+ MAX_RESTART_COUNT = 10 # Max consecutive worker crashes before giving up
51
+ STABILITY_THRESHOLD_MS = 60000 # Worker uptime before restart count resets
30
52
 
31
53
  # ==============================================================================
32
54
  # Utilities
33
55
  # ==============================================================================
34
56
 
35
57
  nowMs = -> Date.now()
36
- formatPort = (protocol, port) -> if (protocol is 'https' and port is 443) or (protocol is 'http' and port is 80) then '' else ":#{port}"
37
-
38
- getWorkerSocketPath = (prefix, id) -> "/tmp/#{prefix}.#{id}.sock"
39
- getControlSocketPath = (prefix) -> "/tmp/#{prefix}.ctl.sock"
40
- getPidFilePath = (prefix) -> "/tmp/#{prefix}.pid"
41
58
 
42
59
  coerceInt = (value, fallback) ->
43
60
  return fallback unless value? and value isnt ''
@@ -97,7 +114,7 @@ logAccessJson = (app, req, res, totalSeconds, workerSeconds) ->
97
114
  url = new URL(req.url)
98
115
  len = res.headers.get('content-length')
99
116
  type = (res.headers.get('content-type') or '').split(';')[0] or undefined
100
- console.log JSON.stringify
117
+ p JSON.stringify
101
118
  t: new Date().toISOString()
102
119
  app: app
103
120
  method: req.method or 'GET'
@@ -125,7 +142,7 @@ logAccessHuman = (app, req, res, totalSeconds, workerSeconds) ->
125
142
  contentType = (res.headers.get('content-type') or '').split(';')[0] or ''
126
143
  sub = if contentType.includes('/') then contentType.split('/')[1] else contentType
127
144
  type = (typeAbbrev[sub] or sub or '').padEnd(4)
128
- console.log "#{timestamp} #{timezone} #{dur} │ #{status} #{type} #{size} │ #{method} #{path}"
145
+ p "#{timestamp} #{timezone} #{dur} │ #{status} #{type} #{size} │ #{method} #{path}"
129
146
 
130
147
  INTERNAL_HEADERS = new Set(['rip-worker-busy', 'rip-worker-id', 'rip-no-log'])
131
148
 
@@ -179,12 +196,12 @@ resolveAppEntry = (appPathInput) ->
179
196
  else if existsSync(two)
180
197
  entryPath = two
181
198
  else
182
- console.log "rip-server: no index.rip found, serving static files from #{abs}\n Create an index.rip to customize, or run 'rip server --help' for options."
199
+ p "rip-server: no index.rip found, serving static files from #{abs}\n Create an index.rip to customize, or run 'rip server --help' for options."
183
200
  entryPath = defaultEntry
184
201
  else
185
202
  unless existsSync(abs)
186
203
  console.error "App path not found: #{abs}"
187
- process.exit(2)
204
+ exit 2
188
205
  baseDir = dirname(abs)
189
206
  entryPath = abs
190
207
 
@@ -346,6 +363,13 @@ parseFlags = (argv) ->
346
363
  readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 30000))
347
364
  jsonLogging: has('--json-logging')
348
365
  accessLog: not has('--no-access-log')
366
+ acme: has('--acme')
367
+ acmeStaging: has('--acme-staging')
368
+ acmeDomain: getKV('--acme-domain=')
369
+ realtimePath: getKV('--realtime-path=') or '/realtime'
370
+ publishSecret: getKV('--publish-secret=') or process.env.RIP_PUBLISH_SECRET or null
371
+ rateLimit: coerceInt(getKV('--rate-limit='), 0)
372
+ rateLimitWindow: coerceInt(getKV('--rate-limit-window='), 60000)
349
373
  watch: watch
350
374
  }
351
375
 
@@ -353,143 +377,13 @@ parseFlags = (argv) ->
353
377
  # Worker Mode
354
378
  # ==============================================================================
355
379
 
356
- runSetup = ->
357
- setupFile = process.env.RIP_SETUP_FILE
358
- try
359
- mod = import!(setupFile)
360
- await Promise.resolve()
361
- fn = mod?.setup or mod?.default
362
- if typeof fn is 'function'
363
- await fn()
364
- catch e
365
- console.error "rip-server: setup failed:", e
366
- process.exit(1)
367
-
368
- runWorker = ->
369
- workerId = parseInt(process.env.WORKER_ID or '0')
370
- maxRequests = parseInt(process.env.MAX_REQUESTS or '10000')
371
- maxSeconds = parseInt(process.env.MAX_SECONDS or '0')
372
- appEntry = process.env.APP_ENTRY
373
- socketPath = process.env.SOCKET_PATH
374
- socketPrefix = process.env.SOCKET_PREFIX
375
- version = parseInt(process.env.RIP_VERSION or '1')
376
-
377
- startedAtMs = Date.now()
378
- # Use object to avoid Rip closure scoping issues with mutable variables
379
- workerState =
380
- appReady: false
381
- inflight: false
382
- handled: 0
383
- handler: null
384
-
385
- getHandler = ->
386
- return workerState.handler if workerState.handler
387
-
388
- try
389
- mod = import!(appEntry)
390
-
391
- # Ensure module has fully executed by yielding to microtask queue
392
- await Promise.resolve()
393
-
394
- fresh = mod.default or mod
395
-
396
- if typeof fresh is 'function'
397
- h = fresh
398
- else if fresh?.fetch?
399
- h = fresh.fetch.bind(fresh)
400
- else
401
- h = globalThis.__ripHandler # Handler set by start() in rip-server mode
402
-
403
- unless h
404
- try
405
- api = import!('@rip-lang/server')
406
- h = api?.startHandler?.()
407
- catch
408
- null
409
-
410
- workerState.handler = h if h
411
- workerState.handler or (-> new Response('not ready', { status: 503 }))
412
- catch e
413
- console.error "[worker #{workerId}] import failed:", e
414
- workerState.handler or (-> new Response('not ready', { status: 503 }))
415
-
416
- selfJoin = ->
417
- try
418
- payload = { op: 'join', workerId, pid: process.pid, socket: socketPath, version }
419
- body = JSON.stringify(payload)
420
- ctl = getControlSocketPath(socketPrefix)
421
- fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
422
- catch
423
- null
424
-
425
- selfQuit = ->
426
- try
427
- payload = { op: 'quit', workerId }
428
- body = JSON.stringify(payload)
429
- ctl = getControlSocketPath(socketPrefix)
430
- fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl })
431
- catch
432
- null
433
-
434
- # Preload handler
435
- try
436
- initial = getHandler!
437
- workerState.appReady = typeof initial is 'function'
438
- catch
439
- null
440
-
441
- server = Bun.serve
442
- unix: socketPath
443
- maxRequestBodySize: 100 * 1024 * 1024
444
- fetch: (req) ->
445
- url = new URL(req.url)
446
- return new Response(if workerState.appReady then 'ok' else 'not-ready') if url.pathname is '/ready'
447
-
448
- if workerState.inflight
449
- return new Response 'busy',
450
- status: 503
451
- headers: { 'Rip-Worker-Busy': '1', 'Retry-After': '0', 'Rip-Worker-Id': String(workerId) }
452
-
453
- handlerFn = getHandler!
454
- workerState.appReady = typeof handlerFn is 'function'
455
- workerState.inflight = true
456
-
457
- try
458
- return new Response('not ready', { status: 503 }) unless typeof handlerFn is 'function'
459
- res = handlerFn!(req)
460
- res = res!(req) if typeof res is 'function'
461
- if res instanceof Response then res else new Response(String(res))
462
- catch err
463
- console.error "[worker #{workerId}] ERROR:", err
464
- new Response('error', { status: 500 })
465
- finally
466
- workerState.inflight = false
467
- workerState.handled++
468
- exceededReqs = workerState.handled >= maxRequests
469
- exceededTime = maxSeconds > 0 and (Date.now() - startedAtMs) / 1000 >= maxSeconds
470
- setTimeout (-> process.exit(0)), 10 if exceededReqs or exceededTime
471
-
472
- selfJoin!
473
-
474
- shutdown = ->
475
- # Wait for in-flight request to complete (with timeout)
476
- start = Date.now()
477
- while workerState.inflight and Date.now() - start < SHUTDOWN_TIMEOUT_MS
478
- await new Promise (r) -> setTimeout(r, 10)
479
- try server.stop() catch then null
480
- selfQuit!
481
- process.exit(0)
482
-
483
- process.on 'SIGTERM', shutdown
484
- process.on 'SIGINT', shutdown
485
-
486
380
  # ==============================================================================
487
381
  # Manager Class
488
382
  # ==============================================================================
489
383
 
490
384
  class Manager
491
385
  constructor: (@flags) ->
492
- @workers = []
386
+ @appWorkers = new Map()
493
387
  @shuttingDown = false
494
388
  @lastCheck = 0
495
389
  @currentMtime = 0
@@ -497,10 +391,19 @@ class Manager
497
391
  @lastRollAt = 0
498
392
  @nextWorkerId = -1
499
393
  @retiringIds = new Set()
394
+ @restartBudgets = new Map() # slotIndex -> { count, backoffMs }
395
+ @deferredDeaths = new Set() # worker IDs that died during rolling restart
500
396
  @currentVersion = 1
501
397
  @server = null
502
398
  @dbUrl = null
503
399
  @appWatchers = new Map()
400
+ @defaultAppId = @flags.appName
401
+
402
+ getWorkers: (appId) ->
403
+ appId = appId or @defaultAppId
404
+ unless @appWorkers.has(appId)
405
+ @appWorkers.set(appId, [])
406
+ @appWorkers.get(appId)
504
407
 
505
408
  start: ->
506
409
  @stop!
@@ -530,12 +433,13 @@ class Manager
530
433
  code = await proc.exited
531
434
  if code isnt 0
532
435
  console.error "rip-server: setup exited with code #{code}"
533
- process.exit(1)
436
+ exit 1
534
437
 
535
- @workers = []
438
+ workers = @getWorkers()
439
+ workers.length = 0
536
440
  for i in [0...@flags.workers]
537
441
  w = @spawnWorker!(@currentVersion)
538
- @workers.push(w)
442
+ workers.push(w)
539
443
 
540
444
  if @flags.reload
541
445
  @currentMtime = @getEntryMtime()
@@ -558,7 +462,6 @@ class Manager
558
462
  entryFile = @flags.appEntry
559
463
  entryBase = basename(entryFile)
560
464
  watchExt = if @flags.watch.startsWith('*.') then @flags.watch.slice(1) else null
561
- debounceMs = @flags.debounce or 250
562
465
  try
563
466
  watch @flags.appBaseDir, { recursive: true }, (event, filename) =>
564
467
  return unless filename
@@ -574,65 +477,28 @@ class Manager
574
477
  catch
575
478
  null
576
479
  catch e
577
- console.warn "rip-server: directory watch failed: #{e.message}"
480
+ warn "rip-server: directory watch failed: #{e.message}"
578
481
 
579
482
  stop: ->
580
- for w in @workers
581
- try w.process.kill() catch then null
582
- try unlinkSync(w.socketPath) catch then null
583
- @workers = []
483
+ for [appId, workers] as @appWorkers
484
+ for w in workers
485
+ try w.process.kill()
486
+ try unlinkSync(w.socketPath)
487
+ @appWorkers.clear()
488
+
489
+ # Close all FS watcher handles
490
+ for [prefix, entry] as @appWatchers
491
+ clearTimeout(entry.timer) if entry.timer
492
+ for watcher in (entry.watchers or [])
493
+ try watcher.close()
494
+ @appWatchers.clear()
584
495
 
585
496
  watchDirs: (prefix, dirs) ->
586
- return if @appWatchers.has(prefix)
587
- timer = null
588
- pending = null
589
- watchers = []
590
- broadcast = (type = 'page') =>
591
- pending = if type is 'page' or pending is 'page' then 'page' else 'styles'
592
- clearTimeout(timer) if timer
593
- timer = setTimeout =>
594
- timer = null
595
- @server?.broadcastChange(prefix, pending)
596
- pending = null
597
- , 300
598
- for dir in dirs
599
- try
600
- w = watch dir, { recursive: true }, (event, filename) ->
601
- if filename?.endsWith('.rip') or filename?.endsWith('.html')
602
- broadcast('page')
603
- else if filename?.endsWith('.css')
604
- broadcast('styles')
605
- watchers.push(w)
606
- catch e
607
- rel = dir.replace(process.cwd() + '/', '')
608
- console.warn "rip-server: watch skipped (#{e.code or 'error'}): #{rel}"
609
- @appWatchers.set prefix, { watchers, timer }
497
+ registerAppWatchDirs(@appWatchers, prefix, dirs, @server, (-> process.cwd()))
610
498
 
611
499
  spawnWorker: (version) ->
612
500
  workerId = ++@nextWorkerId
613
- socketPath = getWorkerSocketPath(@flags.socketPrefix, workerId)
614
- try unlinkSync(socketPath) catch then null
615
-
616
- workerEnv = Object.assign {}, process.env,
617
- RIP_WORKER_MODE: '1'
618
- WORKER_ID: String(workerId)
619
- SOCKET_PATH: socketPath
620
- SOCKET_PREFIX: @flags.socketPrefix
621
- APP_ENTRY: @flags.appEntry
622
- APP_BASE_DIR: @flags.appBaseDir
623
- MAX_REQUESTS: String(@flags.maxRequestsPerWorker)
624
- MAX_SECONDS: String(@flags.maxSecondsPerWorker)
625
- RIP_LOG_JSON: if @flags.jsonLogging then '1' else '0'
626
- RIP_VERSION: String(version or @currentVersion)
627
-
628
- proc = Bun.spawn ['rip', import.meta.path],
629
- stdout: 'inherit'
630
- stderr: 'inherit'
631
- stdin: 'ignore'
632
- cwd: process.cwd()
633
- env: workerEnv
634
-
635
- tracked = { id: workerId, process: proc, socketPath, restartCount: 0, backoffMs: 1000, startedAt: nowMs() }
501
+ tracked = spawnTrackedWorker(@flags, workerId, (version or @currentVersion), nowMs, import.meta.path, @defaultAppId)
636
502
  @monitor(tracked)
637
503
  tracked
638
504
 
@@ -641,44 +507,50 @@ class Manager
641
507
  w.process.exited.then =>
642
508
  return if @shuttingDown
643
509
  return if @retiringIds.has(w.id)
510
+ if @isRolling
511
+ @deferredDeaths.add(w.id)
512
+ return
644
513
 
645
514
  # Notify server to remove dead worker's socket entry (fire-and-forget)
646
- try
647
- ctl = getControlSocketPath(@flags.socketPrefix)
648
- body = JSON.stringify({ op: 'quit', workerId: w.id })
649
- fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl }).catch(-> null)
650
- catch
651
- null
515
+ postWorkerQuit(@flags.socketPrefix, w.id)
516
+
517
+ # Track restart budget by slot (survives worker replacement)
518
+ workers = @getWorkers()
519
+ slotIdx = workers.findIndex((x) -> x.id is w.id)
520
+ return if slotIdx < 0
521
+
522
+ budget = @restartBudgets.get(slotIdx) or { count: 0, backoffMs: 1000 }
523
+
524
+ # Reset budget if worker ran long enough to be considered stable
525
+ if nowMs() - w.startedAt > STABILITY_THRESHOLD_MS
526
+ budget.count = 0
527
+ budget.backoffMs = 1000
528
+
529
+ budget.count++
530
+ budget.backoffMs = Math.min(budget.backoffMs * 2, MAX_BACKOFF_MS)
531
+ @restartBudgets.set(slotIdx, budget)
652
532
 
653
- w.restartCount++
654
- w.backoffMs = Math.min(w.backoffMs * 2, MAX_BACKOFF_MS)
655
- return if w.restartCount > MAX_RESTART_COUNT
533
+ if budget.count > MAX_RESTART_COUNT
534
+ logEvent('worker_abandon', { workerId: w.id, slot: slotIdx, restarts: budget.count })
535
+ return
536
+ @server?.metrics?.workerRestarts++
537
+ logEvent('worker_restart', { workerId: w.id, slot: slotIdx, attempt: budget.count, backoffMs: budget.backoffMs })
656
538
  setTimeout =>
657
- idx = @workers.findIndex((x) -> x.id is w.id)
658
- @workers[idx] = @spawnWorker(@currentVersion) if idx >= 0
659
- , w.backoffMs
539
+ workers[slotIdx] = @spawnWorker(@currentVersion) if slotIdx < workers.length
540
+ , budget.backoffMs
660
541
 
661
542
  waitWorkerReady: (socketPath, timeoutMs = 5000) ->
662
- start = Date.now()
663
- while Date.now() - start < timeoutMs
664
- try
665
- res = fetch!('http://localhost/ready', { unix: socketPath, method: 'GET' })
666
- if res.ok
667
- txt = res.text!
668
- return true if txt is 'ok'
669
- catch
670
- null
671
- await new Promise (r) -> setTimeout(r, 30)
672
- false
543
+ waitForWorkerReady(socketPath, timeoutMs)
673
544
 
674
545
  rollingRestart: ->
675
- olds = [...@workers]
546
+ workers = @getWorkers()
547
+ olds = [...workers]
676
548
  pairs = []
677
- @currentVersion++
549
+ nextVersion = @currentVersion + 1
678
550
 
679
551
  for oldWorker in olds
680
- replacement = @spawnWorker!(@currentVersion)
681
- @workers.push(replacement)
552
+ replacement = @spawnWorker!(nextVersion)
553
+ workers.push(replacement)
682
554
  pairs.push({ old: oldWorker, replacement })
683
555
 
684
556
  # Wait for all replacements and check readiness
@@ -691,35 +563,43 @@ class Manager
691
563
  # Kill failed replacements and keep old workers
692
564
  for pair, i in pairs
693
565
  unless readyResults[i]
694
- try pair.replacement.process.kill() catch then null
695
- @workers = @workers.filter((w) -> w.id isnt pair.replacement.id)
566
+ try pair.replacement.process.kill()
567
+ idx = workers.indexOf(pair.replacement)
568
+ workers.splice(idx, 1) if idx >= 0
569
+ # Reconcile deferred deaths on rollback too
570
+ if @deferredDeaths.size > 0
571
+ for deadId as @deferredDeaths
572
+ idx = workers.findIndex((x) -> x.id is deadId)
573
+ workers[idx] = @spawnWorker(@currentVersion) if idx >= 0
574
+ @deferredDeaths.clear()
696
575
  return
697
576
 
577
+ # All verified — now atomically promote the version
578
+ @currentVersion = nextVersion
579
+
698
580
  # All ready - retire old workers
699
581
  for { old } in pairs
700
582
  @retiringIds.add(old.id)
701
- try old.process.kill() catch then null
583
+ try old.process.kill()
702
584
 
703
585
  Promise.all!(pairs.map(({ old }) -> old.process.exited.catch(-> null)))
704
586
 
705
587
  # Notify server to remove old workers' socket entries
706
- ctl = getControlSocketPath(@flags.socketPrefix)
707
588
  for { old } in pairs
708
- try
709
- body = JSON.stringify({ op: 'quit', workerId: old.id })
710
- fetch!('http://localhost/worker', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl }).catch(-> null)
711
- catch
712
- null
589
+ postWorkerQuit(@flags.socketPrefix, old.id)
713
590
 
714
591
  retiring = new Set(pairs.map((p) -> p.old.id))
715
- @workers = @workers.filter((w) -> not retiring.has(w.id))
592
+ filtered = workers.filter((w) -> not retiring.has(w.id))
593
+ workers.length = 0
594
+ workers.push(...filtered)
716
595
  @retiringIds.delete(id) for id as retiring
717
596
 
718
- shutdown: ->
719
- return if @shuttingDown
720
- @shuttingDown = true
721
- @stop!
722
- process.exit(0)
597
+ # Reconcile any workers that died during the roll
598
+ if @deferredDeaths.size > 0
599
+ for deadId as @deferredDeaths
600
+ idx = workers.findIndex((x) -> x.id is deadId)
601
+ workers[idx] = @spawnWorker(@currentVersion) if idx >= 0
602
+ @deferredDeaths.clear()
723
603
 
724
604
  getEntryMtime: ->
725
605
  try statSync(@flags.appEntry).mtimeMs catch then 0
@@ -733,15 +613,16 @@ class Server
733
613
  @server = null
734
614
  @httpsServer = null
735
615
  @control = null
736
- @sockets = []
737
- @availableWorkers = []
738
- @inflightTotal = 0
739
- @queue = []
740
616
  @urls = []
741
617
  @startedAt = nowMs()
742
- @newestVersion = null
743
618
  @httpsActive = false
744
- @hostRegistry = new Set(['localhost', '127.0.0.1', 'rip.local'])
619
+ @appRegistry = createAppRegistry()
620
+ @defaultAppId = @flags.appName
621
+ @challengeStore = createChallengeStore()
622
+ @acmeManager = null
623
+ @metrics = createMetrics()
624
+ @realtimeHub = createHub()
625
+ @rateLimiter = createRateLimiter(@flags.rateLimit, @flags.rateLimitWindow)
745
626
  @mdnsProcesses = new Map()
746
627
  @watchGroups = new Map()
747
628
  @manager = null
@@ -756,14 +637,23 @@ class Server
756
637
  catch
757
638
  @ripVersion = 'unknown'
758
639
 
640
+ # Register the single app with all its hosts
641
+ appHosts = ['localhost', '127.0.0.1', 'rip.local']
759
642
  for alias in @flags.appAliases
760
643
  host = if alias.includes('.') then alias else "#{alias}.local"
761
- @hostRegistry.add(host)
762
- @hostRegistry.add("#{@flags.appName}.ripdev.io")
644
+ appHosts.push(host)
645
+ appHosts.push("#{@flags.appName}.ripdev.io")
646
+ registerApp(@appRegistry, @defaultAppId,
647
+ hosts: appHosts
648
+ maxQueue: @flags.maxQueue
649
+ queueTimeoutMs: @flags.queueTimeoutMs
650
+ readTimeoutMs: @flags.readTimeoutMs
651
+ )
763
652
 
764
653
  start: ->
765
654
  httpOnly = @flags.httpsPort is null
766
655
  fetchFn = @fetch.bind(@)
656
+ wsOpts = @buildWebSocketHandlers()
767
657
 
768
658
  # Helper to start server, trying the given port first, then incrementing.
769
659
  # Falls back to unprivileged range (3000 for HTTP, 3443 for HTTPS) if
@@ -772,7 +662,7 @@ class Server
772
662
  port = p
773
663
  while port < p + 100
774
664
  try
775
- return Bun.serve(Object.assign({ port, idleTimeout: 0, fetch: fetchFn }, opts))
665
+ return Bun.serve(Object.assign({ port, idleTimeout: 0, fetch: fetchFn }, wsOpts, opts))
776
666
  catch e
777
667
  if e?.code is 'EACCES' and port < 1024
778
668
  port = if opts.tls then 3443 else 3000
@@ -793,44 +683,101 @@ class Server
793
683
  @flags.httpsPort = httpsPort
794
684
  @httpsActive = true
795
685
 
796
- if @flags.redirectHttp
686
+ if @flags.redirectHttp or @flags.acme or @flags.acmeStaging
687
+ challengeStore = @challengeStore
797
688
  try
798
689
  @server = Bun.serve
799
690
  port: 80
800
691
  idleTimeout: 8
801
692
  fetch: (req) ->
802
693
  url = new URL(req.url)
694
+ # Serve ACME HTTP-01 challenges before redirecting
695
+ challengeRes = handleChallengeRequest(url, challengeStore)
696
+ return challengeRes if challengeRes
803
697
  loc = "https://#{url.hostname}:#{httpsPort}#{url.pathname}#{url.search}"
804
698
  new Response(null, { status: 301, headers: { Location: loc } })
805
699
  catch
806
- console.warn 'Warn: could not bind port 80 for HTTP→HTTPS redirect'
700
+ warn 'Warn: could not bind port 80 for HTTP→HTTPS redirect'
807
701
 
808
702
  @flags.httpPort = if @server then @server.port else 0
809
703
 
810
704
  @startControl!
811
705
 
812
- stop: ->
813
- try @server?.stop() catch then null
814
- try @httpsServer?.stop() catch then null
815
- try @control?.stop() catch then null
816
-
817
- for [host, proc] as @mdnsProcesses
818
- try
819
- proc.kill()
820
- console.log "rip-server: stopped advertising #{host} via mDNS"
821
- catch
822
- null
823
- @mdnsProcesses.clear()
706
+ # Periodic queue timeout sweep — expire queued requests even when no workers are available
707
+ @queueSweepTimer = setInterval =>
708
+ for [appId, app] as @appRegistry.apps
709
+ now = Date.now()
710
+ while app.queue.length > 0 and now - app.queue[0].enqueuedAt > app.queueTimeoutMs
711
+ job = app.queue.shift()
712
+ try job.resolve(new Response('Queue timeout', { status: 504 }))
713
+ @metrics.queueTimeouts++
714
+ , 1000
824
715
 
825
- fetch: (req) ->
716
+ stop: ->
717
+ clearInterval(@queueSweepTimer) if @queueSweepTimer
718
+ logEvent('server_stop', { uptime: Math.floor((nowMs() - @startedAt) / 1000) })
719
+ # Drain all per-app queues — resolve pending requests with 503
720
+ for [appId, app] as @appRegistry.apps
721
+ while app.queue.length > 0
722
+ job = app.queue.shift()
723
+ try job.resolve(new Response('Server shutting down', { status: 503 }))
724
+
725
+ # Stop listeners with graceful drain
726
+ try @server?.stop()
727
+ try @httpsServer?.stop()
728
+ try @control?.stop()
729
+
730
+ controlStopMdnsAdvertisements(@mdnsProcesses)
731
+
732
+ fetch: (req, bunServer) ->
826
733
  url = new URL(req.url)
827
734
  host = url.hostname.toLowerCase()
828
735
 
736
+ # Request smuggling defenses
737
+ validation = validateRequest(req)
738
+ return new Response(validation.message, { status: validation.status }) unless validation.valid
739
+
829
740
  # Dashboard for rip.local
830
741
  if host is 'rip.local' and url.pathname in ['/', '']
831
742
  return new Response Bun.file(import.meta.dir + '/server.html')
832
743
 
744
+ # Resolve host to app — all routes below require a valid host
745
+ appId = resolveHost(@appRegistry, host)
746
+ return new Response('Host not found', { status: 404 }) unless appId
747
+ app = getAppState(@appRegistry, appId)
748
+ return new Response('Host not found', { status: 404 }) unless app
749
+
750
+ # Rate limiting — applied early, covers all endpoints
751
+ if @rateLimiter.maxRequests > 0
752
+ clientIp = bunServer?.requestIP?(req)?.address or '127.0.0.1'
753
+ { allowed, retryAfter } = @rateLimiter.check(clientIp)
754
+ return rateLimitResponse(retryAfter) unless allowed
755
+
756
+ # Built-in endpoints (require valid host)
833
757
  return @status() if url.pathname is '/status'
758
+ return @diagnostics() if url.pathname is '/diagnostics'
759
+
760
+ if url.pathname is '/server'
761
+ headers = new Headers({ 'content-type': 'text/plain' })
762
+ @maybeAddSecurityHeaders(headers)
763
+ return new Response('ok', { headers })
764
+
765
+ # WebSocket upgrade for realtime (requires valid host)
766
+ if url.pathname is @flags.realtimePath and bunServer
767
+ clientId = generateClientId()
768
+ if bunServer.upgrade(req, { data: { clientId, headers: req.headers } })
769
+ return
770
+ return new Response('WebSocket upgrade failed', { status: 400 })
771
+
772
+ # External publish for realtime (requires valid host + optional auth)
773
+ if url.pathname is '/publish' and req.method is 'POST'
774
+ if @flags.publishSecret
775
+ authHeader = req.headers.get('authorization') or ''
776
+ unless authHeader is "Bearer #{@flags.publishSecret}"
777
+ return new Response('Unauthorized', { status: 401 })
778
+ body = req.text!
779
+ handlePublish(@realtimeHub, body)
780
+ return new Response('ok')
834
781
 
835
782
  # SSE hot-reload: intercept /{prefix}/watch
836
783
  path = url.pathname
@@ -839,100 +786,88 @@ class Server
839
786
  if @watchGroups.has(watchPrefix)
840
787
  return @handleWatch(watchPrefix)
841
788
  else
842
- return new Response('', { status: 503, headers: { 'Retry-After': '2' } })
789
+ return watchUnavailableResponse()
843
790
 
844
- if url.pathname is '/server'
845
- headers = new Headers({ 'content-type': 'text/plain' })
846
- @maybeAddSecurityHeaders(headers)
847
- return new Response('ok', { headers })
791
+ @metrics.requests++
848
792
 
849
- # Host-based routing guard
850
- if @hostRegistry.size > 0 and not @hostRegistry.has(host)
851
- return new Response('Host not found', { status: 404 })
793
+ # Assign request ID for tracing
794
+ requestId = req.headers.get('x-request-id') or generateRequestId()
852
795
 
853
796
  # Fast path: try available worker
854
- if @inflightTotal < Math.max(1, @sockets.length)
855
- sock = @getNextAvailableSocket()
797
+ if app.inflightTotal < Math.max(1, app.sockets.length)
798
+ sock = @getNextAvailableSocket(app)
856
799
  if sock
857
- @inflightTotal++
800
+ @metrics.forwarded++
801
+ app.inflightTotal++
858
802
  try
859
- return @forwardToWorker!(req, sock)
803
+ return @forwardToWorker!(req, sock, app, requestId)
860
804
  finally
861
- @inflightTotal--
862
- setImmediate => @drainQueue()
805
+ app.inflightTotal--
806
+ setImmediate => @drainQueue(app)
863
807
 
864
- if @queue.length >= @flags.maxQueue
865
- return new Response('Server busy', { status: 503, headers: { 'Retry-After': '1' } })
808
+ if app.queue.length >= app.maxQueue
809
+ @metrics.queueShed++
810
+ return serverBusyResponse()
866
811
 
812
+ @metrics.queued++
867
813
  new Promise (resolve, reject) =>
868
- @queue.push({ req, resolve, reject, enqueuedAt: nowMs() })
814
+ app.queue.push({ req, resolve, reject, enqueuedAt: nowMs() })
869
815
 
870
816
  status: ->
871
- uptime = Math.floor((nowMs() - @startedAt) / 1000)
872
- healthy = @sockets.length > 0
873
- hosts = Array.from(@hostRegistry.values())
874
- version = @serverVersion
817
+ app = getAppState(@appRegistry, @defaultAppId)
818
+ workerCount = if app then app.sockets.length else 0
819
+ body = edgeBuildStatusBody(@startedAt, workerCount, @appRegistry.hostIndex, @serverVersion, @flags.appName, @flags.httpPort, @flags.httpsPort, nowMs)
820
+ headers = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-cache' })
821
+ @maybeAddSecurityHeaders(headers)
822
+ new Response(body, { headers })
823
+
824
+ diagnostics: ->
825
+ snap = @metrics.snapshot(@startedAt, @appRegistry)
875
826
  body = JSON.stringify
876
- status: if healthy then 'healthy' else 'degraded'
877
- version: version
878
- app: @flags.appName
879
- workers: @sockets.length
880
- ports: { http: @flags.httpPort or undefined, https: @flags.httpsPort or undefined }
881
- uptime: uptime
882
- hosts: hosts
827
+ status: if snap.gauges.workersActive > 0 then 'healthy' else 'degraded'
828
+ version: { server: @serverVersion, rip: @ripVersion }
829
+ uptime: snap.uptime
830
+ apps: snap.apps
831
+ metrics:
832
+ requests: snap.counters.requests
833
+ responses: snap.counters.responses
834
+ latency: snap.latency
835
+ queue: snap.counters.queue
836
+ workers: snap.counters.workers
837
+ acme: snap.counters.acme
838
+ websocket: snap.counters.websocket
839
+ gauges: snap.gauges
840
+ realtime: getRealtimeStats(@realtimeHub)
841
+ hosts: Array.from(@appRegistry.hostIndex.keys())
883
842
  headers = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-cache' })
884
843
  @maybeAddSecurityHeaders(headers)
885
844
  new Response(body, { headers })
886
845
 
887
846
  registerWatch: (prefix) ->
888
- return if @watchGroups.has(prefix)
889
- @watchGroups.set prefix, { sseClients: new Set() }
847
+ registerWatchGroup(@watchGroups, prefix)
890
848
 
891
849
  handleWatch: (prefix) ->
892
- group = @watchGroups.get(prefix)
893
- return new Response('not-found', { status: 404 }) unless group
894
- encoder = new TextEncoder()
895
- client = null
896
- new Response new ReadableStream(
897
- start: (controller) ->
898
- send = (type) ->
899
- try controller.enqueue encoder.encode("event: #{if type is 'connected' then 'connected' else 'reload'}\ndata: #{type}\n\n")
900
- catch then null
901
- client = { send }
902
- group.sseClients.add(client)
903
- send('connected')
904
- cancel: ->
905
- group.sseClients.delete(client) if client
906
- ),
907
- headers:
908
- 'Content-Type': 'text/event-stream'
909
- 'Cache-Control': 'no-cache'
910
- 'Connection': 'keep-alive'
850
+ handleWatchGroup(@watchGroups, prefix)
911
851
 
912
852
  broadcastChange: (prefix, type = 'page') ->
913
- group = @watchGroups.get(prefix)
914
- return unless group
915
- dead = []
916
- for client as group.sseClients
917
- try client.send(type)
918
- catch then dead.push(client)
919
- group.sseClients.delete(c) for c in dead
920
-
921
- getNextAvailableSocket: ->
922
- while @availableWorkers.length > 0
923
- worker = @availableWorkers.pop()
924
- return worker if worker.inflight is 0 and @isCurrentVersion(worker)
925
- null
926
-
927
- isCurrentVersion: (worker) ->
928
- @newestVersion is null or worker.version is null or worker.version >= @newestVersion
929
-
930
- releaseWorker: (worker) ->
931
- return unless worker
932
- return unless @sockets.some((s) -> s.socket is worker.socket) # Validate still in pool
933
- worker.inflight = 0
934
- if @isCurrentVersion(worker)
935
- @availableWorkers.push(worker)
853
+ broadcastWatchChange(@watchGroups, prefix, type)
854
+
855
+ getAppForWorker: (socketPath) ->
856
+ for app as @appRegistry.apps.values()
857
+ return app if app.sockets.some((s) -> s.socket is socketPath)
858
+ getAppState(@appRegistry, @defaultAppId)
859
+
860
+ getNextAvailableSocket: (app) ->
861
+ app = app or getAppState(@appRegistry, @defaultAppId)
862
+ schedulerGetNextAvailableSocket(app.availableWorkers, app.newestVersion)
863
+
864
+ isCurrentVersion: (worker, app) ->
865
+ app = app or getAppState(@appRegistry, @defaultAppId)
866
+ schedulerIsCurrentVersion(app.newestVersion, worker)
867
+
868
+ releaseWorker: (worker, app) ->
869
+ app = app or getAppState(@appRegistry, @defaultAppId)
870
+ schedulerReleaseWorker(app.sockets, app.availableWorkers, app.newestVersion, worker)
936
871
 
937
872
  logAccess: (req, res, totalSeconds, workerSeconds) ->
938
873
  if @flags.jsonLogging
@@ -940,15 +875,24 @@ class Server
940
875
  else if @flags.accessLog
941
876
  logAccessHuman(@flags.appName, req, res, totalSeconds, workerSeconds)
942
877
 
943
- buildResponse: (res, req, start, workerSeconds) ->
944
- silent = res.headers.get('rip-no-log') is '1'
945
- headers = stripInternalHeaders(res.headers)
946
- headers.delete('date')
947
- @maybeAddSecurityHeaders(headers)
948
- @logAccess(req, res, (performance.now() - start) / 1000, workerSeconds) unless silent
949
- new Response(res.body, { status: res.status, statusText: res.statusText, headers })
950
-
951
- forwardToWorker: (req, socket) ->
878
+ buildResponse: (res, req, start, workerSeconds, requestId) ->
879
+ totalSeconds = (performance.now() - start) / 1000
880
+ @metrics.recordLatency(totalSeconds)
881
+ @metrics.recordStatus(res.status)
882
+ response = buildUpstreamResponse(
883
+ res,
884
+ req,
885
+ totalSeconds,
886
+ workerSeconds,
887
+ @maybeAddSecurityHeaders.bind(@),
888
+ @logAccess.bind(@),
889
+ stripInternalHeaders
890
+ )
891
+ response.headers.set('X-Request-Id', requestId) if requestId
892
+ response
893
+
894
+ forwardToWorker: (req, socket, app, requestId) ->
895
+ app = app or @getAppForWorker(socket.socket)
952
896
  start = performance.now()
953
897
  res = null
954
898
  workerSeconds = 0
@@ -957,75 +901,67 @@ class Server
957
901
  try
958
902
  socket.inflight = 1
959
903
  t0 = performance.now()
960
- res = @forwardOnce!(req, socket.socket)
904
+ res = @forwardOnce!(req, socket.socket, app)
961
905
  workerSeconds = (performance.now() - t0) / 1000
962
906
 
963
- # Only retry bodyless requests (body stream can't be reused)
964
- canRetry = req.method in ['GET', 'HEAD', 'OPTIONS', 'DELETE']
965
- if canRetry and res.status is 503 and res.headers.get('Rip-Worker-Busy') is '1'
966
- retry = @getNextAvailableSocket()
907
+ if shouldRetryBodylessBusy(req, res)
908
+ retry = @getNextAvailableSocket(app)
967
909
  if retry and retry isnt socket
968
- @releaseWorker(socket)
910
+ @releaseWorker(socket, app)
969
911
  released = true
970
912
  retry.inflight = 1
971
913
  t1 = performance.now()
972
- res = @forwardOnce!(req, retry.socket)
914
+ res = @forwardOnce!(req, retry.socket, app)
973
915
  workerSeconds = (performance.now() - t1) / 1000
974
- @releaseWorker(retry)
975
- return @buildResponse(res, req, start, workerSeconds)
916
+ @releaseWorker(retry, app)
917
+ return @buildResponse(res, req, start, workerSeconds, requestId)
976
918
  catch err
977
919
  console.error "[server] forwardToWorker error:", err.message or err if isDebug()
978
- @sockets = @sockets.filter((x) -> x.socket isnt socket.socket)
979
- @availableWorkers = @availableWorkers.filter((x) -> x.socket isnt socket.socket)
920
+ app.sockets = app.sockets.filter((x) -> x.socket isnt socket.socket)
921
+ app.availableWorkers = app.availableWorkers.filter((x) -> x.socket isnt socket.socket)
980
922
  released = true
981
- return new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } })
923
+ return serviceUnavailableResponse()
982
924
  finally
983
- @releaseWorker(socket) unless released
984
-
985
- return new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } }) unless res
986
- @buildResponse(res, req, start, workerSeconds)
987
-
988
- forwardOnce: (req, socketPath) ->
989
- inUrl = new URL(req.url)
990
- forwardUrl = "http://localhost#{inUrl.pathname}#{inUrl.search}"
991
- readTimeoutMs = @flags.readTimeoutMs
992
-
993
- try
994
- fetchPromise = fetch(forwardUrl, { method: req.method, headers: req.headers, body: req.body, unix: socketPath, decompress: false })
995
- readGuard = new Promise (_, rej) ->
996
- setTimeout (-> rej(new Error('Upstream timeout'))), readTimeoutMs
997
- res = Promise.race!([fetchPromise, readGuard])
998
- res
999
- catch err
1000
- if err.message is 'Upstream timeout'
1001
- return new Response('Gateway timeout', { status: 504 })
1002
- throw err
1003
-
1004
- processJob: (job, worker) ->
1005
- @forwardToWorker(job.req, worker)
1006
- .then((r) -> job.resolve(r))
1007
- .catch((e) -> job.resolve(if e instanceof Response then e else new Response('Internal error', { status: 500 })))
1008
- .finally =>
1009
- @inflightTotal--
1010
- setImmediate => @drainQueue()
1011
-
1012
- drainQueue: ->
1013
- while @inflightTotal < Math.max(1, @sockets.length) and @availableWorkers.length > 0
1014
- job = @queue.shift()
1015
- break unless job
1016
- if nowMs() - job.enqueuedAt > @flags.queueTimeoutMs
1017
- job.resolve(new Response('Queue timeout', { status: 504 }))
1018
- continue
1019
- @inflightTotal++
1020
- worker = @getNextAvailableSocket()
1021
- unless worker
1022
- @inflightTotal--
1023
- break
1024
- @processJob(job, worker)
925
+ @releaseWorker(socket, app) unless released
926
+
927
+ return serviceUnavailableResponse() unless res
928
+ @buildResponse(res, req, start, workerSeconds, requestId)
929
+
930
+ forwardOnce: (req, socketPath, app) ->
931
+ app = app or @getAppForWorker(socketPath)
932
+ forwardOnceWithTimeout(req, socketPath, app.readTimeoutMs, gatewayTimeoutResponse)
933
+
934
+ processJob: (job, worker, app) ->
935
+ app = app or @getAppForWorker(worker.socket)
936
+ processQueuedJob(job, worker, ((req, w) => @forwardToWorker(req, w, app)), =>
937
+ app.inflightTotal--
938
+ setImmediate => @drainQueue(app)
939
+ )
940
+
941
+ drainQueue: (app) ->
942
+ app = app or getAppState(@appRegistry, @defaultAppId)
943
+ metrics = @metrics
944
+ app.inflightTotal = drainQueueOnce(
945
+ app.inflightTotal,
946
+ app.sockets.length,
947
+ (=> app.availableWorkers.length),
948
+ (=> app.queue.shift()),
949
+ ((job) =>
950
+ timedOut = nowMs() - job.enqueuedAt > app.queueTimeoutMs
951
+ metrics.queueTimeouts++ if timedOut
952
+ timedOut
953
+ ),
954
+ queueTimeoutResponse,
955
+ (=> @getNextAvailableSocket(app)),
956
+ ((job, worker) =>
957
+ metrics.forwarded++
958
+ @processJob(job, worker, app)
959
+ )
960
+ )
1025
961
 
1026
962
  startControl: ->
1027
963
  ctlPath = getControlSocketPath(@flags.socketPrefix)
1028
- try unlinkSync(ctlPath) catch then null
964
+ try unlinkSync(ctlPath)
1029
965
  @control = Bun.serve({ unix: ctlPath, fetch: @controlFetch.bind(@) })
1030
966
 
1031
967
  @startMdnsAdvertisement('rip.local')
@@ -1035,7 +971,7 @@ class Server
1035
971
 
1036
972
  port = @flags.httpsPort or @flags.httpPort or 80
1037
973
  protocol = if @flags.httpsPort then 'https' else 'http'
1038
- url = "#{protocol}://#{@flags.appName}.ripdev.io#{formatPort(protocol, port)}"
974
+ url = edgeBuildRipdevUrl(@flags.appName, protocol, port, formatPort)
1039
975
  @urls.push(url)
1040
976
  p "rip-server: #{url}" unless @flags.quiet or @flags.hideUrls
1041
977
 
@@ -1043,127 +979,113 @@ class Server
1043
979
  url = new URL(req.url)
1044
980
 
1045
981
  if req.method is 'POST' and url.pathname is '/worker'
1046
- try
1047
- j = req.json!
1048
- if j?.op is 'join' and typeof j.socket is 'string' and typeof j.workerId is 'number'
1049
- version = if typeof j.version is 'number' then j.version else null
1050
- exists = @sockets.find((x) -> x.socket is j.socket)
1051
- unless exists
1052
- worker = { socket: j.socket, inflight: 0, version, workerId: j.workerId }
1053
- @sockets.push(worker)
1054
- @availableWorkers.push(worker)
1055
- @newestVersion = if @newestVersion is null then version else Math.max(@newestVersion, version) if version?
1056
- return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
1057
-
1058
- if j?.op is 'quit' and typeof j.workerId is 'number'
1059
- @sockets = @sockets.filter((x) -> x.workerId isnt j.workerId)
1060
- @availableWorkers = @availableWorkers.filter((x) -> x.workerId isnt j.workerId)
1061
- return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
1062
- catch
1063
- null
1064
- return new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
982
+ # Peek at appId from payload to route to correct app state
983
+ body = req.text!
984
+ appId = try JSON.parse(body).appId
985
+ appId = appId or @defaultAppId
986
+ app = getAppState(@appRegistry, appId) or getAppState(@appRegistry, @defaultAppId)
987
+ res = handleWorkerControl!(new Request(req.url, { method: 'POST', headers: req.headers, body }),
988
+ sockets: app.sockets
989
+ availableWorkers: app.availableWorkers
990
+ newestVersion: app.newestVersion
991
+ )
992
+ if res.handled
993
+ app.sockets = res.sockets if res.sockets?
994
+ app.availableWorkers = res.availableWorkers if res.availableWorkers?
995
+ app.newestVersion = res.newestVersion if res.newestVersion?
996
+ return res.response
1065
997
 
1066
998
  if req.method is 'POST' and url.pathname is '/watch'
1067
- try
1068
- j = req.json!
1069
- if j?.op is 'watch' and typeof j.prefix is 'string' and Array.isArray(j.dirs)
1070
- @registerWatch(j.prefix)
1071
- @manager?.watchDirs(j.prefix, j.dirs) if j.dirs.length > 0
1072
- return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
1073
- catch
1074
- null
1075
- return new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
999
+ res = handleWatchControl!(req, @registerWatch.bind(@), (prefix, dirs) => @manager?.watchDirs(prefix, dirs))
1000
+ return res.response if res.handled
1076
1001
 
1077
1002
  if url.pathname is '/registry' and req.method is 'GET'
1078
- return new Response(JSON.stringify({ ok: true, hosts: Array.from(@hostRegistry.values()) }), { headers: { 'content-type': 'application/json' } })
1003
+ res = handleRegistryControl(req, @appRegistry.hostIndex)
1004
+ return res.response if res.handled
1079
1005
 
1080
1006
  new Response('not-found', { status: 404 })
1081
1007
 
1082
1008
  maybeAddSecurityHeaders: (headers) ->
1083
- if @httpsActive and @flags.hsts
1084
- headers.set('strict-transport-security', 'max-age=31536000; includeSubDomains') unless headers.has('strict-transport-security')
1009
+ edgeMaybeAddSecurityHeaders(@httpsActive, @flags.hsts, headers)
1085
1010
 
1086
1011
  loadTlsMaterial: ->
1087
- # Explicit cert/key paths
1088
- if @flags.certPath and @flags.keyPath
1089
- try
1090
- cert = readFileSync(@flags.certPath, 'utf8')
1091
- key = readFileSync(@flags.keyPath, 'utf8')
1092
- @printCertSummary(cert)
1093
- return { cert, key }
1094
- catch
1095
- console.error 'Failed to read TLS cert/key from provided paths. Use http or fix paths.'
1096
- process.exit(2)
1097
-
1098
- # Shipped wildcard cert for *.ripdev.io (GlobalSign, valid for all subdomains)
1099
- certsDir = join(import.meta.dir, 'certs')
1100
- certPath = join(certsDir, 'ripdev.io.crt')
1101
- keyPath = join(certsDir, 'ripdev.io.key')
1102
- try
1103
- cert = readFileSync(certPath, 'utf8')
1104
- key = readFileSync(keyPath, 'utf8')
1105
- @printCertSummary(cert)
1106
- return { cert, key }
1107
- catch e
1108
- console.error "rip-server: failed to load TLS certs from #{certsDir}: #{e.message}"
1109
- console.error 'Use --cert/--key to provide your own, or use http to disable TLS.'
1110
- process.exit(2)
1012
+ edgeLoadTlsMaterial(@flags, import.meta.dir, (domain) -> acmeLoadCert(defaultCertDir(), domain))
1111
1013
 
1112
1014
  printCertSummary: (certPem) ->
1113
- try
1114
- x = new X509Certificate(certPem)
1115
- subject = x.subject.split(/,/)[0]?.trim() or x.subject
1116
- issuer = x.issuer.split(/,/)[0]?.trim() or x.issuer
1117
- exp = new Date(x.validTo)
1118
- console.log "rip-server: tls cert #{subject} issued by #{issuer} expires #{exp.toISOString()}"
1119
- catch
1120
- null
1015
+ edgePrintCertSummary(certPem)
1121
1016
 
1122
1017
  getLanIP: ->
1123
- try
1124
- nets = networkInterfaces()
1125
- for name, addrs of nets
1126
- for addr in addrs
1127
- continue if addr.internal or addr.family isnt 'IPv4'
1128
- continue if addr.address.startsWith('169.254.') # Link-local
1129
- return addr.address
1130
- catch
1131
- null
1132
- null
1018
+ controlGetLanIP(networkInterfaces)
1133
1019
 
1134
1020
  startMdnsAdvertisement: (host) ->
1135
- return unless host.endsWith('.local')
1136
- return if @mdnsProcesses.has(host)
1137
-
1138
- lanIP = @getLanIP()
1139
- unless lanIP
1140
- console.log "rip-server: unable to detect LAN IP for mDNS advertisement of #{host}"
1141
- return
1142
-
1143
- port = @flags.httpsPort or @flags.httpPort or 80
1144
- protocol = if @flags.httpsPort then 'https' else 'http'
1145
- serviceType = if @flags.httpsPort then '_https._tcp' else '_http._tcp'
1146
- serviceName = host.replace('.local', '')
1147
-
1021
+ controlStartMdnsAdvertisement(
1022
+ host,
1023
+ @mdnsProcesses,
1024
+ @getLanIP.bind(@),
1025
+ @flags,
1026
+ formatPort,
1027
+ (url) =>
1028
+ @urls.push(url)
1029
+ p "rip-server: #{url}" unless @flags.quiet or @flags.hideUrls
1030
+ )
1031
+
1032
+ proxyRealtimeToWorker: (headers, frameType, body) ->
1033
+ app = getAppState(@appRegistry, @defaultAppId)
1034
+ return null unless app
1035
+ sock = @getNextAvailableSocket(app)
1036
+ return null unless sock
1037
+ proxyHeaders = new Headers(headers)
1038
+ proxyHeaders.set('Sec-WebSocket-Frame', frameType)
1039
+ proxyHeaders.set('Content-Type', 'text/plain')
1040
+ proxyHeaders.delete('Upgrade')
1041
+ proxyHeaders.delete('Connection')
1042
+ proxyHeaders.delete('Sec-WebSocket-Key')
1043
+ proxyHeaders.delete('Sec-WebSocket-Version')
1044
+ proxyHeaders.delete('Sec-WebSocket-Extensions')
1148
1045
  try
1149
- proc = Bun.spawn [
1150
- 'dns-sd', '-P'
1151
- serviceName
1152
- serviceType
1153
- 'local'
1154
- String(port)
1155
- host
1156
- lanIP
1157
- ],
1158
- stdout: 'ignore'
1159
- stderr: 'ignore'
1160
-
1161
- @mdnsProcesses.set(host, proc)
1162
- url = "#{protocol}://#{host}#{formatPort(protocol, port)}"
1163
- @urls.push(url)
1164
- p "rip-server: #{url}" unless @flags.quiet or @flags.hideUrls
1046
+ res = fetch! "http://localhost/v1/realtime",
1047
+ method: 'POST'
1048
+ headers: proxyHeaders
1049
+ body: body or ''
1050
+ unix: sock.socket
1051
+ decompress: false
1052
+ res.text!
1053
+ finally
1054
+ @releaseWorker(sock, app)
1165
1055
  catch e
1166
- console.error "rip-server: failed to advertise #{host} via mDNS:", e.message
1056
+ console.error "realtime: worker proxy failed:", e.message
1057
+ null
1058
+
1059
+ buildWebSocketHandlers: ->
1060
+ hub = @realtimeHub
1061
+ server = @
1062
+
1063
+ websocket:
1064
+ idleTimeout: 120 # seconds — evict idle/stale connections
1065
+ sendPings: true # Bun auto-sends pings to detect dead connections
1066
+
1067
+ open: (ws) ->
1068
+ { clientId, headers } = ws.data
1069
+ addClient(hub, clientId, ws)
1070
+ server.metrics.wsConnections++
1071
+ logEvent 'ws_open', { clientId }
1072
+ response = server.proxyRealtimeToWorker!(headers, 'open', '')
1073
+ processResponse(hub, response, clientId) if response
1074
+
1075
+ message: (ws, message) ->
1076
+ { clientId, headers } = ws.data
1077
+ server.metrics.wsMessages++
1078
+ isBinary = typeof message isnt 'string'
1079
+ msg = if isBinary then Buffer.from(message) else message
1080
+ frameType = if isBinary then 'binary' else 'text'
1081
+ response = server.proxyRealtimeToWorker!(headers, frameType, msg)
1082
+ processResponse(hub, response, clientId) if response
1083
+
1084
+ close: (ws) ->
1085
+ { clientId, headers } = ws.data
1086
+ logEvent 'ws_close', { clientId }
1087
+ removeClient(hub, clientId)
1088
+ server.proxyRealtimeToWorker!(headers, 'close', '')
1167
1089
 
1168
1090
 
1169
1091
  # ==============================================================================
@@ -1171,120 +1093,24 @@ class Server
1171
1093
  # ==============================================================================
1172
1094
 
1173
1095
  main = ->
1174
- # Version flag
1175
- if '--version' in process.argv or '-v' in process.argv
1176
- try
1177
- pkg = JSON.parse(readFileSync(import.meta.dir + '/package.json', 'utf8'))
1178
- console.log "rip-server v#{pkg.version}"
1179
- catch
1180
- console.log 'rip-server (version unknown)'
1096
+ if runVersionOutput(process.argv, readFileSync, import.meta.dir + '/package.json')
1181
1097
  return
1182
1098
 
1183
- # Help flag
1184
- if '--help' in process.argv or '-h' in process.argv
1185
- console.log """
1186
- rip-server - Pure Rip application server
1187
-
1188
- Usage:
1189
- rip serve [options] [app-path] [app-name]
1190
- rip serve [options] [app-path]@<alias1>,<alias2>,...
1191
-
1192
- Options:
1193
- -h, --help Show this help
1194
- -v, --version Show version
1195
- --watch=<glob> Watch glob pattern (default: *.rip)
1196
- --static Disable hot reload and file watching
1197
- --env=<mode> Set environment (dev, production)
1198
- --debug Enable debug logging
1199
- --quiet Suppress URL lines (app prints its own)
1200
-
1201
- Network:
1202
- http HTTP only (no TLS)
1203
- https HTTPS with trusted *.ripdev.io cert (default)
1204
- <port> Listen on specific port
1205
- --cert=<path> TLS certificate file (overrides shipped cert)
1206
- --key=<path> TLS key file (overrides shipped cert)
1207
- --hsts Enable HSTS header
1208
- --no-redirect-http Don't redirect HTTP to HTTPS
1209
-
1210
- Workers:
1211
- w:<n> Number of workers (default: cores/2)
1212
- w:auto One worker per core
1213
- r:<n>,<s>s Restart policy: max requests, max seconds
1214
-
1215
- Examples:
1216
- rip serve Start with ./index.rip (watches *.rip)
1217
- rip serve myapp Start with app name "myapp"
1218
- rip serve http HTTP only (no TLS)
1219
- rip serve --static w:8 Production: no reload, 8 workers
1220
-
1221
- If no index.rip or index.ts is found, a built-in static file server
1222
- activates with directory indexes, auto-detected MIME types, and
1223
- hot-reload. Create an index.rip to customize server behavior.
1224
- """
1099
+ if runHelpOutput(process.argv)
1225
1100
  return
1226
1101
 
1227
- # Helper functions for subcommands
1228
- getKV = (prefix) ->
1229
- for tok in process.argv
1230
- return tok.slice(prefix.length) if tok.startsWith(prefix)
1231
- undefined
1232
-
1233
- findAppPathToken = ->
1234
- for i in [2...process.argv.length]
1235
- tok = process.argv[i]
1236
- pathPart = if tok.includes('@') then tok.split('@')[0] else tok
1237
- looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
1238
- try
1239
- return pathPart if looksLikePath and existsSync(if isAbsolute(pathPart) then pathPart else resolve(process.cwd(), pathPart))
1240
- catch
1241
- null
1242
- undefined
1243
-
1244
- computeSocketPrefix = ->
1245
- override = getKV('--socket-prefix=')
1246
- return override if override
1247
- appTok = findAppPathToken()
1248
- if appTok
1249
- try
1250
- { appName } = resolveAppEntry(appTok)
1251
- return "rip_#{appName}"
1252
- catch
1253
- null
1254
- 'rip_server'
1255
-
1256
- # Subcommand: stop
1257
- if 'stop' in process.argv
1258
- prefix = computeSocketPrefix()
1259
- pidFile = getPidFilePath(prefix)
1260
- try
1261
- if existsSync(pidFile)
1262
- pid = parseInt(readFileSync(pidFile, 'utf8').trim())
1263
- process.kill(pid, 'SIGTERM')
1264
- console.log "rip-server: sent SIGTERM to process #{pid}"
1265
- else
1266
- console.log "rip-server: no PID file found at #{pidFile}, trying pkill..."
1267
- Bun.spawnSync(['pkill', '-f', import.meta.path])
1268
- catch e
1269
- console.error "rip-server: stop failed: #{e.message}"
1270
- return
1102
+ # Subcommands: stop/list
1103
+ if 'stop' in process.argv or 'list' in process.argv
1104
+ prefix = cliComputeSocketPrefix(process.argv, resolveAppEntry, existsSync, isAbsolute, resolve, (-> process.cwd()))
1105
+ if runStopSubcommand(process.argv, prefix, getPidFilePath, existsSync, readFileSync, process.kill.bind(process), Bun.spawnSync, import.meta.path)
1106
+ return
1271
1107
 
1272
- # Subcommand: list
1273
- if 'list' in process.argv
1274
- controlUnix = getControlSocketPath(computeSocketPrefix())
1275
- try
1276
- res = fetch!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
1277
- throw new Error("list failed: #{res.status}") unless res.ok
1278
- j = res.json!
1279
- hosts = if Array.isArray(j?.hosts) then j.hosts else []
1280
- console.log if hosts.length then hosts.join('\n') else '(no hosts)'
1281
- catch e
1282
- console.error "list command failed: #{e?.message or e}"
1283
- process.exit(1)
1284
- return
1108
+ if runListSubcommand(process.argv, prefix, getControlSocketPath, fetch, exit)
1109
+ return
1285
1110
 
1286
1111
  # Normal startup
1287
1112
  flags = parseFlags(process.argv)
1113
+ setEventJsonMode(flags.jsonLogging)
1288
1114
  pidFile = getPidFilePath(flags.socketPrefix)
1289
1115
  writeFileSync(pidFile, String(process.pid))
1290
1116
 
@@ -1293,43 +1119,50 @@ main = ->
1293
1119
  svr.manager = mgr
1294
1120
  mgr.server = svr
1295
1121
 
1296
- cleanup = ->
1297
- console.log 'rip-server: shutting down...'
1298
- try unlinkSync(pidFile) catch then null
1299
- svr.stop()
1300
- mgr.stop!
1301
- try fetch!(mgr.dbUrl + '/shutdown', { method: 'POST' }) if mgr.dbUrl catch then null
1302
- process.exit(0)
1303
-
1304
- process.on 'SIGTERM', cleanup
1305
- process.on 'SIGINT', cleanup
1306
- process.on 'uncaughtException', (err) ->
1307
- console.error 'rip-server: uncaught exception:', err
1308
- cleanup()
1309
- process.on 'unhandledRejection', (err) ->
1310
- console.error 'rip-server: unhandled rejection:', err
1311
- cleanup()
1122
+ # Load config.rip if present — register additional apps
1123
+ configPath = findConfigFile(flags.appEntry)
1124
+ if configPath
1125
+ config = loadConfig!(configPath)
1126
+ if config
1127
+ registered = applyConfig(config, svr.appRegistry, registerApp, dirname(flags.appEntry))
1128
+ p "rip-server: loaded config.rip with #{registered.length} app(s)" unless flags.quiet
1129
+
1130
+ cleanup = createCleanup(pidFile, svr, mgr, unlinkSync, fetch, process)
1131
+ installShutdownHandlers(cleanup, process)
1312
1132
 
1313
1133
  # Suppress top URL lines if setup.rip will print them at the bottom
1314
- setupFile = join(dirname(flags.appEntry), 'setup.rip')
1315
- flags.hideUrls = existsSync(setupFile)
1134
+ flags.hideUrls = computeHideUrls(flags.appEntry, join, dirname, existsSync)
1316
1135
 
1317
1136
  svr.start!
1137
+
1138
+ # ACME auto-TLS: obtain cert if needed, start renewal loop
1139
+ if (flags.acme or flags.acmeStaging) and flags.acmeDomain
1140
+ acmeMgr = createAcmeManager
1141
+ certDir: defaultCertDir()
1142
+ staging: flags.acmeStaging
1143
+ challengeStore: svr.challengeStore
1144
+ onCertRenewed: (domain, result) ->
1145
+ p "rip-acme: cert renewed for #{domain}, graceful restart needed"
1146
+ svr.acmeManager = acmeMgr
1147
+ try acmeMgr.obtainCert!(flags.acmeDomain)
1148
+ catch e
1149
+ console.error "rip-acme: initial cert obtain failed: #{e.message}"
1150
+ console.error "rip-acme: will retry during renewal loop"
1151
+ acmeMgr.startRenewalLoop([flags.acmeDomain])
1152
+
1318
1153
  process.env.RIP_URLS = svr.urls.join(',') # exact URLs the Server just built
1319
1154
  mgr.start!
1320
1155
 
1321
- httpOnly = flags.httpsPort is null
1322
- protocol = if httpOnly then 'http' else 'https'
1323
- port = flags.httpsPort or flags.httpPort or 80
1324
- console.log "rip-server: rip=#{svr.ripVersion} server=#{svr.serverVersion} app=#{flags.appName} workers=#{flags.workers} url=#{protocol}://#{flags.appName}.ripdev.io#{formatPort(protocol, port)}/server"
1156
+ logStartupSummary(svr, flags, edgeBuildRipdevUrl, formatPort)
1157
+ logEvent('server_start', { app: flags.appName, workers: flags.workers })
1325
1158
 
1326
1159
  # ==============================================================================
1327
1160
  # Entry Point
1328
1161
  # ==============================================================================
1329
1162
 
1330
1163
  if process.env.RIP_SETUP_MODE
1331
- runSetup()
1164
+ runSetupMode()
1332
1165
  else if process.env.RIP_WORKER_MODE
1333
- runWorker()
1166
+ runWorkerMode()
1334
1167
  else
1335
1168
  main!