@rip-lang/server 1.3.115 → 1.3.117

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.
Files changed (44) hide show
  1. package/README.md +435 -622
  2. package/api.rip +4 -4
  3. package/control/cli.rip +221 -1
  4. package/control/control.rip +9 -0
  5. package/control/lifecycle.rip +6 -1
  6. package/control/watchers.rip +10 -0
  7. package/control/workers.rip +9 -5
  8. package/default.rip +3 -1
  9. package/docs/READ_VALIDATORS.md +656 -0
  10. package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
  11. package/docs/edge/CONTRACTS.md +60 -69
  12. package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
  13. package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
  14. package/edge/config.rip +584 -52
  15. package/edge/forwarding.rip +6 -2
  16. package/edge/metrics.rip +19 -1
  17. package/edge/registry.rip +29 -3
  18. package/edge/router.rip +138 -0
  19. package/edge/runtime.rip +98 -0
  20. package/edge/static.rip +69 -0
  21. package/edge/tls.rip +23 -0
  22. package/edge/upstream.rip +272 -0
  23. package/edge/verify.rip +73 -0
  24. package/middleware.rip +3 -3
  25. package/package.json +2 -2
  26. package/server.rip +775 -393
  27. package/tests/control.rip +18 -0
  28. package/tests/edgefile.rip +165 -0
  29. package/tests/metrics.rip +16 -0
  30. package/tests/proxy.rip +22 -1
  31. package/tests/registry.rip +27 -0
  32. package/tests/router.rip +101 -0
  33. package/tests/runtime_entrypoints.rip +16 -0
  34. package/tests/servers.rip +262 -0
  35. package/tests/static.rip +64 -0
  36. package/tests/streams_clienthello.rip +108 -0
  37. package/tests/streams_index.rip +53 -0
  38. package/tests/streams_pipe.rip +70 -0
  39. package/tests/streams_router.rip +39 -0
  40. package/tests/streams_runtime.rip +38 -0
  41. package/tests/streams_upstream.rip +34 -0
  42. package/tests/upstream.rip +191 -0
  43. package/tests/verify.rip +148 -0
  44. package/tests/watchers.rip +15 -0
package/server.rip CHANGED
@@ -14,19 +14,19 @@
14
14
 
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
- import { cpus, networkInterfaces } from 'node:os'
17
+ import { networkInterfaces } from 'node:os'
18
18
  import { isCurrentVersion as schedulerIsCurrentVersion, getNextAvailableSocket as schedulerGetNextAvailableSocket, releaseWorker as schedulerReleaseWorker, shouldRetryBodylessBusy } from './edge/queue.rip'
19
19
  import { formatPort, maybeAddSecurityHeaders as edgeMaybeAddSecurityHeaders, buildStatusBody as edgeBuildStatusBody, buildRipdevUrl as edgeBuildRipdevUrl, generateRequestId } from './edge/forwarding.rip'
20
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'
21
+ import { createAppRegistry, registerApp, resolveHost, getAppState, removeApp } from './edge/registry.rip'
22
+ import { watchUnavailableResponse, serverBusyResponse, serviceUnavailableResponse, gatewayTimeoutResponse, queueTimeoutResponse, buildUpstreamResponse, forwardOnceWithTimeout, createWsPassthrough } from './edge/forwarding.rip'
23
23
  import { processQueuedJob, drainQueueOnce } from './edge/queue.rip'
24
24
  import { getControlSocketPath, getPidFilePath } from './control/control.rip'
25
- import { handleWorkerControl, handleWatchControl, handleRegistryControl } from './control/control.rip'
25
+ import { handleWorkerControl, handleWatchControl, handleRegistryControl, handleReloadControl } from './control/control.rip'
26
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'
27
+ import { registerWatchGroup, handleWatchGroup, broadcastWatchChange, registerAppWatchDirs, shouldTriggerCodeReload } from './control/watchers.rip'
28
28
  import { computeHideUrls, logStartupSummary, createCleanup, installShutdownHandlers } from './control/lifecycle.rip'
29
- import { computeSocketPrefix as cliComputeSocketPrefix, runVersionOutput, runHelpOutput, runStopSubcommand, runListSubcommand } from './control/cli.rip'
29
+ import { computeSocketPrefix as cliComputeSocketPrefix, runVersionOutput, runHelpOutput, runStopSubcommand, runListSubcommand, resolveAppEntry, parseFlags, coerceInt } from './control/cli.rip'
30
30
  import { runSetupMode, runWorkerMode } from './control/worker.rip'
31
31
  import { spawnTrackedWorker, postWorkerQuit, waitForWorkerReady } from './control/workers.rip'
32
32
  import { createChallengeStore, handleChallengeRequest } from './acme/store.rip'
@@ -35,9 +35,17 @@ import { loadCert as acmeLoadCert, defaultCertDir } from './acme/store.rip'
35
35
  import { createMetrics } from './edge/metrics.rip'
36
36
  import { setEventJsonMode, logEvent } from './control/lifecycle.rip'
37
37
  import { createHub, generateClientId, addClient, removeClient, processResponse, handlePublish, getRealtimeStats } from './edge/realtime.rip'
38
- import { findConfigFile, loadConfig, applyConfig } from './edge/config.rip'
38
+ import { findConfigFile, findEdgeFile, resolveConfigSource, applyConfig, applyEdgeConfig, formatConfigErrors } from './edge/config.rip'
39
+ import { createEdgeRuntime, createReloadHistoryEntry, restoreRegistrySnapshot, configNote, toUpstreamWsUrl, loadRuntimeConfig, runCheckConfig } from './edge/runtime.rip'
39
40
  import { createRateLimiter, rateLimitResponse } from './edge/ratelimit.rip'
40
41
  import { validateRequest } from './edge/security.rip'
42
+ import { createUpstreamPool, addUpstream, getUpstream, listUpstreams, selectTarget, markTargetBusy, releaseTarget, shouldRetry as shouldRetryUpstream, computeRetryDelayMs, startHealthChecks, stopHealthChecks, checkTargetHealth } from './edge/upstream.rip'
43
+ import { compileRouteTable, matchRoute, describeRoute } from './edge/router.rip'
44
+ import { buildVerificationResult, verifyRouteRuntime } from './edge/verify.rip'
45
+ import { serveStaticRoute, buildRedirectResponse } from './edge/static.rip'
46
+ import { buildTlsArray } from './edge/tls.rip'
47
+ import { buildStreamRuntime, startStreamListeners, stopStreamListeners, streamDiagnostics } from './streams/index.rip'
48
+ import { createStreamRuntime, streamUsesListenPort } from './streams/runtime.rip'
41
49
 
42
50
  # Match capture holder for Rip's =~
43
51
  _ = null
@@ -56,12 +64,6 @@ STABILITY_THRESHOLD_MS = 60000 # Worker uptime before restart count resets
56
64
 
57
65
  nowMs = -> Date.now()
58
66
 
59
- coerceInt = (value, fallback) ->
60
- return fallback unless value? and value isnt ''
61
- n = parseInt(String(value))
62
- if Number.isFinite(n) then n else fallback
63
-
64
- # Environment detection (can be overridden by --env flag)
65
67
  _envOverride = null
66
68
  _debugMode = false
67
69
 
@@ -71,6 +73,10 @@ isDev = ->
71
73
 
72
74
  isDebug = -> _debugMode or process.env.RIP_DEBUG?
73
75
 
76
+ applyFlagSideEffects = (flags) ->
77
+ _envOverride = flags.envOverride if flags.envOverride
78
+ _debugMode = flags.debug is true
79
+
74
80
  formatTimestamp = ->
75
81
  now = new Date()
76
82
  pad = (n, w = 2) -> String(n).padStart(w, '0')
@@ -153,225 +159,9 @@ stripInternalHeaders = (h) ->
153
159
  out.append(k, v)
154
160
  out
155
161
 
156
- # ==============================================================================
157
- # Flag Parsing
158
- # ==============================================================================
159
-
160
- parseWorkersToken = (token, fallback) ->
161
- return fallback unless token
162
- cores = cpus().length
163
- return Math.max(1, cores) if token is 'auto'
164
- return Math.max(1, Math.floor(cores / 2)) if token is 'half'
165
- return Math.max(1, cores * 2) if token is '2x'
166
- return Math.max(1, cores * 3) if token is '3x'
167
- n = parseInt(token)
168
- if Number.isFinite(n) and n > 0 then n else fallback
169
-
170
- parseRestartPolicy = (token, defReqs, defSecs) ->
171
- return { maxRequests: defReqs, maxSeconds: defSecs } unless token
172
- maxRequests = defReqs
173
- maxSeconds = defSecs
174
-
175
- for part in token.split(',').map((s) -> s.trim()).filter(Boolean)
176
- if part.endsWith('s')
177
- secs = parseInt(part.slice(0, -1))
178
- maxSeconds = secs if Number.isFinite(secs) and secs >= 0
179
- else
180
- n = parseInt(part)
181
- maxRequests = n if Number.isFinite(n) and n > 0
182
-
183
- { maxRequests, maxSeconds }
184
-
185
162
  defaultEntry = join(import.meta.dir, 'default.rip')
