@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.
Files changed (44) hide show
  1. package/README.md +435 -622
  2. package/api.rip +4 -4
  3. package/control/cli.rip +221 -1
  4. package/control/control.rip +9 -0
  5. package/control/lifecycle.rip +6 -1
  6. package/control/watchers.rip +10 -0
  7. package/control/workers.rip +9 -5
  8. package/default.rip +3 -1
  9. package/docs/READ_VALIDATORS.md +656 -0
  10. package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
  11. package/docs/edge/CONTRACTS.md +60 -69
  12. package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
  13. package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
  14. package/edge/config.rip +584 -52
  15. package/edge/forwarding.rip +6 -2
  16. package/edge/metrics.rip +19 -1
  17. package/edge/registry.rip +29 -3
  18. package/edge/router.rip +138 -0
  19. package/edge/runtime.rip +98 -0
  20. package/edge/static.rip +69 -0
  21. package/edge/tls.rip +23 -0
  22. package/edge/upstream.rip +272 -0
  23. package/edge/verify.rip +73 -0
  24. package/middleware.rip +3 -3
  25. package/package.json +2 -2
  26. package/server.rip +775 -393
  27. package/tests/control.rip +18 -0
  28. package/tests/edgefile.rip +165 -0
  29. package/tests/metrics.rip +16 -0
  30. package/tests/proxy.rip +22 -1
  31. package/tests/registry.rip +27 -0
  32. package/tests/router.rip +101 -0
  33. package/tests/runtime_entrypoints.rip +16 -0
  34. package/tests/servers.rip +262 -0
  35. package/tests/static.rip +64 -0
  36. package/tests/streams_clienthello.rip +108 -0
  37. package/tests/streams_index.rip +53 -0
  38. package/tests/streams_pipe.rip +70 -0
  39. package/tests/streams_router.rip +39 -0
  40. package/tests/streams_runtime.rip +38 -0
  41. package/tests/streams_upstream.rip +34 -0
  42. package/tests/upstream.rip +191 -0
  43. package/tests/verify.rip +148 -0
  44. package/tests/watchers.rip +15 -0
