@rip-lang/server 1.3.115 → 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.
- package/README.md +435 -622
- package/api.rip +4 -4
- package/control/cli.rip +221 -1
- package/control/control.rip +9 -0
- package/control/lifecycle.rip +6 -1
- package/control/watchers.rip +10 -0
- package/control/workers.rip +9 -5
- package/default.rip +3 -1
- package/docs/READ_VALIDATORS.md +656 -0
- package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
- package/docs/edge/CONTRACTS.md +60 -69
- package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
- package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
- package/edge/config.rip +584 -52
- package/edge/forwarding.rip +6 -2
- package/edge/metrics.rip +19 -1
- package/edge/registry.rip +29 -3
- package/edge/router.rip +138 -0
- package/edge/runtime.rip +98 -0
- package/edge/static.rip +69 -0
- package/edge/tls.rip +23 -0
- package/edge/upstream.rip +272 -0
- package/edge/verify.rip +73 -0
- package/middleware.rip +3 -3
- package/package.json +2 -2
- package/server.rip +775 -393
- package/tests/control.rip +18 -0
- package/tests/edgefile.rip +165 -0
- package/tests/metrics.rip +16 -0
- package/tests/proxy.rip +22 -1
- package/tests/registry.rip +27 -0
- package/tests/router.rip +101 -0
- package/tests/runtime_entrypoints.rip +16 -0
- package/tests/servers.rip +262 -0
- package/tests/static.rip +64 -0
- package/tests/streams_clienthello.rip +108 -0
- package/tests/streams_index.rip +53 -0
- package/tests/streams_pipe.rip +70 -0
- package/tests/streams_router.rip +39 -0
- package/tests/streams_runtime.rip +38 -0
- package/tests/streams_upstream.rip +34 -0
- package/tests/upstream.rip +191 -0
- package/tests/verify.rip +148 -0
- 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:
|
|
566
|
-
text:
|
|
565
|
+
string: (v) -> v.replace(/(?:\t+|\s{2,})/g, ' ')
|
|
566
|
+
text: (v) -> v.replace(/ +/g, ' ')
|
|
567
567
|
|
|
568
568
|
# Name/address
|
|
569
|
-
name:
|
|
570
|
-
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
|
package/control/control.rip
CHANGED
|
@@ -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 = {}
|
package/control/lifecycle.rip
CHANGED
|
@@ -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()
|
package/control/watchers.rip
CHANGED
|
@@ -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) ->
|
package/control/workers.rip
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
21
|
-
APP_BASE_DIR:
|
|
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
|
-
|
|
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 }
|