186
163
 
187
- resolveAppEntry = (appPathInput) ->
188
- abs = if isAbsolute(appPathInput) then appPathInput else resolve(process.cwd(), appPathInput)
189
-
190
- if existsSync(abs) and statSync(abs).isDirectory()
191
- baseDir = abs
192
- one = join(abs, 'index.rip')
193
- two = join(abs, 'index.ts')
194
- if existsSync(one)
195
- entryPath = one
196
- else if existsSync(two)
197
- entryPath = two
198
- else
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."
200
- entryPath = defaultEntry
201
- else
202
- unless existsSync(abs)
203
- console.error "App path not found: #{abs}"
204
- exit 2
205
- baseDir = dirname(abs)
206
- entryPath = abs
207
-
208
- appName = basename(baseDir)
209
- { baseDir, entryPath, appName }
210
-
211
- parseFlags = (argv) ->
212
- rawFlags = new Set()
213
- appPathInput = null
214
- appAliases = []
215
-
216
- # Check if token looks like a flag
217
- isFlag = (tok) ->
218
- tok.startsWith('-') or tok.startsWith('--') or /^\d+$/.test(tok) or tok in ['http', 'https']
219
-
220
- # Try to resolve a token as an app path
221
- tryResolveApp = (tok) ->
222
- looksLikePath = tok.includes('/') or tok.startsWith('.') or isAbsolute(tok) or tok.endsWith('.rip') or tok.endsWith('.ts')
223
- return undefined unless looksLikePath
224
- try
225
- abs = if isAbsolute(tok) then tok else resolve(process.cwd(), tok)
226
- if existsSync(abs) then tok else undefined
227
- catch
228
- undefined
229
-
230
- for i in [2...argv.length]
231
- tok = argv[i]
232
- unless appPathInput
233
- # Handle path@alias syntax
234
- if tok.includes('@') and not tok.startsWith('-')
235
- [pathPart, aliasesPart] = tok.split('@')
236
- maybe = tryResolveApp(pathPart)
237
- if maybe
238
- appPathInput = maybe
239
- appAliases = aliasesPart.split(',').map((a) -> a.trim()).filter((a) -> a)
240
- continue
241
- # pathPart might be an alias, not a path - check if entry exists in cwd
242
- else if not pathPart.includes('/') and not pathPart.startsWith('.')
243
- appAliases = [pathPart].concat(aliasesPart.split(',').map((a) -> a.trim()).filter((a) -> a))
244
- continue
245
-
246
- # Try as path first
247
- maybe = tryResolveApp(tok)
248
- if maybe
249
- appPathInput = maybe
250
- continue
251
-
252
- # If not a flag and not a path, treat as app name/alias
253
- unless isFlag(tok)
254
- appAliases.push(tok)
255
- continue
256
-
257
- rawFlags.add(tok)
258
-
259
- # Default to current directory if no path specified
260
- unless appPathInput
261
- cwd = process.cwd()
262
- indexRip = join(cwd, 'index.rip')
263
- indexTs = join(cwd, 'index.ts')
264
- if existsSync(indexRip)
265
- appPathInput = indexRip
266
- else if existsSync(indexTs)
267
- appPathInput = indexTs
268
- else
269
- appPathInput = cwd
270
-
271
- getKV = (prefix) ->
272
- for f as rawFlags
273
- return f.slice(prefix.length) if f.startsWith(prefix)
274
- undefined
275
-
276
- has = (name) -> rawFlags.has(name)
277
-
278
- { baseDir, entryPath, appName } = resolveAppEntry(appPathInput)
279
- appAliases = [appName] if appAliases.length is 0
280
-
281
- # Parse listener tokens
282
- tokens = Array.from(rawFlags)
283
- bareIntPort = null
284
- hasHttpsKeyword = false
285
- httpsPortToken = null
286
- hasHttpKeyword = false
287
- httpPortToken = null
288
-
289
- for t in tokens
290
- if /^\d+$/.test(t)
291
- bareIntPort = parseInt(t)
292
- else if t is 'https'
293
- hasHttpsKeyword = true
294
- else if t.startsWith('https:')
295
- httpsPortToken = coerceInt(t.slice(6), 0)
296
- else if t is 'http'
297
- hasHttpKeyword = true
298
- else if t.startsWith('http:')
299
- httpPortToken = coerceInt(t.slice(5), 0)
300
-
301
- httpsIntent = bareIntPort? or hasHttpsKeyword or httpsPortToken?
302
- httpIntent = hasHttpKeyword or httpPortToken?
303
- httpsIntent = true unless httpsIntent or httpIntent
304
-
305
- httpPort = if httpIntent then (httpPortToken ?? bareIntPort ?? 0) else 0
306
- httpsPortDerived = if not httpIntent then (bareIntPort or httpsPortToken or 0) else null
307
-
308
- socketPrefixOverride = getKV('--socket-prefix=')
309
- socketPrefix = socketPrefixOverride or "rip_#{appName}"
310
-
311
- cores = cpus().length
312
- workers = parseWorkersToken(getKV('w:'), Math.max(1, Math.floor(cores / 2)))
313
-
314
- policy = parseRestartPolicy(
315
- getKV('r:'),
316
- coerceInt(process.env.RIP_MAX_REQUESTS, 10000),
317
- coerceInt(process.env.RIP_MAX_SECONDS, 3600)
318
- )
319
-
320
- reload = not (has('--static') or process.env.RIP_STATIC is '1')
321
-
322
- # Environment mode (--env=production, --env=dev, etc.)
323
- envValue = getKV('--env=')
324
- if envValue
325
- normalized = envValue.toLowerCase()
326
- _envOverride = switch normalized
327
- when 'prod', 'production' then 'production'
328
- when 'dev', 'development' then 'development'
329
- else normalized
330
-
331
- # Debug mode
332
- _debugMode = has('--debug')
333
-
334
- # Watch mode: on by default, --static disables everything
335
- watch = getKV('--watch=') or '*.rip'
336
-
337
- httpsPort = do ->
338
- kv = getKV('--https-port=')
339
- return coerceInt(kv, 443) if kv?
340
- httpsPortDerived
341
-
342
- {
343
- appPath: resolve(appPathInput)
344
- appBaseDir: baseDir
345
- appEntry: entryPath
346
- appName
347
- appAliases
348
- workers
349
- maxRequestsPerWorker: policy.maxRequests
350
- maxSecondsPerWorker: policy.maxSeconds
351
- httpPort
352
- httpsPort
353
- certPath: getKV('--cert=')
354
- keyPath: getKV('--key=')
355
- hsts: has('--hsts')
356
- redirectHttp: not has('--no-redirect-http')
357
- reload
358
- quiet: has('--quiet')
359
- socketPrefix
360
- maxQueue: coerceInt(getKV('--max-queue='), coerceInt(process.env.RIP_MAX_QUEUE, 512))
361
- queueTimeoutMs: coerceInt(getKV('--queue-timeout-ms='), coerceInt(process.env.RIP_QUEUE_TIMEOUT_MS, 30000))
362
- connectTimeoutMs: coerceInt(getKV('--connect-timeout-ms='), coerceInt(process.env.RIP_CONNECT_TIMEOUT_MS, 2000))
363
- readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 30000))
364
- jsonLogging: has('--json-logging')
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)
373
- watch: watch
374
- }
164
+ # Edge runtime lifecycle helpers are in edge/runtime.rip
375
165
 
376
166
  # ==============================================================================
377
167
  # Worker Mode
@@ -386,9 +176,9 @@ class Manager
386
176
  @appWorkers = new Map()
387
177
  @shuttingDown = false
388
178
  @lastCheck = 0
389
- @currentMtime = 0
390
- @isRolling = false
391
- @lastRollAt = 0
179
+ @currentMtimes = new Map()
180
+ @rollingApps = new Set()
181
+ @lastRollAt = new Map()
392
182
  @nextWorkerId = -1
393
183
  @retiringIds = new Set()
394
184
  @restartBudgets = new Map() # slotIndex -> { count, backoffMs }
@@ -397,6 +187,7 @@ class Manager
397
187
  @server = null
398
188
  @dbUrl = null
399
189
  @appWatchers = new Map()
190
+ @codeWatchers = new Map()
400
191
  @defaultAppId = @flags.appName
401
192
 
402
193
  getWorkers: (appId) ->
@@ -405,79 +196,96 @@ class Manager
405
196
  @appWorkers.set(appId, [])
406
197
  @appWorkers.get(appId)
407
198
 
