@rip-lang/server 1.3.98 → 1.3.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -25
- package/docs/edge/CONFIG_LIFECYCLE.md +73 -0
- package/docs/edge/CONTRACTS.md +146 -0
- package/docs/edge/EDGEFILE_CONTRACT.md +53 -0
- package/docs/edge/M0B_REVIEW_NOTES.md +102 -0
- package/docs/edge/SCHEDULER.md +46 -0
- package/middleware.rip +6 -4
- package/package.json +2 -2
- package/server.rip +469 -636
- package/tests/acme.rip +124 -0
- package/tests/helpers.rip +90 -0
- package/tests/metrics.rip +73 -0
- package/tests/proxy.rip +99 -0
- package/tests/{read.test.rip → read.rip} +10 -11
- package/tests/realtime.rip +147 -0
- package/tests/registry.rip +125 -0
- package/tests/security.rip +95 -0
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 {
|
|
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
|
|
28
|
-
MAX_RESTART_COUNT = 10
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
436
|
+
exit 1
|
|
534
437
|
|
|
535
|
-
|
|
438
|
+
workers = @getWorkers()
|
|
439
|
+
workers.length = 0
|
|
536
440
|
for i in [0...@flags.workers]
|
|
537
441
|
w = @spawnWorker!(@currentVersion)
|
|
538
|
-
|
|
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
|
-
|
|
480
|
+
warn "rip-server: directory watch failed: #{e.message}"
|
|
578
481
|
|
|
579
482
|
stop: ->
|
|
580
|
-
for
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
, w.backoffMs
|
|
539
|
+
workers[slotIdx] = @spawnWorker(@currentVersion) if slotIdx < workers.length
|
|
540
|
+
, budget.backoffMs
|
|
660
541
|
|
|
661
542
|
waitWorkerReady: (socketPath, timeoutMs = 5000) ->
|
|
662
|
-
|
|
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
|
-
|
|
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!(
|
|
681
|
-
|
|
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()
|
|
695
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
|
789
|
+
return watchUnavailableResponse()
|
|
843
790
|
|
|
844
|
-
|
|
845
|
-
headers = new Headers({ 'content-type': 'text/plain' })
|
|
846
|
-
@maybeAddSecurityHeaders(headers)
|
|
847
|
-
return new Response('ok', { headers })
|
|
791
|
+
@metrics.requests++
|
|
848
792
|
|
|
849
|
-
#
|
|
850
|
-
|
|
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
|
|
855
|
-
sock = @getNextAvailableSocket()
|
|
797
|
+
if app.inflightTotal < Math.max(1, app.sockets.length)
|
|
798
|
+
sock = @getNextAvailableSocket(app)
|
|
856
799
|
if sock
|
|
857
|
-
@
|
|
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
|
-
|
|
862
|
-
setImmediate => @drainQueue()
|
|
805
|
+
app.inflightTotal--
|
|
806
|
+
setImmediate => @drainQueue(app)
|
|
863
807
|
|
|
864
|
-
if
|
|
865
|
-
|
|
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
|
-
|
|
814
|
+
app.queue.push({ req, resolve, reject, enqueuedAt: nowMs() })
|
|
869
815
|
|
|
870
816
|
status: ->
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
877
|
-
version:
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
889
|
-
@watchGroups.set prefix, { sseClients: new Set() }
|
|
847
|
+
registerWatchGroup(@watchGroups, prefix)
|
|
890
848
|
|
|
891
849
|
handleWatch: (prefix) ->
|
|
892
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
for
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
979
|
-
|
|
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
|
|
923
|
+
return serviceUnavailableResponse()
|
|
982
924
|
finally
|
|
983
|
-
@releaseWorker(socket) unless released
|
|
984
|
-
|
|
985
|
-
return
|
|
986
|
-
@buildResponse(res, req, start, workerSeconds)
|
|
987
|
-
|
|
988
|
-
forwardOnce: (req, socketPath) ->
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
job
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1164
|
+
runSetupMode()
|
|
1332
1165
|
else if process.env.RIP_WORKER_MODE
|
|
1333
|
-
|
|
1166
|
+
runWorkerMode()
|
|
1334
1167
|
else
|
|
1335
1168
|
main!
|