@rip-lang/server 1.3.114 → 1.3.116

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 (44) hide show
  1. package/README.md +435 -622
  2. package/api.rip +4 -4
  3. package/control/cli.rip +221 -1
  4. package/control/control.rip +9 -0
  5. package/control/lifecycle.rip +6 -1
  6. package/control/watchers.rip +10 -0
  7. package/control/workers.rip +9 -5
  8. package/default.rip +3 -1
  9. package/docs/READ_VALIDATORS.md +656 -0
  10. package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
  11. package/docs/edge/CONTRACTS.md +60 -69
  12. package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
  13. package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
  14. package/edge/config.rip +584 -52
  15. package/edge/forwarding.rip +6 -2
  16. package/edge/metrics.rip +19 -1
  17. package/edge/registry.rip +29 -3
  18. package/edge/router.rip +138 -0
  19. package/edge/runtime.rip +98 -0
  20. package/edge/static.rip +69 -0
  21. package/edge/tls.rip +23 -0
  22. package/edge/upstream.rip +272 -0
  23. package/edge/verify.rip +73 -0
  24. package/middleware.rip +3 -3
  25. package/package.json +2 -2
  26. package/server.rip +775 -393
  27. package/tests/control.rip +18 -0
  28. package/tests/edgefile.rip +165 -0
  29. package/tests/metrics.rip +16 -0
  30. package/tests/proxy.rip +22 -1
  31. package/tests/registry.rip +27 -0
  32. package/tests/router.rip +101 -0
  33. package/tests/runtime_entrypoints.rip +16 -0
  34. package/tests/servers.rip +262 -0
  35. package/tests/static.rip +64 -0
  36. package/tests/streams_clienthello.rip +108 -0
  37. package/tests/streams_index.rip +53 -0
  38. package/tests/streams_pipe.rip +70 -0
  39. package/tests/streams_router.rip +39 -0
  40. package/tests/streams_runtime.rip +38 -0
  41. package/tests/streams_upstream.rip +34 -0
  42. package/tests/upstream.rip +191 -0
  43. package/tests/verify.rip +148 -0
  44. package/tests/watchers.rip +15 -0
package/api.rip CHANGED
@@ -562,12 +562,12 @@ export validators =
562
562
  cents_even: (v) -> v[/^([-+]? ?\$? ?(?:[\d,]+(?:\.\d*)?|\.\d+))$/] and toMoney(_[1], true, true)
563
563
 
564
564
  # Strings & formatting
565
- string: (v) -> v.replace(/(?:\t+|\s{2,})/g, ' ')
566
- text: (v) -> v.replace(/ +/g , ' ')
565
+ string: (v) -> v.replace(/(?:\t+|\s{2,})/g, ' ')
566
+ text: (v) -> v.replace(/ +/g, ' ')
567
567
 
568
568
  # Name/address
569
- name: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'name'
570
- address: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'address'
569
+ name: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'name'
570
+ address: (v) -> v = v.replace(/\s+/g, ' ') and toName v, 'address'
571
571
 
572
572
  # Date & time
573
573
  date: (v) -> v[/^\d{4}(-?)\d{2}\1\d{2}$/] and _[0]
package/control/cli.rip CHANGED
@@ -1,7 +1,222 @@
1
1
  # ==============================================================================
2
- # control/cli.rip — CLI parsing, subcommands, and output
2
+ # control/cli.rip — CLI parsing, flag parsing, subcommands, and output
3
3
  # ==============================================================================
4
4
 