199
+ getAppState: (appId) ->
200
+ @server?.appRegistry?.apps?.get(appId) or null
201
+
202
+ allAppStates: ->
203
+ return [] unless @server?.appRegistry?.apps
204
+ Array.from(@server.appRegistry.apps.values())
205
+
206
+ runSetupForApp: (app) ->
207
+ setupFile = join(app.config.appBaseDir or dirname(app.config.entry or @flags.appEntry), 'setup.rip')
208
+ return unless existsSync(setupFile)
209
+
210
+ dbUrl = process.env.DB_URL or 'http://localhost:4213'
211
+ dbAlreadyRunning = try
212
+ fetch! dbUrl + '/health'
213
+ true
214
+ catch
215
+ false
216
+ @dbUrl = dbUrl unless dbAlreadyRunning
217
+
218
+ setupEnv = Object.assign {}, process.env, app.config.env or {},
219
+ RIP_SETUP_MODE: '1'
220
+ RIP_SETUP_FILE: setupFile
221
+ APP_ID: String(app.appId)
222
+ APP_ENTRY: app.config.entry or @flags.appEntry
223
+ APP_BASE_DIR: app.config.appBaseDir or @flags.appBaseDir
224
+ proc = Bun.spawn ['rip', import.meta.path],
225
+ stdout: 'inherit'
226
+ stderr: 'inherit'
227
+ stdin: 'ignore'
228
+ cwd: process.cwd()
229
+ env: setupEnv
230
+
231
+ code = await proc.exited
232
+ if code isnt 0
233
+ console.error "rip-server: setup exited with code #{code} for app #{app.appId}"
234
+ exit 1
235
+
408
236
  start: ->
409
237
  @stop!
410
238
 
411
- # Run one-time setup if setup.rip exists next to the entry file
412
- setupFile = join(dirname(@flags.appEntry), 'setup.rip')
413
- if existsSync(setupFile)
414
- # Check reachability before setup — determines if WE will start the DB
415
- dbUrl = process.env.DB_URL or 'http://localhost:4213'
416
- dbAlreadyRunning = try
417
- fetch! dbUrl + '/health'
418
- true
419
- catch
420
- false
421
- @dbUrl = dbUrl unless dbAlreadyRunning
422
-
423
- setupEnv = Object.assign {}, process.env,
424
- RIP_SETUP_MODE: '1'
425
- RIP_SETUP_FILE: setupFile
426
- proc = Bun.spawn ['rip', import.meta.path],
427
- stdout: 'inherit'
428
- stderr: 'inherit'
429
- stdin: 'ignore'
430
- cwd: process.cwd()
431
- env: setupEnv
432
-
433
- code = await proc.exited
434
- if code isnt 0
435
- console.error "rip-server: setup exited with code #{code}"
436
- exit 1
437
-
438
- workers = @getWorkers()
439
- workers.length = 0
440
- for i in [0...@flags.workers]
441
- w = @spawnWorker!(@currentVersion)
442
- workers.push(w)
239
+ for app in @allAppStates()
240
+ @runSetupForApp!(app) if app.config.entry
241
+
242
+ for app in @allAppStates()
243
+ workers = @getWorkers(app.appId)
244
+ workers.length = 0
245
+ desiredWorkers = app.config.workers or @flags.workers
246
+ for i in [0...desiredWorkers]
247
+ w = @spawnWorker!(app.appId, @currentVersion)
248
+ workers.push(w)
443
249
 
444
250
  if @flags.reload
445
- @currentMtime = @getEntryMtime()
251
+ for app in @allAppStates()
252
+ @currentMtimes.set(app.appId, @getEntryMtime(app.appId))
253
+
446
254
  interval = setInterval =>
447
255
  return clearInterval(interval) if @shuttingDown
448
256
  now = Date.now()
449
257
  return if now - @lastCheck < 100
450
258
  @lastCheck = now
451
- mt = @getEntryMtime()
452
- if mt > @currentMtime
453
- return if @isRolling or (now - @lastRollAt) < 200
454
- @currentMtime = mt
455
- @isRolling = true
456
- @lastRollAt = now
457
- @rollingRestart().finally => @isRolling = false
259
+ for app in @allAppStates()
260
+ appId = app.appId
261
+ mt = @getEntryMtime(appId)
262
+ previous = @currentMtimes.get(appId) or 0
263
+ if mt > previous
264
+ continue if @rollingApps.has(appId)
265
+ continue if now - (@lastRollAt.get(appId) or 0) < 200
266
+ @currentMtimes.set(appId, mt)
267
+ @rollingApps.add(appId)
268
+ @lastRollAt.set(appId, now)
269
+ @rollingRestart(appId).finally => @rollingApps.delete(appId)
458
270
  , 50
459
271
 
460
- # Watch files in app directory (on by default, --static disables)
272
+ # Watch files in each app directory (on by default, --static disables)
461
273
  if @flags.watch
462
- entryFile = @flags.appEntry
463
- entryBase = basename(entryFile)
464
- watchExt = if @flags.watch.startsWith('*.') then @flags.watch.slice(1) else null
465
- try
466
- watch @flags.appBaseDir, { recursive: true }, (event, filename) =>
467
- return unless filename
468
- if watchExt
469
- return unless filename.endsWith(watchExt)
470
- else
471
- return unless filename is @flags.watch or filename.endsWith("/#{@flags.watch}")
472
- return if filename is entryBase
473
- # Touch entry file to trigger rolling restart
474
- try
475
- now = new Date()
476
- utimesSync(entryFile, now, now)
477
- catch
478
- null
479
- catch e
480
- warn "rip-server: directory watch failed: #{e.message}"
274
+ for app in @allAppStates()
275
+ continue unless app.config.entry and app.config.appBaseDir
276
+ entryFile = app.config.entry
277
+ entryBase = basename(entryFile)
278
+ try
279
+ watcher = watch app.config.appBaseDir, { recursive: true }, (event, filename) =>
280
+ return unless shouldTriggerCodeReload(filename, @flags.watch, entryBase)
281
+ try
282
+ now = new Date()
283
+ utimesSync(entryFile, now, now)
284
+ catch
285
+ null
286
+ @codeWatchers.set(app.appId, watcher)
287
+ catch e
288
+ warn "rip-server: directory watch failed for #{app.appId}: #{e.message}"
481
289
 
482
290
  stop: ->
483
291
  for [appId, workers] as @appWorkers
@@ -492,13 +300,17 @@ class Manager
492
300
  for watcher in (entry.watchers or [])
493
301
  try watcher.close()
494
302
  @appWatchers.clear()
303
+ for [appId, watcher] as @codeWatchers
304
+ try watcher.close()
305
+ @codeWatchers.clear()
495
306
 
496
307
  watchDirs: (prefix, dirs) ->
497
308
  registerAppWatchDirs(@appWatchers, prefix, dirs, @server, (-> process.cwd()))
498
309
 
499
- spawnWorker: (version) ->
310
+ spawnWorker: (appId, version) ->
500
311
  workerId = ++@nextWorkerId
501
- tracked = spawnTrackedWorker(@flags, workerId, (version or @currentVersion), nowMs, import.meta.path, @defaultAppId)
312
+ app = @getAppState(appId) or { appId: appId, config: {} }
313
+ tracked = spawnTrackedWorker(@flags, workerId, (version or @currentVersion), nowMs, import.meta.path, appId, app.config)
502
314
  @monitor(tracked)
503
315
  tracked
504
316
 
@@ -507,7 +319,7 @@ class Manager
507
319
  w.process.exited.then =>
508
320
  return if @shuttingDown
509
321
  return if @retiringIds.has(w.id)
510
- if @isRolling
322
+ if @rollingApps.has(w.appId)
511
323
  @deferredDeaths.add(w.id)
512
324
  return
513
325
 
@@ -515,7 +327,7 @@ class Manager
515
327
  postWorkerQuit(@flags.socketPrefix, w.id)
516
328
 
517
329
  # Track restart budget by slot (survives worker replacement)
518
- workers = @getWorkers()
330
+ workers = @getWorkers(w.appId)
519
331
  slotIdx = workers.findIndex((x) -> x.id is w.id)
520
332
  return if slotIdx < 0
521
333
 
@@ -536,73 +348,69 @@ class Manager
536
348
  @server?.metrics and @server.metrics.workerRestarts++
537
349
  logEvent('worker_restart', { workerId: w.id, slot: slotIdx, attempt: budget.count, backoffMs: budget.backoffMs })
538
350
  setTimeout =>
539
- workers[slotIdx] = @spawnWorker(@currentVersion) if slotIdx < workers.length
351
+ workers[slotIdx] = @spawnWorker(w.appId, @currentVersion) if slotIdx < workers.length
540
352
  , budget.backoffMs
541
353
 
542
354
  waitWorkerReady: (socketPath, timeoutMs = 5000) ->
543
355
  waitForWorkerReady(socketPath, timeoutMs)
544
356
 
