@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.
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 +89 -34
  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
@@ -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: has('--acme')
221
- acmeStaging: has('--acme-staging')
222
- 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')
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
- edgefilePath: getKV('--edgefile=') or getKV('--file=') or getKV('-f=')
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 Edgefile.rip or config.rip and exit
296
- -r, --reload Reload config on a running server
297
- -s, --stop Stop a running server
298
- -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
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=<path> TLS certificate file (overrides shipped cert)
310
- --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)
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 Enable auto TLS via Let's Encrypt
316
- --acme-staging Use Let's Encrypt staging CA (for testing)
317
- --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
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=./Edgefile.rip
334
- rip server -r Reload running server config
335
- rip server -s Stop running server
336
- 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
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 runListSubcommand = (argv, prefix, getControlSocketPathFn, fetchFn, exitFn) ->
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)
@@ -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