5
+ import { existsSync, statSync } from 'node:fs'
6
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
7
+ import { cpus } from 'node:os'
8
+
9
+ # --- Shared helpers ---
10
+
11
+ export coerceInt = (value, fallback) ->
12
+ return fallback unless value? and value isnt ''
13
+ n = parseInt(String(value))
14
+ if Number.isFinite(n) then n else fallback
15
+
16
+ parseWorkersToken = (token, fallback) ->
17
+ return fallback unless token
18
+ cores = cpus().length
19
+ return Math.max(1, cores) if token is 'auto'
20
+ return Math.max(1, Math.floor(cores / 2)) if token is 'half'
21
+ return Math.max(1, cores * 2) if token is '2x'
22
+ return Math.max(1, cores * 3) if token is '3x'
23
+ n = parseInt(token)
24
+ if Number.isFinite(n) and n > 0 then n else fallback
25
+
26
+ parseRestartPolicy = (token, defReqs, defSecs) ->
27
+ return { maxRequests: defReqs, maxSeconds: defSecs } unless token
28
+ maxRequests = defReqs
29
+ maxSeconds = defSecs
30
+ for part in token.split(',').map((s) -> s.trim()).filter(Boolean)
31
+ if part.endsWith('s')
32
+ secs = parseInt(part.slice(0, -1))
33
+ maxSeconds = secs if Number.isFinite(secs) and secs >= 0
34
+ else
35
+ n = parseInt(part)
36
+ maxRequests = n if Number.isFinite(n) and n > 0
37
+ { maxRequests, maxSeconds }
38
+
39
+ export resolveAppEntry = (appPathInput, defaultEntryPath = null) ->
40
+ abs = if isAbsolute(appPathInput) then appPathInput else resolve(process.cwd(), appPathInput)
41
+
42
+ if existsSync(abs) and statSync(abs).isDirectory()
43
+ baseDir = abs
44
+ one = join(abs, 'index.rip')
45
+ two = join(abs, 'index.ts')
46
+ if existsSync(one)
47
+ entryPath = one
48
+ else if existsSync(two)
49
+ entryPath = two
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
+ entryPath = defaultEntryPath or abs
53
+ else
54
+ unless existsSync(abs)
55
+ console.error "App path not found: #{abs}"
56
+ exit 2
57
+ baseDir = dirname(abs)
58
+ entryPath = abs
59
+
60
+ appName = basename(baseDir)
61
+ { baseDir, entryPath, appName }
62
+
63
+ export parseFlags = (argv, defaultEntryPath = null) ->
64
+ rawFlags = new Set()
65
+ appPathInput = null
66
+ appAliases = []
67
+
68
+ isFlag = (tok) ->
69
+ tok.startsWith('-') or tok.startsWith('--') or /^\d+$/.test(tok) or tok in ['http', 'https']
70
+
71
+ tryResolveApp = (tok) ->
72
+ looksLikePath = tok.includes('/') or tok.startsWith('.') or isAbsolute(tok) or tok.endsWith('.rip') or tok.endsWith('.ts')
73
+ return undefined unless looksLikePath
74
+ try
75
+ abs = if isAbsolute(tok) then tok else resolve(process.cwd(), tok)
76
+ if existsSync(abs) then tok else undefined
77
+ catch
78
+ undefined
79
+
80
+ for i in [2...argv.length]
81
+ tok = argv[i]
82
+ unless appPathInput
83
+ if tok.includes('@') and not tok.startsWith('-')
84
+ [pathPart, aliasesPart] = tok.split('@')
85
+ maybe = tryResolveApp(pathPart)
86
+ if maybe
87
+ appPathInput = maybe
88
+ appAliases = aliasesPart.split(',').map((a) -> a.trim()).filter((a) -> a)
89
+ continue
90
+ else if not pathPart.includes('/') and not pathPart.startsWith('.')
91
+ appAliases = [pathPart].concat(aliasesPart.split(',').map((a) -> a.trim()).filter((a) -> a))
92
+ continue
93
+
94
+ maybe = tryResolveApp(tok)
95
+ if maybe
96
+ appPathInput = maybe
97
+ continue
98
+
99
+ unless isFlag(tok)
100
+ appAliases.push(tok)
101
+ continue
102
+
103
+ rawFlags.add(tok)
104
+
105
+ unless appPathInput
106
+ cwd = process.cwd()
107
+ indexRip = join(cwd, 'index.rip')
108
+ indexTs = join(cwd, 'index.ts')
109
+ if existsSync(indexRip)
110
+ appPathInput = indexRip
111
+ else if existsSync(indexTs)
112
+ appPathInput = indexTs
113
+ else
114
+ appPathInput = cwd
115
+
116
+ getKV = (prefix) ->
117
+ for f as rawFlags
118
+ return f.slice(prefix.length) if f.startsWith(prefix)
119
+ undefined
120
+
121
+ has = (name) -> rawFlags.has(name)
122
+
123
+ { baseDir, entryPath, appName } = resolveAppEntry(appPathInput, defaultEntryPath)
124
+ appAliases = [appName] if appAliases.length is 0
125
+
126
+ tokens = Array.from(rawFlags)
127
+ bareIntPort = null
128
+ hasHttpsKeyword = false
129
+ httpsPortToken = null
130
+ hasHttpKeyword = false
131
+ httpPortToken = null
132
+
133
+ for t in tokens
134
+ if /^\d+$/.test(t)
135
+ bareIntPort = parseInt(t)
136
+ else if t is 'https'
137
+ hasHttpsKeyword = true
138
+ else if t.startsWith('https:')
139
+ httpsPortToken = coerceInt(t.slice(6), 0)
140
+ else if t is 'http'
141
+ hasHttpKeyword = true
142
+ else if t.startsWith('http:')
143
+ httpPortToken = coerceInt(t.slice(5), 0)
144
+
145
+ httpsIntent = bareIntPort? or hasHttpsKeyword or httpsPortToken?
146
+ httpIntent = hasHttpKeyword or httpPortToken?
147
+ httpsIntent = true unless httpsIntent or httpIntent
148
+
149
+ httpPort = if httpIntent then (httpPortToken ?? bareIntPort ?? 0) else 0
150
+ httpsPortDerived = if not httpIntent then (bareIntPort or httpsPortToken or 0) else null
151
+
152
+ socketPrefixOverride = getKV('--socket-prefix=')
153
+ socketPrefix = socketPrefixOverride or "rip_#{appName}"
154
+
155
+ cores = cpus().length
156
+ workers = parseWorkersToken(getKV('w:'), Math.max(1, Math.floor(cores / 2)))
157
+
158
+ policy = parseRestartPolicy(
159
+ getKV('r:'),
160
+ coerceInt(process.env.RIP_MAX_REQUESTS, 10000),
161
+ coerceInt(process.env.RIP_MAX_SECONDS, 3600)
162
+ )
163
+
164
+ reload = not (has('--static') or process.env.RIP_STATIC is '1')
165
+
166
+ envValue = getKV('--env=')
167
+ envOverride = null
168
+ if envValue
169
+ normalized = envValue.toLowerCase()
170
+ envOverride = switch normalized
171
+ when 'prod', 'production' then 'production'
172
+ when 'dev', 'development' then 'development'
173
+ else normalized
174
+
175
+ watch = getKV('--watch=') or '*.rip'
176
+
177
+ httpsPort = do ->
178
+ kv = getKV('--https-port=')
179
+ return coerceInt(kv, 443) if kv?
180
+ httpsPortDerived
181
+
182
+ {
183
+ appPath: resolve(appPathInput)
184
+ appBaseDir: baseDir
185
+ appEntry: entryPath
186
+ appName
187
+ appAliases
188
+ workers
189
+ maxRequestsPerWorker: policy.maxRequests
190
+ maxSecondsPerWorker: policy.maxSeconds
191
+ httpPort
192
+ httpsPort
193
+ certPath: getKV('--cert=')
194
+ keyPath: getKV('--key=')
195
+ hsts: has('--hsts')
196
+ redirectHttp: not has('--no-redirect-http')
197
+ reload
198
+ quiet: has('--quiet')
199
+ socketPrefix
200
+ maxQueue: coerceInt(getKV('--max-queue='), coerceInt(process.env.RIP_MAX_QUEUE, 512))
201
+ queueTimeoutMs: coerceInt(getKV('--queue-timeout-ms='), coerceInt(process.env.RIP_QUEUE_TIMEOUT_MS, 30000))
202
+ connectTimeoutMs: coerceInt(getKV('--connect-timeout-ms='), coerceInt(process.env.RIP_CONNECT_TIMEOUT_MS, 2000))
203
+ readTimeoutMs: coerceInt(getKV('--read-timeout-ms='), coerceInt(process.env.RIP_READ_TIMEOUT_MS, 30000))
204
+ jsonLogging: has('--json-logging')
205
+ accessLog: not has('--no-access-log')
206
+ acme: has('--acme')
207
+ acmeStaging: has('--acme-staging')
208
+ acmeDomain: getKV('--acme-domain=')
209
+ realtimePath: getKV('--realtime-path=') or '/realtime'
210
+ publishSecret: getKV('--publish-secret=') or process.env.RIP_PUBLISH_SECRET or null
211
+ rateLimit: coerceInt(getKV('--rate-limit='), 0)
212
+ rateLimitWindow: coerceInt(getKV('--rate-limit-window='), 60000)
213
+ watch: watch
214
+ edgefilePath: getKV('--edgefile=')
215
+ checkConfig: has('--check-config')
216
+ envOverride: envOverride
217
+ debug: has('--debug')
218
+ }
219
+
5
220
  # --- Argv helpers ---