545
- rollingRestart: ->
546
- workers = @getWorkers()
547
- olds = [...workers]
548
- pairs = []
357
+ rollingRestart: (appId = null) ->
358
+ apps = if appId then [@getAppState(appId)].filter(Boolean) else @allAppStates()
549
359
  nextVersion = @currentVersion + 1
550
360
 
551
- for oldWorker in olds
552
- replacement = @spawnWorker!(nextVersion)
553
- workers.push(replacement)
554
- pairs.push({ old: oldWorker, replacement })
555
-
556
- # Wait for all replacements and check readiness
557
- readyResults = Promise.all!(pairs.map((p) => @waitWorkerReady(p.replacement.socketPath, 3000)))
558
-
559
- # Check if all replacements are ready
560
- allReady = readyResults.every((ready) -> ready)
561
- unless allReady
562
- console.error "[manager] Rolling restart aborted: not all new workers ready"
563
- # Kill failed replacements and keep old workers
564
- for pair, i in pairs
565
- unless readyResults[i]
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()
575
- return
361
+ for app in apps
362
+ workers = @getWorkers(app.appId)
363
+ olds = [...workers]
364
+ pairs = []
365
+
366
+ for oldWorker in olds
367
+ replacement = @spawnWorker!(app.appId, nextVersion)
368
+ workers.push(replacement)
369
+ pairs.push({ old: oldWorker, replacement })
370
+
371
+ readyResults = Promise.all!(pairs.map((p) => @waitWorkerReady(p.replacement.socketPath, 3000)))
372
+ allReady = readyResults.every((ready) -> ready)
373
+ unless allReady
374
+ console.error "[manager] Rolling restart aborted: not all new workers ready for app #{app.appId}"
375
+ for pair, i in pairs
376
+ unless readyResults[i]
377
+ try pair.replacement.process.kill()
378
+ idx = workers.indexOf(pair.replacement)
379
+ workers.splice(idx, 1) if idx >= 0
380
+ if @deferredDeaths.size > 0
381
+ for deadId as @deferredDeaths
382
+ idx = workers.findIndex((x) -> x.id is deadId)
383
+ workers[idx] = @spawnWorker(app.appId, @currentVersion) if idx >= 0
384
+ @deferredDeaths.clear()
385
+ return
576
386
 
577
- # All verified — now atomically promote the version
578
- @currentVersion = nextVersion
387
+ @currentVersion = nextVersion
579
388
 
580
- # All ready - retire old workers
581
- for { old } in pairs
582
- @retiringIds.add(old.id)
583
- try old.process.kill()
389
+ for { old } in pairs
390
+ @retiringIds.add(old.id)
391
+ try old.process.kill()
584
392
 
585
- Promise.all!(pairs.map(({ old }) -> old.process.exited.catch(-> null)))
393
+ Promise.all!(pairs.map(({ old }) -> old.process.exited.catch(-> null)))
586
394
 
587
- # Notify server to remove old workers' socket entries
588
- for { old } in pairs
589
- postWorkerQuit(@flags.socketPrefix, old.id)
395
+ for { old } in pairs
396
+ postWorkerQuit(@flags.socketPrefix, old.id)
590
397
 
591
- retiring = new Set(pairs.map((p) -> p.old.id))
592
- filtered = workers.filter((w) -> not retiring.has(w.id))
593
- workers.length = 0
594
- workers.push(...filtered)
595
- @retiringIds.delete(id) for id as retiring
398
+ retiring = new Set(pairs.map((p) -> p.old.id))
399
+ filtered = workers.filter((w) -> not retiring.has(w.id))
400
+ workers.length = 0
401
+ workers.push(...filtered)
402
+ @retiringIds.delete(id) for id as retiring
596
403
 
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()
404
+ if @deferredDeaths.size > 0
405
+ for deadId as @deferredDeaths
406
+ idx = workers.findIndex((x) -> x.id is deadId)
407
+ workers[idx] = @spawnWorker(app.appId, @currentVersion) if idx >= 0
408
+ @deferredDeaths.clear()
603
409
 
604
- getEntryMtime: ->
605
- try statSync(@flags.appEntry).mtimeMs catch then 0
410
+ getEntryMtime: (appId = null) ->
411
+ app = @getAppState(appId or @defaultAppId)
412
+ entry = app?.config?.entry or @flags.appEntry
413
+ try statSync(entry).mtimeMs catch then 0
606
414
 
607
415
  # ==============================================================================
608
416
  # Server Class
@@ -612,10 +420,16 @@ class Server
612
420
  constructor: (@flags) ->
613
421
  @server = null
614
422
  @httpsServer = null
423
+ @internalHttpsServer = null
615
424
  @control = null
616
425
  @urls = []
617
426
  @startedAt = nowMs()
618
427
  @httpsActive = false
428
+ @streamListenersDeferred = false
429
+ @tlsMaterial = null
430
+ @multiplexerPorts =
431
+ publicHttpsPort: null
432
+ internalHttpsPort: null
619
433
  @appRegistry = createAppRegistry()
620
434
  @defaultAppId = @flags.appName
621
435
  @challengeStore = createChallengeStore()
@@ -626,6 +440,15 @@ class Server
626
440
  @mdnsProcesses = new Map()
627
441
  @watchGroups = new Map()
628
442
  @manager = null
443
+ @configuredAppIds = new Set()
444
+ @edgeRuntime = createEdgeRuntime()
445
+ @retiredEdgeRuntimes = []
446
+ @streamRuntime = createStreamRuntime()
447
+ @retiredStreamRuntimes = []
448
+ @reloadAttemptSeq = 0
449
+ @configInfo = @edgeRuntime.configInfo
450
+ @configInfo.reloadHistory = []
451
+ @configInfo.lastReload = null
629
452
  try
630
453
  pkg = JSON.parse(readFileSync(import.meta.dir + '/package.json', 'utf8'))
631
454
  @serverVersion = pkg.version
@@ -644,44 +467,426 @@ class Server
644
467
  appHosts.push(host)
645
468
  appHosts.push("#{@flags.appName}.ripdev.io")
646
469
  registerApp(@appRegistry, @defaultAppId,
470
+ entry: @flags.appEntry
471
+ appBaseDir: @flags.appBaseDir
647
472
  hosts: appHosts
473
+ workers: @flags.workers
648
474
  maxQueue: @flags.maxQueue
649
475
  queueTimeoutMs: @flags.queueTimeoutMs
650
476
  readTimeoutMs: @flags.readTimeoutMs
477
+ env: {}
651
478
  )
652
479
 
653
- start: ->
654
- httpOnly = @flags.httpsPort is null
480
+ clearConfiguredApps: ->
481
+ for appId in @configuredAppIds
482
+ removeApp(@appRegistry, appId)
483
+ @configuredAppIds.clear()
484
+
485
+ retainEdgeRuntime: (runtime) ->
486
+ runtime.inflight++
487
+ runtime
488
+
489
+ releaseEdgeRuntime: (runtime) ->
490
+ runtime.inflight = Math.max(0, runtime.inflight - 1)
491
+ @cleanupRetiredEdgeRuntimes()
492
+
493
+ retainEdgeRuntimeWs: (runtime) ->
494
+ runtime.wsConnections++
495
+ runtime
496
+
497
+ releaseEdgeRuntimeWs: (runtime) ->
498
+ runtime.wsConnections = Math.max(0, runtime.wsConnections - 1)
499
+ @cleanupRetiredEdgeRuntimes()
500
+
501
+ retainStreamRuntime: (runtime) ->
502
+ runtime.inflight++
503
+ runtime
504
+
505
+ releaseStreamRuntime: (runtime) ->
506
+ runtime.inflight = Math.max(0, runtime.inflight - 1)
507
+ @cleanupRetiredStreamRuntimes()
508
+
509
+ cleanupRetiredEdgeRuntimes: ->
510
+ keep = []
511
+ for runtime in @retiredEdgeRuntimes
512
+ if runtime.inflight > 0 or runtime.wsConnections > 0
513
+ keep.push(runtime)
514
+ else
515
+ stopHealthChecks(runtime.upstreamPool)
516
+ @retiredEdgeRuntimes = keep
517
+
518
+ cleanupRetiredStreamRuntimes: ->
519
+ keep = []
520
+ for runtime in @retiredStreamRuntimes
521
+ if runtime.inflight > 0
522
+ keep.push(runtime)
523
+ else
524
+ stopStreamListeners(runtime, true)
525
+ @retiredStreamRuntimes = keep
526
+
527
+ appendReloadHistory: (entry) ->
528
+ history = [entry].concat(@configInfo.reloadHistory or [])
529
+ history.length = Math.min(history.length, 10)
530
+ @configInfo = Object.assign({}, @configInfo,
531
+ lastReload: entry
532
+ reloadHistory: history
533
+ )
534
+ @edgeRuntime.configInfo = @configInfo if @edgeRuntime?.configInfo
535
+
536
+ nextReloadAttemptId: ->
537
+ @reloadAttemptSeq++
538
+ "reload-#{@reloadAttemptSeq}"
539
+
540
+ verifyEdgeRuntime: (runtime, loaded) ->
541
+ return buildVerificationResult(true) unless runtime?.configInfo?.kind is 'edge'
542
+ verifyRouteRuntime(runtime, @appRegistry, @defaultAppId, getUpstream, checkTargetHealth, getAppState, runtime.verifyPolicy or runtime.configInfo.verifyPolicy or {})
543
+
544
+ buildEdgeRuntime: (loaded) ->
545
+ return createEdgeRuntime() unless loaded?.source?.kind is 'edge'
546
+ upstreamPool = createUpstreamPool()
547
+ for upstreamId, upstreamConfig of (loaded.normalized.upstreams or {})
548
+ addUpstream(upstreamPool, upstreamId, upstreamConfig)
549
+ routeTable = compileRouteTable(loaded.normalized.routes or [], loaded.normalized.sites or {})
550
+ configInfo =
551
+ kind: loaded.source.kind
552
+ path: loaded.summary.path
553
+ version: loaded.summary.version
554
+ counts: loaded.summary.counts
555
+ lastResult: 'loaded'
556
+ loadedAt: new Date().toISOString()
557
+ note: configNote(loaded)
558
+ lastError: null
559
+ lastErrorCode: null
560
+ lastErrorDetails: null
561
+ rolledBackFrom: null
562
+ verifyPolicy: loaded.normalized.edge?.verify or null
563
+ certs: loaded.normalized.certs or null
564
+ activeRouteDescriptions: (routeTable.routes or []).map(describeRoute).filter(Boolean)
565
+ lastReload: @configInfo?.lastReload or null
566
+ reloadHistory: @configInfo?.reloadHistory or []
567
+ runtime = createEdgeRuntime(configInfo, upstreamPool, routeTable)
568
+ startHealthChecks(runtime.upstreamPool)
569
+ runtime
570
+
571
+ buildStreamRuntimeForConfig: (loaded) ->
572
+ return createStreamRuntime() unless loaded?.source?.kind is 'edge'
573
+ buildStreamRuntime(loaded.normalized)
574
+
575
+ startAppServerOnPort: (p, opts = {}) ->
655
576
  fetchFn = @fetch.bind(@)
