@rip-lang/server 1.3.125 → 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 +95 -36
- 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
|
|
@@ -123,8 +145,12 @@ export parseFlags = (argv, defaultEntryPath = null) ->
|
|
|
123
145
|
appPathInput = cwd
|
|
124
146
|
|
|
125
147
|
getKV = (prefix) ->
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
tokens = Array.from(rawFlags)
|
|
149
|
+
bare = prefix.slice(0, -1)
|
|
150
|
+
for i in [0...tokens.length]
|
|
151
|
+
return tokens[i].slice(prefix.length) if tokens[i].startsWith(prefix)
|
|
152
|
+
if tokens[i] is bare and tokens[i + 1] and not tokens[i + 1].startsWith('-')
|
|
153
|
+
return tokens[i + 1]
|
|
128
154
|
undefined
|
|
129
155
|
|
|
130
156
|
has = (name) -> rawFlags.has(name)
|
|
@@ -213,17 +239,20 @@ export parseFlags = (argv, defaultEntryPath = null) ->
|
|
|
213
239
|
readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 30000))
|
|
214
240
|
jsonLogging: has('--json-logging')
|
|
215
241
|
accessLog: not has('--no-access-log')
|
|
216
|
-
acme:
|
|
217
|
-
acmeStaging:
|
|
218
|
-
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')
|
|
219
246
|
realtimePath: getKV('--realtime-path=') or '/realtime'
|
|
220
247
|
publishSecret: getKV('--publish-secret=') or process.env.RIP_PUBLISH_SECRET or null
|
|
221
248
|
rateLimit: coerceInt(getKV('--rate-limit='), 0)
|
|
222
249
|
rateLimitWindow: coerceInt(getKV('--rate-limit-window='), 60000)
|
|
223
250
|
watch: watch
|
|
224
|
-
|
|
251
|
+
configPath: getKV('--file=') or getKV('-f=')
|
|
225
252
|
checkConfig: has('--check-config') or has('--check') or has('-c')
|
|
253
|
+
showInfo: has('--info') or has('-i')
|
|
226
254
|
reloadConfig: has('--reload') or has('-r')
|
|
255
|
+
listApps: has('--list') or has('-l')
|
|
227
256
|
stopServer: has('--stop') or has('-s')
|
|
228
257
|
envOverride: envOverride
|
|
229
258
|
debug: has('--debug')
|
|
@@ -288,10 +317,12 @@ export runHelpOutput = (argv) ->
|
|
|
288
317
|
Options:
|
|
289
318
|
-h, --help Show this help
|
|
290
319
|
-v, --version Show version
|
|
291
|
-
-c, --check Validate
|
|
292
|
-
-
|
|
293
|
-
-
|
|
294
|
-
-
|
|
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
|
|
295
326
|
--watch=<glob> Watch glob pattern (default: *.rip)
|
|
296
327
|
--static Disable hot reload and file watching
|
|
297
328
|
--env=<mode> Set environment (dev, production)
|
|
@@ -302,15 +333,15 @@ export runHelpOutput = (argv) ->
|
|
|
302
333
|
http HTTP only (no TLS)
|
|
303
334
|
https HTTPS with trusted *.ripdev.io cert (default)
|
|
304
335
|
<port> Listen on specific port
|
|
305
|
-
--cert
|
|
306
|
-
--key
|
|
336
|
+
--cert <path> TLS certificate file (overrides shipped cert)
|
|
337
|
+
--key <path> TLS key file (overrides shipped cert)
|
|
307
338
|
--hsts Enable HSTS header
|
|
308
339
|
--no-redirect-http Don't redirect HTTP to HTTPS
|
|
309
340
|
|
|
310
341
|
ACME (auto TLS):
|
|
311
|
-
--acme
|
|
312
|
-
--acme-staging
|
|
313
|
-
--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
|
|
314
345
|
|
|
315
346
|
Realtime (WebSocket):
|
|
316
347
|
--realtime-path=<path> WebSocket endpoint path (default: /realtime)
|
|
@@ -326,10 +357,12 @@ export runHelpOutput = (argv) ->
|
|
|
326
357
|
rip server http HTTP only (no TLS)
|
|
327
358
|
rip server --static w:8 Production: no reload, 8 workers
|
|
328
359
|
rip server -c Validate config and exit
|
|
329
|
-
rip server -c -f
|
|
330
|
-
rip server -
|
|
331
|
-
rip server -
|
|
332
|
-
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
|
|
333
366
|
|
|
334
367
|
If no index.rip or index.ts is found, a built-in static file server
|
|
335
368
|
activates with directory indexes, auto-detected MIME types, and
|
|
@@ -352,21 +385,7 @@ export stopServer = (prefix, getPidFilePathFn, existsSyncFn, readFileSyncFn, kil
|
|
|
352
385
|
catch e
|
|
353
386
|
console.error "rip-server: stop failed: #{e.message}"
|
|
354
387
|
|
|
355
|
-
export
|
|
356
|
-
return false unless 'list' in argv
|
|
357
|
-
controlUnix = getControlSocketPathFn(prefix)
|
|
358
|
-
try
|
|
359
|
-
res = fetchFn!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
|
|
360
|
-
throw new Error("list failed: #{res.status}") unless res.ok
|
|
361
|
-
j = res.json!
|
|
362
|
-
hosts = if Array.isArray(j?.hosts) then j.hosts else []
|
|
363
|
-
p if hosts.length then hosts.join('\n') else '(no hosts)'
|
|
364
|
-
catch e
|
|
365
|
-
console.error "list command failed: #{e?.message or e}"
|
|
366
|
-
exitFn(1)
|
|
367
|
-
true
|
|
368
|
-
|
|
369
|
-
export runReloadSubcommand = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
388
|
+
export reloadConfig = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
370
389
|
controlUnix = getControlSocketPathFn(prefix)
|
|
371
390
|
try
|
|
372
391
|
res = fetchFn!('http://localhost/reload', { unix: controlUnix, method: 'POST' })
|
|
@@ -380,3 +399,43 @@ export runReloadSubcommand = (prefix, getControlSocketPathFn, fetchFn, exitFn) -
|
|
|
380
399
|
catch e
|
|
381
400
|
console.error "rip-server: reload failed: #{e?.message or e}"
|
|
382
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
|