6
221
 
7
222
  export getKV = (argv, prefix) ->
@@ -51,6 +266,8 @@ export runHelpOutput = (argv) ->
51
266
  Options:
52
267
  -h, --help Show this help
53
268
  -v, --version Show version
269
+ --edgefile=<path> Load Edgefile.rip from an explicit path
270
+ --check-config Validate Edgefile.rip or config.rip and exit
54
271
  --watch=<glob> Watch glob pattern (default: *.rip)
55
272
  --static Disable hot reload and file watching
56
273
  --env=<mode> Set environment (dev, production)
@@ -84,6 +301,9 @@ export runHelpOutput = (argv) ->
84
301
  rip serve myapp Start with app name "myapp"
85
302
  rip serve http HTTP only (no TLS)
86
303
  rip serve --static w:8 Production: no reload, 8 workers
304
+ rip serve --check-config
305
+ rip serve --check-config --edgefile=./Edgefile.rip
306
+ rip serve --edgefile=./Edgefile.rip
87
307
 
88
308
  If no index.rip or index.ts is found, a built-in static file server
89
309
  activates with directory indexes, auto-detected MIME types, and
@@ -66,6 +66,15 @@ export handleWatchControl = (req, registerWatch, watchDirs) ->
66
66
  return { handled: true, response: jsonOk() }