@@ -0,0 +1,18 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { handleReloadControl } from '../control/control.rip'
3
+
4
+ test "handleReloadControl ignores non-POST requests", ->
5
+ req = new Request('http://localhost/reload', { method: 'GET' })
6
+ eq handleReloadControl(req, { ok: true }).handled, false
7
+
8
+ test "handleReloadControl returns 200 for successful reload", ->
9
+ req = new Request('http://localhost/reload', { method: 'POST' })
10
+ res = handleReloadControl(req, { ok: true, id: 'reload-1', result: 'applied' })
11
+ eq res.handled, true
12
+ eq res.response.status, 200
13
+
14
+ test "handleReloadControl returns 409 for rejected reload", ->
15
+ req = new Request('http://localhost/reload', { method: 'POST' })
16
+ res = handleReloadControl(req, { ok: false, id: 'reload-2', result: 'rolled_back', reason: 'boom' })
17
+ eq res.handled, true
18
+ eq res.response.status, 409
@@ -0,0 +1,165 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { normalizeEdgeConfig, normalizeLegacyConfig, summarizeConfig, formatConfigErrors } from '../edge/config.rip'
3
+
4
+ getErrors = (fn) ->
5
+ try
6
+ fn()
7
+ []
8
+ catch e
9
+ e.validationErrors or []
10
+
11
+ test "normalizeEdgeConfig accepts hosts with upstreams", ->
12
+ normalized = normalizeEdgeConfig(
13
+ hosts:
14
+ '*.example.com':
15
+ routes: [
16
+ { path: '/*', upstream: 'app' }
17
+ ]
18
+ upstreams:
19
+ app:
20
+ targets: ['http://127.0.0.1:3000']
21
+ , '/srv')
22
+ eq normalized.kind, 'edge'
23
+ eq Object.keys(normalized.upstreams).length, 1
24
+
25
+ test "normalizeEdgeConfig accepts hosts with apps", ->
26
+ normalized = normalizeEdgeConfig(
27
+ hosts:
28
+ '*.example.com':
29
+ app: 'admin'
30
+ apps:
31
+ admin:
32
+ entry: './admin/index.rip'
33
+ , '/srv')
34
+ eq Object.keys(normalized.apps).length, 1
35
+ eq normalized.sites['*.example.com'].routes[0].app, 'admin'
36
+
37
+ test "normalizeEdgeConfig version and edge are optional", ->
38
+ config = normalizeEdgeConfig(
39
+ hosts:
40
+ '*.example.com':
41
+ root: '/mnt/site'
42
+ , '/srv')
43
+ eq config.version, 1
44
+ ok config.edge
45
+
46
+ test "normalizeEdgeConfig rejects wildcard ACME domains", ->
47
+ errors = getErrors ->
48
+ normalizeEdgeConfig(
49
+ edge:
50
+ acme: true
51
+ acmeDomains: ['*.example.com']
52
+ hosts:
53
+ '*.example.com':
54
+ root: '/mnt/site'
55
+ , '/srv')
56
+ ok errors.some((err) -> err.code is 'E_ACME_WILDCARD')
57
+
58
+ test "normalizeEdgeConfig rejects routes with multiple actions", ->
59
+ errors = getErrors ->
60
+ normalizeEdgeConfig(
61
+ upstreams:
62
+ api:
63
+ targets: ['http://127.0.0.1:4000']
64
+ apps:
65
+ admin:
66
+ entry: './admin/index.rip'
67
+ hosts:
68
+ '*.example.com':
69
+ routes: [
70
+ { path: '/x', upstream: 'api', app: 'admin' }
71
+ ]
72
+ , '/srv')
73
+ ok errors.some((err) -> err.code is 'E_ROUTE_ACTION')
74
+
75
+ test "normalizeEdgeConfig rejects websocket route without upstream", ->
76
+ errors = getErrors ->
77
+ normalizeEdgeConfig(
78
+ apps:
79
+ admin:
80
+ entry: './admin/index.rip'
81
+ hosts:
82
+ '*.example.com':
83
+ routes: [
84
+ { path: '/ws', app: 'admin', websocket: true }
85
+ ]
86
+ , '/srv')
87
+ ok errors.some((err) -> err.code is 'E_ROUTE_WEBSOCKET')
88
+
89
+ test "normalizeEdgeConfig preserves verify policy", ->
90
+ normalized = normalizeEdgeConfig(
91
+ edge:
92
+ verify:
93
+ requireHealthyUpstreams: false
94
+ requireReadyApps: true
95
+ includeUnroutedManagedApps: false
96
+ minHealthyTargetsPerUpstream: 2
97
+ hosts:
98
+ '*.example.com':
99
+ root: '/mnt/site'
100
+ , '/srv')
101
+ eq normalized.edge.verify.requireHealthyUpstreams, false
102
+ eq normalized.edge.verify.includeUnroutedManagedApps, false
103
+ eq normalized.edge.verify.minHealthyTargetsPerUpstream, 2
104
+
105
+ test "normalizeEdgeConfig accepts streamUpstreams and streams", ->
106
+ normalized = normalizeEdgeConfig(
107
+ hosts:
108
+ '*.example.com':
109
+ root: '/mnt/site'
110
+ streamUpstreams:
111
+ incus:
112
+ targets: ['127.0.0.1:8443']
113
+ streams: [
114
+ { listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
115
+ ]
116
+ , '/srv')
117
+ ok normalized.streamUpstreams['incus']
118
+ eq normalized.streams.length, 1
119
+
120
+ test "normalizeEdgeConfig rejects unknown stream upstream refs", ->
121
+ errors = getErrors ->
122
+ normalizeEdgeConfig(
123
+ hosts:
124
+ '*.example.com':
125
+ root: '/mnt/site'
126
+ streams: [
127
+ { listen: 8443, sni: ['incus.example.com'], upstream: 'missing' }
128
+ ]
129
+ , '/srv')
130
+ ok errors.some((err) -> err.code is 'E_STREAM_UPSTREAM_REF')
131
+
132
+ test "formatConfigErrors includes code path and hint", ->
133
+ output = formatConfigErrors('Edgefile.rip', [
134
+ code: 'E_ROUTE_ACTION'
135
+ path: 'routes[0]'
136
+ message: 'route must define exactly one action'
137
+ hint: 'Choose one action field per route.'
138
+ ])
139
+ ok output.includes('[E_ROUTE_ACTION]')
140
+ ok output.includes('routes[0]')
141
+ ok output.includes('Hint:')
142
+
143
+ test "summarizeConfig reports canonical counts", ->
144
+ summary = summarizeConfig('/srv/Edgefile.rip',
145
+ kind: 'edge'
146
+ version: 1
147
+ edge: {}
148
+ upstreams: { app: { targets: ['http://127.0.0.1:3000'] } }
149
+ apps: { admin: { entry: '/srv/admin/index.rip' } }
150
+ routes: []
151
+ sites: { '*.example.com': { routes: [{ path: '/*', upstream: 'app' }] } }
152
+ )
153
+ eq summary.counts.upstreams, 1
154
+ eq summary.counts.apps, 1
155
+ eq summary.counts.sites, 1
156
+
157
+ test "normalizeLegacyConfig validates legacy apps object", ->
158
+ normalized = normalizeLegacyConfig(
159
+ apps:
160
+ web:
161
+ entry: './index.rip'
162
+ hosts: ['example.com']
163
+ , '/srv')
164
+ eq normalized.kind, 'legacy'
165
+ eq Object.keys(normalized.apps).length, 1
package/tests/metrics.rip CHANGED
@@ -71,3 +71,19 @@ test "snapshot returns expected shape", ->
71
71
  eq snap.counters.responses['2xx'], 90
72
72
  eq snap.counters.responses['5xx'], 10
73
73
  eq snap.apps, []
74
+
75
+ test "snapshot includes upstream health gauges", ->
76
+ m = createMetrics()
77
+ upstreamPool =
78
+ upstreams: new Map([
79
+ ['app',
80
+ targets: [
81
+ { healthy: true, circuitState: 'closed' }
82
+ { healthy: false, circuitState: 'open' }
83
+ ]
84
+ ]
85
+ ])
86
+ snap = m.snapshot(Date.now() - 1000, null, upstreamPool)
87
+ eq snap.upstreams.length, 1
88
+ eq snap.gauges.upstreamTargetsHealthy, 1
89
+ eq snap.gauges.upstreamTargetsUnhealthy, 1
package/tests/proxy.rip CHANGED
@@ -1,7 +1,7 @@
1
1
  # proxy.rip — reverse proxy and config loader tests
2
2
 
3
3
  import { test, eq, ok } from '../test.rip'
4
- import { normalizeAppConfig, applyConfig } from '../edge/config.rip'
4
+ import { normalizeAppConfig, applyConfig, applyEdgeConfig } from '../edge/config.rip'
5
5
  import { createAppRegistry, registerApp, resolveHost, getAppState } from '../edge/registry.rip'
6
6
  import { broadcastBinary, createHub, addClient } from '../edge/realtime.rip'
7
7
  import { setEventJsonMode, logEvent } from '../control/lifecycle.rip'
@@ -49,6 +49,27 @@ test "applyConfig handles empty apps", ->
49
49
  registered = applyConfig({ apps: {} }, registry, registerApp, '/srv')
50
50
  eq registered.length, 0
51
51
 
52
+ test "applyEdgeConfig preserves managed app entry workers and env", ->
53
+ registry = createAppRegistry()
54
+ config =
55
+ kind: 'edge'
56
+ apps:
57
+ admin:
58
+ entry: '/srv/admin/index.rip'
59
+ hosts: ['admin.example.com']
60
+ workers: 3
61
+ maxQueue: 700
62
+ queueTimeoutMs: 1234
63
+ readTimeoutMs: 5678
64
+ env: { ADMIN_MODE: '1' }
65
+ registered = applyEdgeConfig(config, registry, registerApp, '/srv')
66
+ eq registered.length, 1
67
+ state = getAppState(registry, 'admin')
68
+ eq state.config.entry, '/srv/admin/index.rip'
69
+ eq state.config.workers, 3
70
+ eq state.config.env.ADMIN_MODE, '1'
71
+ eq state.maxQueue, 700
72
+
52
73
  # --- Binary broadcast ---
53
74
 
54
75
  test "broadcastBinary delivers to group members", ->
@@ -29,6 +29,27 @@ test "resolveHost returns null for unknown host", ->
29
29
  r = createAppRegistry()
30
30
  eq resolveHost(r, 'unknown.com'), null
31
31
 
32
+ test "resolveHost matches wildcard host", ->
33
+ r = createAppRegistry()
34
+ registerApp(r, 'wild', { hosts: ['*.example.com'] })
35
+ eq resolveHost(r, 'api.example.com'), 'wild'
36
+
37
+ test "resolveHost wildcard does not span multiple labels", ->
38
+ r = createAppRegistry()
39
+ registerApp(r, 'wild', { hosts: ['*.example.com'] })
40
+ eq resolveHost(r, 'a.b.example.com'), null
41
+
42
+ test "resolveHost falls back to catchall host", ->
43
+ r = createAppRegistry()
44
+ registerApp(r, 'catchall', { hosts: ['*'] })
45
+ eq resolveHost(r, 'unknown.com'), 'catchall'
46
+
47
+ test "resolveHost prefers exact host over wildcard", ->
48
+ r = createAppRegistry()
49
+ registerApp(r, 'wild', { hosts: ['*.example.com'] })
50
+ registerApp(r, 'exact', { hosts: ['api.example.com'] })
51
+ eq resolveHost(r, 'api.example.com'), 'exact'
52
+
32
53
  test "getAppState returns app or null", ->
33
54
  r = createAppRegistry()
34
55
  registerApp(r, 'app1', { hosts: [] })
@@ -43,6 +64,12 @@ test "removeApp cleans up hosts and app", ->
43
64
  eq r.hostIndex.size, 0
44
65
  eq resolveHost(r, 'a.com'), null
45
66
 
67
+ test "removeApp cleans wildcard entries", ->
68
+ r = createAppRegistry()
69
+ registerApp(r, 'wild', { hosts: ['*.example.com'] })
70
+ removeApp(r, 'wild')
71
+ eq resolveHost(r, 'api.example.com'), null
72
+
46
73
  test "AppState has correct defaults", ->
47
74
  r = createAppRegistry()
48
75
  state = registerApp(r, 'app1', { hosts: ['h.com'] })
@@ -0,0 +1,101 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { compileRouteTable, matchRoute, describeRoute } from '../edge/router.rip'
3
+
4
+ test "compileRouteTable keeps global route order", ->
5
+ table = compileRouteTable([
6
+ { id: 'one', path: '/a', upstream: 'app' }
7
+ { id: 'two', path: '/b', upstream: 'app' }
8
+ ])
9
+ eq table.routes.length, 2
10
+ eq table.routes[0].order, 0
11
+ eq table.routes[1].order, 1
12
+
13
+ test "compileRouteTable merges site routes with inherited host", ->
14
+ table = compileRouteTable([],
15
+ admin.example.com:
16
+ routes: [{ path: '/*', app: 'admin' }]
17
+ )
18
+ eq table.routes.length, 1
19
+ eq table.routes[0].host, 'admin.example.com'
20
+
21
+ test "matchRoute prefers exact host over catchall", ->
22
+ table = compileRouteTable([
23
+ { path: '/*', upstream: 'fallback' }
24
+ { host: 'api.example.com', path: '/*', upstream: 'api' }
25
+ ])
26
+ eq matchRoute(table, 'api.example.com', '/users').upstream, 'api'
27
+
28
+ test "matchRoute prefers wildcard host over catchall", ->
29
+ table = compileRouteTable([
30
+ { path: '/*', upstream: 'fallback' }
31
+ { host: '*.example.com', path: '/*', upstream: 'wild' }
32
+ ])
33
+ eq matchRoute(table, 'api.example.com', '/users').upstream, 'wild'
34
+
35
+ test "matchRoute does not let wildcard span multiple labels", ->
36
+ table = compileRouteTable([
37
+ { host: '*.example.com', path: '/*', upstream: 'wild' }
38
+ { path: '/*', upstream: 'fallback' }
39
+ ])
40
+ eq matchRoute(table, 'a.b.example.com', '/users').upstream, 'fallback'
41
+
42
+ test "matchRoute prefers exact path over prefix", ->
43
+ table = compileRouteTable([
44
+ { path: '/api/*', upstream: 'api' }
45
+ { path: '/api/users', upstream: 'users' }
46
+ ])
47
+ eq matchRoute(table, 'example.com', '/api/users').upstream, 'users'
48
+
49
+ test "matchRoute prefers longer prefix", ->
50
+ table = compileRouteTable([
51
+ { path: '/api/*', upstream: 'api' }
52
+ { path: '/api/admin/*', upstream: 'admin' }
53
+ ])
54
+ eq matchRoute(table, 'example.com', '/api/admin/users').upstream, 'admin'
55
+
56
+ test "matchRoute uses lower priority number before order", ->
57
+ table = compileRouteTable([
58
+ { path: '/api/*', upstream: 'one', priority: 10 }
59
+ { path: '/api/*', upstream: 'two', priority: 5 }
60
+ ])
61
+ eq matchRoute(table, 'example.com', '/api/x').upstream, 'two'
62
+
63
+ test "matchRoute filters by method", ->
64
+ table = compileRouteTable([
65
+ { path: '/submit', upstream: 'read', methods: ['GET'] }
66
+ { path: '/submit', upstream: 'write', methods: ['POST'] }
67
+ ])
68
+ eq matchRoute(table, 'example.com', '/submit', 'POST').upstream, 'write'
69
+
70
+ test "matchRoute returns null when nothing matches", ->
71
+ table = compileRouteTable([
72
+ { host: 'api.example.com', path: '/api/*', upstream: 'api' }
73
+ ])
74
+ eq matchRoute(table, 'example.com', '/api/x'), null
75
+
76
+ test "describeRoute summarizes action", ->
77
+ table = compileRouteTable([
78
+ { host: 'api.example.com', path: '/api/*', upstream: 'api' }
79
+ ])
80
+ summary = describeRoute(matchRoute(table, 'api.example.com', '/api/x'))
81
+ ok summary.includes('proxy:api')
82
+
83
+ test "compiled route preserves root field", ->
84
+ table = compileRouteTable([], {
85
+ '*.example.com':
86
+ routes: [
87
+ { path: '/*', static: '.', root: '/mnt/site', spa: true }
88
+ ]
89
+ })
90
+ matched = matchRoute(table, 'app.example.com', '/index.html')
91
+ eq matched.root, '/mnt/site'
92
+
93
+ test "compiled route preserves spa field", ->
94
+ table = compileRouteTable([], {
95
+ '*.example.com':
96
+ routes: [
97
+ { path: '/*', static: '.', root: '/mnt/site', spa: true }
98
+ ]
99
+ })
100
+ matched = matchRoute(table, 'app.example.com', '/index.html')
101
+ eq matched.spa, true
@@ -0,0 +1,16 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { coerceInt, parseFlags } from '../control/cli.rip'
3
+ import { createEdgeRuntime, createReloadHistoryEntry } from '../edge/runtime.rip'
4
+
5
+ test "control cli helpers load and parse basic flags", ->
6
+ flags = parseFlags(['bun', 'server', '--https-port=9445', '--edgefile=./Edgefile.rip', 'packages/server/default.rip'])
7
+ eq coerceInt('42', 0), 42
8
+ eq flags.httpsPort, 9445
9
+ ok flags.edgefilePath.endsWith('Edgefile.rip')
10
+
11
+ test "edge runtime helpers load and create summary entries", ->
12
+ runtime = createEdgeRuntime()
13
+ entry = createReloadHistoryEntry('reload-1', 'test', null, 1, 'applied')
14
+ eq runtime.configInfo.kind, 'none'
15
+ eq entry.id, 'reload-1'
16
+ eq entry.result, 'applied'
@@ -0,0 +1,262 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { normalizeEdgeConfig } from '../edge/config.rip'
3
+
4
+ # --- v2 basic normalization ---
5
+
6
+ test "normalizes hosts into sites object shape", ->
7
+ config = normalizeEdgeConfig({
8
+ version: 2
9
+ edge: {}
10
+ hosts:
11
+ '*.trusthealth.com':
12
+ cert: '/ssl/trusthealth.com.crt'
13
+ key: '/ssl/trusthealth.com.key'
14
+ root: '/mnt/trusthealth'
15
+ routes: [
16
+ { path: '/*', static: '.', spa: true }
17
+ ]
18
+ }, '/home/shreeve')
19
+ eq config.version, 2
20
+ eq config.kind, 'edge'
21
+ ok config.sites['*.trusthealth.com']
22
+ eq config.sites['*.trusthealth.com'].host, '*.trusthealth.com'
23
+ eq config.sites['*.trusthealth.com'].routes.length, 1
24
+
25
+ test "v2 extracts cert map from server blocks", ->
26
+ config = normalizeEdgeConfig({
27
+ version: 2
28
+ edge: {}
29
+ hosts:
30
+ '*.trusthealth.com':
31
+ cert: '/ssl/trusthealth.com.crt'
32
+ key: '/ssl/trusthealth.com.key'
33
+ routes: [
34
+ { path: '/*', static: '/mnt/web' }
35
+ ]
36
+ '*.zionlabshare.com':
37
+ cert: '/ssl/zionlabshare.com.crt'
38
+ key: '/ssl/zionlabshare.com.key'
39
+ routes: [
40
+ { path: '/*', static: '/mnt/zion' }
41
+ ]
42
+ }, '/home/shreeve')
43
+ ok config.certs
44
+ ok config.certs['*.trusthealth.com']
45
+ ok config.certs['*.zionlabshare.com']
46
+ ok config.certs['*.trusthealth.com'].certPath.endsWith('trusthealth.com.crt')
47
+
48
+ test "v2 inherits server root into routes", ->
49
+ config = normalizeEdgeConfig({
50
+ version: 2
51
+ edge: {}
52
+ hosts:
53
+ '*.example.com':
54
+ root: '/mnt/site'
55
+ routes: [
56
+ { path: '/*', static: '.' }
57
+ ]
58
+ }, '/home/shreeve')
59
+ route = config.sites['*.example.com'].routes[0]
60
+ ok route.root.includes('mnt/site')
61
+
62
+ test "v2 route root overrides server root", ->
63
+ config = normalizeEdgeConfig({
64
+ version: 2
65
+ edge: {}
66
+ hosts:
67
+ '*.example.com':
68
+ root: '/mnt/default'
69
+ routes: [
70
+ { path: '/*', static: '.', root: '/mnt/override' }
71
+ ]
72
+ }, '/home/shreeve')
73
+ route = config.sites['*.example.com'].routes[0]
74
+ ok route.root.includes('mnt/override')
75
+
76
+ test "v2 routes inherit host from server key", ->
77
+ config = normalizeEdgeConfig({
78
+ version: 2
79
+ edge: {}
80
+ hosts:
81
+ app.example.com:
82
+ routes: [
83
+ { path: '/*', static: '/mnt/app' }
84
+ ]
85
+ }, '/home/shreeve')
86
+ route = config.sites['app.example.com'].routes[0]
87
+ eq route.host, 'app.example.com'
88
+
89
+ test "v2 preserves spa flag on routes", ->
90
+ config = normalizeEdgeConfig({
91
+ version: 2
92
+ edge: {}
93
+ hosts:
94
+ '*.example.com':
95
+ routes: [
96
+ { path: '/*', static: '/mnt/app', spa: true }
97
+ ]
98
+ }, '/home/shreeve')
99
+ route = config.sites['*.example.com'].routes[0]
100
+ eq route.spa, true
101
+
102
+ # --- validation errors ---
103
+
104
+ test "rejects server block with no routes and no root", ->
105
+ threw = false
106
+ try
107
+ normalizeEdgeConfig({
108
+ version: 1
109
+ edge: {}
110
+ hosts:
111
+ '*.example.com':
112
+ cert: '/ssl/example.com.crt'
113
+ key: '/ssl/example.com.key'
114
+ }, '/home/shreeve')
115
+ catch e
116
+ threw = true
117
+ ok e.validationErrors.some((err) -> err.code is 'E_SERVER_ROUTES')
118
+ ok threw
119
+
120
+ test "root-only server block generates implicit static route", ->
121
+ config = normalizeEdgeConfig({
122
+ version: 1
123
+ edge: {}
124
+ hosts:
125
+ '*.example.com':
126
+ root: '/mnt/site'
127
+ }, '/home/shreeve')
128
+ routes = config.sites['*.example.com'].routes
129
+ eq routes.length, 1
130
+ eq routes[0].static, '.'
131
+ ok routes[0].root.includes('mnt/site')
132
+
133
+ test "root-only server block with spa", ->
134
+ config = normalizeEdgeConfig({
135
+ version: 1
136
+ edge: {}
137
+ hosts:
138
+ '*.example.com':
139
+ root: '/mnt/site'
140
+ spa: true
141
+ }, '/home/shreeve')
142
+ eq config.sites['*.example.com'].routes[0].spa, true
143
+
144
+ test "passthrough server block creates stream entries", ->
145
+ config = normalizeEdgeConfig({
146
+ version: 1
147
+ hosts:
148
+ incus.example.com:
149
+ passthrough: '127.0.0.1:8443'
150
+ }, '/home/shreeve')
151
+ ok config.streamUpstreams['passthrough-incus.example.com']
152
+ eq config.streams.length, 1
153
+ eq config.streams[0].sni[0], 'incus.example.com'
154
+
155
+ test "passthrough server block has no sites entry", ->
156
+ config = normalizeEdgeConfig({
157
+ version: 1
158
+ hosts:
159
+ incus.example.com:
160
+ passthrough: '127.0.0.1:8443'
161
+ }, '/home/shreeve')
162
+ eq config.sites['incus.example.com'], undefined
163
+
164
+ test "edge and version are optional", ->
165
+ config = normalizeEdgeConfig({
166
+ hosts:
167
+ '*.example.com':
168
+ root: '/mnt/site'
169
+ }, '/home/shreeve')
170
+ eq config.version, 1
171
+ ok config.edge
172
+
173
+ test "v2 rejects route with host field", ->
174
+ threw = false
175
+ try
176
+ normalizeEdgeConfig({
177
+ version: 2
178
+ edge: {}
179
+ hosts:
180
+ '*.example.com':
181
+ routes: [{ path: '/*', static: '.', host: 'other.com' }]
182
+ }, '/home/shreeve')
183
+ catch e
184
+ threw = true
185
+ ok e.validationErrors.some((err) -> err.code is 'E_SERVER_ROUTE_HOST')
186
+ ok threw
187
+
188
+ test "v2 validates upstream references in server routes", ->
189
+ threw = false
190
+ try
191
+ normalizeEdgeConfig({
192
+ version: 2
193
+ edge: {}
194
+ hosts:
195
+ '*.example.com':
196
+ routes: [{ path: '/api/*', upstream: 'nonexistent' }]
197
+ }, '/home/shreeve')
198
+ catch e
199
+ threw = true
200
+ ok e.validationErrors.some((err) -> err.code is 'E_ROUTE_UPSTREAM_REF')
201
+ ok threw
202
+
203
+ test "v2 validates app references in server routes", ->
204
+ threw = false
205
+ try
206
+ normalizeEdgeConfig({
207
+ version: 2
208
+ edge: {}
209
+ hosts:
210
+ '*.example.com':
211
+ routes: [{ path: '/*', app: 'nonexistent' }]
212
+ }, '/home/shreeve')
213
+ catch e
214
+ threw = true
215
+ ok e.validationErrors.some((err) -> err.code is 'E_ROUTE_APP_REF')
216
+ ok threw
217
+
218
+
219
+ test "server block with app field creates app route", ->
220
+ config = normalizeEdgeConfig({
221
+ hosts:
222
+ 'dev.example.com':
223
+ cert: '/ssl/example.com.crt'
224
+ key: '/ssl/example.com.key'
225
+ app: 'browser'
226
+ apps:
227
+ browser:
228
+ entry: '/srv/default.rip'
229
+ root: '/mnt/www'
230
+ }, '/home/shreeve')
231
+ route = config.sites['dev.example.com'].routes[0]
232
+ eq route.app, 'browser'
233
+ eq route.host, 'dev.example.com'
234
+ ok config.certs['dev.example.com']
235
+
236
+ test "server block rejects unknown app reference", ->
237
+ threw = false
238
+ try
239
+ normalizeEdgeConfig({
240
+ hosts:
241
+ 'dev.example.com':
242
+ app: 'nonexistent'
243
+ }, '/home/shreeve')
244
+ catch e
245
+ threw = true
246
+ ok e.validationErrors.some((err) -> err.code is 'E_SERVER_APP_REF')
247
+ ok threw
248
+
249
+ test "inline app definition on host block", ->
250
+ config = normalizeEdgeConfig({
251
+ hosts:
252
+ 'api.example.com':
253
+ cert: '/ssl/example.com.crt'
254
+ key: '/ssl/example.com.key'
255
+ app:
256
+ entry: '/srv/myapi/index.rip'
257
+ root: '/srv/myapi'
258
+ }, '/home/shreeve')
259
+ route = config.sites['api.example.com'].routes[0]
260
+ eq route.app, 'app-api.example.com'
261
+ ok config.apps['app-api.example.com']
262
+ ok config.apps['app-api.example.com'].entry.includes('myapi')