@rip-lang/server 1.3.110 → 1.3.112

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/edge/queue.rip ADDED
@@ -0,0 +1,53 @@
1
+ # ==============================================================================
2
+ # edge/queue.rip — scheduling, queue drain, and job processing
3
+ # ==============================================================================
4
+
5
+ # --- Scheduler ---
6
+
7
+ export isCurrentVersion = (newestVersion, worker) ->
8
+ newestVersion is null or worker.version is null or worker.version >= newestVersion
9
+
10
+ export getNextAvailableSocket = (availableWorkers, newestVersion) ->
11
+ while availableWorkers.length > 0
12
+ worker = availableWorkers.pop()
13
+ return worker if worker.inflight is 0 and isCurrentVersion(newestVersion, worker)
14
+ null
15
+
16
+ export releaseWorker = (sockets, availableWorkers, newestVersion, worker) ->
17
+ return unless worker
18
+ return unless sockets.some((s) -> s.socket is worker.socket)
19
+ worker.inflight = 0
20
+ if isCurrentVersion(newestVersion, worker)
21
+ availableWorkers.push(worker)
22
+
23
+ export shouldRetryBodylessBusy = (req, res) ->
24
+ req.method in ['GET', 'HEAD', 'OPTIONS', 'DELETE'] and
25
+ res.status is 503 and
26
+ res.headers.get('Rip-Worker-Busy') is '1'
27
+
28
+ # --- Queue ---
29
+
30
+ resolveJobError = (e) ->
31
+ if e instanceof Response then e else new Response('Internal error', { status: 500 })
32
+
33
+ export processQueuedJob = (job, worker, forwardToWorker, onFinally) ->
34
+ forwardToWorker(job.req, worker)
35
+ .then((r) -> job.resolve(r))
36
+ .catch((e) -> job.resolve(resolveJobError(e)))
37
+ .finally(-> onFinally())
38
+
39
+ export drainQueueOnce = (inflightTotal, socketsLength, availableWorkersLength, shiftJob, isJobTimedOut, queueTimeoutResponse, getNextAvailableSocket, processJob) ->
40
+ while inflightTotal < Math.max(1, socketsLength) and availableWorkersLength() > 0
41
+ job = shiftJob()
42
+ break unless job
43
+ if isJobTimedOut(job)
44
+ job.resolve(queueTimeoutResponse())
45
+ continue
46
+ inflightTotal++
47
+ worker = getNextAvailableSocket()
48
+ unless worker
49
+ inflightTotal--
50
+ job.resolve(new Response('Service unavailable', { status: 503, headers: { 'Retry-After': '1' } }))
51
+ break
52
+ processJob(job, worker)
53
+ inflightTotal
@@ -0,0 +1,63 @@
1
+ # ==============================================================================
2
+ # edge/ratelimit.rip — per-IP sliding window rate limiter
3
+ # ==============================================================================
4
+
5
+ DEFAULT_WINDOW_MS = 60000 # 1 minute
6
+ DEFAULT_MAX_REQUESTS = 1000 # per window
7
+ CLEANUP_INTERVAL_MS = 30000 # prune stale entries
8
+ MAX_BUCKETS = 100000 # hard cap on tracked IPs to prevent memory exhaustion
9
+
10
+ export createRateLimiter = (maxRequests, windowMs) ->
11
+ # Explicit check: 0 means disabled, null/undefined gets default
12
+ maxRequests = if typeof maxRequests is 'number' then maxRequests else DEFAULT_MAX_REQUESTS
13
+ windowMs = if typeof windowMs is 'number' and windowMs > 0 then windowMs else DEFAULT_WINDOW_MS
14
+ buckets = new Map()
15
+
16
+ cleanupTimer = if maxRequests > 0
17
+ setInterval ->
18
+ now = Date.now()
19
+ for [ip, timestamps] as buckets
20
+ fresh = timestamps.filter((t) -> now - t < windowMs)
21
+ if fresh.length is 0
22
+ buckets.delete(ip)
23
+ else
24
+ buckets.set(ip, fresh)
25
+ , CLEANUP_INTERVAL_MS
26
+ else
27
+ null
28
+
29
+ limiter =
30
+ maxRequests: maxRequests
31
+ windowMs: windowMs
32
+
33
+ check: (ip) ->
34
+ return { allowed: true, remaining: 0, retryAfter: 0 } if maxRequests <= 0
35
+ now = Date.now()
36
+ timestamps = buckets.get(ip) or []
37
+ timestamps = timestamps.filter((t) -> now - t < windowMs)
38
+ if timestamps.length >= maxRequests
39
+ buckets.set(ip, timestamps)
40
+ retryAfter = Math.ceil((timestamps[0] + windowMs - now) / 1000)
41
+ return { allowed: false, remaining: 0, retryAfter }
42
+ timestamps.push(now)
43
+ buckets.set(ip, timestamps)
44
+ # Evict oldest-inserted bucket if over cap (FIFO, not LRU — simple and bounded)
45
+ if buckets.size > MAX_BUCKETS
46
+ oldest = buckets.keys().next().value
47
+ buckets.delete(oldest)
48
+ { allowed: true, remaining: maxRequests - timestamps.length, retryAfter: 0 }
49
+
50
+ reset: (ip) ->
51
+ buckets.delete(ip)
52
+
53
+ stop: ->
54
+ clearInterval(cleanupTimer) if cleanupTimer
55
+
56
+ limiter
57
+
58
+ export rateLimitResponse = (retryAfter) ->
59
+ new Response 'Rate limit exceeded',
60
+ status: 429
61
+ headers:
62
+ 'Retry-After': String(retryAfter)
63
+ 'Content-Type': 'text/plain'
@@ -0,0 +1,200 @@
1
+ # ==============================================================================
2
+ # edge/realtime.rip — Bam-style WebSocket realtime hub
3
+ # ==============================================================================
4
+ #
5
+ # Manages WebSocket connections, group membership, and message routing.
6
+ # Backend stays HTTP-only — the hub proxies WS events as HTTP POSTs and
7
+ # processes JSON responses for membership updates and message delivery.
8
+ #
9
+ # Protocol keys:
10
+ # @ target groups (who receives)
11
+ # + subscribe to groups
12
+ # - unsubscribe from groups
13
+ # > senders (exclude from delivery)
14
+ # * close with reason
15
+ # Other keys are event payloads delivered to targets
16
+
17
+ import { randomBytes } from 'node:crypto'
18
+
19
+ # --- Hub lifecycle ---
20
+
21
+ export createHub = ->
22
+ sockets: new Map() # clientId -> ws
23
+ members: new Map() # groupId -> Set(clientId) (bidirectional)
24
+ backendUrl: null # set during wiring
25
+ deliveries: 0
26
+ messages: 0
27
+ connections: 0
28
+
29
+ export generateClientId = ->
30
+ randomBytes(16).toString('hex')
31
+
32
+ # --- Client management ---
33
+
34
+ export addClient = (hub, clientId, ws) ->
35
+ hub.sockets.set(clientId, ws)
36
+ hub.members.set(clientId, new Set())
37
+ hub.connections++
38
+ clientId
39
+
40
+ export removeClient = (hub, clientId) ->
41
+ hub.sockets.delete(clientId)
42
+ groups = hub.members.get(clientId)
43
+ hub.members.delete(clientId)
44
+ if groups
45
+ for group as groups
46
+ set = hub.members.get(group)
47
+ if set
48
+ set.delete(clientId)
49
+ hub.members.delete(group) if set.size is 0
50
+ return
51
+
52
+ # --- Membership ---
53
+
54
+ applySubscribe = (hub, groups, channels) ->
55
+ for group as groups
56
+ set = hub.members.get(group)
57
+ if set
58
+ for ch as channels
59
+ set.add(ch)
60
+ else
61
+ hub.members.set(group, new Set(channels))
62
+ for ch as channels
63
+ set = hub.members.get(ch)
64
+ if set
65
+ for group as groups
66
+ set.add(group)
67
+ else
68
+ hub.members.set(ch, new Set(groups))
69
+
70
+ applyUnsubscribe = (hub, groups, channels) ->
71
+ for group as groups
72
+ set = hub.members.get(group)
73
+ if set
74
+ for ch as channels
75
+ set.delete(ch)
76
+ hub.members.delete(group) if set.size is 0
77
+ for ch as channels
78
+ set = hub.members.get(ch)
79
+ if set
80
+ for group as groups
81
+ set.delete(group)
82
+ hub.members.delete(ch) if set.size is 0
83
+
84
+ # --- Message routing ---
85
+
86
+ resolveTargets = (hub, groups) ->
87
+ clients = new Set()
88
+ for group as groups
89
+ set = hub.members.get(group)
90
+ if set
91
+ if String(group).startsWith('/')
92
+ for id as set
93
+ clients.add(id)
94
+ else
95
+ clients.add(group)
96
+ clients
97
+
98
+ deliverToClients = (hub, clients, senders, payload) ->
99
+ for id as clients
100
+ continue if senders and senders.has(String(id))
101
+ ws = hub.sockets.get(id)
102
+ if ws
103
+ try
104
+ ws.send(payload)
105
+ hub.deliveries++
106
+ hub.messages++
107
+
108
+ # --- Process backend response ---
109
+
110
+ export processResponse = (hub, body, clientId) ->
111
+ try
112
+ json = JSON.parse(body)
113
+ return unless json and typeof json is 'object' # reject primitives (strings, numbers, booleans)
114
+ list = if Array.isArray(json) then json else [json]
115
+
116
+ for item in list
117
+ groups = null
118
+ concat = null
119
+ remove = null
120
+ senders = null
121
+ bundle = {}
122
+
123
+ for key, val of item
124
+ switch key
125
+ when '@' then groups = new Set(val.map(String))
126
+ when '+' then concat = new Set(val.map(String))
127
+ when '-' then remove = new Set(val.map(String))
128
+ when '>' then senders = new Set(val.map(String))
129
+ when '*' then null # close reason — handled by caller
130
+ else bundle[key] = val
131
+
132
+ groups = new Set([clientId]) if not groups and clientId
133
+
134
+ if groups
135
+ applySubscribe(hub, groups, concat) if concat
136
+ applyUnsubscribe(hub, groups, remove) if remove
137
+
138
+ if groups and Object.keys(bundle).length > 0
139
+ clients = resolveTargets(hub, groups)
140
+ deliverToClients(hub, clients, senders, JSON.stringify(bundle))
141
+ catch e
142
+ console.error "realtime: invalid response:", e.message
143
+
144
+ # --- Proxy to backend ---
145
+
146
+ export proxyToBackend = (hub, headers, frameType, body) ->
147
+ return unless hub.backendUrl
148
+ proxyHeaders = new Headers(headers)
149
+ proxyHeaders.set('Sec-WebSocket-Frame', frameType)
150
+ proxyHeaders.delete('Upgrade')
151
+ proxyHeaders.delete('Connection')
152
+ proxyHeaders.delete('Sec-WebSocket-Key')
153
+ proxyHeaders.delete('Sec-WebSocket-Version')
154
+ proxyHeaders.delete('Sec-WebSocket-Extensions')
155
+
156
+ try
157
+ res = fetch! hub.backendUrl,
158
+ method: 'POST'
159
+ headers: proxyHeaders
160
+ body: body or ''
161
+ res.text!
162
+ catch e
163
+ console.error "realtime: proxy failed:", e.message
164
+ null
165
+
166
+ # --- Binary delivery (for CRDT and raw data) ---
167
+
168
+ export broadcastBinary = (hub, groupIds, data, excludeClientId) ->
169
+ clients = new Set()
170
+ for group in groupIds
171
+ set = hub.members.get(String(group))
172
+ if set
173
+ if String(group).startsWith('/')
174
+ for id as set
175
+ clients.add(id)
176
+ else
177
+ clients.add(String(group))
178
+ for id as clients
179
+ continue if excludeClientId and id is excludeClientId
180
+ ws = hub.sockets.get(id)
181
+ if ws
182
+ try
183
+ ws.send(data)
184
+ hub.deliveries++
185
+ hub.messages++
186
+ return
187
+
188
+ # --- Publish (external) ---
189
+
190
+ export handlePublish = (hub, body) ->
191
+ processResponse(hub, body, null)
192
+
193
+ # --- Stats ---
194
+
195
+ export getRealtimeStats = (hub) ->
196
+ clients: hub.sockets.size
197
+ groups: hub.members.size - hub.sockets.size # groups minus client entries
198
+ deliveries: hub.deliveries
199
+ messages: hub.messages
200
+ connections: hub.connections
@@ -0,0 +1,44 @@
1
+ # ==============================================================================
2
+ # edge/registry.rip — per-app state and multi-app registry
3
+ # ==============================================================================
4
+
5
+ createAppState = (appId, config) ->
6
+ appId: appId
7
+ config: config
8
+ sockets: []
9
+ availableWorkers: []
10
+ queue: []
11
+ inflightTotal: 0
12
+ newestVersion: null
13
+ maxQueue: config.maxQueue or 512
14
+ queueTimeoutMs: config.queueTimeoutMs or 30000
15
+ readTimeoutMs: config.readTimeoutMs or 30000
16
+ hosts: config.hosts or []
17
+
18
+ export createAppRegistry = ->
19
+ apps: new Map()
20
+ hostIndex: new Map()
21
+
22
+ export registerApp = (registry, appId, config) ->
23
+ state = createAppState(appId, config)
24
+ registry.apps.set(appId, state)
25
+ for host in (config.hosts or [])
26
+ registry.hostIndex.set(host.toLowerCase(), appId)
27
+ state
28
+
29
+ export removeApp = (registry, appId) ->
30
+ state = registry.apps.get(appId)
31
+ return unless state
32
+ for host in state.hosts
33
+ h = host.toLowerCase()
34
+ registry.hostIndex.delete(h) if registry.hostIndex.get(h) is appId
35
+ registry.apps.delete(appId)
36
+
37
+ export resolveHost = (registry, hostname) ->
38
+ registry.hostIndex.get(hostname.toLowerCase()) ?? null
39
+
40
+ export getAppState = (registry, appId) ->
41
+ registry.apps.get(appId) ?? null
42
+
43
+ export getAllApps = (registry) ->
44
+ Array.from(registry.apps.values())
@@ -0,0 +1,38 @@
1
+ # ==============================================================================
2
+ # edge/security.rip — request smuggling and validation defenses
3
+ # ==============================================================================
4
+
5
+ MAX_URL_LENGTH = 8192
6
+
7
+ export validateRequest = (req) ->
8
+ url = new URL(req.url)
9
+
10
+ # Reject oversized URLs
11
+ if url.href.length > MAX_URL_LENGTH
12
+ return { valid: false, status: 400, message: 'URL too long' }
13
+
14
+ # Reject conflicting Content-Length and Transfer-Encoding
15
+ hasContentLength = req.headers.has('content-length')
16
+ hasTransferEncoding = req.headers.has('transfer-encoding')
17
+ if hasContentLength and hasTransferEncoding
18
+ return { valid: false, status: 400, message: 'Conflicting Content-Length and Transfer-Encoding' }
19
+
20
+ # Reject multiple Host headers (check for comma which indicates multiple values)
21
+ hostHeader = req.headers.get('host')
22
+ if hostHeader and hostHeader.includes(',')
23
+ return { valid: false, status: 400, message: 'Multiple Host headers' }
24
+
25
+ # Reject null bytes in URL
26
+ if url.pathname.includes('\x00') or url.search.includes('\x00')
27
+ return { valid: false, status: 400, message: 'Null byte in URL' }
28
+
29
+ # Reject path traversal — three decodes beyond URL parser
30
+ # Catches single (%2e), double (%252e), and triple (%25252e) encoding
31
+ pathname = url.pathname
32
+ d1 = try decodeURIComponent(pathname) catch then pathname
33
+ d2 = try decodeURIComponent(d1) catch then d1
34
+ d3 = try decodeURIComponent(d2) catch then d2
35
+ if pathname.includes('..') or d1.includes('..') or d2.includes('..') or d3.includes('..')
36
+ return { valid: false, status: 400, message: 'Path traversal rejected' }
37
+
38
+ { valid: true }
package/edge/tls.rip ADDED
@@ -0,0 +1,49 @@
1
+ # ==============================================================================
2
+ # edge/tls.rip — TLS material loading helpers
3
+ # ==============================================================================
4
+
5
+ import { readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { X509Certificate } from 'node:crypto'
8
+
9
+ export printCertSummary = (certPem) ->
10
+ try
11
+ x = new X509Certificate(certPem)
12
+ subject = x.subject.split(/,/)[0]?.trim() or x.subject
13
+ issuer = x.issuer.split(/,/)[0]?.trim() or x.issuer
14
+ exp = new Date(x.validTo)
15
+ p "rip-server: tls cert #{subject} issued by #{issuer} expires #{exp.toISOString()}"
16
+
17
+ export loadTlsMaterial = (flags, serverDir, acmeLoadCertFn) ->
18
+ # Explicit cert/key paths
19
+ if flags.certPath and flags.keyPath
20
+ try
21
+ cert = readFileSync(flags.certPath, 'utf8')
22
+ key = readFileSync(flags.keyPath, 'utf8')
23
+ printCertSummary(cert)
24
+ return { cert, key }
25
+ catch
26
+ console.error 'Failed to read TLS cert/key from provided paths. Use http or fix paths.'
27
+ exit 2
28
+
29
+ # ACME-managed cert (if domain is configured)
30
+ if acmeLoadCertFn and (flags.acme or flags.acmeStaging) and flags.acmeDomain
31
+ acmeCert = acmeLoadCertFn(flags.acmeDomain)
32
+ if acmeCert
33
+ p "rip-server: using ACME cert for #{flags.acmeDomain}"
34
+ printCertSummary(acmeCert.cert)
35
+ return { cert: acmeCert.cert, key: acmeCert.key }
36
+
37
+ # Shipped wildcard cert for *.ripdev.io (GlobalSign, valid for all subdomains)
38
+ certsDir = join(serverDir, 'certs')
39
+ certPath = join(certsDir, 'ripdev.io.crt')
40
+ keyPath = join(certsDir, 'ripdev.io.key')
41
+ try
42
+ cert = readFileSync(certPath, 'utf8')
43
+ key = readFileSync(keyPath, 'utf8')
44
+ printCertSummary(cert)
45
+ return { cert, key }
46
+ catch e
47
+ console.error "rip-server: failed to load TLS certs from #{certsDir}: #{e.message}"
48
+ console.error 'Use --cert/--key to provide your own, or use http to disable TLS.'
49
+ exit 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/server",
3
- "version": "1.3.110",
3
+ "version": "1.3.112",
4
4
  "description": "Pure Rip web framework and application server",
5
5
  "type": "module",
6
6
  "main": "api.rip",
@@ -53,10 +53,13 @@
53
53
  "middleware.rip",
54
54
  "server.rip",
55
55
  "server.html",
56
- "certs/",
56
+ "acme/",
57
57
  "bin/",
58
- "tests/",
58
+ "certs/",
59
+ "control/",
59
60
  "docs/",
61
+ "edge/",
62
+ "tests/",
60
63
  "README.md"
61
64
  ]
62
65
  }