67
67
  { handled: true, response: jsonBad() }
68
68
 
69
+ export handleReloadControl = (req, result) ->
70
+ return { handled: false } unless req.method is 'POST'
71
+ body = JSON.stringify(result or { ok: false, result: 'rejected', reason: 'unknown' })
72
+ status = if result?.ok then 200 else 409
73
+ return {
74
+ handled: true
75
+ response: new Response(body, { status, headers: { 'content-type': 'application/json' } })
76
+ }
77
+
69
78
  export handleRegistryControl = (req, hostIndex) ->
70
79
  return { handled: false } unless req.method is 'GET'
71
80
  hostMap = {}
@@ -60,9 +60,14 @@ export createCleanup = (pidFile, server, manager, unlinkSyncFn, fetchFn, process
60
60
  try unlinkSyncFn(pidFile)
61
61
  processObj.exit(0)
62
62
 
63
- export installShutdownHandlers = (cleanup, processObj) ->
63
+ export installShutdownHandlers = (cleanup, processObj, onReload = null) ->
64
64
  processObj.on 'SIGTERM', cleanup
65
65
  processObj.on 'SIGINT', cleanup
66
+ if onReload
67
+ processObj.on 'SIGHUP', ->
68
+ try onReload!()
69
+ catch err
70
+ console.error 'rip-server: config reload failed:', err?.message or err
66
71
  processObj.on 'uncaughtException', (err) ->
67
72
  console.error 'rip-server: uncaught exception:', err
68
73
  cleanup()
@@ -39,6 +39,16 @@ export broadcastWatchChange = (watchGroups, prefix, type = 'page') ->
39
39
  catch then dead.push(client)
40
40
  group.sseClients.delete(c) for c in dead
41
41
 
42
+ # --- Code reload matching ---
43
+
44
+ export shouldTriggerCodeReload = (filename, watchSpec, entryBase = null) ->
45
+ return false unless filename
46
+ return false if entryBase and filename is entryBase
47
+ if watchSpec?.startsWith('*.')
48
+ filename.endsWith(watchSpec.slice(1))
49
+ else
50
+ filename is watchSpec or filename.endsWith("/#{watchSpec}")
51
+
42
52
  # --- App directory watchers ---
43
53
 
44
54
  export registerAppWatchDirs = (appWatchers, prefix, dirs, server, cwdFn) ->
@@ -7,18 +7,22 @@ import { getWorkerSocketPath, getControlSocketPath } from './control.rip'
7
7
 
8
8
  # --- Spawn ---
9
9
 
10
- export spawnTrackedWorker = (flags, workerId, version, nowMsFn, importMetaPath, appId) ->
10
+ export spawnTrackedWorker = (flags, workerId, version, nowMsFn, importMetaPath, appId, appConfig = {}) ->
11
11
  socketPath = getWorkerSocketPath(flags.socketPrefix, workerId)
12
12
  try unlinkSync(socketPath)
13
13
 
14
- workerEnv = Object.assign {}, process.env,
14
+ appEntry = appConfig.entry or flags.appEntry
15
+ appBaseDir = appConfig.appBaseDir or flags.appBaseDir
16
+ workerEnvExtra = appConfig.env or {}
17
+
18
+ workerEnv = Object.assign {}, process.env, workerEnvExtra,
15
19
  RIP_WORKER_MODE: '1'
16
20
  WORKER_ID: String(workerId)
17
21
  SOCKET_PATH: socketPath
18
22
  SOCKET_PREFIX: flags.socketPrefix
19
23
  APP_ID: String(appId or flags.appName or 'default')
20
- APP_ENTRY: flags.appEntry
21
- APP_BASE_DIR: flags.appBaseDir
24
+ APP_ENTRY: appEntry
25
+ APP_BASE_DIR: workerEnvExtra.APP_BASE_DIR or appBaseDir
22
26
  MAX_REQUESTS: String(flags.maxRequestsPerWorker)
23
27
  MAX_SECONDS: String(flags.maxSecondsPerWorker)
24
28
  RIP_LOG_JSON: if flags.jsonLogging then '1' else '0'
@@ -31,7 +35,7 @@ export spawnTrackedWorker = (flags, workerId, version, nowMsFn, importMetaPath,
31
35
  cwd: process.cwd()
32
36
  env: workerEnv
33
37
 
34
- { id: workerId, process: proc, socketPath, restartCount: 0, backoffMs: 1000, startedAt: nowMsFn() }
38
+ { id: workerId, process: proc, socketPath, restartCount: 0, backoffMs: 1000, startedAt: nowMsFn(), appId }
35
39
 
36
40
  # --- Health and control notifications ---
37
41
 
package/default.rip CHANGED
@@ -425,7 +425,9 @@ notFound ->
425
425
 
426
426
  if stat.isDirectory()
427
427
  return new Response(null, { status: 301, headers: { Location: "#{reqPath}/" } }) unless reqPath.endsWith('/')
428
- try return @send join(path, 'index.html'), 'text/html; charset=UTF-8' if statSync(join(path, 'index.html')).isFile()
428
+ indexPath = join(path, 'index.html')
429
+ if (try statSync(indexPath).isFile())
430
+ return @send indexPath, 'text/html; charset=UTF-8'
429
431
  return new Response renderIndex(reqPath, path), { headers: { 'Content-Type': 'text/html; charset=UTF-8' } }
430
432
 
431
433
  new Response 'Not Found', { status: 404 }