656
577
  wsOpts = @buildWebSocketHandlers()
578
+ port = p
579
+ while port < p + 100
580
+ try
581
+ return Bun.serve(Object.assign({ port, idleTimeout: 0, fetch: fetchFn }, wsOpts, opts))
582
+ catch e
583
+ if e?.code is 'EACCES' and port < 1024
584
+ port = if opts.tls then 3443 else 3000
585
+ p = port
586
+ continue
587
+ throw e unless e?.code is 'EADDRINUSE'
588
+ port++
589
+ throw new Error "No available port found (tried #{p}–#{p + 99})"
590
+
591
+ captureHttpsMode: ->
592
+ {
593
+ useInternal: Boolean(@internalHttpsServer)
594
+ publicHttpsPort: @flags.httpsPort or null
595
+ }
596
+
597
+ shouldUseInternalHttps: (runtime, publicHttpsPort = @flags.httpsPort or null) ->
598
+ return false unless @tlsMaterial and publicHttpsPort?
599
+ streamUsesListenPort(runtime, publicHttpsPort)
600
+
601
+ applyHttpsMode: (useInternal, publicHttpsPort = @flags.httpsPort or null) ->
602
+ return unless @tlsMaterial and publicHttpsPort?
603
+ if useInternal
604
+ try @httpsServer?.stop()
605
+ @httpsServer = null
606
+ unless @internalHttpsServer
607
+ fetchFn = @fetch.bind(@)
608
+ wsOpts = @buildWebSocketHandlers()
609
+ @internalHttpsServer = Bun.serve(Object.assign(
610
+ { hostname: '127.0.0.1', port: 0, idleTimeout: 0, fetch: fetchFn }
611
+ wsOpts
612
+ { tls: @tlsMaterial }
613
+ ))
614
+ @httpsActive = true
615
+ @flags.httpsPort = publicHttpsPort
616
+ @multiplexerPorts =
617
+ publicHttpsPort: publicHttpsPort
618
+ internalHttpsPort: @internalHttpsServer.port
619
+ return
657
620
 
