@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.
Files changed (69) hide show
  1. package/{docs/READ_VALIDATORS.md → API.md} +41 -119
  2. package/CONFIG.md +408 -0
  3. package/README.md +246 -1109
  4. package/acme/crypto.rip +0 -2
  5. package/browse.rip +62 -0
  6. package/control/cli.rip +95 -36
  7. package/control/lifecycle.rip +67 -1
  8. package/control/manager.rip +250 -0
  9. package/control/mdns.rip +3 -0
  10. package/middleware.rip +1 -1
  11. package/package.json +14 -11
  12. package/server.rip +189 -673
  13. package/serving/config.rip +766 -0
  14. package/{edge → serving}/forwarding.rip +2 -2
  15. package/serving/logging.rip +101 -0
  16. package/{edge → serving}/metrics.rip +29 -1
  17. package/serving/proxy.rip +99 -0
  18. package/{edge → serving}/queue.rip +1 -1
  19. package/{edge → serving}/ratelimit.rip +1 -1
  20. package/{edge → serving}/realtime.rip +71 -2
  21. package/{edge → serving}/registry.rip +1 -1
  22. package/{edge → serving}/router.rip +3 -3
  23. package/{edge → serving}/runtime.rip +18 -16
  24. package/{edge → serving}/security.rip +1 -1
  25. package/serving/static.rip +393 -0
  26. package/{edge → serving}/tls.rip +3 -7
  27. package/{edge → serving}/upstream.rip +4 -4
  28. package/{edge → serving}/verify.rip +16 -16
  29. package/streams/{tls_clienthello.rip → clienthello.rip} +1 -1
  30. package/streams/config.rip +8 -8
  31. package/streams/index.rip +5 -5
  32. package/streams/router.rip +2 -2
  33. package/tests/acme.rip +1 -1
  34. package/tests/config.rip +215 -0
  35. package/tests/control.rip +1 -1
  36. package/tests/{runtime_entrypoints.rip → entrypoints.rip} +11 -7
  37. package/tests/extracted.rip +118 -0
  38. package/tests/helpers.rip +4 -4
  39. package/tests/metrics.rip +3 -3
  40. package/tests/proxy.rip +9 -8
  41. package/tests/read.rip +1 -1
  42. package/tests/realtime.rip +3 -3
  43. package/tests/registry.rip +4 -4
  44. package/tests/router.rip +27 -27
  45. package/tests/runner.rip +70 -0
  46. package/tests/security.rip +4 -4
  47. package/tests/servers.rip +102 -136
  48. package/tests/static.rip +2 -2
  49. package/tests/streams_clienthello.rip +2 -2
  50. package/tests/streams_index.rip +4 -4
  51. package/tests/streams_pipe.rip +1 -1
  52. package/tests/streams_router.rip +10 -10
  53. package/tests/streams_runtime.rip +4 -4
  54. package/tests/streams_upstream.rip +1 -1
  55. package/tests/upstream.rip +2 -2
  56. package/tests/verify.rip +18 -18
  57. package/tests/watchers.rip +4 -4
  58. package/default.rip +0 -435
  59. package/docs/edge/CONFIG_LIFECYCLE.md +0 -111
  60. package/docs/edge/CONTRACTS.md +0 -137
  61. package/docs/edge/EDGEFILE_CONTRACT.md +0 -282
  62. package/docs/edge/M0B_REVIEW_NOTES.md +0 -102
  63. package/docs/edge/SCHEDULER.md +0 -46
  64. package/docs/logo.png +0 -0
  65. package/docs/logo.svg +0 -13
  66. package/docs/social.png +0 -0
  67. package/edge/config.rip +0 -607
  68. package/edge/static.rip +0 -69
  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
- else if not pathPart.includes('/') and not pathPart.startsWith('.')
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
- for f as rawFlags
127
- return f.slice(prefix.length) if f.startsWith(prefix)
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: has('--acme')
217
- acmeStaging: has('--acme-staging')
218
- acmeDomain: getKV('--acme-domain=')
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
- edgefilePath: getKV('--edgefile=') or getKV('--file=') or getKV('-f=')
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 Edgefile.rip or config.rip and exit
292
- -r, --reload Reload config on a running server
293
- -s, --stop Stop a running server
294
- -f, --file=<path> Load Edgefile.rip from an explicit path
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=<path> TLS certificate file (overrides shipped cert)
306
- --key=<path> TLS key file (overrides shipped cert)
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 Enable auto TLS via Let's Encrypt
312
- --acme-staging Use Let's Encrypt staging CA (for testing)
313
- --acme-domain=<domain> Domain for ACME cert (required with --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=./Edgefile.rip
330
- rip server -r Reload running server config
331
- rip server -s Stop running server
332
- rip server -f=./Edgefile.rip
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 runListSubcommand = (argv, prefix, getControlSocketPathFn, fetchFn, exitFn) ->
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)
@@ -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
- p "rip-server: rip=#{server.ripVersion} server=#{server.serverVersion} app=#{flags.appName} workers=#{flags.workers} url=#{url}/server"
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 (legacy)
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