@rip-lang/server 1.3.98 → 1.3.99
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
package/tests/acme.rip
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# test-acme.rip — ACME challenges, store, and crypto tests
|
|
2
|
+
|
|
3
|
+
import { test, eq, ok } from '../test.rip'
|
|
4
|
+
import { createChallengeStore, handleChallengeRequest } from '../acme/store.rip'
|
|
5
|
+
import { defaultCertDir, ensureDir, saveCert, loadCert, needsRenewal, listCerts } from '../acme/store.rip'
|
|
6
|
+
import { b64url, generateAccountKeyPair, exportPublicJwk, thumbprint, signJws } from '../acme/crypto.rip'
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
# --- Challenge store ---
|
|
12
|
+
|
|
13
|
+
test "createChallengeStore set/get/has/remove", ->
|
|
14
|
+
store = createChallengeStore()
|
|
15
|
+
eq store.has('tok1'), false
|
|
16
|
+
store.set('tok1', 'auth1')
|
|
17
|
+
eq store.has('tok1'), true
|
|
18
|
+
eq store.get('tok1'), 'auth1'
|
|
19
|
+
store.remove('tok1')
|
|
20
|
+
eq store.has('tok1'), false
|
|
21
|
+
eq store.get('tok1'), null
|
|
22
|
+
|
|
23
|
+
test "handleChallengeRequest serves token", ->
|
|
24
|
+
store = createChallengeStore()
|
|
25
|
+
store.set('abc123', 'abc123.thumbprint')
|
|
26
|
+
url = new URL('http://example.com/.well-known/acme-challenge/abc123')
|
|
27
|
+
res = handleChallengeRequest(url, store)
|
|
28
|
+
ok res
|
|
29
|
+
eq res.status, 200
|
|
30
|
+
|
|
31
|
+
test "handleChallengeRequest returns null for unknown token", ->
|
|
32
|
+
store = createChallengeStore()
|
|
33
|
+
url = new URL('http://example.com/.well-known/acme-challenge/unknown')
|
|
34
|
+
res = handleChallengeRequest(url, store)
|
|
35
|
+
eq res, null
|
|
36
|
+
|
|
37
|
+
test "handleChallengeRequest returns null for non-challenge path", ->
|
|
38
|
+
store = createChallengeStore()
|
|
39
|
+
url = new URL('http://example.com/other/path')
|
|
40
|
+
res = handleChallengeRequest(url, store)
|
|
41
|
+
eq res, null
|
|
42
|
+
|
|
43
|
+
# --- Cert store ---
|
|
44
|
+
|
|
45
|
+
test "saveCert and loadCert round-trip", ->
|
|
46
|
+
dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
|
|
47
|
+
saveCert(dir, 'test.com', 'CERT-PEM', 'KEY-PEM')
|
|
48
|
+
rec = loadCert(dir, 'test.com')
|
|
49
|
+
ok rec
|
|
50
|
+
eq rec.cert, 'CERT-PEM'
|
|
51
|
+
eq rec.key, 'KEY-PEM'
|
|
52
|
+
rmSync(dir, { recursive: true })
|
|
53
|
+
|
|
54
|
+
test "loadCert returns null for missing domain", ->
|
|
55
|
+
dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
|
|
56
|
+
rec = loadCert(dir, 'missing.com')
|
|
57
|
+
eq rec, null
|
|
58
|
+
rmSync(dir, { recursive: true })
|
|
59
|
+
|
|
60
|
+
test "needsRenewal returns true for missing cert", ->
|
|
61
|
+
dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
|
|
62
|
+
eq needsRenewal(dir, 'missing.com'), true
|
|
63
|
+
rmSync(dir, { recursive: true })
|
|
64
|
+
|
|
65
|
+
test "listCerts returns domain directories", ->
|
|
66
|
+
dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
|
|
67
|
+
saveCert(dir, 'a.com', 'C', 'K')
|
|
68
|
+
saveCert(dir, 'b.com', 'C', 'K')
|
|
69
|
+
certs = listCerts(dir)
|
|
70
|
+
ok certs.includes('a.com')
|
|
71
|
+
ok certs.includes('b.com')
|
|
72
|
+
rmSync(dir, { recursive: true })
|
|
73
|
+
|
|
74
|
+
test "defaultCertDir returns a path", ->
|
|
75
|
+
d = defaultCertDir()
|
|
76
|
+
ok typeof d is 'string'
|
|
77
|
+
ok d.includes('.rip')
|
|
78
|
+
|
|
79
|
+
# --- Crypto ---
|
|
80
|
+
|
|
81
|
+
test "b64url encodes correctly", ->
|
|
82
|
+
eq b64url('hello'), 'aGVsbG8'
|
|
83
|
+
eq b64url(''), ''
|
|
84
|
+
|
|
85
|
+
test "b64url strips padding", ->
|
|
86
|
+
encoded = b64url('a')
|
|
87
|
+
eq encoded.includes('='), false
|
|
88
|
+
|
|
89
|
+
test "generateAccountKeyPair returns keypair", ->
|
|
90
|
+
pair = generateAccountKeyPair()
|
|
91
|
+
ok pair.publicKey
|
|
92
|
+
ok pair.privateKey
|
|
93
|
+
|
|
94
|
+
test "exportPublicJwk returns EC P-256 JWK", ->
|
|
95
|
+
pair = generateAccountKeyPair()
|
|
96
|
+
jwk = exportPublicJwk(pair)
|
|
97
|
+
eq jwk.kty, 'EC'
|
|
98
|
+
eq jwk.crv, 'P-256'
|
|
99
|
+
ok jwk.x
|
|
100
|
+
ok jwk.y
|
|
101
|
+
|
|
102
|
+
test "thumbprint returns 43-char base64url", ->
|
|
103
|
+
pair = generateAccountKeyPair()
|
|
104
|
+
jwk = exportPublicJwk(pair)
|
|
105
|
+
tp = thumbprint(jwk)
|
|
106
|
+
eq tp.length, 43
|
|
107
|
+
|
|
108
|
+
test "signJws returns valid JWS structure", ->
|
|
109
|
+
pair = generateAccountKeyPair()
|
|
110
|
+
jwk = exportPublicJwk(pair)
|
|
111
|
+
jws = signJws({ test: true }, 'https://example.com', 'nonce1', pair.privateKey, jwk, null)
|
|
112
|
+
parsed = JSON.parse(jws)
|
|
113
|
+
ok parsed.protected
|
|
114
|
+
ok parsed.payload
|
|
115
|
+
ok parsed.signature
|
|
116
|
+
|
|
117
|
+
test "signJws with kid omits jwk from header", ->
|
|
118
|
+
pair = generateAccountKeyPair()
|
|
119
|
+
jwk = exportPublicJwk(pair)
|
|
120
|
+
jws = signJws({}, 'https://example.com', 'nonce1', pair.privateKey, jwk, 'https://acme/acct/1')
|
|
121
|
+
parsed = JSON.parse(jws)
|
|
122
|
+
header = JSON.parse(Buffer.from(parsed.protected.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString())
|
|
123
|
+
eq header.kid, 'https://acme/acct/1'
|
|
124
|
+
eq header.jwk, undefined
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# test-helpers.rip — edge/helpers and forwarding response factory tests
|
|
2
|
+
|
|
3
|
+
import { test, eq, ok } from '../test.rip'
|
|
4
|
+
import { formatPort, maybeAddSecurityHeaders, buildStatusBody, buildRipdevUrl } from '../edge/forwarding.rip'
|
|
5
|
+
import { watchUnavailableResponse, serverBusyResponse, serviceUnavailableResponse, gatewayTimeoutResponse, queueTimeoutResponse } from '../edge/forwarding.rip'
|
|
6
|
+
|
|
7
|
+
# --- formatPort ---
|
|
8
|
+
|
|
9
|
+
test "formatPort hides default HTTPS port", ->
|
|
10
|
+
eq formatPort('https', 443), ''
|
|
11
|
+
|
|
12
|
+
test "formatPort hides default HTTP port", ->
|
|
13
|
+
eq formatPort('http', 80), ''
|
|
14
|
+
|
|
15
|
+
test "formatPort shows non-default port", ->
|
|
16
|
+
eq formatPort('https', 3443), ':3443'
|
|
17
|
+
eq formatPort('http', 8080), ':8080'
|
|
18
|
+
|
|
19
|
+
# --- maybeAddSecurityHeaders ---
|
|
20
|
+
|
|
21
|
+
test "maybeAddSecurityHeaders sets HSTS when active", ->
|
|
22
|
+
headers = new Headers()
|
|
23
|
+
maybeAddSecurityHeaders(true, true, headers)
|
|
24
|
+
ok headers.has('strict-transport-security')
|
|
25
|
+
|
|
26
|
+
test "maybeAddSecurityHeaders skips when not HTTPS", ->
|
|
27
|
+
headers = new Headers()
|
|
28
|
+
maybeAddSecurityHeaders(false, true, headers)
|
|
29
|
+
eq headers.has('strict-transport-security'), false
|
|
30
|
+
|
|
31
|
+
test "maybeAddSecurityHeaders skips when HSTS disabled", ->
|
|
32
|
+
headers = new Headers()
|
|
33
|
+
maybeAddSecurityHeaders(true, false, headers)
|
|
34
|
+
eq headers.has('strict-transport-security'), false
|
|
35
|
+
|
|
36
|
+
test "maybeAddSecurityHeaders does not overwrite existing", ->
|
|
37
|
+
headers = new Headers({ 'strict-transport-security': 'custom' })
|
|
38
|
+
maybeAddSecurityHeaders(true, true, headers)
|
|
39
|
+
eq headers.get('strict-transport-security'), 'custom'
|
|
40
|
+
|
|
41
|
+
# --- buildRipdevUrl ---
|
|
42
|
+
|
|
43
|
+
test "buildRipdevUrl with default port", ->
|
|
44
|
+
url = buildRipdevUrl('myapp', 'https', 443, formatPort)
|
|
45
|
+
eq url, 'https://myapp.ripdev.io'
|
|
46
|
+
|
|
47
|
+
test "buildRipdevUrl with custom port", ->
|
|
48
|
+
url = buildRipdevUrl('myapp', 'https', 3443, formatPort)
|
|
49
|
+
eq url, 'https://myapp.ripdev.io:3443'
|
|
50
|
+
|
|
51
|
+
# --- buildStatusBody ---
|
|
52
|
+
|
|
53
|
+
test "buildStatusBody returns valid JSON", ->
|
|
54
|
+
hostIndex = new Map([['localhost', 'app1']])
|
|
55
|
+
body = buildStatusBody(Date.now() - 5000, 2, hostIndex, '1.0.0', 'myapp', 80, 443, (-> Date.now()))
|
|
56
|
+
parsed = JSON.parse(body)
|
|
57
|
+
eq parsed.status, 'healthy'
|
|
58
|
+
eq parsed.workers, 2
|
|
59
|
+
eq parsed.app, 'myapp'
|
|
60
|
+
ok parsed.hosts.includes('localhost')
|
|
61
|
+
|
|
62
|
+
test "buildStatusBody degraded when no workers", ->
|
|
63
|
+
hostIndex = new Map()
|
|
64
|
+
body = buildStatusBody(Date.now(), 0, hostIndex, '1.0', 'app', 80, null, (-> Date.now()))
|
|
65
|
+
parsed = JSON.parse(body)
|
|
66
|
+
eq parsed.status, 'degraded'
|
|
67
|
+
|
|
68
|
+
# --- Response factories ---
|
|
69
|
+
|
|
70
|
+
test "watchUnavailableResponse returns 503", ->
|
|
71
|
+
res = watchUnavailableResponse()
|
|
72
|
+
eq res.status, 503
|
|
73
|
+
eq res.headers.get('Retry-After'), '2'
|
|
74
|
+
|
|
75
|
+
test "serverBusyResponse returns 503", ->
|
|
76
|
+
res = serverBusyResponse()
|
|
77
|
+
eq res.status, 503
|
|
78
|
+
eq res.headers.get('Retry-After'), '1'
|
|
79
|
+
|
|
80
|
+
test "serviceUnavailableResponse returns 503", ->
|
|
81
|
+
res = serviceUnavailableResponse()
|
|
82
|
+
eq res.status, 503
|
|
83
|
+
|
|
84
|
+
test "gatewayTimeoutResponse returns 504", ->
|
|
85
|
+
res = gatewayTimeoutResponse()
|
|
86
|
+
eq res.status, 504
|
|
87
|
+
|
|
88
|
+
test "queueTimeoutResponse returns 504", ->
|
|
89
|
+
res = queueTimeoutResponse()
|
|
90
|
+
eq res.status, 504
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# test-metrics.rip — edge/metrics.rip tests
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { test, eq, ok, near } from '../test.rip'
|
|
6
|
+
import { createMetrics } from '../edge/metrics.rip'
|
|
7
|
+
|
|
8
|
+
test "createMetrics returns object with counters", ->
|
|
9
|
+
m = createMetrics()
|
|
10
|
+
eq m.requests, 0
|
|
11
|
+
eq m.forwarded, 0
|
|
12
|
+
eq m.responses2xx, 0
|
|
13
|
+
eq m.queueShed, 0
|
|
14
|
+
|
|
15
|
+
test "counter increments", ->
|
|
16
|
+
m = createMetrics()
|
|
17
|
+
m.requests++
|
|
18
|
+
m.requests++
|
|
19
|
+
m.forwarded++
|
|
20
|
+
eq m.requests, 2
|
|
21
|
+
eq m.forwarded, 1
|
|
22
|
+
|
|
23
|
+
test "recordStatus tracks 2xx", ->
|
|
24
|
+
m = createMetrics()
|
|
25
|
+
m.recordStatus(200)
|
|
26
|
+
m.recordStatus(201)
|
|
27
|
+
m.recordStatus(204)
|
|
28
|
+
eq m.responses2xx, 3
|
|
29
|
+
eq m.responses4xx, 0
|
|
30
|
+
|
|
31
|
+
test "recordStatus tracks 4xx and 5xx", ->
|
|
32
|
+
m = createMetrics()
|
|
33
|
+
m.recordStatus(404)
|
|
34
|
+
m.recordStatus(500)
|
|
35
|
+
m.recordStatus(503)
|
|
36
|
+
eq m.responses4xx, 1
|
|
37
|
+
eq m.responses5xx, 2
|
|
38
|
+
|
|
39
|
+
test "recordLatency and percentiles with single value", ->
|
|
40
|
+
m = createMetrics()
|
|
41
|
+
m.recordLatency(0.05)
|
|
42
|
+
p = m.percentiles()
|
|
43
|
+
near p.p50, 0.05
|
|
44
|
+
near p.p95, 0.05
|
|
45
|
+
near p.p99, 0.05
|
|
46
|
+
|
|
47
|
+
test "percentiles with multiple values", ->
|
|
48
|
+
m = createMetrics()
|
|
49
|
+
for i in [1..100]
|
|
50
|
+
m.recordLatency(i / 1000)
|
|
51
|
+
p = m.percentiles()
|
|
52
|
+
ok p.p50 >= 0.045 and p.p50 <= 0.055
|
|
53
|
+
ok p.p95 >= 0.090 and p.p95 <= 0.100
|
|
54
|
+
ok p.p99 >= 0.095 and p.p99 <= 0.105
|
|
55
|
+
|
|
56
|
+
test "percentiles empty returns zeros", ->
|
|
57
|
+
m = createMetrics()
|
|
58
|
+
p = m.percentiles()
|
|
59
|
+
eq p.p50, 0
|
|
60
|
+
eq p.p95, 0
|
|
61
|
+
eq p.p99, 0
|
|
62
|
+
|
|
63
|
+
test "snapshot returns expected shape", ->
|
|
64
|
+
m = createMetrics()
|
|
65
|
+
m.requests = 100
|
|
66
|
+
m.responses2xx = 90
|
|
67
|
+
m.responses5xx = 10
|
|
68
|
+
snap = m.snapshot(Date.now() - 60000, null)
|
|
69
|
+
ok snap.uptime >= 59 and snap.uptime <= 61
|
|
70
|
+
eq snap.counters.requests, 100
|
|
71
|
+
eq snap.counters.responses['2xx'], 90
|
|
72
|
+
eq snap.counters.responses['5xx'], 10
|
|
73
|
+
eq snap.apps, []
|
package/tests/proxy.rip
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# proxy.rip — reverse proxy and config loader tests
|
|
2
|
+
|
|
3
|
+
import { test, eq, ok } from '../test.rip'
|
|
4
|
+
import { normalizeAppConfig, applyConfig } from '../edge/config.rip'
|
|
5
|
+
import { createAppRegistry, registerApp, resolveHost, getAppState } from '../edge/registry.rip'
|
|
6
|
+
import { broadcastBinary, createHub, addClient } from '../edge/realtime.rip'
|
|
7
|
+
import { setEventJsonMode, logEvent } from '../control/lifecycle.rip'
|
|
8
|
+
|
|
9
|
+
# --- Config normalization ---
|
|
10
|
+
|
|
11
|
+
test "normalizeAppConfig sets defaults", ->
|
|
12
|
+
config = normalizeAppConfig('myapp', {}, '/base')
|
|
13
|
+
eq config.id, 'myapp'
|
|
14
|
+
eq config.maxQueue, 512
|
|
15
|
+
eq config.queueTimeoutMs, 30000
|
|
16
|
+
eq config.readTimeoutMs, 30000
|
|
17
|
+
eq config.hosts.length, 0
|
|
18
|
+
|
|
19
|
+
test "normalizeAppConfig preserves explicit values", ->
|
|
20
|
+
config = normalizeAppConfig('api', { hosts: ['api.com'], workers: 8, maxQueue: 2048 }, '/base')
|
|
21
|
+
eq config.hosts[0], 'api.com'
|
|
22
|
+
eq config.workers, 8
|
|
23
|
+
eq config.maxQueue, 2048
|
|
24
|
+
|
|
25
|
+
test "normalizeAppConfig resolves relative entry path", ->
|
|
26
|
+
config = normalizeAppConfig('app', { entry: './index.rip' }, '/srv/apps')
|
|
27
|
+
ok config.entry.startsWith('/')
|
|
28
|
+
ok config.entry.includes('index.rip')
|
|
29
|
+
|
|
30
|
+
test "normalizeAppConfig keeps absolute entry path", ->
|
|
31
|
+
config = normalizeAppConfig('app', { entry: '/opt/app/index.rip' }, '/srv')
|
|
32
|
+
eq config.entry, '/opt/app/index.rip'
|
|
33
|
+
|
|
34
|
+
# --- Config apply ---
|
|
35
|
+
|
|
36
|
+
test "applyConfig registers apps in registry", ->
|
|
37
|
+
registry = createAppRegistry()
|
|
38
|
+
config =
|
|
39
|
+
apps:
|
|
40
|
+
web: { hosts: ['example.com'], workers: 4 }
|
|
41
|
+
api: { hosts: ['api.example.com'], workers: 2 }
|
|
42
|
+
registered = applyConfig(config, registry, registerApp, '/srv')
|
|
43
|
+
eq registered.length, 2
|
|
44
|
+
eq resolveHost(registry, 'example.com'), 'web'
|
|
45
|
+
eq resolveHost(registry, 'api.example.com'), 'api'
|
|
46
|
+
|
|
47
|
+
test "applyConfig handles empty apps", ->
|
|
48
|
+
registry = createAppRegistry()
|
|
49
|
+
registered = applyConfig({ apps: {} }, registry, registerApp, '/srv')
|
|
50
|
+
eq registered.length, 0
|
|
51
|
+
|
|
52
|
+
# --- Binary broadcast ---
|
|
53
|
+
|
|
54
|
+
test "broadcastBinary delivers to group members", ->
|
|
55
|
+
hub = createHub()
|
|
56
|
+
received = []
|
|
57
|
+
addClient(hub, 'c1', { send: ((data) -> received.push(data)) })
|
|
58
|
+
addClient(hub, 'c2', { send: ((data) -> received.push(data)) })
|
|
59
|
+
# Subscribe both to /room
|
|
60
|
+
hub.members.get('c1').add('/room')
|
|
61
|
+
hub.members.get('c2').add('/room')
|
|
62
|
+
hub.members.set('/room', new Set(['c1', 'c2']))
|
|
63
|
+
# Broadcast binary
|
|
64
|
+
buf = new Uint8Array([1, 2, 3, 4])
|
|
65
|
+
broadcastBinary(hub, ['/room'], buf, null)
|
|
66
|
+
eq received.length, 2
|
|
67
|
+
|
|
68
|
+
test "broadcastBinary excludes sender", ->
|
|
69
|
+
hub = createHub()
|
|
70
|
+
received = { c1: [], c2: [] }
|
|
71
|
+
addClient(hub, 'c1', { send: ((data) -> received.c1.push(data)) })
|
|
72
|
+
addClient(hub, 'c2', { send: ((data) -> received.c2.push(data)) })
|
|
73
|
+
hub.members.get('c1').add('/room')
|
|
74
|
+
hub.members.get('c2').add('/room')
|
|
75
|
+
hub.members.set('/room', new Set(['c1', 'c2']))
|
|
76
|
+
broadcastBinary(hub, ['/room'], new Uint8Array([5, 6]), 'c1')
|
|
77
|
+
eq received.c1.length, 0
|
|
78
|
+
eq received.c2.length, 1
|
|
79
|
+
|
|
80
|
+
test "broadcastBinary to direct client", ->
|
|
81
|
+
hub = createHub()
|
|
82
|
+
received = []
|
|
83
|
+
addClient(hub, 'c1', { send: ((data) -> received.push(data)) })
|
|
84
|
+
hub.members.set('c1', new Set())
|
|
85
|
+
broadcastBinary(hub, ['c1'], new Uint8Array([7, 8]), null)
|
|
86
|
+
eq received.length, 1
|
|
87
|
+
|
|
88
|
+
# --- Event logger ---
|
|
89
|
+
|
|
90
|
+
test "logEvent emits without error", ->
|
|
91
|
+
setEventJsonMode(false)
|
|
92
|
+
logEvent('test_event', { key: 'val' })
|
|
93
|
+
ok true
|
|
94
|
+
|
|
95
|
+
test "logEvent JSON mode emits without error", ->
|
|
96
|
+
setEventJsonMode(true)
|
|
97
|
+
logEvent('test_event', { key: 'val' })
|
|
98
|
+
setEventJsonMode(false)
|
|
99
|
+
ok true
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# Each test is self-contained: t(name, input, expected, fn)
|
|
8
8
|
# No HTTP server needed — uses requestContext.run() directly.
|
|
9
9
|
#
|
|
10
|
-
# Run: rip packages/server/tests/read.
|
|
10
|
+
# Run: rip packages/server/test/tests/read.rip
|
|
11
11
|
#
|
|
12
12
|
|
|
13
13
|
import { read, requestContext } from '../api.rip'
|
|
@@ -33,16 +33,16 @@ t = (name, was, now, fn) ->
|
|
|
33
33
|
actual = fn()
|
|
34
34
|
if deepEq(actual, now)
|
|
35
35
|
passed++
|
|
36
|
-
|
|
36
|
+
p "✓ #{name}"
|
|
37
37
|
else
|
|
38
38
|
failed++
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
p "✗ #{name}"
|
|
40
|
+
p " was: #{stringify(was)}"
|
|
41
|
+
p " expected: #{stringify(now)}"
|
|
42
|
+
p " actual: #{stringify(actual)}"
|
|
43
43
|
catch err
|
|
44
44
|
failed++
|
|
45
|
-
|
|
45
|
+
p "✗ #{name} — threw: #{err.message}"
|
|
46
46
|
|
|
47
47
|
# f(name, was, fn) — expect a throw
|
|
48
48
|
f = (name, was, fn) ->
|
|
@@ -51,10 +51,10 @@ f = (name, was, fn) ->
|
|
|
51
51
|
try
|
|
52
52
|
fn()
|
|
53
53
|
failed++
|
|
54
|
-
|
|
54
|
+
p "✗ #{name} — expected throw"
|
|
55
55
|
catch
|
|
56
56
|
passed++
|
|
57
|
-
|
|
57
|
+
p "✓ #{name}"
|
|
58
58
|
|
|
59
59
|
# ==============================================================================
|
|
60
60
|
# read() basics (no validator)
|
|
@@ -250,5 +250,4 @@ t "required w/ miss()" , undefined, 'ok' -> read 'v', 'email!', -> 'ok'
|
|
|
250
250
|
# Results
|
|
251
251
|
# ==============================================================================
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
process.exit(if failed > 0 then 1 else 0)
|
|
253
|
+
p "\n#{passed} passed, #{failed} failed (#{total} total)"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# realtime.rip — edge/realtime.rip unit tests
|
|
2
|
+
|
|
3
|
+
import { test, eq, ok } from '../test.rip'
|
|
4
|
+
import { createHub, generateClientId, addClient, removeClient, processResponse, handlePublish, getRealtimeStats } from '../edge/realtime.rip'
|
|
5
|
+
|
|
6
|
+
# --- Hub lifecycle ---
|
|
7
|
+
|
|
8
|
+
test "createHub returns empty hub", ->
|
|
9
|
+
hub = createHub()
|
|
10
|
+
eq hub.sockets.size, 0
|
|
11
|
+
eq hub.members.size, 0
|
|
12
|
+
eq hub.deliveries, 0
|
|
13
|
+
|
|
14
|
+
test "generateClientId returns 32-char hex", ->
|
|
15
|
+
id = generateClientId()
|
|
16
|
+
eq id.length, 32
|
|
17
|
+
ok /^[0-9a-f]+$/.test(id)
|
|
18
|
+
|
|
19
|
+
test "generateClientId is unique", ->
|
|
20
|
+
a = generateClientId()
|
|
21
|
+
b = generateClientId()
|
|
22
|
+
ok a isnt b
|
|
23
|
+
|
|
24
|
+
# --- Client management ---
|
|
25
|
+
|
|
26
|
+
test "addClient stores socket and creates membership", ->
|
|
27
|
+
hub = createHub()
|
|
28
|
+
ws = { send: (->), readyState: 1 }
|
|
29
|
+
addClient(hub, 'c1', ws)
|
|
30
|
+
eq hub.sockets.size, 1
|
|
31
|
+
eq hub.sockets.get('c1'), ws
|
|
32
|
+
ok hub.members.has('c1')
|
|
33
|
+
|
|
34
|
+
test "removeClient cleans up socket and memberships", ->
|
|
35
|
+
hub = createHub()
|
|
36
|
+
addClient(hub, 'c1', { send: (->), readyState: 1 })
|
|
37
|
+
# Subscribe c1 to a group
|
|
38
|
+
processResponse(hub, JSON.stringify({ '+': ['room1'], '@': ['c1'] }), 'c1')
|
|
39
|
+
ok hub.members.has('room1')
|
|
40
|
+
# Remove client
|
|
41
|
+
removeClient(hub, 'c1')
|
|
42
|
+
eq hub.sockets.size, 0
|
|
43
|
+
eq hub.members.has('c1'), false
|
|
44
|
+
|
|
45
|
+
# --- Membership via processResponse ---
|
|
46
|
+
|
|
47
|
+
test "subscribe adds client to group", ->
|
|
48
|
+
hub = createHub()
|
|
49
|
+
addClient(hub, 'c1', { send: (->), readyState: 1 })
|
|
50
|
+
processResponse(hub, JSON.stringify({ '+': ['room1'] }), 'c1')
|
|
51
|
+
ok hub.members.has('room1')
|
|
52
|
+
ok hub.members.get('room1').has('c1')
|
|
53
|
+
ok hub.members.get('c1').has('room1')
|
|
54
|
+
|
|
55
|
+
test "unsubscribe removes client from group", ->
|
|
56
|
+
hub = createHub()
|
|
57
|
+
addClient(hub, 'c1', { send: (->), readyState: 1 })
|
|
58
|
+
processResponse(hub, JSON.stringify({ '+': ['room1'] }), 'c1')
|
|
59
|
+
processResponse(hub, JSON.stringify({ '-': ['room1'] }), 'c1')
|
|
60
|
+
eq hub.members.has('room1'), false
|
|
61
|
+
|
|
62
|
+
test "subscribe to multiple groups", ->
|
|
63
|
+
hub = createHub()
|
|
64
|
+
addClient(hub, 'c1', { send: (->), readyState: 1 })
|
|
65
|
+
processResponse(hub, JSON.stringify({ '+': ['room1', 'room2'] }), 'c1')
|
|
66
|
+
ok hub.members.get('c1').has('room1')
|
|
67
|
+
ok hub.members.get('c1').has('room2')
|
|
68
|
+
|
|
69
|
+
# --- Message delivery ---
|
|
70
|
+
|
|
71
|
+
test "deliver to direct client target", ->
|
|
72
|
+
hub = createHub()
|
|
73
|
+
received = []
|
|
74
|
+
addClient(hub, 'c1', { send: ((msg) -> received.push(msg)) })
|
|
75
|
+
addClient(hub, 'c2', { send: ((msg) -> received.push(msg)) })
|
|
76
|
+
# c1 sends, targeting c2 directly
|
|
77
|
+
processResponse(hub, JSON.stringify({ '@': ['c2'], 'chat': 'hello' }), 'c1')
|
|
78
|
+
eq received.length, 1
|
|
79
|
+
parsed = JSON.parse(received[0])
|
|
80
|
+
eq parsed.chat, 'hello'
|
|
81
|
+
|
|
82
|
+
test "deliver to channel group members", ->
|
|
83
|
+
hub = createHub()
|
|
84
|
+
received = { c1: [], c2: [] }
|
|
85
|
+
addClient(hub, 'c1', { send: ((msg) -> received.c1.push(msg)) })
|
|
86
|
+
addClient(hub, 'c2', { send: ((msg) -> received.c2.push(msg)) })
|
|
87
|
+
# Subscribe both to /room
|
|
88
|
+
processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c1')
|
|
89
|
+
processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c2')
|
|
90
|
+
# Send to /room
|
|
91
|
+
processResponse(hub, JSON.stringify({ '@': ['/room'], 'msg': 'hi' }), null)
|
|
92
|
+
ok received.c1.length > 0
|
|
93
|
+
ok received.c2.length > 0
|
|
94
|
+
|
|
95
|
+
test "sender exclusion via > key", ->
|
|
96
|
+
hub = createHub()
|
|
97
|
+
received = { c1: [], c2: [] }
|
|
98
|
+
addClient(hub, 'c1', { send: ((msg) -> received.c1.push(msg)) })
|
|
99
|
+
addClient(hub, 'c2', { send: ((msg) -> received.c2.push(msg)) })
|
|
100
|
+
# Both in /room
|
|
101
|
+
processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c1')
|
|
102
|
+
processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c2')
|
|
103
|
+
# Send from c1, exclude c1
|
|
104
|
+
processResponse(hub, JSON.stringify({ '>': ['c1'], '@': ['/room'], 'msg': 'hi' }), null)
|
|
105
|
+
eq received.c1.length, 0
|
|
106
|
+
ok received.c2.length > 0
|
|
107
|
+
|
|
108
|
+
# --- External publish ---
|
|
109
|
+
|
|
110
|
+
test "handlePublish delivers without clientId", ->
|
|
111
|
+
hub = createHub()
|
|
112
|
+
received = []
|
|
113
|
+
addClient(hub, 'c1', { send: ((msg) -> received.push(msg)) })
|
|
114
|
+
handlePublish(hub, JSON.stringify({ '@': ['c1'], 'event': 'data' }))
|
|
115
|
+
eq received.length, 1
|
|
116
|
+
|
|
117
|
+
# --- Stats ---
|
|
118
|
+
|
|
119
|
+
test "getRealtimeStats reflects hub state", ->
|
|
120
|
+
hub = createHub()
|
|
121
|
+
addClient(hub, 'c1', { send: (->), readyState: 1 })
|
|
122
|
+
addClient(hub, 'c2', { send: (->), readyState: 1 })
|
|
123
|
+
stats = getRealtimeStats(hub)
|
|
124
|
+
eq stats.clients, 2
|
|
125
|
+
eq stats.connections, 2
|
|
126
|
+
|
|
127
|
+
# --- Array response ---
|
|
128
|
+
|
|
129
|
+
test "processResponse handles array of items", ->
|
|
130
|
+
hub = createHub()
|
|
131
|
+
received = []
|
|
132
|
+
addClient(hub, 'c1', { send: ((msg) -> received.push(msg)) })
|
|
133
|
+
# Array with two items
|
|
134
|
+
processResponse(hub, JSON.stringify([
|
|
135
|
+
{ '+': ['room1'] }
|
|
136
|
+
{ '@': ['c1'], 'a': 1 }
|
|
137
|
+
]), 'c1')
|
|
138
|
+
ok hub.members.get('c1').has('room1')
|
|
139
|
+
eq received.length, 1
|
|
140
|
+
|
|
141
|
+
# --- Invalid JSON ---
|
|
142
|
+
|
|
143
|
+
test "processResponse handles invalid JSON gracefully", ->
|
|
144
|
+
hub = createHub()
|
|
145
|
+
processResponse(hub, 'not json', 'c1')
|
|
146
|
+
# Should not throw — just log error
|
|
147
|
+
eq hub.sockets.size, 0
|