658
- # Helper to start server, trying the given port first, then incrementing.
659
- # Falls back to unprivileged range (3000 for HTTP, 3443 for HTTPS) if
660
- # the initial port fails due to permission denied (EACCES).
661
- startOnPort = (p, opts = {}) =>
662
- port = p
663
- while port < p + 100
664
- try
665
- return Bun.serve(Object.assign({ port, idleTimeout: 0, fetch: fetchFn }, wsOpts, opts))
666
- catch e
667
- if e?.code is 'EACCES' and port < 1024
668
- port = if opts.tls then 3443 else 3000
669
- p = port
670
- continue
671
- throw e unless e?.code is 'EADDRINUSE'
672
- port++
673
- throw new Error "No available port found (tried #{p}–#{p + 99})"
621
+ try @internalHttpsServer?.stop()
622
+ @internalHttpsServer = null
623
+ unless @httpsServer?.port is publicHttpsPort
624
+ try @httpsServer?.stop()
625
+ @httpsServer = @startAppServerOnPort(publicHttpsPort, { tls: @tlsMaterial })
626
+ @httpsActive = true
627
+ @flags.httpsPort = @httpsServer.port
628
+ @multiplexerPorts =
629
+ publicHttpsPort: @httpsServer.port
630
+ internalHttpsPort: null
631
+
632
+ restoreHttpsMode: (snapshot) ->
633
+ return unless snapshot?.publicHttpsPort?
634
+ @applyHttpsMode(snapshot.useInternal is true, snapshot.publicHttpsPort)
635
+
636
+ buildStreamListenerOptions: ->
637
+ options =
638
+ hostname: '0.0.0.0'
639
+ onConnectionOpen: @retainStreamRuntime.bind(@)
640
+ onConnectionClose: @releaseStreamRuntime.bind(@)
641
+ if @internalHttpsServer and @flags.httpsPort?
642
+ fallback = {}
643
+ fallback[@flags.httpsPort] =
644
+ hostname: '127.0.0.1'
645
+ port: @internalHttpsServer.port
646
+ options.httpFallback = fallback
647
+ options
648
+
649
+ startActiveStreamListeners: ->
650
+ startStreamListeners(@streamRuntime, @buildStreamListenerOptions())
651
+ @streamListenersDeferred = false
652
+
653
+ activateEdgeRuntime: (runtime) ->
654
+ return unless runtime
655
+ oldRuntime = @edgeRuntime
656
+ @edgeRuntime = runtime
657
+ @configInfo = runtime.configInfo
658
+ if oldRuntime and oldRuntime isnt runtime
659
+ oldRuntime.retiredAt = new Date().toISOString()
660
+ @retiredEdgeRuntimes.push(oldRuntime)
661
+ @cleanupRetiredEdgeRuntimes()
662
+ oldRuntime
663
+
664
+ activateStreamRuntime: (runtime, options = {}) ->
665
+ return unless runtime
666
+ oldRuntime = @streamRuntime
667
+ httpsSnapshot = @captureHttpsMode()
668
+ if oldRuntime and oldRuntime isnt runtime
669
+ stopStreamListeners(oldRuntime, false)
670
+ oldRuntime.retiredAt = new Date().toISOString()
671
+ try
672
+ unless options.deferListeners is true
673
+ @applyHttpsMode(@shouldUseInternalHttps(runtime, httpsSnapshot.publicHttpsPort), httpsSnapshot.publicHttpsPort) if @tlsMaterial and httpsSnapshot.publicHttpsPort?
674
+ startStreamListeners(runtime, @buildStreamListenerOptions())
675
+ @streamListenersDeferred = false
676
+ else
677
+ @streamListenersDeferred = true
678
+ catch err
679
+ @restoreHttpsMode(httpsSnapshot) if @tlsMaterial and httpsSnapshot.publicHttpsPort?
680
+ if oldRuntime and oldRuntime isnt runtime
681
+ startStreamListeners(oldRuntime, @buildStreamListenerOptions()) unless oldRuntime.listeners.size > 0
682
+ oldRuntime.retiredAt = null
683
+ throw err
684
+ @streamRuntime = runtime
685
+ if oldRuntime and oldRuntime isnt runtime
686
+ @retiredStreamRuntimes.push(oldRuntime)
687
+ @cleanupRetiredStreamRuntimes()
688
+ oldRuntime
689
+
690
+ rollbackEdgeRuntime: (oldRuntime, failedRuntime, snapshot, verification) ->
691
+ restoreRegistrySnapshot(@appRegistry, snapshot)
692
+ @configuredAppIds = new Set(snapshot.configured)
693
+
694
+ stopHealthChecks(failedRuntime.upstreamPool)
695
+ @retiredEdgeRuntimes = @retiredEdgeRuntimes.filter((runtime) -> runtime isnt oldRuntime and runtime isnt failedRuntime)
696
+ if oldRuntime
697
+ oldRuntime.retiredAt = null
698
+ @edgeRuntime = oldRuntime
699
+ @configInfo = Object.assign({}, oldRuntime.configInfo,
700
+ lastResult: 'rolled_back'
701
+ lastError: verification.message
702
+ lastErrorCode: verification.code
703
+ lastErrorDetails: verification.details or null
704
+ rolledBackFrom: failedRuntime?.configInfo?.path or null
705
+ loadedAt: new Date().toISOString()
706
+ )
707
+ failedRuntime.retiredAt = new Date().toISOString()
708
+ failedRuntime.configInfo = Object.assign({}, failedRuntime.configInfo,
709
+ lastResult: 'rolled_back'
710
+ lastError: verification.message
711
+ lastErrorCode: verification.code
712
+ lastErrorDetails: verification.details or null
713
+ )
714
+ @retiredEdgeRuntimes.push(failedRuntime)
715
+ @cleanupRetiredEdgeRuntimes()
716
+
717
+ rollbackStreamRuntime: (oldRuntime, failedRuntime) ->
718
+ stopStreamListeners(failedRuntime, true)
719
+ @retiredStreamRuntimes = @retiredStreamRuntimes.filter((runtime) -> runtime isnt oldRuntime and runtime isnt failedRuntime)
720
+ if oldRuntime
721
+ oldRuntime.retiredAt = null
722
+ @applyHttpsMode(@shouldUseInternalHttps(oldRuntime), @flags.httpsPort) if @tlsMaterial and @flags.httpsPort?
723
+ startStreamListeners(oldRuntime, @buildStreamListenerOptions()) unless oldRuntime.listeners.size > 0
724
+ @streamRuntime = oldRuntime
725
+ @cleanupRetiredStreamRuntimes()
726
+
727
+ reloadRuntimeConfig: (flags, source = 'startup', verifyAfterActivate = false) ->
728
+ attemptId = @nextReloadAttemptId()
729
+ oldVersion = @configInfo?.version or null
730
+ try
731
+ loaded = loadRuntimeConfig!(flags)
732
+ applied = @applyRuntimeConfig(loaded,
733
+ source: source
734
+ verifyAfterActivate: verifyAfterActivate
735
+ )
736
+ newVersion = @configInfo?.version or null
737
+ result = if applied then (@configInfo?.lastResult or 'loaded') else 'rejected'
738
+ reason = if applied then null else (@configInfo?.lastError or 'rejected')
739
+ code = if applied then null else (@configInfo?.lastErrorCode or null)
740
+ details = if applied then null else (@configInfo?.lastErrorDetails or null)
741
+ entry = createReloadHistoryEntry(attemptId, source, oldVersion, newVersion, result, reason, code, details)
742
+ @appendReloadHistory(entry)
743
+ if loaded and not flags.quiet
744
+ label = if loaded.source.kind is 'edge' then 'Edgefile.rip' else 'config.rip'
745
+ p "rip-server: loaded #{label} with #{loaded.summary.counts.apps} app(s), #{loaded.summary.counts.upstreams} upstream(s), #{loaded.summary.counts.routes} route(s)"
746
+ p "rip-server: note: #{@configInfo.note}" if @configInfo.note
747
+ logEvent('config_loaded',
748
+ id: attemptId
749
+ source: source
750
+ kind: @configInfo.kind
751
+ path: @configInfo.path
752
+ oldVersion: oldVersion
753
+ newVersion: newVersion
754
+ result: result
755
+ reason: reason
756
+ code: code
757
+ apps: @configInfo.counts.apps
758
+ upstreams: @configInfo.counts.upstreams
759
+ routes: @configInfo.counts.routes
760
+ sites: @configInfo.counts.sites
761
+ ) if loaded or source isnt 'startup'
762
+ {
763
+ ok: applied
764
+ id: attemptId
765
+ source: source
766
+ result: result
767
+ reason: reason
768
+ code: code
769
+ details: details
770
+ oldVersion: oldVersion
771
+ newVersion: newVersion
772
+ }
773
+ catch e
774
+ @configInfo = Object.assign({}, @configInfo,
775
+ lastResult: 'rejected'
776
+ loadedAt: new Date().toISOString()
777
+ lastError: e.message or String(e)
778
+ lastErrorCode: 'reload_exception'
779
+ lastErrorDetails: null
780
+ )
781
+ entry = createReloadHistoryEntry(attemptId, source, oldVersion, @configInfo?.version or null, 'rejected', e.message or String(e), 'reload_exception', null)
782
+ @appendReloadHistory(entry)
783
+ if e.validationErrors
784
+ label = if flags.edgefilePath or findEdgeFile(flags.appEntry) then 'Edgefile.rip' else 'config.rip'
785
+ console.error formatConfigErrors(label, e.validationErrors)
786
+ else
787
+ console.error "rip-server: failed to load active config: #{e.message or e}"
788
+ logEvent('config_loaded',
789
+ id: attemptId
790
+ source: source
791
+ kind: @configInfo.kind
792
+ path: @configInfo.path
793
+ oldVersion: oldVersion
794
+ newVersion: @configInfo?.version or null
795
+ result: 'rejected'
796
+ reason: e.message or String(e)
797
+ code: 'reload_exception'
798
+ )
799
+ {
800
+ ok: false
801
+ id: attemptId
802
+ source: source
803
+ result: 'rejected'
804
+ reason: e.message or String(e)
805
+ code: 'reload_exception'
806
+ details: null
807
+ oldVersion: oldVersion
808
+ newVersion: @configInfo?.version or null
809
+ }
810
+
811
+ applyRuntimeConfig: (loaded, options = {}) ->
812
+ source = options.source or 'startup'
813
+ verifyAfterActivate = options.verifyAfterActivate is true
814
+ snapshot =
815
+ apps: new Map(@appRegistry.apps)
816
+ hostIndex: new Map(@appRegistry.hostIndex)
817
+ wildcardIndex: new Map(@appRegistry.wildcardIndex)
818
+ configured: new Set(@configuredAppIds)
819
+ oldRuntime = @edgeRuntime
820
+ oldStreamRuntime = @streamRuntime
821
+ stagedRuntime = @buildEdgeRuntime(loaded)
822
+ stagedStreamRuntime = @buildStreamRuntimeForConfig(loaded)
823
+
824
+ @clearConfiguredApps()
825
+ unless loaded
826
+ @activateEdgeRuntime(stagedRuntime)
827
+ @activateStreamRuntime(stagedStreamRuntime, deferListeners: source is 'startup')
828
+ return
829
+
830
+ baseDir = dirname(loaded.source.path)
831
+ try
832
+ registered = if loaded.source.kind is 'edge'
833
+ applyEdgeConfig(loaded.normalized, @appRegistry, registerApp, baseDir)
834
+ else
835
+ applyConfig(loaded.normalized, @appRegistry, registerApp, baseDir)
836
+ for app in registered
837
+ @configuredAppIds.add(app.id) unless app.id is @defaultAppId
838
+ oldRuntime = @activateEdgeRuntime(stagedRuntime)
839
+ oldStreamRuntime = @activateStreamRuntime(stagedStreamRuntime, deferListeners: source is 'startup')
840
+ if verifyAfterActivate
841
+ result = @verifyEdgeRuntime!(stagedRuntime, loaded)
842
+ unless result.ok
843
+ @rollbackEdgeRuntime(oldRuntime, stagedRuntime, snapshot, result)
844
+ @rollbackStreamRuntime(oldStreamRuntime, stagedStreamRuntime)
845
+ logEvent('config_rollback',
846
+ source: source
847
+ oldVersion: oldRuntime?.configInfo?.version
848
+ newVersion: stagedRuntime?.configInfo?.version
849
+ reason: result.message
850
+ code: result.code
851
+ )
852
+ return false
853
+ @configInfo = Object.assign({}, @configInfo,
854
+ lastResult: 'applied'
855
+ lastError: null
856
+ lastErrorCode: null
857
+ lastErrorDetails: null
858
+ rolledBackFrom: null
859
+ loadedAt: new Date().toISOString()
860
+ )
861
+ @edgeRuntime.configInfo = @configInfo
862
+ logEvent('config_activated',
863
+ source: source
864
+ oldVersion: oldRuntime?.configInfo?.version
865
+ newVersion: stagedRuntime?.configInfo?.version
866
+ result: 'applied'
867
+ )
868
+ true
869
+ catch err
870
+ stopHealthChecks(stagedRuntime.upstreamPool)
871
+ stopStreamListeners(stagedStreamRuntime, true)
872
+ restoreRegistrySnapshot(@appRegistry, snapshot)
873
+ @configuredAppIds = new Set(snapshot.configured)
874
+ throw err
875
+
876
+ start: ->
877
+ httpOnly = @flags.httpsPort is null
674
878
 
