@rip-lang/server 1.3.111 → 1.3.113

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.
@@ -0,0 +1,138 @@
1
+ # ==============================================================================
2
+ # acme/client.rip — RFC 8555 ACME protocol client (zero dependencies)
3
+ # ==============================================================================
4
+
5
+ import { signJws, thumbprint } from './crypto.rip'
6
+
7
+ LETS_ENCRYPT = 'https://acme-v02.api.letsencrypt.org/directory'
8
+ LETS_ENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'
9
+
10
+ export { LETS_ENCRYPT, LETS_ENCRYPT_STAGING }
11
+
12
+ # --- Nonce management ---
13
+
14
+ getNonce = (client) ->
15
+ if client.nonces.length > 0
16
+ return client.nonces.pop()
17
+ res = fetch!(client.directory.newNonce, { method: 'HEAD' })
18
+ res.headers.get('replay-nonce')
19
+
20
+ saveNonce = (client, res) ->
21
+ nonce = res.headers.get('replay-nonce')
22
+ client.nonces.push(nonce) if nonce
23
+
24
+ # --- Signed request ---
25
+
26
+ acmeRequest = (client, url, payload, retryCount = 0) ->
27
+ nonce = getNonce!(client)
28
+ body = signJws(payload, url, nonce, client.accountKey, client.publicJwk, client.kid)
29
+ res = fetch! url,
30
+ method: 'POST'
31
+ headers: { 'content-type': 'application/jose+json' }
32
+ body: body
33
+ saveNonce(client, res)
34
+
35
+ if res.status >= 400
36
+ errBody = try res.json!
37
+ # Retry once on badNonce (RFC 8555 Section 6.5)
38
+ if errBody?.type is 'urn:ietf:params:acme:error:badNonce' and retryCount < 1
39
+ return acmeRequest!(client, url, payload, retryCount + 1)
40
+ throw new Error("ACME error #{res.status}: #{errBody?.detail or res.statusText}")
41
+
42
+ location = res.headers.get('location')
43
+ contentType = res.headers.get('content-type') or ''
44
+
45
+ data = if contentType.includes('json')
46
+ res.json!
47
+ else
48
+ res.text!
49
+
50
+ { data, location, status: res.status, headers: res.headers }
51
+
52
+ # --- Client lifecycle ---
53
+
54
+ export createClient = (directoryUrl) ->
55
+ directoryUrl = directoryUrl or LETS_ENCRYPT
56
+ res = fetch!(directoryUrl)
57
+ directory = res.json!
58
+ {
59
+ directoryUrl
60
+ directory
61
+ accountKey: null
62
+ publicJwk: null
63
+ kid: null
64
+ nonces: []
65
+ }
66
+
67
+ export setAccountKey = (client, privateKey, publicJwk) ->
68
+ client.accountKey = privateKey
69
+ client.publicJwk = publicJwk
70
+ client
71
+
72
+ # --- Account ---
73
+
74
+ export newAccount = (client) ->
75
+ { data, location } = acmeRequest! client, client.directory.newAccount,
76
+ termsOfServiceAgreed: true
77
+ client.kid = location
78
+ { accountUrl: location, account: data }
79
+
80
+ # --- Order ---
81
+
82
+ export newOrder = (client, domains) ->
83
+ identifiers = domains.map((d) -> { type: 'dns', value: d })
84
+ { data, location } = acmeRequest! client, client.directory.newOrder, { identifiers }
85
+ { orderUrl: location, order: data }
86
+
87
+ export getAuthorization = (client, authUrl) ->
88
+ { data } = acmeRequest! client, authUrl, ''
89
+ data
90
+
91
+ export getHttp01Challenge = (auth) ->
92
+ auth.challenges.find((c) -> c.type is 'http-01')
93
+
94
+ export keyAuthorization = (token, publicJwk) ->
95
+ "#{token}.#{thumbprint(publicJwk)}"
96
+
97
+ export respondToChallenge = (client, challengeUrl) ->
98
+ { data } = acmeRequest! client, challengeUrl, {}
99
+ data
100
+
101
+ # --- Polling ---
102
+
103
+ export pollOrderStatus = (client, orderUrl, maxAttempts = 30, intervalMs = 2000) ->
104
+ for i in [0...maxAttempts]
105
+ { data } = acmeRequest! client, orderUrl, ''
106
+ return data if data.status is 'valid'
107
+ return data if data.status is 'invalid'
108
+ throw new Error("ACME order failed: #{data.status}") if data.status in ['expired', 'revoked', 'deactivated']
109
+ await new Promise (r) -> setTimeout(r, intervalMs)
110
+ throw new Error('ACME order timed out waiting for validation')
111
+
112
+ export pollAuthorizationStatus = (client, authUrl, maxAttempts = 30, intervalMs = 2000) ->
113
+ for i in [0...maxAttempts]
114
+ { data } = acmeRequest! client, authUrl, ''
115
+ return data if data.status is 'valid'
116
+ throw new Error("ACME authorization failed: #{data.status}") if data.status is 'invalid'
117
+ await new Promise (r) -> setTimeout(r, intervalMs)
118
+ throw new Error('ACME authorization timed out')
119
+
120
+ # --- Finalize and download ---
121
+
122
+ export finalizeOrder = (client, finalizeUrl, csrB64url) ->
123
+ { data } = acmeRequest! client, finalizeUrl, { csr: csrB64url }
124
+ data
125
+
126
+ export downloadCert = (client, certUrl) ->
127
+ nonce = getNonce!(client)
128
+ body = signJws('', certUrl, nonce, client.accountKey, client.publicJwk, client.kid)
129
+ res = fetch! certUrl,
130
+ method: 'POST'
131
+ headers:
132
+ 'content-type': 'application/jose+json'
133
+ 'accept': 'application/pem-certificate-chain'
134
+ body: body
135
+ saveNonce(client, res)
136
+ if res.status >= 400
137
+ throw new Error("ACME cert download failed: #{res.status}")
138
+ res.text!
@@ -0,0 +1,130 @@
1
+ # ==============================================================================
2
+ # acme/crypto.rip — ACME protocol crypto helpers (zero dependencies)
3
+ # ==============================================================================
4
+
5
+ import { generateKeyPairSync, createSign, createHash, createPrivateKey } from 'node:crypto'
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
7
+ import { tmpdir } from 'node:os'
8
+ import { join, dirname } from 'node:path'
9
+
10
+ # --- Base64url ---
11
+
12
+ export b64url = (buf) ->
13
+ s = if typeof buf is 'string' then Buffer.from(buf).toString('base64') else Buffer.from(buf).toString('base64')
14
+ s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
15
+
16
+ b64urlBuf = (buf) -> b64url(buf)
17
+
18
+ # --- Key generation ---
19
+
20
+ export generateAccountKeyPair = ->
21
+ generateKeyPairSync('ec', { namedCurve: 'P-256' })
22
+
23
+ export generateCertKeyPair = ->
24
+ generateKeyPairSync('ec', { namedCurve: 'P-256' })
25
+
26
+ export exportPrivateKeyPem = (keyPair) ->
27
+ keyPair.privateKey.export({ format: 'pem', type: 'pkcs8' })
28
+
29
+ export exportPublicJwk = (keyPair) ->
30
+ jwk = keyPair.publicKey.export({ format: 'jwk' })
31
+ { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }
32
+
33
+ # --- Account key persistence ---
34
+
35
+ export loadOrCreateAccountKey = (storePath) ->
36
+ accountFile = join(storePath, 'account.json')
37
+ if existsSync(accountFile)
38
+ data = JSON.parse(readFileSync(accountFile, 'utf8'))
39
+ privKey = createPrivateKey({ key: Buffer.from(data.privateKeyPem), format: 'pem' })
40
+ pubJwk = data.publicJwk
41
+ return { privateKey: privKey, publicJwk: pubJwk }
42
+
43
+ pair = generateAccountKeyPair()
44
+ pubJwk = exportPublicJwk(pair)
45
+ privPem = exportPrivateKeyPem(pair)
46
+ mkdirSync(dirname(accountFile), { recursive: true, mode: 0o700 }) unless existsSync(dirname(accountFile))
47
+ writeFileSync(accountFile, JSON.stringify({ publicJwk: pubJwk, privateKeyPem: privPem }), { encoding: 'utf8', mode: 0o600 })
48
+ { privateKey: pair.privateKey, publicJwk: pubJwk }
49
+
50
+ # --- JWK Thumbprint (RFC 7638) ---
51
+
52
+ export thumbprint = (jwk) ->
53
+ canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })
54
+ hash = createHash('sha256').update(canonical).digest()
55
+ b64url(hash)
56
+
57
+ # --- JWS Signing (ES256 / ACME) ---
58
+
59
+ derToRaw = (derSig) ->
60
+ # Convert DER-encoded ECDSA signature to raw r||s (64 bytes for P-256)
61
+ offset = 2
62
+ rLen = derSig[offset + 1]
63
+ r = derSig.slice(offset + 2, offset + 2 + rLen)
64
+ offset = offset + 2 + rLen
65
+ sLen = derSig[offset + 1]
66
+ s = derSig.slice(offset + 2, offset + 2 + sLen)
67
+ # Pad or trim to 32 bytes each
68
+ pad = (buf, len) ->
69
+ return buf.slice(buf.length - len) if buf.length > len
70
+ return buf if buf.length is len
71
+ out = Buffer.alloc(len)
72
+ buf.copy(out, len - buf.length)
73
+ out
74
+ Buffer.concat([pad(r, 32), pad(s, 32)])
75
+
76
+ export signJws = (payload, url, nonce, privateKey, publicJwk, kid) ->
77
+ protectedHeader = if kid
78
+ { alg: 'ES256', kid, nonce, url }
79
+ else
80
+ { alg: 'ES256', jwk: publicJwk, nonce, url }
81
+
82
+ protectedB64 = b64url(JSON.stringify(protectedHeader))
83
+ payloadB64 = if payload is '' then '' else b64url(JSON.stringify(payload))
84
+
85
+ sigInput = "#{protectedB64}.#{payloadB64}"
86
+ signer = createSign('SHA256')
87
+ signer.update(sigInput)
88
+ derSig = signer.sign(privateKey)
89
+ rawSig = derToRaw(derSig)
90
+
91
+ JSON.stringify
92
+ protected: protectedB64
93
+ payload: payloadB64
94
+ signature: b64url(rawSig)
95
+
96
+ # --- CSR generation via openssl ---
97
+
98
+ export generateCsr = (domain, keyPem) ->
99
+ # Write key to temp file, generate CSR, read it back
100
+ tmpDir = mkdtempSync(join(tmpdir(), 'rip-acme-'))
101
+ keyPath = join(tmpDir, 'key.pem')
102
+ csrPath = join(tmpDir, 'csr.der')
103
+ extPath = join(tmpDir, 'san.cnf')
104
+ writeFileSync(keyPath, keyPem, { encoding: 'utf8', mode: 0o600 })
105
+
106
+ # OpenSSL config with SAN extension (required by modern CAs)
107
+ sanConfig = "[req]\ndistinguished_name=dn\nreq_extensions=san\n[dn]\nCN=#{domain}\n[san]\nsubjectAltName=DNS:#{domain}\n"
108
+ writeFileSync(extPath, sanConfig, { encoding: 'utf8', mode: 0o600 })
109
+
110
+ result = Bun.spawnSync [
111
+ 'openssl', 'req', '-new'
112
+ '-key', keyPath
113
+ '-subj', "/CN=#{domain}"
114
+ '-config', extPath
115
+ '-outform', 'DER'
116
+ '-out', csrPath
117
+ ],
118
+ stdout: 'pipe'
119
+ stderr: 'pipe'
120
+
121
+ if result.exitCode isnt 0
122
+ stderr = Buffer.from(result.stderr or []).toString('utf8')
123
+ throw new Error("CSR generation failed: #{stderr}")
124
+
125
+ csrDer = readFileSync(csrPath)
126
+
127
+ # Cleanup
128
+ try rmSync(tmpDir, { recursive: true, force: true })
129
+
130
+ b64url(csrDer)
@@ -0,0 +1,107 @@
1
+ # ==============================================================================
2
+ # acme/manager.rip — ACME certificate lifecycle orchestrator
3
+ # ==============================================================================
4
+
5
+ import { loadOrCreateAccountKey, generateCertKeyPair, exportPrivateKeyPem, generateCsr } from './crypto.rip'
6
+ import { createClient, setAccountKey, newAccount, newOrder, getAuthorization, getHttp01Challenge, keyAuthorization, respondToChallenge, pollAuthorizationStatus, pollOrderStatus, finalizeOrder, downloadCert, LETS_ENCRYPT, LETS_ENCRYPT_STAGING } from './client.rip'
7
+ import { defaultCertDir, saveCert, loadCert, needsRenewal } from './store.rip'
8
+
9
+ export createAcmeManager = (options = {}) ->
10
+ certDir = options.certDir or defaultCertDir()
11
+ staging = options.staging or false
12
+ directoryUrl = if staging then LETS_ENCRYPT_STAGING else LETS_ENCRYPT
13
+ challengeStore = options.challengeStore or null
14
+ onCertRenewed = options.onCertRenewed or (->)
15
+ renewalTimer = null
16
+
17
+ manager =
18
+ certDir: certDir
19
+ staging: staging
20
+ directoryUrl: directoryUrl
21
+ challengeStore: challengeStore
22
+ onCertRenewed: onCertRenewed
23
+
24
+ obtainCert: (domain) ->
25
+ p "rip-acme: obtaining cert for #{domain}#{if staging then ' (staging)' else ''}"
26
+
27
+ # Load or create account key
28
+ account = loadOrCreateAccountKey(certDir)
29
+
30
+ # Create ACME client and register account
31
+ client = createClient!(directoryUrl)
32
+ setAccountKey(client, account.privateKey, account.publicJwk)
33
+ newAccount!(client)
34
+
35
+ # Create order
36
+ { orderUrl, order } = newOrder!(client, [domain])
37
+
38
+ # Process authorizations
39
+ for authUrl in order.authorizations
40
+ auth = getAuthorization!(client, authUrl)
41
+ continue if auth.status is 'valid'
42
+
43
+ challenge = getHttp01Challenge(auth)
44
+ unless challenge
45
+ throw new Error("No HTTP-01 challenge available for #{domain}")
46
+
47
+ # Set challenge token for the HTTP server to serve
48
+ keyAuth = keyAuthorization(challenge.token, client.publicJwk)
49
+ if challengeStore
50
+ challengeStore.set(challenge.token, keyAuth)
51
+
52
+ try
53
+ # Tell CA we're ready
54
+ respondToChallenge!(client, challenge.url)
55
+
56
+ # Wait for authorization to be validated
57
+ pollAuthorizationStatus!(client, authUrl)
58
+ finally
59
+ # Always clean up challenge token, even on failure
60
+ challengeStore.remove(challenge.token) if challengeStore
61
+
62
+ # Generate cert key and CSR
63
+ certKey = generateCertKeyPair()
64
+ certKeyPem = exportPrivateKeyPem(certKey)
65
+ csrB64 = generateCsr(domain, certKeyPem)
66
+
67
+ # Finalize order with CSR
68
+ finalizeOrder!(client, order.finalize, csrB64)
69
+
70
+ # Poll until cert is ready
71
+ completedOrder = pollOrderStatus!(client, orderUrl)
72
+ unless completedOrder.certificate
73
+ throw new Error('ACME order completed but no certificate URL returned')
74
+
75
+ # Download cert chain
76
+ certPem = downloadCert!(client, completedOrder.certificate)
77
+
78
+ # Store cert and key
79
+ saveCert(certDir, domain, certPem, certKeyPem)
80
+ p "rip-acme: cert obtained and stored for #{domain}"
81
+
82
+ { cert: certPem, key: certKeyPem }
83
+
84
+ renewIfNeeded: (domain) ->
85
+ if needsRenewal(certDir, domain)
86
+ p "rip-acme: cert for #{domain} needs renewal"
87
+ result = manager.obtainCert!(domain)
88
+ manager.onCertRenewed(domain, result)
89
+ return true
90
+ false
91
+
92
+ startRenewalLoop: (domains, intervalMs = 12 * 60 * 60 * 1000) ->
93
+ manager.stopRenewalLoop()
94
+ check = ->
95
+ for domain in domains
96
+ try manager.renewIfNeeded!(domain)
97
+ catch e
98
+ console.error "rip-acme: renewal failed for #{domain}:", e.message
99
+ renewalTimer = setInterval(check, intervalMs)
100
+ # Run first check after a short delay
101
+ setTimeout(check, 5000)
102
+
103
+ stopRenewalLoop: ->
104
+ clearInterval(renewalTimer) if renewalTimer
105
+ renewalTimer = null
106
+
107
+ manager
package/acme/store.rip ADDED
@@ -0,0 +1,67 @@
1
+ # ==============================================================================
2
+ # acme/store.rip — certificate storage, lookup, and HTTP-01 challenge store
3
+ # ==============================================================================
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { X509Certificate } from 'node:crypto'
9
+
10
+ export defaultCertDir = -> join(homedir(), '.rip', 'certs')
11
+
12
+ export ensureDir = (dir) ->
13
+ mkdirSync(dir, { recursive: true }) unless existsSync(dir)
14
+ dir
15
+
16
+ export domainDir = (baseDir, domain) ->
17
+ ensureDir(join(ensureDir(baseDir), domain))
18
+
19
+ export saveCert = (baseDir, domain, certPem, keyPem) ->
20
+ dir = domainDir(baseDir, domain)
21
+ writeFileSync(join(dir, 'cert.pem'), certPem, { encoding: 'utf8', mode: 0o644 })
22
+ writeFileSync(join(dir, 'key.pem'), keyPem, { encoding: 'utf8', mode: 0o600 })
23
+ { certPath: join(dir, 'cert.pem'), keyPath: join(dir, 'key.pem') }
24
+
25
+ export loadCert = (baseDir, domain) ->
26
+ dir = join(baseDir, domain)
27
+ certPath = join(dir, 'cert.pem')
28
+ keyPath = join(dir, 'key.pem')
29
+ return null unless existsSync(certPath) and existsSync(keyPath)
30
+ cert = readFileSync(certPath, 'utf8')
31
+ key = readFileSync(keyPath, 'utf8')
32
+ notAfter = null
33
+ try
34
+ x = new X509Certificate(cert)
35
+ notAfter = new Date(x.validTo).getTime()
36
+ { cert, key, certPath, keyPath, notAfter }
37
+
38
+ export needsRenewal = (baseDir, domain, thresholdDays = 30) ->
39
+ rec = loadCert(baseDir, domain)
40
+ return true unless rec?.notAfter
41
+ msLeft = rec.notAfter - Date.now()
42
+ daysLeft = msLeft / (1000 * 60 * 60 * 24)
43
+ daysLeft < thresholdDays
44
+
45
+ export listCerts = (baseDir) ->
46
+ return [] unless existsSync(baseDir)
47
+ entries = readdirSync(baseDir, { withFileTypes: true })
48
+ entries.filter((e) -> e.isDirectory()).map((e) -> e.name)
49
+
50
+ # --- HTTP-01 challenge token store ---
51
+
52
+ export createChallengeStore = ->
53
+ tokens = new Map()
54
+
55
+ set: (token, keyAuthorization) -> tokens.set(token, keyAuthorization)
56
+ get: (token) -> tokens.get(token) ?? null
57
+ has: (token) -> tokens.has(token)
58
+ remove: (token) -> tokens.delete(token)
59
+ clear: -> tokens.clear()
60
+
61
+ export handleChallengeRequest = (url, challengeStore) ->
62
+ prefix = '/.well-known/acme-challenge/'
63
+ return null unless url.pathname.startsWith(prefix)
64
+ token = url.pathname.slice(prefix.length)
65
+ return null unless token and challengeStore.has(token)
66
+ keyAuth = challengeStore.get(token)
67
+ new Response(keyAuth, { headers: { 'content-type': 'application/octet-stream' } })
@@ -0,0 +1,123 @@
1
+ # ==============================================================================
2
+ # control/cli.rip — CLI parsing, subcommands, and output
3
+ # ==============================================================================
4
+
5
+ # --- Argv helpers ---
6
+
7
+ export getKV = (argv, prefix) ->
8
+ for tok in argv
9
+ return tok.slice(prefix.length) if tok.startsWith(prefix)
10
+ undefined
11
+
12
+ export findAppPathToken = (argv, existsSyncFn, isAbsoluteFn, resolveFn, cwdFn) ->
13
+ for i in [2...argv.length]
14
+ tok = argv[i]
15
+ pathPart = if tok.includes('@') then tok.split('@')[0] else tok
16
+ looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
17
+ try
18
+ return pathPart if looksLikePath and existsSyncFn(if isAbsoluteFn(pathPart) then pathPart else resolveFn(cwdFn(), pathPart))
19
+ undefined
20
+
21
+ export computeSocketPrefix = (argv, resolveAppEntryFn, existsSyncFn, isAbsoluteFn, resolveFn, cwdFn) ->
22
+ override = getKV(argv, '--socket-prefix=')
23
+ return override if override
24
+ appTok = findAppPathToken(argv, existsSyncFn, isAbsoluteFn, resolveFn, cwdFn)
25
+ if appTok
26
+ try
27
+ { appName } = resolveAppEntryFn(appTok)
28
+ return "rip_#{appName}"
29
+ 'rip_server'
30
+
31
+ # --- Output ---
32
+
33
+ export runVersionOutput = (argv, readFileSyncFn, packageJsonPath) ->
34
+ return false unless '--version' in argv or '-v' in argv
35
+ try
36
+ pkg = JSON.parse(readFileSyncFn(packageJsonPath, 'utf8'))
37
+ p "rip-server v#{pkg.version}"
38
+ catch
39
+ p 'rip-server (version unknown)'
40
+ true
41
+
42
+ export runHelpOutput = (argv) ->
43
+ return false unless '--help' in argv or '-h' in argv
44
+ p """
45
+ rip-server - Pure Rip application server
46
+
47
+ Usage:
48
+ rip serve [options] [app-path] [app-name]
49
+ rip serve [options] [app-path]@<alias1>,<alias2>,...
50
+
51
+ Options:
52
+ -h, --help Show this help
53
+ -v, --version Show version
54
+ --watch=<glob> Watch glob pattern (default: *.rip)
55
+ --static Disable hot reload and file watching
56
+ --env=<mode> Set environment (dev, production)
57
+ --debug Enable debug logging
58
+ --quiet Suppress URL lines (app prints its own)
59
+
60
+ Network:
61
+ http HTTP only (no TLS)
62
+ https HTTPS with trusted *.ripdev.io cert (default)
63
+ <port> Listen on specific port
64
+ --cert=<path> TLS certificate file (overrides shipped cert)
65
+ --key=<path> TLS key file (overrides shipped cert)
66
+ --hsts Enable HSTS header
67
+ --no-redirect-http Don't redirect HTTP to HTTPS
68
+
69
+ ACME (auto TLS):
70
+ --acme Enable auto TLS via Let's Encrypt
71
+ --acme-staging Use Let's Encrypt staging CA (for testing)
72
+ --acme-domain=<domain> Domain for ACME cert (required with --acme)
73
+
74
+ Realtime (WebSocket):
75
+ --realtime-path=<path> WebSocket endpoint path (default: /realtime)
76
+
77
+ Workers:
78
+ w:<n> Number of workers (default: cores/2)
79
+ w:auto One worker per core
80
+ r:<n>,<s>s Restart policy: max requests, max seconds
81
+
82
+ Examples:
83
+ rip serve Start with ./index.rip (watches *.rip)
84
+ rip serve myapp Start with app name "myapp"
85
+ rip serve http HTTP only (no TLS)
86
+ rip serve --static w:8 Production: no reload, 8 workers
87
+
88
+ If no index.rip or index.ts is found, a built-in static file server
89
+ activates with directory indexes, auto-detected MIME types, and
90
+ hot-reload. Create an index.rip to customize server behavior.
91
+ """
92
+ true
93
+
94
+ # --- Subcommands ---
95
+
96
+ export runStopSubcommand = (argv, prefix, getPidFilePathFn, existsSyncFn, readFileSyncFn, killFn, spawnSyncFn, importMetaPath) ->
97
+ return false unless 'stop' in argv
98
+ pidFile = getPidFilePathFn(prefix)
99
+ try
100
+ if existsSyncFn(pidFile)
101
+ pid = parseInt(readFileSyncFn(pidFile, 'utf8').trim())
102
+ killFn(pid, 'SIGTERM')
103
+ p "rip-server: sent SIGTERM to process #{pid}"
104
+ else
105
+ p "rip-server: no PID file found at #{pidFile}, trying pkill..."
106
+ spawnSyncFn(['pkill', '-f', importMetaPath])
107
+ catch e
108
+ console.error "rip-server: stop failed: #{e.message}"
109
+ true
110
+
111
+ export runListSubcommand = (argv, prefix, getControlSocketPathFn, fetchFn, exitFn) ->
112
+ return false unless 'list' in argv
113
+ controlUnix = getControlSocketPathFn(prefix)
114
+ try
115
+ res = fetchFn!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
116
+ throw new Error("list failed: #{res.status}") unless res.ok
117
+ j = res.json!
118
+ hosts = if Array.isArray(j?.hosts) then j.hosts else []
119
+ p if hosts.length then hosts.join('\n') else '(no hosts)'
120
+ catch e
121
+ console.error "list command failed: #{e?.message or e}"
122
+ exitFn(1)
123
+ true
@@ -0,0 +1,75 @@
1
+ # ==============================================================================
2
+ # control/control.rip — paths, worker registry, and control socket handlers
3
+ # ==============================================================================
4
+
5
+ # --- Socket and PID paths ---
6
+
7
+ export getWorkerSocketPath = (prefix, id) -> "/tmp/#{prefix}.#{id}.sock"
8
+ export getControlSocketPath = (prefix) -> "/tmp/#{prefix}.ctl.sock"
9
+ export getPidFilePath = (prefix) -> "/tmp/#{prefix}.pid"
10
+
11
+ # --- Worker registry ---
12
+
13
+ registerWorker = (sockets, availableWorkers, payload) ->
14
+ version = if typeof payload.version is 'number' then payload.version else null
15
+ exists = sockets.find((x) -> x.socket is payload.socket)
16
+ unless exists
17
+ worker = { socket: payload.socket, inflight: 0, version, workerId: payload.workerId }
18
+ sockets.push(worker)
19
+ availableWorkers.push(worker)
20
+ { version }
21
+
22
+ removeWorkerById = (sockets, availableWorkers, workerId) ->
23
+ # Mutate in-place to avoid race conditions with concurrent join/quit
24
+ i = sockets.length
25
+ while i-- > 0
26
+ sockets.splice(i, 1) if sockets[i].workerId is workerId
27
+ i = availableWorkers.length
28
+ while i-- > 0
29
+ availableWorkers.splice(i, 1) if availableWorkers[i].workerId is workerId
30
+ return
31
+
32
+ # --- Control socket handlers ---
33
+
34
+ jsonOk = -> new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
35
+ jsonBad = -> new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
36
+
37
+ export handleWorkerControl = (req, state) ->
38
+ return { handled: false } unless req.method is 'POST'
39
+ try
40
+ j = req.json!
41
+ if j?.op is 'join' and typeof j.socket is 'string' and typeof j.workerId is 'number'
42
+ { version } = registerWorker(state.sockets, state.availableWorkers, j)
43
+ newestVersion = state.newestVersion
44
+ newestVersion = if newestVersion is null then version else Math.max(newestVersion, version) if version?
45
+ return
46
+ handled: true
47
+ response: jsonOk()
48
+ sockets: state.sockets
49
+ availableWorkers: state.availableWorkers
50
+ newestVersion: newestVersion
51
+
52
+ if j?.op is 'quit' and typeof j.workerId is 'number'
53
+ removeWorkerById(state.sockets, state.availableWorkers, j.workerId)
54
+ return
55
+ handled: true
56
+ response: jsonOk()
57
+ { handled: true, response: jsonBad(), sockets: state.sockets, availableWorkers: state.availableWorkers, newestVersion: state.newestVersion }
58
+
59
+ export handleWatchControl = (req, registerWatch, watchDirs) ->
60
+ return { handled: false } unless req.method is 'POST'
61
+ try
62
+ j = req.json!
63
+ if j?.op is 'watch' and typeof j.prefix is 'string' and Array.isArray(j.dirs)
64
+ registerWatch(j.prefix)
65
+ watchDirs(j.prefix, j.dirs) if j.dirs.length > 0
66
+ return { handled: true, response: jsonOk() }
67
+ { handled: true, response: jsonBad() }
68
+
69
+ export handleRegistryControl = (req, hostIndex) ->
70
+ return { handled: false } unless req.method is 'GET'
71
+ hostMap = {}
72
+ for [host, appId] as hostIndex
73
+ (hostMap[appId] = hostMap[appId] or []).push(host)
74
+ response = new Response(JSON.stringify({ ok: true, hosts: Array.from(hostIndex.keys()), apps: hostMap }), { headers: { 'content-type': 'application/json' } })
75
+ { handled: true, response }