@rip-lang/server 1.4.15 → 1.4.16

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/control/cli.rip CHANGED
@@ -254,6 +254,7 @@ export parseFlags = (argv, defaultEntryPath = null) ->
254
254
  reloadConfig: has('--reload') or has('-r')
255
255
  listApps: has('--list') or has('-l')
256
256
  stopServer: has('--stop') or has('-s')
257
+ restartServer: has('--restart')
257
258
  envOverride: envOverride
258
259
  debug: has('--debug')
259
260
  }
@@ -322,6 +323,7 @@ export runHelpOutput = (argv) ->
322
323
  -i, --info Show running server status
323
324
  -l, --list List registered hosts
324
325
  -r, --reload Reload config on running server
326
+ --restart Hot restart (rebuild everything, keep listeners)
325
327
  -s, --stop Stop running server
326
328
  --watch=<glob> Watch glob pattern (default: *.rip)
327
329
  --static Disable hot reload and file watching
@@ -400,6 +402,21 @@ export reloadConfig = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
400
402
  warn "rip-server: reload failed: #{e?.message or e}"
401
403
  exitFn(1)
402
404
 
405
+ export restartServer = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
406
+ controlUnix = getControlSocketPathFn(prefix)
407
+ try
408
+ res = fetchFn!('http://localhost/restart', { unix: controlUnix, method: 'POST' })
409
+ j = res.json!
410
+ if j?.ok
411
+ p "rip-server: hot restart complete"
412
+ else
413
+ reason = j?.error or 'unknown'
414
+ warn "rip-server: restart failed: #{reason}"
415
+ exitFn(1)
416
+ catch e
417
+ warn "rip-server: restart failed: #{e?.message or e}"
418
+ exitFn(1)
419
+
403
420
  export listApps = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
404
421
  controlUnix = getControlSocketPathFn(prefix)
405
422
  try
@@ -244,6 +244,17 @@ export class Manager
244
244
  workers[idx] = @spawnWorker(app.appId, @currentVersion) if idx >= 0
245
245
  @deferredDeaths.clear()
246
246
 
247
+ teardown: ->
248
+ @shuttingDown = true
249
+ @stop!
250
+ @shuttingDown = false
251
+ @currentMtimes.clear()
252
+ @rollingApps.clear()
253
+ @lastRollAt.clear()
254
+ @retiringIds.clear()
255
+ @restartBudgets.clear()
256
+ @deferredDeaths.clear()
257
+
247
258
  getEntryMtime: (appId = null) ->
248
259
  app = @getAppState(appId or @defaultAppId)
249
260
  entry = app?.config?.entry or @flags.appEntry
@@ -63,6 +63,10 @@ export runWorkerMode = ->
63
63
  workerState.handler or (-> new Response('not ready', { status: 503 }))
64
64
  catch e
65
65
  warn "[worker #{workerId}] import failed:", e
66
+ if typeof e.formatHTML is 'function'
67
+ html = "<!DOCTYPE html><html><head><meta charset=utf-8><title>Rip Compile Error</title><style>body{margin:40px auto;max-width:900px;background:#1e1e2e;color:#cdd6f4;font-family:system-ui,sans-serif}</style></head><body>#{e.formatHTML()}</body></html>"
68
+ workerState.handler = -> new Response(html, { status: 500, headers: { 'content-type': 'text/html; charset=UTF-8' } })
69
+ return workerState.handler
66
70
  workerState.handler or (-> new Response('not ready', { status: 503 }))
67
71
 
68
72
  selfJoin = ->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/server",
3
- "version": "1.4.15",
3
+ "version": "1.4.16",
4
4
  "description": "Bun-native content server: static sites, apps, HTTP proxy, and TCP/TLS passthrough",
5
5
  "type": "module",
6
6
  "main": "api.rip",
@@ -47,7 +47,7 @@
47
47
  "author": "Steve Shreeve <steve.shreeve@gmail.com>",
48
48
  "license": "MIT",
49
49
  "dependencies": {
50
- "rip-lang": ">=3.13.134"
50
+ "rip-lang": ">=3.13.135"
51
51
  },