675
879
  if httpOnly
676
- @server = startOnPort(@flags.httpPort or 80)
880
+ @server = @startAppServerOnPort(@flags.httpPort or 80)
677
881
  @flags.httpPort = @server.port
678
882
  else
679
- tls = @loadTlsMaterial!
680
- @httpsServer = startOnPort(@flags.httpsPort or 443, { tls })
681
-
682
- httpsPort = @httpsServer.port
683
- @flags.httpsPort = httpsPort
684
- @httpsActive = true
883
+ @tlsMaterial = @loadTlsMaterial!
884
+ if @edgeRuntime?.configInfo?.certs and Object.keys(@edgeRuntime.configInfo.certs).length > 0
885
+ @tlsMaterial = buildTlsArray(@tlsMaterial, @edgeRuntime.configInfo.certs)
886
+ publicHttpsPort = @flags.httpsPort or 443
887
+ @applyHttpsMode(@shouldUseInternalHttps(@streamRuntime, publicHttpsPort), publicHttpsPort)
888
+ httpsPort = @flags.httpsPort
889
+ @streamListenersDeferred = true if @internalHttpsServer and @streamRuntime.listeners.size is 0
685
890
 
686
891
  if @flags.redirectHttp or @flags.acme or @flags.acmeStaging
687
892
  challengeStore = @challengeStore
@@ -701,6 +906,8 @@ class Server
701
906
 
702
907
  @flags.httpPort = if @server then @server.port else 0
703
908
 
909
+ @startActiveStreamListeners() if @streamListenersDeferred or @streamRuntime.listeners.size is 0
910
+
704
911
  @startControl!
705
912
 
706
913
  # Periodic queue timeout sweep — expire queued requests even when no workers are available
@@ -714,6 +921,10 @@ class Server
714
921
  , 1000
715
922
 
716
923
  stop: ->
924
+ stopHealthChecks(@edgeRuntime.upstreamPool)
925
+ stopHealthChecks(runtime.upstreamPool) for runtime in @retiredEdgeRuntimes
926
+ stopStreamListeners(@streamRuntime, true)
927
+ stopStreamListeners(runtime, true) for runtime in @retiredStreamRuntimes
717
928
  clearInterval(@queueSweepTimer) if @queueSweepTimer
718
929
  logEvent('server_stop', { uptime: Math.floor((nowMs() - @startedAt) / 1000) })
719
930
  # Drain all per-app queues — resolve pending requests with 503
@@ -725,10 +936,97 @@ class Server
725
936
  # Stop listeners with graceful drain
726
937
  try @server?.stop()
727
938
  try @httpsServer?.stop()
939
+ try @internalHttpsServer?.stop()
728
940
  try @control?.stop()
729
941
 
730
942
  controlStopMdnsAdvertisements(@mdnsProcesses)
731
943
 
944
+ proxyRouteToUpstream: (req, route, requestId, clientIp, runtime) ->
945
+ upstream = getUpstream(runtime.upstreamPool, route.upstream)
946
+ return serviceUnavailableResponse() unless upstream
947
+
948
+ attempt = 1
949
+ start = performance.now()
950
+
951
+ while attempt <= upstream.retry.attempts
952
+ target = selectTarget(upstream, runtime.upstreamPool.nowFn)
953
+ return serviceUnavailableResponse() unless target
954
+
955
+ markTargetBusy(target)
956
+ workerSeconds = 0
957
+ res = null
958
+ success = false
959
+
960
+ try
961
+ t0 = performance.now()
962
+ timeoutMs = route.timeouts?.readMs or upstream.timeouts?.readMs or @flags.readTimeoutMs
963
+ res = proxyToUpstream!(req, target.url,
964
+ timeoutMs: timeoutMs
965
+ clientIp: clientIp
966
+ )
967
+ workerSeconds = (performance.now() - t0) / 1000
968
+ success = res.status < 500
969
+ catch err
970
+ console.error "[server] proxyRouteToUpstream error:", err.message or err if isDebug()
971
+ res = serviceUnavailableResponse()
972
+ finally
973
+ releaseTarget(target, workerSeconds * 1000, success, runtime.upstreamPool)
974
+
975
+ if res and shouldRetryUpstream(upstream.retry, req.method, res.status, false) and attempt < upstream.retry.attempts
976
+ delayMs = computeRetryDelayMs(upstream.retry, attempt, runtime.upstreamPool.randomFn)
977
+ await new Promise (r) -> setTimeout(r, delayMs)
978
+ attempt++
979
+ continue
980
+
981
+ totalSeconds = (performance.now() - start) / 1000
982
+ @metrics.forwarded++
983
+ @metrics.recordLatency(totalSeconds)
984
+ @metrics.recordStatus(res.status)
985
+ response = buildUpstreamResponse(
986
+ res,
987
+ req,
988
+ totalSeconds,
989
+ workerSeconds,
990
+ @maybeAddSecurityHeaders.bind(@),
991
+ @logAccess.bind(@),
992
+ stripInternalHeaders
993
+ )
994
+ response.headers.set('X-Request-Id', requestId) if requestId
995
+ response.headers.set('X-Rip-Route', route.id) if route.id
996
+ return response
997
+
998
+ serviceUnavailableResponse()
999
+
1000
+ upgradeProxyWebSocket: (req, bunServer, route, requestId, runtime) ->
1001
+ upstream = getUpstream(runtime.upstreamPool, route.upstream)
1002
+ return new Response('Service unavailable', { status: 503 }) unless upstream
1003
+ target = selectTarget(upstream, runtime.upstreamPool.nowFn)
1004
+ return new Response('Service unavailable', { status: 503 }) unless target
1005
+
1006
+ inUrl = new URL(req.url)
1007
+ protocols = (req.headers.get('sec-websocket-protocol') or '')
1008
+ .split(',')
1009
+ .map((p) -> p.trim())
1010
+ .filter(Boolean)
1011
+
1012
+ markTargetBusy(target)
1013
+ data =
1014
+ kind: 'edge-proxy'
1015
+ runtime: runtime
1016
+ requestId: requestId
1017
+ routeId: route.id
1018
+ upstreamTarget: target
1019
+ upstreamUrl: toUpstreamWsUrl(target.url, inUrl.pathname, inUrl.search)
1020
+ protocols: protocols
1021
+ passthrough: null
1022
+ released: false
1023
+
1024
+ if bunServer.upgrade(req, { data })
1025
+ return
1026
+
1027
+ releaseTarget(target, 0, false, runtime.upstreamPool)
1028
+ new Response('WebSocket upgrade failed', { status: 400 })
1029
+
732
1030
  fetch: (req, bunServer) ->
733
1031
  url = new URL(req.url)
734
1032
  host = url.hostname.toLowerCase()
@@ -741,19 +1039,13 @@ class Server
741
1039
  if host is 'rip.local' and url.pathname in ['/', '']
742
1040
  return new Response Bun.file(import.meta.dir + '/server.html')
743
1041
 
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
1042
  # Rate limiting — applied early, covers all endpoints
1043
+ clientIp = bunServer?.requestIP?(req)?.address or '127.0.0.1'
751
1044
  if @rateLimiter.maxRequests > 0
752
- clientIp = bunServer?.requestIP?(req)?.address or '127.0.0.1'
753
1045
  { allowed, retryAfter } = @rateLimiter.check(clientIp)
754
1046
  return rateLimitResponse(retryAfter) unless allowed
755
1047
 
756
- # Built-in endpoints (require valid host)
1048
+ # Built-in endpoints
757
1049
  return @status() if url.pathname is '/status'
758
1050
  return @diagnostics() if url.pathname is '/diagnostics'
759
1051
 
@@ -793,6 +1085,30 @@ class Server
793
1085
  # Assign request ID for tracing
794
1086
  requestId = req.headers.get('x-request-id') or generateRequestId()
795
1087
 
1088
+ # Edge route table — upstream proxy routes can handle hosts outside the managed app registry
1089
+ runtime = @edgeRuntime
1090
+ matchedRoute = matchRoute(runtime.routeTable, host, url.pathname, req.method)
1091
+ if matchedRoute?.upstream and matchedRoute.websocket and bunServer
1092
+ return @upgradeProxyWebSocket(req, bunServer, matchedRoute, requestId, runtime)
1093
+ if matchedRoute?.upstream
1094
+ @retainEdgeRuntime(runtime)
1095
+ try
1096
+ return @proxyRouteToUpstream!(req, matchedRoute, requestId, clientIp, runtime)
1097
+ finally
1098
+ @releaseEdgeRuntime(runtime)
1099
+ if matchedRoute?.static
1100
+ return serveStaticRoute(req, url, matchedRoute)
1101
+ if matchedRoute?.redirect
1102
+ return buildRedirectResponse(req, url, matchedRoute)
1103
+
1104
+ # Resolve host to app — app traffic falls back to the managed worker registry
1105
+ appId = matchedRoute?.app or resolveHost(@appRegistry, host)
1106
+ return new Response('Host not found', { status: 404 }) unless appId
1107
+ app = getAppState(@appRegistry, appId)
1108
+ return new Response('Host not found', { status: 404 }) unless app
1109
+
1110
+ @retainEdgeRuntime(runtime) if matchedRoute?.app
1111
+
796
1112
  # Fast path: try available worker
