@rip-lang/server 1.3.126 → 1.4.1
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/{docs/READ_VALIDATORS.md → API.md} +41 -119
- package/CONFIG.md +408 -0
- package/README.md +246 -1109
- package/acme/crypto.rip +0 -2
- package/browse.rip +62 -0
- package/control/cli.rip +89 -34
- package/control/lifecycle.rip +67 -1
- package/control/manager.rip +250 -0
- package/control/mdns.rip +3 -0
- package/middleware.rip +1 -1
- package/package.json +14 -11
- package/server.rip +189 -673
- package/serving/config.rip +766 -0
- package/{edge → serving}/forwarding.rip +2 -2
- package/serving/logging.rip +101 -0
- package/{edge → serving}/metrics.rip +29 -1
- package/serving/proxy.rip +99 -0
- package/{edge → serving}/queue.rip +1 -1
- package/{edge → serving}/ratelimit.rip +1 -1
- package/{edge → serving}/realtime.rip +71 -2
- package/{edge → serving}/registry.rip +1 -1
- package/{edge → serving}/router.rip +3 -3
- package/{edge → serving}/runtime.rip +18 -16
- package/{edge → serving}/security.rip +1 -1
- package/serving/static.rip +393 -0
- package/{edge → serving}/tls.rip +3 -7
- package/{edge → serving}/upstream.rip +4 -4
- package/{edge → serving}/verify.rip +16 -16
- package/streams/{tls_clienthello.rip → clienthello.rip} +1 -1
- package/streams/config.rip +8 -8
- package/streams/index.rip +5 -5
- package/streams/router.rip +2 -2
- package/tests/acme.rip +1 -1
- package/tests/config.rip +215 -0
- package/tests/control.rip +1 -1
- package/tests/{runtime_entrypoints.rip → entrypoints.rip} +11 -7
- package/tests/extracted.rip +118 -0
- package/tests/helpers.rip +4 -4
- package/tests/metrics.rip +3 -3
- package/tests/proxy.rip +9 -8
- package/tests/read.rip +1 -1
- package/tests/realtime.rip +3 -3
- package/tests/registry.rip +4 -4
- package/tests/router.rip +27 -27
- package/tests/runner.rip +70 -0
- package/tests/security.rip +4 -4
- package/tests/servers.rip +102 -136
- package/tests/static.rip +2 -2
- package/tests/streams_clienthello.rip +2 -2
- package/tests/streams_index.rip +4 -4
- package/tests/streams_pipe.rip +1 -1
- package/tests/streams_router.rip +10 -10
- package/tests/streams_runtime.rip +4 -4
- package/tests/streams_upstream.rip +1 -1
- package/tests/upstream.rip +2 -2
- package/tests/verify.rip +18 -18
- package/tests/watchers.rip +4 -4
- package/default.rip +0 -435
- package/docs/edge/CONFIG_LIFECYCLE.md +0 -111
- package/docs/edge/CONTRACTS.md +0 -137
- package/docs/edge/EDGEFILE_CONTRACT.md +0 -282
- package/docs/edge/M0B_REVIEW_NOTES.md +0 -102
- package/docs/edge/SCHEDULER.md +0 -46
- package/docs/logo.png +0 -0
- package/docs/logo.svg +0 -13
- package/docs/social.png +0 -0
- package/edge/config.rip +0 -607
- package/edge/static.rip +0 -69
- package/tests/edgefile.rip +0 -165
package/acme/crypto.rip
CHANGED
|
@@ -13,8 +13,6 @@ export b64url = (buf) ->
|
|
|
13
13
|
s = if typeof buf is 'string' then Buffer.from(buf).toString('base64') else Buffer.from(buf).toString('base64')
|
|
14
14
|
s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
15
15
|
|
|
16
|
-
b64urlBuf = (buf) -> b64url(buf)
|
|
17
|
-
|
|
18
16
|
# --- Key generation ---
|
|
19
17
|
|
|
20
18
|
export generateAccountKeyPair = ->
|
package/browse.rip
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/server — Directory Browser
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Built-in file browser when no index.rip or index.ts exists in the target
|
|
6
|
+
# directory. Activated automatically by `rip server` when no app entry is found.
|
|
7
|
+
#
|
|
8
|
+
# Delegates rendering to serving/static.rip for unified directory listing,
|
|
9
|
+
# markdown rendering, and file serving across all serving modes.
|
|
10
|
+
# ==============================================================================
|
|
11
|
+
|
|
12
|
+
import { use, start, notFound } from '@rip-lang/server'
|
|
13
|
+
import { serve } from '@rip-lang/server/middleware'
|
|
14
|
+
import { statSync } from 'node:fs'
|
|
15
|
+
import { join, resolve, basename } from 'node:path'
|
|
16
|
+
import { renderDirectoryListing, renderMarkdown, renderTextFile, isTextFile, serveRipHighlightGrammar } from './serving/static.rip'
|
|
17
|
+
|
|
18
|
+
root = resolve(process.env.APP_BASE_DIR or process.cwd())
|
|
19
|
+
rootSlash = root + '/'
|
|
20
|
+
rootName = basename(root) or root
|
|
21
|
+
|
|
22
|
+
use serve dir: root, bundle: [], watch: true
|
|
23
|
+
|
|
24
|
+
notFound ->
|
|
25
|
+
if @req.path is '/_rip/hljs-rip.js'
|
|
26
|
+
res = serveRipHighlightGrammar()
|
|
27
|
+
return res if res
|
|
28
|
+
|
|
29
|
+
reqPath = try decodeURIComponent(@req.path) catch then @req.path
|
|
30
|
+
path = resolve(root, reqPath.slice(1))
|
|
31
|
+
|
|
32
|
+
unless path is root or path.startsWith(rootSlash)
|
|
33
|
+
return new Response 'Not Found', { status: 404 }
|
|
34
|
+
|
|
35
|
+
try
|
|
36
|
+
stat = statSync(path)
|
|
37
|
+
catch
|
|
38
|
+
return new Response 'Not Found', { status: 404 }
|
|
39
|
+
|
|
40
|
+
if stat.isFile()
|
|
41
|
+
accept = @req.header('accept') or ''
|
|
42
|
+
if accept.includes('text/html')
|
|
43
|
+
if path.endsWith('.md')
|
|
44
|
+
try
|
|
45
|
+
html = renderMarkdown(path)
|
|
46
|
+
return new Response html, { headers: { 'Content-Type': 'text/html; charset=UTF-8' } }
|
|
47
|
+
if isTextFile(path) and not path.endsWith('.rip')
|
|
48
|
+
try
|
|
49
|
+
html = renderTextFile(path)
|
|
50
|
+
return new Response html, { headers: { 'Content-Type': 'text/html; charset=UTF-8' } }
|
|
51
|
+
return @send path
|
|
52
|
+
|
|
53
|
+
if stat.isDirectory()
|
|
54
|
+
return new Response(null, { status: 301, headers: { Location: "#{reqPath}/" } }) unless reqPath.endsWith('/')
|
|
55
|
+
indexPath = join(path, 'index.html')
|
|
56
|
+
if (try statSync(indexPath).isFile())
|
|
57
|
+
return @send indexPath, 'text/html; charset=UTF-8'
|
|
58
|
+
return new Response renderDirectoryListing(reqPath, path, rootName), { headers: { 'Content-Type': 'text/html; charset=UTF-8' } }
|
|
59
|
+
|
|
60
|
+
new Response 'Not Found', { status: 404 }
|
|
61
|
+
|
|
62
|
+
start()
|
package/control/cli.rip
CHANGED
|
@@ -48,7 +48,6 @@ export resolveAppEntry = (appPathInput, defaultEntryPath = null) ->
|
|
|
48
48
|
else if existsSync(two)
|
|
49
49
|
entryPath = two
|
|
50
50
|
else
|
|
51
|
-
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."
|
|
52
51
|
entryPath = defaultEntryPath or abs
|
|
53
52
|
else
|
|
54
53
|
unless existsSync(abs)
|
|
@@ -60,6 +59,13 @@ export resolveAppEntry = (appPathInput, defaultEntryPath = null) ->
|
|
|
60
59
|
appName = basename(baseDir)
|
|
61
60
|
{ baseDir, entryPath, appName }
|
|
62
61
|
|
|
62
|
+
export VALUE_FLAGS = new Set(%w[
|
|
63
|
+
-f --file --cert --key --watch --env --https-port
|
|
64
|
+
--socket-prefix --acme --acme-staging --realtime-path --publish-secret
|
|
65
|
+
--max-queue --queue-timeout-ms --connect-timeout-ms --read-timeout-ms
|
|
66
|
+
--rate-limit --rate-limit-window -o --output
|
|
67
|
+
])
|
|
68
|
+
|
|
63
69
|
export parseFlags = (argv, defaultEntryPath = null) ->
|
|
64
70
|
rawFlags = new Set()
|
|
65
71
|
appPathInput = null
|
|
@@ -78,8 +84,17 @@ export parseFlags = (argv, defaultEntryPath = null) ->
|
|
|
78
84
|
catch
|
|
79
85
|
undefined
|
|
80
86
|
|
|
87
|
+
skipNext = false
|
|
81
88
|
for i in [2...argv.length]
|
|
89
|
+
if skipNext
|
|
90
|
+
rawFlags.add(argv[i])
|
|
91
|
+
skipNext = false
|
|
92
|
+
continue
|
|
82
93
|
tok = argv[i]
|
|
94
|
+
if VALUE_FLAGS.has(tok)
|
|
95
|
+
rawFlags.add(tok)
|
|
96
|
+
skipNext = true
|
|
97
|
+
continue
|
|
83
98
|
unless appPathInput
|
|
84
99
|
if tok.includes('@') and not tok.startsWith('-')
|
|
85
100
|
[pathPart, aliasesPart] = tok.split('@')
|
|
@@ -91,7 +106,14 @@ export parseFlags = (argv, defaultEntryPath = null) ->
|
|
|
91
106
|
appNameOverride = aliases[0]
|
|
92
107
|
appAliases = aliases
|
|
93
108
|
continue
|
|
94
|
-
|
|
109
|
+
looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
|
|
110
|
+
if looksLikePath
|
|
111
|
+
appPathInput = pathPart
|
|
112
|
+
if aliases.length > 0
|
|
113
|
+
appNameOverride = aliases[0]
|
|
114
|
+
appAliases = aliases
|
|
115
|
+
continue
|
|
116
|
+
else
|
|
95
117
|
appNameOverride = pathPart
|
|
96
118
|
appAliases = [pathPart].concat(aliases)
|
|
97
119
|
continue
|
|
@@ -217,17 +239,20 @@ export parseFlags = (argv, defaultEntryPath = null) ->
|
|
|
217
239
|
readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 30000))
|
|
218
240
|
jsonLogging: has('--json-logging')
|
|
219
241
|
accessLog: not has('--no-access-log')
|
|
220
|
-
acme:
|
|
221
|
-
acmeStaging:
|
|
222
|
-
acmeDomain: getKV('--acme-
|
|
242
|
+
acme: Boolean(getKV('--acme=') or getKV('--acme-staging='))
|
|
243
|
+
acmeStaging: Boolean(getKV('--acme-staging='))
|
|
244
|
+
acmeDomain: getKV('--acme=') or getKV('--acme-staging=') or null
|
|
245
|
+
noAcme: has('--no-acme')
|
|
223
246
|
realtimePath: getKV('--realtime-path=') or '/realtime'
|
|
224
247
|
publishSecret: getKV('--publish-secret=') or process.env.RIP_PUBLISH_SECRET or null
|
|
225
248
|
rateLimit: coerceInt(getKV('--rate-limit='), 0)
|
|
226
249
|
rateLimitWindow: coerceInt(getKV('--rate-limit-window='), 60000)
|
|
227
250
|
watch: watch
|
|
228
|
-
|
|
251
|
+
configPath: getKV('--file=') or getKV('-f=')
|
|
229
252
|
checkConfig: has('--check-config') or has('--check') or has('-c')
|
|
253
|
+
showInfo: has('--info') or has('-i')
|
|
230
254
|
reloadConfig: has('--reload') or has('-r')
|
|
255
|
+
listApps: has('--list') or has('-l')
|
|
231
256
|
stopServer: has('--stop') or has('-s')
|
|
232
257
|
envOverride: envOverride
|
|
233
258
|
debug: has('--debug')
|
|
@@ -292,10 +317,12 @@ export runHelpOutput = (argv) ->
|
|
|
292
317
|
Options:
|
|
293
318
|
-h, --help Show this help
|
|
294
319
|
-v, --version Show version
|
|
295
|
-
-c, --check Validate
|
|
296
|
-
-
|
|
297
|
-
-
|
|
298
|
-
-
|
|
320
|
+
-c, --check Validate config and exit
|
|
321
|
+
-f, --file <path> Specify config file path
|
|
322
|
+
-i, --info Show running server status
|
|
323
|
+
-l, --list List registered hosts
|
|
324
|
+
-r, --reload Reload config on running server
|
|
325
|
+
-s, --stop Stop running server
|
|
299
326
|
--watch=<glob> Watch glob pattern (default: *.rip)
|
|
300
327
|
--static Disable hot reload and file watching
|
|
301
328
|
--env=<mode> Set environment (dev, production)
|
|
@@ -306,15 +333,15 @@ export runHelpOutput = (argv) ->
|
|
|
306
333
|
http HTTP only (no TLS)
|
|
307
334
|
https HTTPS with trusted *.ripdev.io cert (default)
|
|
308
335
|
<port> Listen on specific port
|
|
309
|
-
--cert
|
|
310
|
-
--key
|
|
336
|
+
--cert <path> TLS certificate file (overrides shipped cert)
|
|
337
|
+
--key <path> TLS key file (overrides shipped cert)
|
|
311
338
|
--hsts Enable HSTS header
|
|
312
339
|
--no-redirect-http Don't redirect HTTP to HTTPS
|
|
313
340
|
|
|
314
341
|
ACME (auto TLS):
|
|
315
|
-
--acme
|
|
316
|
-
--acme-staging
|
|
317
|
-
--acme
|
|
342
|
+
--acme <domain> Obtain TLS cert via Let's Encrypt for <domain>
|
|
343
|
+
--acme-staging <domain> Same as --acme but use Let's Encrypt staging CA
|
|
344
|
+
--no-acme Disable ACME even if serve.rip configures it
|
|
318
345
|
|
|
319
346
|
Realtime (WebSocket):
|
|
320
347
|
--realtime-path=<path> WebSocket endpoint path (default: /realtime)
|
|
@@ -330,10 +357,12 @@ export runHelpOutput = (argv) ->
|
|
|
330
357
|
rip server http HTTP only (no TLS)
|
|
331
358
|
rip server --static w:8 Production: no reload, 8 workers
|
|
332
359
|
rip server -c Validate config and exit
|
|
333
|
-
rip server -c -f
|
|
334
|
-
rip server -
|
|
335
|
-
rip server -
|
|
336
|
-
rip server -
|
|
360
|
+
rip server -c -f hosts.rip
|
|
361
|
+
rip server -i Show server status
|
|
362
|
+
rip server -l List registered hosts
|
|
363
|
+
rip server -r Reload config
|
|
364
|
+
rip server -s Stop server
|
|
365
|
+
rip server -f hosts.rip
|
|
337
366
|
|
|
338
367
|
If no index.rip or index.ts is found, a built-in static file server
|
|
339
368
|
activates with directory indexes, auto-detected MIME types, and
|
|
@@ -356,21 +385,7 @@ export stopServer = (prefix, getPidFilePathFn, existsSyncFn, readFileSyncFn, kil
|
|
|
356
385
|
catch e
|
|
357
386
|
console.error "rip-server: stop failed: #{e.message}"
|
|
358
387
|
|
|
359
|
-
export
|
|
360
|
-
return false unless 'list' in argv
|
|
361
|
-
controlUnix = getControlSocketPathFn(prefix)
|
|
362
|
-
try
|
|
363
|
-
res = fetchFn!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
|
|
364
|
-
throw new Error("list failed: #{res.status}") unless res.ok
|
|
365
|
-
j = res.json!
|
|
366
|
-
hosts = if Array.isArray(j?.hosts) then j.hosts else []
|
|
367
|
-
p if hosts.length then hosts.join('\n') else '(no hosts)'
|
|
368
|
-
catch e
|
|
369
|
-
console.error "list command failed: #{e?.message or e}"
|
|
370
|
-
exitFn(1)
|
|
371
|
-
true
|
|
372
|
-
|
|
373
|
-
export runReloadSubcommand = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
388
|
+
export reloadConfig = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
374
389
|
controlUnix = getControlSocketPathFn(prefix)
|
|
375
390
|
try
|
|
376
391
|
res = fetchFn!('http://localhost/reload', { unix: controlUnix, method: 'POST' })
|
|
@@ -384,3 +399,43 @@ export runReloadSubcommand = (prefix, getControlSocketPathFn, fetchFn, exitFn) -
|
|
|
384
399
|
catch e
|
|
385
400
|
console.error "rip-server: reload failed: #{e?.message or e}"
|
|
386
401
|
exitFn(1)
|
|
402
|
+
|
|
403
|
+
export listApps = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
404
|
+
controlUnix = getControlSocketPathFn(prefix)
|
|
405
|
+
try
|
|
406
|
+
res = fetchFn!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
|
|
407
|
+
throw new Error("list failed: #{res.status}") unless res.ok
|
|
408
|
+
j = res.json!
|
|
409
|
+
hosts = if Array.isArray(j?.hosts) then j.hosts else []
|
|
410
|
+
p if hosts.length then hosts.join('\n') else '(no hosts)'
|
|
411
|
+
catch e
|
|
412
|
+
console.error "rip-server: list failed: #{e?.message or e}"
|
|
413
|
+
exitFn(1)
|
|
414
|
+
|
|
415
|
+
formatUptime = (seconds) ->
|
|
416
|
+
return "#{seconds}s" if seconds < 60
|
|
417
|
+
return "#{Math.floor(seconds / 60)}m" if seconds < 3600
|
|
418
|
+
hours = Math.floor(seconds / 3600)
|
|
419
|
+
mins = Math.floor((seconds %% 3600) / 60)
|
|
420
|
+
if mins > 0 then "#{hours}h #{mins}m" else "#{hours}h"
|
|
421
|
+
|
|
422
|
+
export showInfo = (fetchFn, exitFn) ->
|
|
423
|
+
try
|
|
424
|
+
res = fetchFn!('https://localhost/diagnostics', { tls: { rejectUnauthorized: false } })
|
|
425
|
+
throw new Error("status #{res.status}") unless res.ok
|
|
426
|
+
d = res.json!
|
|
427
|
+
version = d.version?.server or 'unknown'
|
|
428
|
+
workers = d.gauges?.workersActive or 0
|
|
429
|
+
hosts = d.hosts?.length or 0
|
|
430
|
+
upstreamsTotal = d.proxies?.length or 0
|
|
431
|
+
healthy = d.gauges?.upstreamTargetsHealthy or 0
|
|
432
|
+
unhealthy = d.gauges?.upstreamTargetsUnhealthy or 0
|
|
433
|
+
upstreamStatus = if unhealthy > 0 then "#{healthy} healthy, #{unhealthy} unhealthy" else "#{healthy} healthy"
|
|
434
|
+
upstreamPart = if upstreamsTotal > 0 then " | #{upstreamsTotal} proxy (#{upstreamStatus})" else ''
|
|
435
|
+
uptime = formatUptime(d.uptime or 0)
|
|
436
|
+
status = d.status or 'unknown'
|
|
437
|
+
p "rip server v#{version} | #{status} | #{workers} workers | #{hosts} hosts#{upstreamPart} | uptime #{uptime}"
|
|
438
|
+
exitFn(if status is 'healthy' then 0 else 1)
|
|
439
|
+
catch e
|
|
440
|
+
console.error "rip-server: info failed: #{e?.message or e}"
|
|
441
|
+
exitFn(1)
|
package/control/lifecycle.rip
CHANGED
|
@@ -25,11 +25,76 @@ export computeHideUrls = (appEntry, joinFn, dirnameFn, existsSyncFn) ->
|
|
|
25
25
|
existsSyncFn(setupFile)
|
|
26
26
|
|
|
27
27
|
export logStartupSummary = (server, flags, buildRipdevUrlFn, formatPortFn) ->
|
|
28
|
+
return if flags.quiet
|
|
29
|
+
|
|
30
|
+
d = '\x1b[2m'
|
|
31
|
+
r = '\x1b[0m'
|
|
32
|
+
b = '\x1b[1m'
|
|
33
|
+
c = '\x1b[36m'
|
|
34
|
+
g = '\x1b[32m'
|
|
35
|
+
y = '\x1b[33m'
|
|
36
|
+
|
|
28
37
|
httpOnly = flags.httpsPort is null
|
|
29
38
|
protocol = if httpOnly then 'http' else 'https'
|
|
30
39
|
port = flags.httpsPort or flags.httpPort or 80
|
|
31
40
|
url = buildRipdevUrlFn(flags.appName, protocol, port, formatPortFn)
|
|
32
|
-
|
|
41
|
+
|
|
42
|
+
configInfo = server.servingRuntime?.configInfo
|
|
43
|
+
certs = configInfo?.certs or {}
|
|
44
|
+
routeDescs = configInfo?.activeRouteDescriptions or []
|
|
45
|
+
streamRoutes = server.streamRuntime?.routeTable?.routes or []
|
|
46
|
+
|
|
47
|
+
hosts = []
|
|
48
|
+
for route in streamRoutes
|
|
49
|
+
sni = route.sni or route.id
|
|
50
|
+
hosts.push { name: sni, action: 'passthrough', tls: false }
|
|
51
|
+
for desc in routeDescs
|
|
52
|
+
parts = desc.split(' ')
|
|
53
|
+
host = parts[0]
|
|
54
|
+
raw = parts.slice(2).join(' ')
|
|
55
|
+
action = if raw.startsWith('proxy:') then "proxy #{d}→#{r} #{raw.slice(6)}"
|
|
56
|
+
else if raw.startsWith('app:') then 'app'
|
|
57
|
+
else if raw.startsWith('static') then 'static'
|
|
58
|
+
else raw
|
|
59
|
+
hosts.push { name: host, action, tls: !!certs[host] }
|
|
60
|
+
|
|
61
|
+
upstreamList = []
|
|
62
|
+
pool = server.servingRuntime?.upstreamPool
|
|
63
|
+
if pool
|
|
64
|
+
for [id, upstream] as pool.upstreams
|
|
65
|
+
targets = upstream.targets.map((t) -> t.url).join(', ')
|
|
66
|
+
upstreamList.push { id, targets }
|
|
67
|
+
|
|
68
|
+
maxHost = 0
|
|
69
|
+
maxHost = Math.max(maxHost, h.name.length) for h in hosts
|
|
70
|
+
|
|
71
|
+
lines = []
|
|
72
|
+
lines.push ''
|
|
73
|
+
lines.push " #{b}rip server#{r} #{d}v#{server.serverVersion}#{r}"
|
|
74
|
+
lines.push ''
|
|
75
|
+
|
|
76
|
+
if configInfo?.path
|
|
77
|
+
lines.push " #{d}config#{r} #{configInfo.path}"
|
|
78
|
+
lines.push " #{d}workers#{r} #{flags.workers}"
|
|
79
|
+
|
|
80
|
+
if hosts.length > 0
|
|
81
|
+
lines.push ''
|
|
82
|
+
for h in hosts
|
|
83
|
+
scheme = if h.tls or h.action is 'passthrough' then 'https://' else 'http://'
|
|
84
|
+
pad = ' '.repeat(maxHost - h.name.length + 2)
|
|
85
|
+
tls = if h.tls then " #{g}✓#{r}#{d}tls#{r}" else ''
|
|
86
|
+
lines.push " #{c}#{scheme}#{h.name}#{r}#{pad}#{d}#{h.action}#{r}#{tls}"
|
|
87
|
+
|
|
88
|
+
if upstreamList.length > 0
|
|
89
|
+
lines.push ''
|
|
90
|
+
for u in upstreamList
|
|
91
|
+
lines.push " #{d}↑#{r} #{y}#{u.id}#{r} #{d}→#{r} #{u.targets}"
|
|
92
|
+
|
|
93
|
+
lines.push ''
|
|
94
|
+
lines.push " #{g}#{b}→#{r} #{g}#{url}#{r}"
|
|
95
|
+
lines.push ''
|
|
96
|
+
|
|
97
|
+
p lines.join('\n')
|
|
33
98
|
|
|
34
99
|
FORCED_EXIT_MS = 35000
|
|
35
100
|
|
|
@@ -38,6 +103,7 @@ export createCleanup = (pidFile, server, manager, unlinkSyncFn, fetchFn, process
|
|
|
38
103
|
->
|
|
39
104
|
return if called
|
|
40
105
|
called = true
|
|
106
|
+
process.stdout.write('\r\x1b[K')
|
|
41
107
|
p 'rip-server: shutting down...'
|
|
42
108
|
|
|
43
109
|
# Safety net: force exit if graceful shutdown stalls
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/manager.rip — worker pool manager
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { existsSync, statSync, unlinkSync, watch, utimesSync } from 'node:fs'
|
|
6
|
+
import { basename, dirname, join } from 'node:path'
|
|
7
|
+
import { spawnTrackedWorker, postWorkerQuit, waitForWorkerReady } from './workers.rip'
|
|
8
|
+
import { shouldTriggerCodeReload, registerAppWatchDirs } from './watchers.rip'
|
|
9
|
+
import { logEvent } from './lifecycle.rip'
|
|
10
|
+
|
|
11
|
+
MAX_BACKOFF_MS = 30000
|
|
12
|
+
MAX_RESTART_COUNT = 10
|
|
13
|
+
STABILITY_THRESHOLD_MS = 60000
|
|
14
|
+
|
|
15
|
+
nowMs = -> Date.now()
|
|
16
|
+
|
|
17
|
+
export class Manager
|
|
18
|
+
constructor: (@flags, @serverPath) ->
|
|
19
|
+
@appWorkers = new Map()
|
|
20
|
+
@shuttingDown = false
|
|
21
|
+
@lastCheck = 0
|
|
22
|
+
@currentMtimes = new Map()
|
|
23
|
+
@rollingApps = new Set()
|
|
24
|
+
@lastRollAt = new Map()
|
|
25
|
+
@nextWorkerId = -1
|
|
26
|
+
@retiringIds = new Set()
|
|
27
|
+
@restartBudgets = new Map()
|
|
28
|
+
@deferredDeaths = new Set()
|
|
29
|
+
@currentVersion = 1
|
|
30
|
+
@server = null
|
|
31
|
+
@dbUrl = null
|
|
32
|
+
@appWatchers = new Map()
|
|
33
|
+
@codeWatchers = new Map()
|
|
34
|
+
@defaultAppId = @flags.appName
|
|
35
|
+
|
|
36
|
+
getWorkers: (appId) ->
|
|
37
|
+
appId = appId or @defaultAppId
|
|
38
|
+
unless @appWorkers.has(appId)
|
|
39
|
+
@appWorkers.set(appId, [])
|
|
40
|
+
@appWorkers.get(appId)
|
|
41
|
+
|
|
42
|
+
getAppState: (appId) ->
|
|
43
|
+
@server?.appRegistry?.apps?.get(appId) or null
|
|
44
|
+
|
|
45
|
+
allAppStates: ->
|
|
46
|
+
return [] unless @server?.appRegistry?.apps
|
|
47
|
+
Array.from(@server.appRegistry.apps.values())
|
|
48
|
+
|
|
49
|
+
runSetupForApp: (app) ->
|
|
50
|
+
setupFile = join(app.config.appBaseDir or dirname(app.config.entry or @flags.appEntry), 'setup.rip')
|
|
51
|
+
return unless existsSync(setupFile)
|
|
52
|
+
|
|
53
|
+
dbUrl = process.env.DB_URL or 'http://localhost:4213'
|
|
54
|
+
dbAlreadyRunning = try
|
|
55
|
+
fetch! dbUrl + '/health'
|
|
56
|
+
true
|
|
57
|
+
catch
|
|
58
|
+
false
|
|
59
|
+
@dbUrl = dbUrl unless dbAlreadyRunning
|
|
60
|
+
|
|
61
|
+
setupEnv = Object.assign {}, process.env, app.config.env or {},
|
|
62
|
+
RIP_SETUP_MODE: '1'
|
|
63
|
+
RIP_SETUP_FILE: setupFile
|
|
64
|
+
APP_ID: String(app.appId)
|
|
65
|
+
APP_ENTRY: app.config.entry or @flags.appEntry
|
|
66
|
+
APP_BASE_DIR: app.config.appBaseDir or @flags.appBaseDir
|
|
67
|
+
proc = Bun.spawn ['rip', @serverPath],
|
|
68
|
+
stdout: 'inherit'
|
|
69
|
+
stderr: 'inherit'
|
|
70
|
+
stdin: 'ignore'
|
|
71
|
+
cwd: process.cwd()
|
|
72
|
+
env: setupEnv
|
|
73
|
+
|
|
74
|
+
code = await proc.exited
|
|
75
|
+
if code isnt 0
|
|
76
|
+
console.error "rip-server: setup exited with code #{code} for app #{app.appId}"
|
|
77
|
+
exit 1
|
|
78
|
+
|
|
79
|
+
start: ->
|
|
80
|
+
@stop!
|
|
81
|
+
|
|
82
|
+
for app in @allAppStates()
|
|
83
|
+
@runSetupForApp!(app) if app.config.entry
|
|
84
|
+
|
|
85
|
+
for app in @allAppStates()
|
|
86
|
+
workers = @getWorkers(app.appId)
|
|
87
|
+
workers.length = 0
|
|
88
|
+
desiredWorkers = app.config.workers or @flags.workers
|
|
89
|
+
for i in [0...desiredWorkers]
|
|
90
|
+
w = @spawnWorker!(app.appId, @currentVersion)
|
|
91
|
+
workers.push(w)
|
|
92
|
+
|
|
93
|
+
if @flags.reload
|
|
94
|
+
for app in @allAppStates()
|
|
95
|
+
@currentMtimes.set(app.appId, @getEntryMtime(app.appId))
|
|
96
|
+
|
|
97
|
+
interval = setInterval =>
|
|
98
|
+
return clearInterval(interval) if @shuttingDown
|
|
99
|
+
now = Date.now()
|
|
100
|
+
return if now - @lastCheck < 100
|
|
101
|
+
@lastCheck = now
|
|
102
|
+
for app in @allAppStates()
|
|
103
|
+
appId = app.appId
|
|
104
|
+
mt = @getEntryMtime(appId)
|
|
105
|
+
previous = @currentMtimes.get(appId) or 0
|
|
106
|
+
if mt > previous
|
|
107
|
+
continue if @rollingApps.has(appId)
|
|
108
|
+
continue if now - (@lastRollAt.get(appId) or 0) < 200
|
|
109
|
+
@currentMtimes.set(appId, mt)
|
|
110
|
+
@rollingApps.add(appId)
|
|
111
|
+
@lastRollAt.set(appId, now)
|
|
112
|
+
@rollingRestart(appId).finally => @rollingApps.delete(appId)
|
|
113
|
+
, 50
|
|
114
|
+
|
|
115
|
+
if @flags.watch
|
|
116
|
+
for app in @allAppStates()
|
|
117
|
+
continue unless app.config.entry and app.config.appBaseDir
|
|
118
|
+
entryFile = app.config.entry
|
|
119
|
+
entryBase = basename(entryFile)
|
|
120
|
+
try
|
|
121
|
+
watcher = watch app.config.appBaseDir, { recursive: true }, (event, filename) =>
|
|
122
|
+
return unless shouldTriggerCodeReload(filename, @flags.watch, entryBase)
|
|
123
|
+
try
|
|
124
|
+
now = new Date()
|
|
125
|
+
utimesSync(entryFile, now, now)
|
|
126
|
+
catch
|
|
127
|
+
null
|
|
128
|
+
@codeWatchers.set(app.appId, watcher)
|
|
129
|
+
catch e
|
|
130
|
+
warn "rip-server: directory watch failed for #{app.appId}: #{e.message}"
|
|
131
|
+
|
|
132
|
+
stop: ->
|
|
133
|
+
for [appId, workers] as @appWorkers
|
|
134
|
+
for w in workers
|
|
135
|
+
try w.process.kill()
|
|
136
|
+
try unlinkSync(w.socketPath)
|
|
137
|
+
@appWorkers.clear()
|
|
138
|
+
|
|
139
|
+
for [prefix, entry] as @appWatchers
|
|
140
|
+
clearTimeout(entry.timer) if entry.timer
|
|
141
|
+
for watcher in (entry.watchers or [])
|
|
142
|
+
try watcher.close()
|
|
143
|
+
@appWatchers.clear()
|
|
144
|
+
for [appId, watcher] as @codeWatchers
|
|
145
|
+
try watcher.close()
|
|
146
|
+
@codeWatchers.clear()
|
|
147
|
+
|
|
148
|
+
watchDirs: (prefix, dirs) ->
|
|
149
|
+
registerAppWatchDirs(@appWatchers, prefix, dirs, @server, (-> process.cwd()))
|
|
150
|
+
|
|
151
|
+
spawnWorker: (appId, version) ->
|
|
152
|
+
workerId = ++@nextWorkerId
|
|
153
|
+
app = @getAppState(appId) or { appId: appId, config: {} }
|
|
154
|
+
tracked = spawnTrackedWorker(@flags, workerId, (version or @currentVersion), nowMs, @serverPath, appId, app.config)
|
|
155
|
+
@monitor(tracked)
|
|
156
|
+
tracked
|
|
157
|
+
|
|
158
|
+
monitor: (w) ->
|
|
159
|
+
w.process.exited.then =>
|
|
160
|
+
return if @shuttingDown
|
|
161
|
+
return if @retiringIds.has(w.id)
|
|
162
|
+
if @rollingApps.has(w.appId)
|
|
163
|
+
@deferredDeaths.add(w.id)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
postWorkerQuit(@flags.socketPrefix, w.id)
|
|
167
|
+
|
|
168
|
+
workers = @getWorkers(w.appId)
|
|
169
|
+
slotIdx = workers.findIndex((x) -> x.id is w.id)
|
|
170
|
+
return if slotIdx < 0
|
|
171
|
+
|
|
172
|
+
budget = @restartBudgets.get(slotIdx) or { count: 0, backoffMs: 1000 }
|
|
173
|
+
|
|
174
|
+
if nowMs() - w.startedAt > STABILITY_THRESHOLD_MS
|
|
175
|
+
budget.count = 0
|
|
176
|
+
budget.backoffMs = 1000
|
|
177
|
+
|
|
178
|
+
budget.count++
|
|
179
|
+
budget.backoffMs = Math.min(budget.backoffMs * 2, MAX_BACKOFF_MS)
|
|
180
|
+
@restartBudgets.set(slotIdx, budget)
|
|
181
|
+
|
|
182
|
+
if budget.count > MAX_RESTART_COUNT
|
|
183
|
+
logEvent('worker_abandon', { workerId: w.id, slot: slotIdx, restarts: budget.count })
|
|
184
|
+
return
|
|
185
|
+
@server?.metrics and @server.metrics.workerRestarts++
|
|
186
|
+
logEvent('worker_restart', { workerId: w.id, slot: slotIdx, attempt: budget.count, backoffMs: budget.backoffMs })
|
|
187
|
+
setTimeout =>
|
|
188
|
+
workers[slotIdx] = @spawnWorker(w.appId, @currentVersion) if slotIdx < workers.length
|
|
189
|
+
, budget.backoffMs
|
|
190
|
+
|
|
191
|
+
waitWorkerReady: (socketPath, timeoutMs = 5000) ->
|
|
192
|
+
waitForWorkerReady(socketPath, timeoutMs)
|
|
193
|
+
|
|
194
|
+
rollingRestart: (appId = null) ->
|
|
195
|
+
apps = if appId then [@getAppState(appId)].filter(Boolean) else @allAppStates()
|
|
196
|
+
nextVersion = @currentVersion + 1
|
|
197
|
+
|
|
198
|
+
for app in apps
|
|
199
|
+
workers = @getWorkers(app.appId)
|
|
200
|
+
olds = [...workers]
|
|
201
|
+
pairs = []
|
|
202
|
+
|
|
203
|
+
for oldWorker in olds
|
|
204
|
+
replacement = @spawnWorker!(app.appId, nextVersion)
|
|
205
|
+
workers.push(replacement)
|
|
206
|
+
pairs.push({ old: oldWorker, replacement })
|
|
207
|
+
|
|
208
|
+
readyResults = Promise.all!(pairs.map((p) => @waitWorkerReady(p.replacement.socketPath, 3000)))
|
|
209
|
+
allReady = readyResults.every((ready) -> ready)
|
|
210
|
+
unless allReady
|
|
211
|
+
console.error "[manager] Rolling restart aborted: not all new workers ready for app #{app.appId}"
|
|
212
|
+
for pair, i in pairs
|
|
213
|
+
unless readyResults[i]
|
|
214
|
+
try pair.replacement.process.kill()
|
|
215
|
+
idx = workers.indexOf(pair.replacement)
|
|
216
|
+
workers.splice(idx, 1) if idx >= 0
|
|
217
|
+
if @deferredDeaths.size > 0
|
|
218
|
+
for deadId as @deferredDeaths
|
|
219
|
+
idx = workers.findIndex((x) -> x.id is deadId)
|
|
220
|
+
workers[idx] = @spawnWorker(app.appId, @currentVersion) if idx >= 0
|
|
221
|
+
@deferredDeaths.clear()
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
@currentVersion = nextVersion
|
|
225
|
+
|
|
226
|
+
for { old } in pairs
|
|
227
|
+
@retiringIds.add(old.id)
|
|
228
|
+
try old.process.kill()
|
|
229
|
+
|
|
230
|
+
Promise.all!(pairs.map(({ old }) -> old.process.exited.catch(-> null)))
|
|
231
|
+
|
|
232
|
+
for { old } in pairs
|
|
233
|
+
postWorkerQuit(@flags.socketPrefix, old.id)
|
|
234
|
+
|
|
235
|
+
retiring = new Set(pairs.map((p) -> p.old.id))
|
|
236
|
+
filtered = workers.filter((w) -> not retiring.has(w.id))
|
|
237
|
+
workers.length = 0
|
|
238
|
+
workers.push(...filtered)
|
|
239
|
+
@retiringIds.delete(id) for id as retiring
|
|
240
|
+
|
|
241
|
+
if @deferredDeaths.size > 0
|
|
242
|
+
for deadId as @deferredDeaths
|
|
243
|
+
idx = workers.findIndex((x) -> x.id is deadId)
|
|
244
|
+
workers[idx] = @spawnWorker(app.appId, @currentVersion) if idx >= 0
|
|
245
|
+
@deferredDeaths.clear()
|
|
246
|
+
|
|
247
|
+
getEntryMtime: (appId = null) ->
|
|
248
|
+
app = @getAppState(appId or @defaultAppId)
|
|
249
|
+
entry = app?.config?.entry or @flags.appEntry
|
|
250
|
+
try statSync(entry).mtimeMs catch then 0
|
package/control/mdns.rip
CHANGED
|
@@ -34,6 +34,9 @@ export startMdnsAdvertisement = (host, mdnsProcesses, getLanIP, flags, formatPor
|
|
|
34
34
|
serviceName = host.replace('.local', '')
|
|
35
35
|
|
|
36
36
|
try
|
|
37
|
+
which = Bun.spawnSync(['which', 'dns-sd'], { stdout: 'ignore', stderr: 'ignore' })
|
|
38
|
+
return if which.exitCode
|
|
39
|
+
|
|
37
40
|
proc = Bun.spawn [
|
|
38
41
|
'dns-sd', '-P'
|
|
39
42
|
serviceName
|
package/middleware.rip
CHANGED
|
@@ -476,7 +476,7 @@ pre{margin:0;white-space:pre-wrap;word-break:break-word}
|
|
|
476
476
|
# dir: string — app directory on disk (default: '.')
|
|
477
477
|
# routes: string — page components directory, relative to dir (default: 'routes')
|
|
478
478
|
# bundle: string[]|object — directories for bundle endpoint(s) (default: ['components'])
|
|
479
|
-
# Array: single bundle at {prefix}/bundle
|
|
479
|
+
# Array: single bundle at {prefix}/bundle
|
|
480
480
|
# Object: named bundles — { ui: [...], app: [...] } → {prefix}/ui, {prefix}/app
|
|
481
481
|
# app: string — URL mount point (default: '')
|
|
482
482
|
# title: string — document title
|