52
52
  "files": [
53
53
  "api.rip",
package/server.rip CHANGED
@@ -40,7 +40,7 @@ import { getControlSocketPath, getPidFilePath, handleWorkerControl, handleWatchC
40
40
  import { getLanIP as controlGetLanIP, startMdnsAdvertisement as controlStartMdnsAdvertisement, stopMdnsAdvertisements as controlStopMdnsAdvertisements } from './control/mdns.rip'
41
41
  import { registerWatchGroup, handleWatchGroup, broadcastWatchChange } from './control/watchers.rip'
42
42
  import { computeHideUrls, logStartupSummary, createCleanup, installShutdownHandlers, setEventJsonMode, logEvent } from './control/lifecycle.rip'
43
- import { runVersionOutput, runHelpOutput, stopServer, reloadConfig, listApps, showInfo, parseFlags } from './control/cli.rip'
43
+ import { runVersionOutput, runHelpOutput, stopServer, restartServer, reloadConfig, listApps, showInfo, parseFlags } from './control/cli.rip'
44
44
  import { runSetupMode, runWorkerMode } from './control/worker.rip'
45
45
  import { Manager } from './control/manager.rip'
46
46
 
@@ -581,6 +581,47 @@ class Server
581
581
 
582
582
  controlStopMdnsAdvertisements(@mdnsProcesses)
583
583
 
584
+ restart: ->
585
+ logEvent('server_restart', { uptime: Math.floor((nowMs() - @startedAt) / 1000) })
586
+
587
+ # --- Teardown (listeners stay open) ---
588
+ @manager?.teardown!
589
+ stopHealthChecks(@servingRuntime.upstreamPool)
590
+ stopHealthChecks(runtime.upstreamPool) for runtime in @retiredServingRuntimes
591
+ stopStreamListeners(@streamRuntime, true)
592
+ stopStreamListeners(runtime, true) for runtime in @retiredStreamRuntimes
593
+ clearInterval(@queueSweepTimer) if @queueSweepTimer
594
+ try @control?.stop()
595
+ controlStopMdnsAdvertisements(@mdnsProcesses)
596
+
597
+ # Drain queues
598
+ for [appId, app] as @appRegistry.apps
599
+ while app.queue.length > 0
600
+ job = app.queue.shift()
601
+ try job.resolve(new Response('Server restarting', { status: 503, headers: { 'Retry-After': '1' } }))
602
+
603
+ # --- Reload ---
604
+ @tlsMaterial = @loadTlsMaterial()
605
+ @reloadRuntimeConfig!(@flags, 'restart', false)
606
+
607
+ # --- Rebuild ---
608
+ @queueSweepTimer = setInterval =>
609
+ for [appId, app] as @appRegistry.apps
610
+ now = Date.now()
611
+ while app.queue.length > 0 and now - app.queue[0].enqueuedAt > app.queueTimeoutMs
612
+ job = app.queue.shift()
613
+ try job.resolve(new Response('Queue timeout', { status: 504 }))
614
+ @metrics.queueTimeouts++
615
+ , 1000
616
+
617
+ @startControl!
618
+ @startActiveStreamListeners() if @streamListenersDeferred or @streamRuntime.listeners.size is 0
619
+
620
+ # --- Restart workers ---
621
+ @manager?.start!
622
+
623
+ logEvent('server_restart_complete', {})
624
+
584
625
  proxyRouteToUpstream: (req, route, requestId, clientIp, runtime) ->
585
626
  proxyRouteToUpstreamFn!(req, route, requestId, clientIp, runtime, @metrics, @flags, @maybeAddSecurityHeaders.bind(@), @logAccess.bind(@))
586
627
 
@@ -913,6 +954,13 @@ class Server
913
954
  res = handleReloadControl(req, result)
914
955
  return res.response if res.handled
915
956
 
957
+ if req.method is 'POST' and url.pathname is '/restart'
958
+ try
959
+ @restart!
960
+ return new Response(JSON.stringify({ ok: true, restarted: true }), { headers: { 'content-type': 'application/json' } })
961
+ catch e
962
+ return new Response(JSON.stringify({ ok: false, error: e?.message or 'restart failed' }), { status: 500, headers: { 'content-type': 'application/json' } })
963
+
916
964
  if url.pathname is '/registry' and req.method is 'GET'
917
965
  res = handleRegistryControl(req, @appRegistry.hostIndex)
918
966
  return res.response if res.handled
@@ -991,6 +1039,9 @@ main = ->
991
1039
  if flags.listApps
992
1040
  listApps!(flags.socketPrefix, getControlSocketPath, fetch, exit)
993
1041
  return
1042
+ if flags.restartServer
1043
+ restartServer!(flags.socketPrefix, getControlSocketPath, fetch, exit)
1044
+ return
994
1045
  if flags.stopServer
995
1046
  stopServer(flags.socketPrefix, getPidFilePath, existsSync, readFileSync, process.kill.bind(process), Bun.spawnSync, import.meta.path)
996
1047
  return
@@ -5,6 +5,7 @@
5
5
  import { existsSync, statSync } from 'node:fs'
6
6
  import { join, dirname, resolve, basename } from 'node:path'
7
7
  import { normalizeStreams, parseHostPort } from '../streams/config.rip'
8
+ import { scanSslDirectory, matchCertForHost } from './tls.rip'
8
9
 
9
10
  CONFIG_FILE = 'serve.rip'
10
11
  VALID_REDIRECT_STATUSES = new Set([301, 302, 307, 308])
@@ -687,8 +688,225 @@ export normalizeAppConfig = (appId, appConfig, baseDir) ->
687
688
  throw validationError("app #{appId}", errors) if errors.length > 0
688
689
  normalized
689
690
 
691
+ # ==============================================================================
692
+ # serve.rip normalizer: ssl + sites + apps
693
+ # ==============================================================================
694
+
695
+ SERVE_KEYS = new Set(%w[ssl sites apps version server])
696
+
697
+ parseAppSpec = (appName, spec, knownSites, errors, path) ->
698
+ tokens = String(spec or '').trim().split(/\s+/).filter(Boolean)
699
+ unless tokens.length > 0
700
+ pushError(errors, 'E_APP_EMPTY', path, "app #{appName}: empty spec")
701
+ return null
702
+
703
+ targetTokens = tokens.filter (t) -> /^(https?:\/\/|tcp:\/\/|\.\/|\.\.\/|\/)/.test(t)
704
+
705
+ if targetTokens.length > 1
706
+ pushError(errors, 'E_APP_MULTI_TARGET', path, "app #{appName}: multiple targets: #{targetTokens.join(', ')}", 'Only one path or URL prefix is allowed.')
707
+ return null
708
+
709
+ if targetTokens.length is 1
710
+ target = targetTokens[0]
711
+ siteNames = tokens.filter (t) -> t isnt target
712
+ else
713
+ target = '.'
714
+ siteNames = tokens
715
+
716
+ unknown = siteNames.filter (s) -> not knownSites.has(s)
717
+ if unknown.length > 0
718
+ pushError(errors, 'E_APP_UNKNOWN_SITE', path, "app #{appName}: unknown site(s): #{unknown.join(', ')}", "Define these in the sites section.")
719
+ return null
720
+
721
+ kind = if /^https?:\/\//.test(target) then 'http-proxy'
722
+ else if /^tcp:\/\//.test(target) then 'tcp-proxy'
723
+ else 'local'
724
+
725
+ { kind, target, siteNames }
726
+
727
+ normalizeServeRip = (config, baseDir) ->
728
+ errors = []
729
+
730
+ for key of config
731
+ unless SERVE_KEYS.has(key)
732
+ pushError(errors, 'E_UNKNOWN_KEY', key, "unknown top-level key: #{key}", 'Allowed keys: ssl, sites, apps, version, server.')
733
+
734
+ # SSL directory
735
+ sslPath = if typeof config.ssl is 'string' then config.ssl else null
736
+ certMap = new Map()
737
+ if sslPath
738
+ certMap = scanSslDirectory(sslPath)
739
+
740
+ # Sites: siteName -> hostname
741
+ siteToHost = new Map()
742
+ hostToSite = new Map()
743
+ if config.sites?
744
+ unless isPlainObject(config.sites)
745
+ pushError(errors, 'E_SITES_TYPE', 'sites', 'sites must be an object')
746
+ else
747
+ for siteName, hostname of config.sites
748
+ host = String(hostname).toLowerCase().trim()
749
+ unless host
750
+ pushError(errors, 'E_SITE_EMPTY', "sites.#{siteName}", "site #{siteName}: hostname is empty")
751
+ continue
752
+ if hostToSite.has(host)
753
+ pushError(errors, 'E_SITE_DUPLICATE_HOST', "sites.#{siteName}", "hostname #{host} is already assigned to site #{hostToSite.get(host)}")
754
+ continue
755
+ siteToHost.set(String(siteName), host)
756
+ hostToSite.set(host, String(siteName))
757
+
758
+ knownSites = new Set(siteToHost.keys())
759
+
760
+ # Server settings (optional, defaults)
761
+ serverSettings = normalizeServerSettings(config.server or {}, errors)
762
+
763
+ # Apps
764
+ apps = {}
765
+ proxies = {}
766
+ httpProxies = {}
767
+ tcpProxies = {}
768
+ sites = {}
769
+ resolvedCerts = {}
770
+ streamUpstreams = {}
771
+ streams = []
772
+
773
+ if config.apps?
774
+ unless isPlainObject(config.apps)
775
+ pushError(errors, 'E_APPS_TYPE', 'apps', 'apps must be an object')
776
+ else
777
+ for appName, appSpec of config.apps
778
+ unless typeof appSpec is 'string'
779
+ if isPlainObject(appSpec)
780
+ pushError(errors, 'E_APP_OBJECT', "apps.#{appName}", "object app specs are not supported; use a string like 'siteName1 siteName2'")
781
+ else
782
+ pushError(errors, 'E_APP_INVALID', "apps.#{appName}", "app value must be a string")
783
+ continue
784
+
785
+ parsed = parseAppSpec(appName, appSpec, knownSites, errors, "apps.#{appName}")
786
+ continue unless parsed
787
+
788
+ hostnames = parsed.siteNames.map (s) -> siteToHost.get(s)
789
+
790
+ if parsed.kind is 'local'
791
+ entryPath = if parsed.target is '.' then baseDir else resolve(baseDir, parsed.target)
792
+ indexFile = join(entryPath, 'index.rip')
793
+ entry = if existsSync(indexFile) then indexFile else null
794
+
795
+ apps[appName] =
796
+ id: appName
797
+ entry: entry
798
+ hosts: hostnames
799
+ workers: null
800
+ maxQueue: 512
801
+ queueTimeoutMs: 30000
802
+ readTimeoutMs: 30000
803
+ env: {}
804
+
805
+ for hostname in hostnames
806
+ sites[hostname] =
807
+ host: hostname
808
+ timeouts: {}
809
+ routes: [
810
+ id: "#{appName}-default"
811
+ host: hostname
812
+ path: '/'
813
+ methods: '*'
814
+ priority: 0
815
+ proxy: null
816
+ app: appName
817
+ static: null
818
+ root: null
819
+ spa: false
820
+ browse: false
821
+ redirect: null
822
+ headers: null
823
+ websocket: false
824
+ timeouts: {}
825
+ ]
826
+
827
+ cert = matchCertForHost(certMap, hostname) if certMap.size > 0
828
+ resolvedCerts[hostname] = cert if cert
829
+
830
+ else if parsed.kind is 'http-proxy'
831
+ proxyId = "proxy-#{appName}"
832
+ url = new URL(parsed.target)
833
+ proxies[proxyId] =
834
+ id: proxyId
835
+ kind: 'http'
836
+ hosts: ["#{url.protocol}//#{url.host}"]
837
+ targets: [{ url: parsed.target, weight: 1 }]
838
+ healthCheck: null
839
+ retry: { count: 1, delayMs: 0 }
840
+ timeouts: { connectMs: 10000, readMs: 30000 }
841
+ httpProxies[proxyId] = proxies[proxyId]
842
+
843
+ for hostname in hostnames
844
+ sites[hostname] =
845
+ host: hostname
846
+ timeouts: {}
847
+ routes: [
848
+ id: "#{appName}-proxy"
849
+ host: hostname
850
+ path: '/'
851
+ methods: '*'
852
+ priority: 0
853
+ proxy: proxyId
854
+ app: null
855
+ static: null
856
+ root: null
857
+ spa: false
858
+ browse: false
859
+ redirect: null
860
+ headers: null
861
+ websocket: true
862
+ timeouts: {}
863
+ ]
864
+
865
+ cert = matchCertForHost(certMap, hostname) if certMap.size > 0
866
+ resolvedCerts[hostname] = cert if cert
867
+
868
+ else if parsed.kind is 'tcp-proxy'
869
+ proxyId = "tcp-#{appName}"
870
+ url = new URL(parsed.target.replace('tcp://', 'http://'))
871
+ tcpProxies[proxyId] =
872
+ id: proxyId
873
+ targets: [{ host: url.hostname, port: parseInt(url.port) }]
874
+ connectTimeoutMs: 10000
875
+ streamUpstreams[proxyId] = tcpProxies[proxyId]
876
+
877
+ for hostname in hostnames
878
+ streams.push
879
+ id: proxyId
880
+ order: streams.length
881
+ listen: 443
882
+ sni: [hostname]
883
+ proxy: proxyId
884
+ timeouts: {}
885
+
886
+ cert = matchCertForHost(certMap, hostname) if certMap.size > 0
887
+ resolvedCerts[hostname] = cert if cert
888
+
889
+ throw validationError(CONFIG_FILE, errors) if errors.length > 0
890
+
891
+ {
892
+ kind: 'serve'
893
+ version: config.version or 2
894
+ server: serverSettings
895
+ proxies
896
+ upstreams: httpProxies
897
+ apps
898
+ certs: {}
899
+ rules: {}
900
+ groups: {}
901
+ routes: []
902
+ sites
903
+ resolvedCerts
904
+ streamUpstreams
905
+ streams
906
+ }
907
+
690
908
  export normalizeConfig = (config, baseDir) ->
691
- normalizeServeConfig(config, baseDir)
909
+ normalizeServeRip(config, baseDir)
692
910
 
693
911
  export normalizeEdgeConfig = (config, baseDir) -> normalizeConfig(config, baseDir)
694
912
 
package/serving/tls.rip CHANGED
@@ -2,8 +2,8 @@
2
2
  # serving/tls.rip — TLS material loading helpers
3
3
  # ==============================================================================
4
4
 
5
- import { readFileSync } from 'node:fs'
6
- import { join } from 'node:path'
5
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
6
+ import { join, basename, extname } from 'node:path'
7
7
  import { X509Certificate } from 'node:crypto'
8
8
 
9
9
  hostSpecificity = (serverName) ->
@@ -66,3 +66,69 @@ export loadTlsMaterial = (flags, serverDir, acmeLoadCertFn) ->
66
66
  warn "rip-server: failed to load TLS certs from #{certsDir}: #{e.message}"
67
67
  warn 'Use --cert/--key to provide your own, or use http to disable TLS.'
68
68
  exit 2
69
+
70
+ # ==============================================================================
71
+ # SSL directory scanning — scan for cert/key pairs, extract SANs
72
+ # ==============================================================================
73
+
74
+ normalizeHostname = (host) ->
75
+ return null unless typeof host is 'string'
76
+ host = host.trim().toLowerCase().replace(/\.$/, '')
77
+ return null unless host.length > 0
78
+ host
79
+
80
+ parseCertSans = (certPath) ->
81
+ pem = readFileSync(certPath, 'utf8')
82
+ x = new X509Certificate(pem)
83
+ san = x.subjectAltName or ''
84
+ san.split(',')
85
+ .map (entry) -> entry.trim()
86
+ .filter (entry) -> entry.startsWith('DNS:')
87
+ .map (entry) -> normalizeHostname(entry.slice(4))
88
+ .filter Boolean
89
+
90
+ export scanSslDirectory = (sslPath) ->
91
+ certMap = new Map()
92
+ return certMap unless sslPath
93
+
94
+ files = try
95
+ readdirSync(sslPath).sort()
96
+ catch err
97
+ throw new Error "cannot read ssl directory #{sslPath}: #{err.message}"
98
+
99
+ fileSet = new Set(files)
100
+
101
+ for crtFile in files
102
+ continue unless extname(crtFile) is '.crt'
103
+ stem = basename(crtFile, '.crt')
104
+ keyFile = "#{stem}.key"
105
+ continue unless fileSet.has(keyFile)
106
+
107
+ certPath = join(sslPath, crtFile)
108
+ keyPath = join(sslPath, keyFile)
109
+
110
+ sans = null
111
+ try
112
+ sans = parseCertSans(certPath)
113
+ catch err
114
+ warn "rip-server: failed to parse certificate #{certPath}: #{err.message}"
115
+ continue unless sans
116
+
117
+ for san in sans
118
+ certMap.set(san, { certPath, keyPath }) unless certMap.has(san)
119
+
120
+ certMap
121
+
122
+ export matchCertForHost = (certMap, hostname) ->
123
+ host = normalizeHostname(hostname)
124
+ return null unless host
125
+
126
+ return certMap.get(host) if certMap.has(host)
127
+
128
+ hostLabels = host.split('.')
129
+ return null unless hostLabels.length >= 2
130
+
131
+ wildcard = "*.#{hostLabels.slice(1).join('.')}"
132
+ return certMap.get(wildcard) if certMap.has(wildcard)
133
+
134
+ null