797
1113
  if app.inflightTotal < Math.max(1, app.sockets.length)
798
1114
  sock = @getNextAvailableSocket(app)
@@ -802,15 +1118,26 @@ class Server
802
1118
  try
803
1119
  return @forwardToWorker!(req, sock, app, requestId)
804
1120
  finally
1121
+ @releaseEdgeRuntime(runtime) if matchedRoute?.app
805
1122
  app.inflightTotal--
806
1123
  setImmediate => @drainQueue(app)
807
1124
 
808
1125
  if app.queue.length >= app.maxQueue
809
1126
  @metrics.queueShed++
1127
+ @releaseEdgeRuntime(runtime) if matchedRoute?.app
810
1128
  return serverBusyResponse()
811
1129
 
812
1130
  @metrics.queued++
813
1131
  new Promise (resolve, reject) =>
1132
+ if matchedRoute?.app
1133
+ originalResolve = resolve
1134
+ originalReject = reject
1135
+ resolve = (value) =>
1136
+ @releaseEdgeRuntime(runtime)
1137
+ originalResolve(value)
1138
+ reject = (err) =>
1139
+ @releaseEdgeRuntime(runtime)
1140
+ originalReject(err)
814
1141
  app.queue.push({ req, resolve, reject, enqueuedAt: nowMs() })
815
1142
 
816
1143
  status: ->
@@ -822,12 +1149,13 @@ class Server
822
1149
  new Response(body, { headers })
823
1150
 
824
1151
  diagnostics: ->
825
- snap = @metrics.snapshot(@startedAt, @appRegistry)
1152
+ snap = @metrics.snapshot(@startedAt, @appRegistry, @edgeRuntime.upstreamPool)
826
1153
  body = JSON.stringify
827
1154
  status: if snap.gauges.workersActive > 0 then 'healthy' else 'degraded'
828
1155
  version: { server: @serverVersion, rip: @ripVersion }
829
1156
  uptime: snap.uptime
830
1157
  apps: snap.apps
1158
+ upstreams: snap.upstreams
831
1159
  metrics:
832
1160
  requests: snap.counters.requests
833
1161
  responses: snap.counters.responses
@@ -839,6 +1167,33 @@ class Server
839
1167
  gauges: snap.gauges
840
1168
  realtime: getRealtimeStats(@realtimeHub)
841
1169
  hosts: Array.from(@appRegistry.hostIndex.keys())
1170
+ streams: streamDiagnostics(@streamRuntime)
1171
+ config: Object.assign({}, @configInfo,
1172
+ multiplexer:
1173
+ enabled: Boolean(@internalHttpsServer)
1174
+ publicHttpsPort: @multiplexerPorts.publicHttpsPort
1175
+ internalHttpsPort: @multiplexerPorts.internalHttpsPort
1176
+ activeRuntime:
1177
+ id: @edgeRuntime.id
1178
+ inflight: @edgeRuntime.inflight
1179
+ wsConnections: @edgeRuntime.wsConnections
1180
+ retiredRuntimes: @retiredEdgeRuntimes.map((runtime) ->
1181
+ id: runtime.id
1182
+ inflight: runtime.inflight
1183
+ wsConnections: runtime.wsConnections
1184
+ retiredAt: runtime.retiredAt
1185
+ )
1186
+ activeStreamRuntime:
1187
+ id: @streamRuntime.id
1188
+ inflight: @streamRuntime.inflight
1189
+ listeners: Array.from(@streamRuntime.listeners.keys())
1190
+ retiredStreamRuntimes: @retiredStreamRuntimes.map((runtime) ->
1191
+ id: runtime.id
1192
+ inflight: runtime.inflight
1193
+ listeners: Array.from(runtime.listeners.keys())
1194
+ retiredAt: runtime.retiredAt
1195
+ )
1196
+ )
842
1197
  headers = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-cache' })
843
1198
  @maybeAddSecurityHeaders(headers)
844
1199
  new Response(body, { headers })
@@ -999,6 +1354,11 @@ class Server
999
1354
  res = handleWatchControl!(req, @registerWatch.bind(@), (prefix, dirs) => @manager?.watchDirs(prefix, dirs))
1000
1355
  return res.response if res.handled
1001
1356
 
1357
+ if req.method is 'POST' and url.pathname is '/reload'
1358
+ result = @reloadRuntimeConfig!(@flags, 'control_api', true)
1359
+ res = handleReloadControl(req, result)
1360
+ return res.response if res.handled
1361
+
1002
1362
  if url.pathname is '/registry' and req.method is 'GET'
1003
1363
  res = handleRegistryControl(req, @appRegistry.hostIndex)
1004
1364
  return res.response if res.handled
@@ -1065,6 +1425,11 @@ class Server
1065
1425
  sendPings: true # Bun auto-sends pings to detect dead connections
1066
1426
 
1067
1427
  open: (ws) ->
1428
+ if ws.data?.kind is 'edge-proxy'
1429
+ server.retainEdgeRuntimeWs(ws.data.runtime)
1430
+ ws.data.passthrough = createWsPassthrough(ws, ws.data.upstreamUrl, ws.data.protocols or [])
1431
+ return
1432
+
1068
1433
  { clientId, headers } = ws.data
1069
1434
  addClient(hub, clientId, ws)
1070
1435
  server.metrics.wsConnections++
@@ -1073,6 +1438,10 @@ class Server
1073
1438
  processResponse(hub, response, clientId) if response
1074
1439
 
1075
1440
  message: (ws, message) ->
1441
+ if ws.data?.kind is 'edge-proxy'
1442
+ ws.data.passthrough?.sendToUpstream(message)
1443
+ return
1444
+
1076
1445
  { clientId, headers } = ws.data
1077
1446
  server.metrics.wsMessages++
1078
1447
  isBinary = typeof message isnt 'string'
@@ -1082,6 +1451,14 @@ class Server
1082
1451
  processResponse(hub, response, clientId) if response
1083
1452
 
1084
1453
  close: (ws) ->
1454
+ if ws.data?.kind is 'edge-proxy'
1455
+ ws.data.passthrough?.close()
1456
+ unless ws.data.released
1457
+ releaseTarget(ws.data.upstreamTarget, 0, true, ws.data.runtime.upstreamPool)
1458
+ server.releaseEdgeRuntimeWs(ws.data.runtime)
1459
+ ws.data.released = true
1460
+ return
1461
+
1085
1462
  { clientId, headers } = ws.data
1086
1463
  logEvent 'ws_close', { clientId }
1087
1464
  removeClient(hub, clientId)
@@ -1109,7 +1486,11 @@ main = ->
1109
1486
  return
1110
1487
 
1111
1488
  # Normal startup
1112
- flags = parseFlags(process.argv)
1489
+ flags = parseFlags(process.argv, defaultEntry)
1490
+ applyFlagSideEffects(flags)
1491
+ if flags.checkConfig
1492
+ runCheckConfig!(flags)
1493
+ return
1113
1494
  setEventJsonMode(flags.jsonLogging)
1114
1495
  pidFile = getPidFilePath(flags.socketPrefix)
1115
1496
  writeFileSync(pidFile, String(process.pid))
@@ -1119,16 +1500,17 @@ main = ->
1119
1500
  svr.manager = mgr
1120
1501
  mgr.server = svr
1121
1502
 
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
1503
+ startupReload = svr.reloadRuntimeConfig!(flags, 'startup', false)
1504
+ exit 1 unless startupReload.ok
1129
1505
 
1130
1506
  cleanup = createCleanup(pidFile, svr, mgr, unlinkSync, fetch, process)
1131
- installShutdownHandlers(cleanup, process)
1507
+ installShutdownHandlers(cleanup, process, ->
1508
+ p 'rip-server: received SIGHUP, reloading config...'
1509
+ if svr.reloadRuntimeConfig!(flags, 'sighup', true).ok
1510
+ p 'rip-server: config reload applied'
1511
+ else
1512
+ p 'rip-server: config reload rejected, keeping previous config'
1513
+ )
1132
1514
 
1133
1515
  # Suppress top URL lines if setup.rip will print them at the bottom
1134
1516
  flags.hideUrls = computeHideUrls(flags.appEntry, join, dirname, existsSync)