@rip-lang/server 1.3.98 → 1.3.100
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 +292 -25
- package/docs/edge/CONFIG_LIFECYCLE.md +73 -0
- package/docs/edge/CONTRACTS.md +146 -0
- package/docs/edge/EDGEFILE_CONTRACT.md +53 -0
- package/docs/edge/M0B_REVIEW_NOTES.md +102 -0
- package/docs/edge/SCHEDULER.md +46 -0
- package/middleware.rip +6 -4
- package/package.json +2 -2
- package/server.rip +469 -636
- package/tests/acme.rip +124 -0
- package/tests/helpers.rip +90 -0
- package/tests/metrics.rip +73 -0
- package/tests/proxy.rip +99 -0
- package/tests/{read.test.rip → read.rip} +10 -11
- package/tests/realtime.rip +147 -0
- package/tests/registry.rip +125 -0
- package/tests/security.rip +95 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# test-registry.rip — edge/registry, scheduler, and queue tests
|
|
2
|
+
|
|
3
|
+
import { test, eq, ok } from '../test.rip'
|
|
4
|
+
import { createAppRegistry, registerApp, resolveHost, getAppState, removeApp } from '../edge/registry.rip'
|
|
5
|
+
import { isCurrentVersion, getNextAvailableSocket, releaseWorker, shouldRetryBodylessBusy, drainQueueOnce } from '../edge/queue.rip'
|
|
6
|
+
|
|
7
|
+
# --- Registry ---
|
|
8
|
+
|
|
9
|
+
test "createAppRegistry returns empty maps", ->
|
|
10
|
+
r = createAppRegistry()
|
|
11
|
+
eq r.apps.size, 0
|
|
12
|
+
eq r.hostIndex.size, 0
|
|
13
|
+
|
|
14
|
+
test "registerApp adds app and indexes hosts", ->
|
|
15
|
+
r = createAppRegistry()
|
|
16
|
+
registerApp(r, 'myapp', { hosts: ['example.com', 'www.example.com'] })
|
|
17
|
+
eq r.apps.size, 1
|
|
18
|
+
eq r.hostIndex.size, 2
|
|
19
|
+
eq resolveHost(r, 'example.com'), 'myapp'
|
|
20
|
+
eq resolveHost(r, 'www.example.com'), 'myapp'
|
|
21
|
+
|
|
22
|
+
test "resolveHost is case-insensitive", ->
|
|
23
|
+
r = createAppRegistry()
|
|
24
|
+
registerApp(r, 'app1', { hosts: ['Example.COM'] })
|
|
25
|
+
eq resolveHost(r, 'example.com'), 'app1'
|
|
26
|
+
eq resolveHost(r, 'EXAMPLE.COM'), 'app1'
|
|
27
|
+
|
|
28
|
+
test "resolveHost returns null for unknown host", ->
|
|
29
|
+
r = createAppRegistry()
|
|
30
|
+
eq resolveHost(r, 'unknown.com'), null
|
|
31
|
+
|
|
32
|
+
test "getAppState returns app or null", ->
|
|
33
|
+
r = createAppRegistry()
|
|
34
|
+
registerApp(r, 'app1', { hosts: [] })
|
|
35
|
+
ok getAppState(r, 'app1')
|
|
36
|
+
eq getAppState(r, 'nope'), null
|
|
37
|
+
|
|
38
|
+
test "removeApp cleans up hosts and app", ->
|
|
39
|
+
r = createAppRegistry()
|
|
40
|
+
registerApp(r, 'app1', { hosts: ['a.com', 'b.com'] })
|
|
41
|
+
removeApp(r, 'app1')
|
|
42
|
+
eq r.apps.size, 0
|
|
43
|
+
eq r.hostIndex.size, 0
|
|
44
|
+
eq resolveHost(r, 'a.com'), null
|
|
45
|
+
|
|
46
|
+
test "AppState has correct defaults", ->
|
|
47
|
+
r = createAppRegistry()
|
|
48
|
+
state = registerApp(r, 'app1', { hosts: ['h.com'] })
|
|
49
|
+
eq state.sockets.length, 0
|
|
50
|
+
eq state.inflightTotal, 0
|
|
51
|
+
eq state.maxQueue, 512
|
|
52
|
+
eq state.queueTimeoutMs, 30000
|
|
53
|
+
|
|
54
|
+
# --- Scheduler ---
|
|
55
|
+
|
|
56
|
+
test "getNextAvailableSocket returns worker with 0 inflight", ->
|
|
57
|
+
workers = [{ socket: '/a', inflight: 0, version: 1, workerId: 0 }]
|
|
58
|
+
sock = getNextAvailableSocket(workers, null)
|
|
59
|
+
eq sock.socket, '/a'
|
|
60
|
+
|
|
61
|
+
test "getNextAvailableSocket skips inflight workers", ->
|
|
62
|
+
workers = [
|
|
63
|
+
{ socket: '/a', inflight: 1, version: 1, workerId: 0 }
|
|
64
|
+
{ socket: '/b', inflight: 0, version: 1, workerId: 1 }
|
|
65
|
+
]
|
|
66
|
+
sock = getNextAvailableSocket(workers, null)
|
|
67
|
+
eq sock.socket, '/b'
|
|
68
|
+
|
|
69
|
+
test "getNextAvailableSocket returns null when all busy", ->
|
|
70
|
+
workers = [{ socket: '/a', inflight: 1, version: 1, workerId: 0 }]
|
|
71
|
+
sock = getNextAvailableSocket(workers, null)
|
|
72
|
+
eq sock, null
|
|
73
|
+
|
|
74
|
+
test "isCurrentVersion with no version tracking", ->
|
|
75
|
+
ok isCurrentVersion(null, { version: 1 })
|
|
76
|
+
ok isCurrentVersion(null, { version: null })
|
|
77
|
+
|
|
78
|
+
test "releaseWorker resets inflight and re-adds to pool", ->
|
|
79
|
+
w = { socket: '/a', inflight: 1, version: 1, workerId: 0 }
|
|
80
|
+
sockets = [w]
|
|
81
|
+
available = []
|
|
82
|
+
releaseWorker(sockets, available, null, w)
|
|
83
|
+
eq w.inflight, 0
|
|
84
|
+
eq available.length, 1
|
|
85
|
+
|
|
86
|
+
test "shouldRetryBodylessBusy for GET + 503 + busy header", ->
|
|
87
|
+
req = { method: 'GET' }
|
|
88
|
+
headers = new Headers({ 'Rip-Worker-Busy': '1' })
|
|
89
|
+
res = { status: 503, headers }
|
|
90
|
+
ok shouldRetryBodylessBusy(req, res)
|
|
91
|
+
|
|
92
|
+
test "shouldRetryBodylessBusy rejects POST", ->
|
|
93
|
+
req = { method: 'POST' }
|
|
94
|
+
headers = new Headers({ 'Rip-Worker-Busy': '1' })
|
|
95
|
+
res = { status: 503, headers }
|
|
96
|
+
eq shouldRetryBodylessBusy(req, res), false
|
|
97
|
+
|
|
98
|
+
# --- Queue ---
|
|
99
|
+
|
|
100
|
+
test "drainQueueOnce processes one job from queue", ->
|
|
101
|
+
processed = []
|
|
102
|
+
queue = [{ req: 'r1', resolve: (->), enqueuedAt: Date.now() }]
|
|
103
|
+
inflightOut = drainQueueOnce(
|
|
104
|
+
0, 2, (-> 1),
|
|
105
|
+
(-> queue.shift()),
|
|
106
|
+
(-> false),
|
|
107
|
+
(->),
|
|
108
|
+
(-> { socket: '/a', inflight: 0 }),
|
|
109
|
+
((job, worker) -> processed.push(job.req))
|
|
110
|
+
)
|
|
111
|
+
eq processed.length, 1
|
|
112
|
+
eq inflightOut, 1
|
|
113
|
+
|
|
114
|
+
test "drainQueueOnce resolves timed-out job", ->
|
|
115
|
+
resolved = []
|
|
116
|
+
queue = [{ req: 'r1', resolve: ((r) -> resolved.push('timeout')), enqueuedAt: 0 }]
|
|
117
|
+
drainQueueOnce(
|
|
118
|
+
0, 2, (-> 1),
|
|
119
|
+
(-> queue.shift()),
|
|
120
|
+
(-> true),
|
|
121
|
+
(-> 'timeout-response'),
|
|
122
|
+
(-> null),
|
|
123
|
+
(->)
|
|
124
|
+
)
|
|
125
|
+
eq resolved[0], 'timeout'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# security.rip — rate limiter, request validation, and request ID tests
|
|
2
|
+
|
|
3
|
+
import { test, eq, ok } from '../test.rip'
|
|
4
|
+
import { createRateLimiter, rateLimitResponse } from '../edge/ratelimit.rip'
|
|
5
|
+
import { validateRequest } from '../edge/security.rip'
|
|
6
|
+
import { generateRequestId } from '../edge/forwarding.rip'
|
|
7
|
+
|
|
8
|
+
# --- Request ID ---
|
|
9
|
+
|
|
10
|
+
test "generateRequestId returns req- prefixed hex", ->
|
|
11
|
+
id = generateRequestId()
|
|
12
|
+
ok id.startsWith('req-')
|
|
13
|
+
eq id.length, 16 # "req-" + 12 hex chars (6 random bytes)
|
|
14
|
+
|
|
15
|
+
test "generateRequestId is unique", ->
|
|
16
|
+
a = generateRequestId()
|
|
17
|
+
b = generateRequestId()
|
|
18
|
+
ok a isnt b
|
|
19
|
+
|
|
20
|
+
# --- Rate limiter ---
|
|
21
|
+
|
|
22
|
+
test "createRateLimiter allows requests under limit", ->
|
|
23
|
+
limiter = createRateLimiter(5, 60000)
|
|
24
|
+
result = limiter.check('1.2.3.4')
|
|
25
|
+
eq result.allowed, true
|
|
26
|
+
eq result.remaining, 4
|
|
27
|
+
limiter.stop()
|
|
28
|
+
|
|
29
|
+
test "rate limiter rejects when limit exceeded", ->
|
|
30
|
+
limiter = createRateLimiter(3, 60000)
|
|
31
|
+
limiter.check('1.2.3.4')
|
|
32
|
+
limiter.check('1.2.3.4')
|
|
33
|
+
limiter.check('1.2.3.4')
|
|
34
|
+
result = limiter.check('1.2.3.4')
|
|
35
|
+
eq result.allowed, false
|
|
36
|
+
ok result.retryAfter > 0
|
|
37
|
+
limiter.stop()
|
|
38
|
+
|
|
39
|
+
test "rate limiter tracks IPs independently", ->
|
|
40
|
+
limiter = createRateLimiter(2, 60000)
|
|
41
|
+
limiter.check('1.1.1.1')
|
|
42
|
+
limiter.check('1.1.1.1')
|
|
43
|
+
result = limiter.check('2.2.2.2')
|
|
44
|
+
eq result.allowed, true
|
|
45
|
+
limiter.stop()
|
|
46
|
+
|
|
47
|
+
test "rate limiter reset clears IP", ->
|
|
48
|
+
limiter = createRateLimiter(2, 60000)
|
|
49
|
+
limiter.check('1.1.1.1')
|
|
50
|
+
limiter.check('1.1.1.1')
|
|
51
|
+
limiter.reset('1.1.1.1')
|
|
52
|
+
result = limiter.check('1.1.1.1')
|
|
53
|
+
eq result.allowed, true
|
|
54
|
+
limiter.stop()
|
|
55
|
+
|
|
56
|
+
test "rateLimitResponse returns 429", ->
|
|
57
|
+
res = rateLimitResponse(30)
|
|
58
|
+
eq res.status, 429
|
|
59
|
+
eq res.headers.get('Retry-After'), '30'
|
|
60
|
+
|
|
61
|
+
# --- Request validation ---
|
|
62
|
+
|
|
63
|
+
test "validateRequest accepts normal request", ->
|
|
64
|
+
req = new Request('http://localhost/hello')
|
|
65
|
+
result = validateRequest(req)
|
|
66
|
+
eq result.valid, true
|
|
67
|
+
|
|
68
|
+
test "validateRequest rejects CL+TE conflict", ->
|
|
69
|
+
req = new Request 'http://localhost/',
|
|
70
|
+
headers: { 'content-length': '10', 'transfer-encoding': 'chunked' }
|
|
71
|
+
result = validateRequest(req)
|
|
72
|
+
eq result.valid, false
|
|
73
|
+
eq result.status, 400
|
|
74
|
+
|
|
75
|
+
test "validateRequest rejects multiple Host headers", ->
|
|
76
|
+
req = new Request 'http://localhost/',
|
|
77
|
+
headers: { host: 'a.com, b.com' }
|
|
78
|
+
result = validateRequest(req)
|
|
79
|
+
eq result.valid, false
|
|
80
|
+
|
|
81
|
+
test "URL parser normalizes basic path traversal", ->
|
|
82
|
+
# Bun's URL parser resolves .. before it reaches validateRequest
|
|
83
|
+
url = new URL('http://localhost/../../../etc/passwd')
|
|
84
|
+
eq url.pathname, '/etc/passwd' # already normalized — safe
|
|
85
|
+
|
|
86
|
+
test "validateRequest rejects double-encoded traversal", ->
|
|
87
|
+
# %252e%252e decodes to %2e%2e on first pass, then .. on second
|
|
88
|
+
req = new Request('http://localhost/%252e%252e/%252e%252e/etc/passwd')
|
|
89
|
+
result = validateRequest(req)
|
|
90
|
+
eq result.valid, false
|
|
91
|
+
|
|
92
|
+
test "validateRequest accepts normal paths with dots", ->
|
|
93
|
+
req = new Request('http://localhost/api/v1.0/users')
|
|
94
|
+
result = validateRequest(req)
|
|
95
|
+
eq result.valid, true
|