@rip-lang/server 1.3.115 → 1.3.116
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 +435 -622
- package/api.rip +4 -4
- package/control/cli.rip +221 -1
- package/control/control.rip +9 -0
- package/control/lifecycle.rip +6 -1
- package/control/watchers.rip +10 -0
- package/control/workers.rip +9 -5
- package/default.rip +3 -1
- package/docs/READ_VALIDATORS.md +656 -0
- package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
- package/docs/edge/CONTRACTS.md +60 -69
- package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
- package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
- package/edge/config.rip +584 -52
- package/edge/forwarding.rip +6 -2
- package/edge/metrics.rip +19 -1
- package/edge/registry.rip +29 -3
- package/edge/router.rip +138 -0
- package/edge/runtime.rip +98 -0
- package/edge/static.rip +69 -0
- package/edge/tls.rip +23 -0
- package/edge/upstream.rip +272 -0
- package/edge/verify.rip +73 -0
- package/middleware.rip +3 -3
- package/package.json +2 -2
- package/server.rip +775 -393
- package/tests/control.rip +18 -0
- package/tests/edgefile.rip +165 -0
- package/tests/metrics.rip +16 -0
- package/tests/proxy.rip +22 -1
- package/tests/registry.rip +27 -0
- package/tests/router.rip +101 -0
- package/tests/runtime_entrypoints.rip +16 -0
- package/tests/servers.rip +262 -0
- package/tests/static.rip +64 -0
- package/tests/streams_clienthello.rip +108 -0
- package/tests/streams_index.rip +53 -0
- package/tests/streams_pipe.rip +70 -0
- package/tests/streams_router.rip +39 -0
- package/tests/streams_runtime.rip +38 -0
- package/tests/streams_upstream.rip +34 -0
- package/tests/upstream.rip +191 -0
- package/tests/verify.rip +148 -0
- 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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
@
|
|
390
|
-
@
|
|
391
|
-
@lastRollAt =
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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 @
|
|
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
|
-
|
|
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
|
|
552
|
-
|
|
553
|
-
workers
|
|
554
|
-
pairs
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
@currentVersion = nextVersion
|
|
387
|
+
@currentVersion = nextVersion
|
|
579
388
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
try old.process.kill()
|
|
389
|
+
for { old } in pairs
|
|
390
|
+
@retiringIds.add(old.id)
|
|
391
|
+
try old.process.kill()
|
|
584
392
|
|
|
585
|
-
|
|
393
|
+
Promise.all!(pairs.map(({ old }) -> old.process.exited.catch(-> null)))
|
|
586
394
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
postWorkerQuit(@flags.socketPrefix, old.id)
|
|
395
|
+
for { old } in pairs
|
|
396
|
+
postWorkerQuit(@flags.socketPrefix, old.id)
|
|
590
397
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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 =
|
|
880
|
+
@server = @startAppServerOnPort(@flags.httpPort or 80)
|
|
677
881
|
@flags.httpPort = @server.port
|
|
678
882
|
else
|
|
679
|
-
|
|
680
|
-
@
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
@
|
|
684
|
-
|
|
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
|
|
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
|
-
|
|
1123
|
-
|
|
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)
|