@toa.io/extensions.exposition 1.0.0-alpha.269 → 1.0.0-alpha.270
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/CHANGELOG.md +31 -0
- package/components/exposition.stash/manifest.toa.yaml +13 -4
- package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
- package/documentation/io.md +26 -23
- package/documentation/notes/throttling.md +39 -5
- package/features/etag.feature +55 -5
- package/features/io.throttle.feature +3 -5
- package/package.json +5 -5
- package/schemas/io/throttle.cos.yaml +2 -3
- package/source/Directive.test.ts +3 -0
- package/source/Directive.ts +25 -4
- package/source/Endpoint.ts +7 -11
- package/source/Factory.ts +16 -1
- package/source/HTTP/Context.ts +39 -8
- package/source/HTTP/Server.ts +16 -11
- package/source/HTTP/exceptions.ts +9 -3
- package/source/HTTP/messages.ts +31 -0
- package/source/RTD/Directives.ts +6 -0
- package/source/directives/auth/Authorization.ts +29 -3
- package/source/directives/io/Directive.ts +5 -4
- package/source/directives/io/IO.ts +15 -10
- package/source/directives/io/Input.ts +3 -3
- package/source/directives/io/Output.ts +12 -4
- package/source/directives/io/Throttle.ts +9 -12
- package/source/directives/io/lib/throttle/Configuration.test.ts +1 -2
- package/source/directives/io/lib/throttle/Configuration.ts +2 -4
- package/source/directives/io/lib/throttle/Quotas.test.ts +233 -148
- package/source/directives/io/lib/throttle/Quotas.ts +129 -53
- package/source/directives/io/lib/throttle/Sync.test.ts +153 -0
- package/source/directives/io/lib/throttle/Sync.ts +99 -0
- package/source/directives/io/lib/throttle/index.ts +1 -0
- package/source/directives/io/schemas.test.ts +7 -1
- package/transpiled/Directive.d.ts +2 -0
- package/transpiled/Directive.js +16 -4
- package/transpiled/Directive.js.map +1 -1
- package/transpiled/Endpoint.js +7 -8
- package/transpiled/Endpoint.js.map +1 -1
- package/transpiled/Factory.js +14 -1
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/HTTP/Context.d.ts +1 -1
- package/transpiled/HTTP/Context.js +30 -8
- package/transpiled/HTTP/Context.js.map +1 -1
- package/transpiled/HTTP/Server.js +14 -10
- package/transpiled/HTTP/Server.js.map +1 -1
- package/transpiled/HTTP/exceptions.d.ts +5 -2
- package/transpiled/HTTP/exceptions.js +7 -3
- package/transpiled/HTTP/exceptions.js.map +1 -1
- package/transpiled/HTTP/messages.d.ts +2 -0
- package/transpiled/HTTP/messages.js +21 -0
- package/transpiled/HTTP/messages.js.map +1 -1
- package/transpiled/RTD/Directives.d.ts +5 -0
- package/transpiled/directives/auth/Authorization.d.ts +1 -0
- package/transpiled/directives/auth/Authorization.js +21 -2
- package/transpiled/directives/auth/Authorization.js.map +1 -1
- package/transpiled/directives/io/Directive.d.ts +4 -4
- package/transpiled/directives/io/IO.d.ts +9 -4
- package/transpiled/directives/io/IO.js +14 -9
- package/transpiled/directives/io/IO.js.map +1 -1
- package/transpiled/directives/io/Input.d.ts +1 -1
- package/transpiled/directives/io/Input.js +3 -3
- package/transpiled/directives/io/Input.js.map +1 -1
- package/transpiled/directives/io/Output.d.ts +2 -0
- package/transpiled/directives/io/Output.js +9 -3
- package/transpiled/directives/io/Output.js.map +1 -1
- package/transpiled/directives/io/Throttle.d.ts +3 -5
- package/transpiled/directives/io/Throttle.js +8 -10
- package/transpiled/directives/io/Throttle.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Configuration.d.ts +0 -1
- package/transpiled/directives/io/lib/throttle/Configuration.js +2 -3
- package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Quotas.d.ts +46 -22
- package/transpiled/directives/io/lib/throttle/Quotas.js +107 -46
- package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Sync.d.ts +30 -0
- package/transpiled/directives/io/lib/throttle/Sync.js +76 -0
- package/transpiled/directives/io/lib/throttle/Sync.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/index.d.ts +1 -0
- package/transpiled/directives/io/lib/throttle/index.js +3 -1
- package/transpiled/directives/io/lib/throttle/index.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
- package/components/exposition.stash/operations/count.js +0 -7
package/documentation/io.md
CHANGED
|
@@ -70,31 +70,32 @@ exposition:
|
|
|
70
70
|
status: 404
|
|
71
71
|
requests: 500
|
|
72
72
|
interval: 30
|
|
73
|
-
cooldown: 30
|
|
74
73
|
```
|
|
75
74
|
|
|
76
|
-
Requests are
|
|
77
|
-
requests
|
|
78
|
-
`
|
|
75
|
+
Requests are metered per `key`. A key may spend `requests` at once, and earns them back at a rate of
|
|
76
|
+
`requests` per `interval` seconds. A request carrying a key with nothing left to spend is answered
|
|
77
|
+
`429 Too Many Requests`, with a `Retry-After` saying how long until one would be admitted again.
|
|
78
|
+
`interval` is in seconds.
|
|
79
79
|
|
|
80
|
-
`requests` is
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
So `requests` is the burst, and `requests / interval` the rate it is repaid at. There is no window to
|
|
81
|
+
save a budget up in and spend twice across the edge of, and no lockout to sit out: a key that has
|
|
82
|
+
overspent is admitted again as soon as it has earned a single request back, and a client that keeps
|
|
83
|
+
asking meanwhile is not penalised for it.
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
`requests` is a budget for the **whole group** of gateway instances, not for one of them. Instances
|
|
86
|
+
reconcile through Redis on a timer, so they converge on one number rather than each enforcing the
|
|
87
|
+
limit separately. Between two of those, an instance goes on what it alone has spent — so the group
|
|
88
|
+
can overshoot by what the other instances admit within that window, and by no more. The window is a
|
|
89
|
+
tenth of `interval`, between a quarter of a second and two seconds, whatever `interval` is. Precise
|
|
90
|
+
per-request enforcement is not what this is for.
|
|
91
91
|
|
|
92
|
-
An instance that cannot reach Redis keeps throttling on what it has seen itself
|
|
92
|
+
An instance that cannot reach Redis keeps throttling on what it has seen itself and keeps serving,
|
|
93
|
+
and reports what it could not the next time it gets through.
|
|
93
94
|
|
|
94
95
|
### Key components
|
|
95
96
|
|
|
96
|
-
What a request is
|
|
97
|
-
`[route, ip]`
|
|
97
|
+
What a request is metered against. Give one, or a list — a list keys on the combination, so
|
|
98
|
+
`[route, ip]` meters each address separately on each route.
|
|
98
99
|
|
|
99
100
|
- `ip` — the client address, read from `X-Forwarded-For` where it is set and not private,
|
|
100
101
|
and from the connection otherwise.
|
|
@@ -107,11 +108,11 @@ What a request is counted against. Give one, or a list — a list keys on the co
|
|
|
107
108
|
|
|
108
109
|
### Conditions
|
|
109
110
|
|
|
110
|
-
What is
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
What is metered, as opposed to what it is metered against. A request that fails a condition is served
|
|
112
|
+
and costs nothing. Conditions are evaluated against the response, so they cannot decide whether to
|
|
113
|
+
refuse a request — only whether it is charged for, once it has been answered.
|
|
113
114
|
|
|
114
|
-
- `status: <code>` —
|
|
115
|
+
- `status: <code>` — charge only for responses with this status.
|
|
115
116
|
|
|
116
117
|
```yaml
|
|
117
118
|
io:throttle:
|
|
@@ -119,6 +120,8 @@ io:throttle:
|
|
|
119
120
|
condition:
|
|
120
121
|
status: 404
|
|
121
122
|
requests: 20
|
|
122
|
-
interval:
|
|
123
|
-
cooldown: 600
|
|
123
|
+
interval: 600
|
|
124
124
|
```
|
|
125
|
+
|
|
126
|
+
An address may probe for twenty missing paths at once, and thereafter for one every thirty seconds.
|
|
127
|
+
Requests that find something are served and never counted against it.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Decentralized Request Throttling
|
|
2
2
|
|
|
3
|
-
> **Superseded.** The design below was never implemented. `io:throttle` now
|
|
4
|
-
> [
|
|
5
|
-
>
|
|
6
|
-
>
|
|
7
|
-
>
|
|
3
|
+
> **Superseded.** The design below was never implemented. `io:throttle` is now a
|
|
4
|
+
> [GCRA](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) deciding in each process, with
|
|
5
|
+
> its state periodically reconciled through Redis by way of the `stash` extension's `meter`. It
|
|
6
|
+
> answers the same forces the same way — decide locally, reconcile in the background, never fail the
|
|
7
|
+
> request path — and the section below records what it does instead. The problem statement and the
|
|
8
|
+
> forces have not changed.
|
|
8
9
|
|
|
9
10
|
## Problem
|
|
10
11
|
|
|
@@ -86,3 +87,36 @@ When nodes are added or removed, the algorithm will adapt in the upcoming interv
|
|
|
86
87
|
in a span on each node.
|
|
87
88
|
2. Time desynchronization between nodes should be insignificant for the selected `INTERVAL` (i.e.,
|
|
88
89
|
`INTERVAL` >> desync). See Extension point 3.
|
|
90
|
+
|
|
91
|
+
## What was implemented instead
|
|
92
|
+
|
|
93
|
+
Windows were dropped for a GCRA. A key holds one theoretical arrival time: the moment it would be
|
|
94
|
+
back at zero if nothing more arrived. An admitted request pushes it `interval / requests`
|
|
95
|
+
milliseconds further out, time drags it back, and a request is admitted while it stays within
|
|
96
|
+
`interval` of now. So `requests` is what a key may spend at once and `requests / interval` the rate
|
|
97
|
+
it earns back, with no window edge to burst across and no lockout to time out of — which is why
|
|
98
|
+
`cooldown` is gone.
|
|
99
|
+
|
|
100
|
+
Deciding reads nothing but a local map, so the request path stays free of I/O, exactly as force 1
|
|
101
|
+
demands. What the other gateways have spent arrives on a timer, as **debt**: the milliseconds a key
|
|
102
|
+
owes, draining at a millisecond a millisecond. Debt is the right thing to send for two reasons.
|
|
103
|
+
|
|
104
|
+
1. It is a duration, not a moment, so processes exchanging it need not agree on the time — force 2,
|
|
105
|
+
answered by not needing an answer. The one clock that matters is Redis', read inside the script.
|
|
106
|
+
2. It is additive in admissions, so a process reports only its own increments and Redis keeps the
|
|
107
|
+
running total. Nothing has to be divided by a number of nodes, which is as well: the gateway
|
|
108
|
+
cannot observe how many of it there are.
|
|
109
|
+
|
|
110
|
+
The consequences worth knowing:
|
|
111
|
+
|
|
112
|
+
- **Reconciling is decoupled from `interval`.** The timer runs at a tenth of `interval`, clamped
|
|
113
|
+
between 250ms and 2s, so a minute-long budget is no staler than a second-long one. The overshoot
|
|
114
|
+
is bounded by what the other instances admit within one tick — the caveat that replaces the old
|
|
115
|
+
note's `MAX_REQUESTS / N` per span.
|
|
116
|
+
- **The cost is one round trip a tick**, not one per key. Every quota in the process flushes into a
|
|
117
|
+
single script call, which matters because a limiter keyed on `ip` watches as many keys as it has
|
|
118
|
+
clients.
|
|
119
|
+
- **Redis keys expire on their own**, after the debt on them plus a grace, so nothing sweeps them.
|
|
120
|
+
- **Failure degrades, as force 3 demands.** A tick that cannot reach Redis leaves its debt to the
|
|
121
|
+
next one rather than losing it; until then the instance throttles on what it alone has seen, and
|
|
122
|
+
keeps serving.
|
package/features/etag.feature
CHANGED
|
@@ -85,27 +85,77 @@ Feature: Optimistic concurrency control
|
|
|
85
85
|
io:output: true
|
|
86
86
|
GET: enumerate
|
|
87
87
|
"""
|
|
88
|
+
# sorted and bounded, or the collection does not enumerate the same way twice
|
|
88
89
|
When the following request is received:
|
|
89
90
|
"""
|
|
90
|
-
GET /pots
|
|
91
|
+
GET /pots/?sort=id:asc&limit=2 HTTP/1.1
|
|
91
92
|
host: nex.toa.io
|
|
92
93
|
accept: application/yaml
|
|
93
94
|
"""
|
|
94
95
|
Then the following reply is sent:
|
|
95
96
|
"""
|
|
96
97
|
200 OK
|
|
97
|
-
etag: ${{ etag }}
|
|
98
|
+
etag: "${{ etag }}"
|
|
98
99
|
"""
|
|
100
|
+
# the same representation must be asked for, the tag identifies it
|
|
99
101
|
When the following request is received:
|
|
100
102
|
"""
|
|
101
|
-
GET /pots
|
|
103
|
+
GET /pots/?sort=id:asc&limit=2 HTTP/1.1
|
|
102
104
|
host: nex.toa.io
|
|
103
|
-
|
|
105
|
+
accept: application/yaml
|
|
106
|
+
if-none-match: "${{ etag }}"
|
|
104
107
|
"""
|
|
105
108
|
Then the following reply is sent:
|
|
106
109
|
"""
|
|
107
110
|
304 Not Modified
|
|
108
|
-
etag: ${{ etag }}
|
|
111
|
+
etag: "${{ etag }}"
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
Scenario: `etag` of a reply that carries no version
|
|
115
|
+
Given the `echo` is running with the following manifest:
|
|
116
|
+
"""yaml
|
|
117
|
+
exposition:
|
|
118
|
+
/:name:
|
|
119
|
+
io:output: true
|
|
120
|
+
GET: compute
|
|
121
|
+
"""
|
|
122
|
+
When the following request is received:
|
|
123
|
+
"""
|
|
124
|
+
GET /echo/Bob/ HTTP/1.1
|
|
125
|
+
host: nex.toa.io
|
|
126
|
+
accept: text/plain
|
|
127
|
+
"""
|
|
128
|
+
Then the following reply is sent:
|
|
129
|
+
"""
|
|
130
|
+
200 OK
|
|
131
|
+
etag: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
|
|
132
|
+
|
|
133
|
+
Hello Bob
|
|
134
|
+
"""
|
|
135
|
+
When the following request is received:
|
|
136
|
+
"""
|
|
137
|
+
GET /echo/Bob/ HTTP/1.1
|
|
138
|
+
host: nex.toa.io
|
|
139
|
+
accept: text/plain
|
|
140
|
+
if-none-match: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
|
|
141
|
+
"""
|
|
142
|
+
Then the following reply is sent:
|
|
143
|
+
"""
|
|
144
|
+
304 Not Modified
|
|
145
|
+
etag: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
|
|
146
|
+
"""
|
|
147
|
+
# the tag identifies the representation, which is what `vary: accept` says
|
|
148
|
+
When the following request is received:
|
|
149
|
+
"""
|
|
150
|
+
GET /echo/Bob/ HTTP/1.1
|
|
151
|
+
host: nex.toa.io
|
|
152
|
+
accept: application/yaml
|
|
153
|
+
if-none-match: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
|
|
154
|
+
"""
|
|
155
|
+
Then the following reply is sent:
|
|
156
|
+
"""
|
|
157
|
+
200 OK
|
|
158
|
+
etag: "360c52c694c418b4a793a4815e611206ab17fe04836351a65638a15d1491810b"
|
|
109
159
|
"""
|
|
110
160
|
|
|
111
161
|
Scenario: Weak `etag`
|
|
@@ -9,7 +9,6 @@ Feature: Request throttling
|
|
|
9
9
|
key: [path]
|
|
10
10
|
requests: 1
|
|
11
11
|
interval: 1
|
|
12
|
-
cooldown: 1
|
|
13
12
|
GET:
|
|
14
13
|
endpoint: hello
|
|
15
14
|
"""
|
|
@@ -25,11 +24,13 @@ Feature: Request throttling
|
|
|
25
24
|
"""
|
|
26
25
|
GET /echo/beacon/ HTTP/1.1
|
|
27
26
|
"""
|
|
27
|
+
# an emission is a second, which is what the budget takes to earn one back
|
|
28
28
|
Then the following reply is sent:
|
|
29
29
|
"""
|
|
30
30
|
429 Too Many Requests
|
|
31
|
+
retry-after: 1
|
|
31
32
|
"""
|
|
32
|
-
Then after
|
|
33
|
+
Then after 2 seconds
|
|
33
34
|
When the following request is received:
|
|
34
35
|
"""
|
|
35
36
|
GET /echo/beacon/ HTTP/1.1
|
|
@@ -49,7 +50,6 @@ Feature: Request throttling
|
|
|
49
50
|
key: [route]
|
|
50
51
|
requests: 1
|
|
51
52
|
interval: 5
|
|
52
|
-
cooldown: 5
|
|
53
53
|
GET:
|
|
54
54
|
dev:stub:
|
|
55
55
|
hello: true
|
|
@@ -84,7 +84,6 @@ Feature: Request throttling
|
|
|
84
84
|
- segment: id
|
|
85
85
|
requests: 1
|
|
86
86
|
interval: 5
|
|
87
|
-
cooldown: 5
|
|
88
87
|
GET:
|
|
89
88
|
dev:stub:
|
|
90
89
|
hello: true
|
|
@@ -129,7 +128,6 @@ Feature: Request throttling
|
|
|
129
128
|
key: [identity]
|
|
130
129
|
requests: 1
|
|
131
130
|
interval: 5
|
|
132
|
-
cooldown: 5
|
|
133
131
|
endpoint: hello
|
|
134
132
|
"""
|
|
135
133
|
And transient identity alice
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/extensions.exposition",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.270",
|
|
4
4
|
"description": "Toa Exposition",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@simplewebauthn/server": "13.3.3",
|
|
21
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
21
|
+
"@toa.io/core": "1.0.0-alpha.270",
|
|
22
22
|
"@toa.io/generic": "1.0.0-alpha.254",
|
|
23
|
-
"@toa.io/schemas": "1.0.0-alpha.
|
|
23
|
+
"@toa.io/schemas": "1.0.0-alpha.270",
|
|
24
24
|
"bcryptjs": "3.0.3",
|
|
25
25
|
"content-type": "2.1.0",
|
|
26
26
|
"error-value": "0.4.4",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"minimatch": "10.2.6",
|
|
32
32
|
"msgpackr": "2.1.0",
|
|
33
33
|
"negotiator": "1.1.0",
|
|
34
|
-
"openspan": "1.0.0-alpha.
|
|
34
|
+
"openspan": "1.0.0-alpha.270",
|
|
35
35
|
"paseto": "3.1.4"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
},
|
|
63
63
|
"testEnvironment": "node"
|
|
64
64
|
},
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
|
|
66
66
|
}
|
|
@@ -31,13 +31,12 @@ properties:
|
|
|
31
31
|
requests:
|
|
32
32
|
type: integer
|
|
33
33
|
minimum: 1
|
|
34
|
-
interval:
|
|
34
|
+
interval:
|
|
35
35
|
description: Seconds
|
|
36
36
|
type: integer
|
|
37
37
|
minimum: 1
|
|
38
|
-
|
|
38
|
+
additionalProperties: false
|
|
39
39
|
required:
|
|
40
40
|
- key
|
|
41
41
|
- requests
|
|
42
42
|
- interval
|
|
43
|
-
- cooldown
|
package/source/Directive.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
|
|
|
10
10
|
name: 'foo',
|
|
11
11
|
mandatory: true,
|
|
12
12
|
create: jest.fn((_0: any, _1: any, _2: any) => generate() as any),
|
|
13
|
+
arrange: jest.fn(),
|
|
13
14
|
preflight: jest.fn(),
|
|
14
15
|
settle: jest.fn(),
|
|
15
16
|
dispose: jest.fn()
|
|
@@ -18,6 +19,7 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
|
|
|
18
19
|
name: 'qux',
|
|
19
20
|
mandatory: true,
|
|
20
21
|
create: jest.fn((_0: any, _1: any, _2: any) => generate() as any),
|
|
22
|
+
arrange: jest.fn(),
|
|
21
23
|
preflight: jest.fn(),
|
|
22
24
|
settle: jest.fn(),
|
|
23
25
|
dispose: jest.fn()
|
|
@@ -26,6 +28,7 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
|
|
|
26
28
|
name: 'bar',
|
|
27
29
|
mandatory: false,
|
|
28
30
|
create: jest.fn((_0: string, _1: any, _2: any) => generate() as any),
|
|
31
|
+
arrange: jest.fn(),
|
|
29
32
|
preflight: jest.fn(),
|
|
30
33
|
settle: jest.fn(),
|
|
31
34
|
dispose: jest.fn()
|
package/source/Directive.ts
CHANGED
|
@@ -7,18 +7,27 @@ import type * as RTD from './RTD'
|
|
|
7
7
|
export class Directives implements RTD.Directives {
|
|
8
8
|
private readonly sets: RTD.DirectiveSet[]
|
|
9
9
|
|
|
10
|
+
/** the span of a stage depends only on the set, so it is built once per route */
|
|
11
|
+
private readonly spans: Spans[]
|
|
12
|
+
|
|
10
13
|
public constructor (sets: RTD.DirectiveSet[]) {
|
|
11
14
|
this.sets = sets
|
|
15
|
+
this.spans = sets.map((set) => ({
|
|
16
|
+
preflight: options(set, 'preflight'),
|
|
17
|
+
settle: options(set, 'settle')
|
|
18
|
+
}))
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
public async preflight (context: Context, parameters: RTD.Parameter[]): Promise<Output> {
|
|
15
22
|
let output = null
|
|
16
23
|
|
|
17
|
-
for (
|
|
24
|
+
for (let i = 0; i < this.sets.length; i++) {
|
|
25
|
+
const set = this.sets[i]
|
|
26
|
+
|
|
18
27
|
if (set.family.preflight === undefined)
|
|
19
28
|
continue
|
|
20
29
|
|
|
21
|
-
const out = await console.span(
|
|
30
|
+
const out = await console.span(this.spans[i].preflight,
|
|
22
31
|
async () => await set.family.preflight!(set.directives, context, parameters))
|
|
23
32
|
|
|
24
33
|
if (out === null)
|
|
@@ -34,10 +43,13 @@ export class Directives implements RTD.Directives {
|
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
public async settle (context: Context, response: OutgoingMessage): Promise<void> {
|
|
37
|
-
for (
|
|
46
|
+
for (let i = 0; i < this.sets.length; i++) {
|
|
47
|
+
const set = this.sets[i]
|
|
48
|
+
|
|
38
49
|
if (set.family.settle !== undefined)
|
|
39
|
-
await console.span(
|
|
50
|
+
await console.span(this.spans[i].settle,
|
|
40
51
|
async () => await set.family.settle!(set.directives, context, response))
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
public dispose (): void {
|
|
@@ -106,6 +118,10 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
|
|
|
106
118
|
// The rest keep the order they were declared in, the sort being stable.
|
|
107
119
|
sets.sort((a, b) => this.rank(a.family.name) - this.rank(b.family.name))
|
|
108
120
|
|
|
121
|
+
// whatever order a family needs among its own directives is fixed here, not per request
|
|
122
|
+
for (const set of sets)
|
|
123
|
+
set.family.arrange?.(set.directives)
|
|
124
|
+
|
|
109
125
|
const directives = new Directives(sets)
|
|
110
126
|
|
|
111
127
|
this.instances.push(directives)
|
|
@@ -135,6 +151,11 @@ function options (set: RTD.DirectiveSet, stage: 'preflight' | 'settle'): SpanOpt
|
|
|
135
151
|
return options
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
interface Spans {
|
|
155
|
+
preflight: SpanOptions
|
|
156
|
+
settle: SpanOptions
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
export const shortcuts: RTD.syntax.Shortcuts = new Map([
|
|
139
160
|
['anonymous', 'auth:anonymous'],
|
|
140
161
|
['anyone', 'auth:anyone'],
|
package/source/Endpoint.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Readable } from 'node:stream'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
2
|
import { console } from 'openspan'
|
|
4
3
|
import { Mapping } from './Mapping'
|
|
5
4
|
import * as http from './HTTP'
|
|
@@ -125,16 +124,13 @@ export class Endpoint implements RTD.Endpoint {
|
|
|
125
124
|
if (reply instanceof Readable)
|
|
126
125
|
return false
|
|
127
126
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
message.headers.set('etag', hash)
|
|
127
|
+
/*
|
|
128
|
+
* A reply that carries no version is tagged with a hash of its body. The body is
|
|
129
|
+
* serialized anyway when the response is written, so the tag is computed from what
|
|
130
|
+
* is actually sent rather than from a second serialization of the reply — which
|
|
131
|
+
* makes it specific to the negotiated representation, hence `vary: accept`.
|
|
132
|
+
*/
|
|
133
|
+
message.etag = true
|
|
138
134
|
|
|
139
135
|
return false
|
|
140
136
|
}
|
package/source/Factory.ts
CHANGED
|
@@ -82,7 +82,22 @@ function configureLogs (): void {
|
|
|
82
82
|
|
|
83
83
|
const tracesEnv = process.env[TRACES_ENV]
|
|
84
84
|
|
|
85
|
-
traces(tracesEnv === undefined ?
|
|
85
|
+
traces(tracesEnv === undefined ? development() : decode<TracesOptions>(tracesEnv))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Tracing is off unless it is configured. The console exporter is a local development
|
|
90
|
+
* mechanism, so it is turned on for `toa dev` and for a boot trace the CLI has already
|
|
91
|
+
* asked for (`runtime/boot/src/span.js`), and nowhere else — a deployment that wants
|
|
92
|
+
* traces annotates `telemetry.traces.exporters`.
|
|
93
|
+
*
|
|
94
|
+
* The gateway boots without the telemetry extension, hence the copy of
|
|
95
|
+
* `extensions/telemetry/source/extension.ts`.
|
|
96
|
+
*/
|
|
97
|
+
function development (): TracesOptions {
|
|
98
|
+
const local = process.env.TOA_DEV === '1' || process.env.TOA_BOOT_TRACE === '1'
|
|
99
|
+
|
|
100
|
+
return local ? { exporters: { console: {} } } : {}
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
package/source/HTTP/Context.ts
CHANGED
|
@@ -25,18 +25,23 @@ export class Context {
|
|
|
25
25
|
|
|
26
26
|
private consumed = false
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// eslint-disable-next-line max-params
|
|
29
|
+
public constructor (authority: string, request: IncomingMessage, properties: Properties,
|
|
30
|
+
url: URL) {
|
|
29
31
|
this.authority = authority
|
|
30
32
|
this.request = request
|
|
31
33
|
|
|
32
34
|
this.id = crypto.randomUUID()
|
|
33
|
-
|
|
35
|
+
// parsed by the server, which had to parse it anyway to know the request is valid
|
|
36
|
+
this.url = url
|
|
34
37
|
this.timing = new Timing()
|
|
35
38
|
this.debug = properties.debug
|
|
36
39
|
this.log(request)
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
const accept = this.request.headers.accept
|
|
42
|
+
|
|
43
|
+
if (accept !== undefined) {
|
|
44
|
+
const match = SUBTYPE.exec(accept)
|
|
40
45
|
|
|
41
46
|
if (match !== null) {
|
|
42
47
|
const {
|
|
@@ -50,11 +55,10 @@ export class Context {
|
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
const
|
|
54
|
-
const mediaType = negotiator.mediaType(types)
|
|
58
|
+
const encoder = negotiate(this.request)
|
|
55
59
|
|
|
56
|
-
if (
|
|
57
|
-
this.encoder =
|
|
60
|
+
if (encoder !== undefined)
|
|
61
|
+
this.encoder = encoder
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
public async buffer (): Promise<Buffer> {
|
|
@@ -97,4 +101,31 @@ interface Properties {
|
|
|
97
101
|
debug: boolean
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Negotiation parses the header and sorts the candidates, and the value repeats:
|
|
106
|
+
* clients send one of a handful of `accept` strings. Bounded, the header is theirs.
|
|
107
|
+
*/
|
|
108
|
+
function negotiate (request: IncomingMessage): Format | undefined {
|
|
109
|
+
const accept = request.headers.accept ?? ''
|
|
110
|
+
const known = NEGOTIATED.get(accept)
|
|
111
|
+
|
|
112
|
+
if (known !== undefined)
|
|
113
|
+
return known === NONE ? undefined : known
|
|
114
|
+
|
|
115
|
+
const mediaType = new Negotiator(request).mediaType(types)
|
|
116
|
+
const encoder = mediaType === undefined ? undefined : formats[mediaType]
|
|
117
|
+
|
|
118
|
+
if (NEGOTIATED.size >= NEGOTIATED_LIMIT)
|
|
119
|
+
NEGOTIATED.clear()
|
|
120
|
+
|
|
121
|
+
NEGOTIATED.set(accept, encoder ?? NONE)
|
|
122
|
+
|
|
123
|
+
return encoder
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** distinguishes "negotiated to nothing" from "not negotiated yet" */
|
|
127
|
+
const NONE = Symbol('not acceptable') as unknown as Format
|
|
128
|
+
const NEGOTIATED = new Map<string, Format>()
|
|
129
|
+
const NEGOTIATED_LIMIT = 1024
|
|
130
|
+
|
|
100
131
|
const SUBTYPE = /^(?<type>\w{1,32})\/(vnd\.toa\.(?<subtype>\S{1,32})\+)(?<suffix>\S{1,32})$/
|
package/source/HTTP/Server.ts
CHANGED
|
@@ -92,10 +92,10 @@ export class Server extends Connector {
|
|
|
92
92
|
response.destroy()
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
const
|
|
95
|
+
const url = parse(request)
|
|
96
96
|
|
|
97
|
-
if (
|
|
98
|
-
console.warn('Invalid request', errorAttributes(request,
|
|
97
|
+
if (url instanceof Error) {
|
|
98
|
+
console.warn('Invalid request', errorAttributes(request, url))
|
|
99
99
|
|
|
100
100
|
response.writeHead(400).end()
|
|
101
101
|
|
|
@@ -129,8 +129,8 @@ export class Server extends Connector {
|
|
|
129
129
|
const remote = trace(request.headers)
|
|
130
130
|
|
|
131
131
|
const processing = remote === null
|
|
132
|
-
? this.serve(request, response, authority)
|
|
133
|
-
: run(remote, async () => await this.serve(request, response, authority))
|
|
132
|
+
? this.serve(request, response, authority, url)
|
|
133
|
+
: run(remote, async () => await this.serve(request, response, authority, url))
|
|
134
134
|
|
|
135
135
|
processing.catch((error) => {
|
|
136
136
|
console.error('Request processing failed', error)
|
|
@@ -140,9 +140,11 @@ export class Server extends Connector {
|
|
|
140
140
|
})
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// eslint-disable-next-line max-params
|
|
143
144
|
private async serve (request: http.IncomingMessage,
|
|
144
145
|
response: http.ServerResponse,
|
|
145
|
-
authority: string
|
|
146
|
+
authority: string,
|
|
147
|
+
url: URL): Promise<void> {
|
|
146
148
|
await console.span({
|
|
147
149
|
name: `${request.method} ${request.url}`,
|
|
148
150
|
kind: 'server',
|
|
@@ -151,7 +153,7 @@ export class Server extends Connector {
|
|
|
151
153
|
}, async () => {
|
|
152
154
|
response.setHeader('ray', current()!.traceId)
|
|
153
155
|
|
|
154
|
-
const context = new Context(authority, request as IncomingMessage, this.properties)
|
|
156
|
+
const context = new Context(authority, request as IncomingMessage, this.properties, url)
|
|
155
157
|
|
|
156
158
|
await this.process!(context)
|
|
157
159
|
.then(this.success(context, response))
|
|
@@ -201,6 +203,10 @@ export class Server extends Connector {
|
|
|
201
203
|
|
|
202
204
|
const message: OutgoingMessage = { status: response.statusCode }
|
|
203
205
|
|
|
206
|
+
// eslint-disable-next-line max-depth
|
|
207
|
+
if (exception instanceof Exception && exception.headers !== undefined)
|
|
208
|
+
message.headers = exception.headers
|
|
209
|
+
|
|
204
210
|
// eslint-disable-next-line max-depth
|
|
205
211
|
if (context.encoder === null)
|
|
206
212
|
message.body = undefined
|
|
@@ -226,11 +232,10 @@ export class Server extends Connector {
|
|
|
226
232
|
}
|
|
227
233
|
}
|
|
228
234
|
|
|
229
|
-
|
|
235
|
+
/** Parsing the URL is how a request is validated, so the `Context` is handed the result. */
|
|
236
|
+
function parse (request: http.IncomingMessage): URL | Error {
|
|
230
237
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return null
|
|
238
|
+
return new URL(request.url!, `https://${request.headers.host}`)
|
|
234
239
|
} catch (error) {
|
|
235
240
|
return error as Error
|
|
236
241
|
}
|
|
@@ -2,10 +2,14 @@ export class Exception extends Error {
|
|
|
2
2
|
public readonly status: number
|
|
3
3
|
public readonly body?: any
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Headers the status is not complete without, like `Retry-After` on a 429. */
|
|
6
|
+
public readonly headers?: Headers
|
|
7
|
+
|
|
8
|
+
protected constructor (status: number, body?: any, headers?: Headers) {
|
|
6
9
|
super()
|
|
7
10
|
this.status = status
|
|
8
11
|
this.body = body
|
|
12
|
+
this.headers = headers
|
|
9
13
|
}
|
|
10
14
|
}
|
|
11
15
|
|
|
@@ -79,8 +83,10 @@ export class UnprocessableEntity extends ClientError {
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
export class TooManyRequests extends ClientError {
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
/** @param retry Seconds until the request would be admitted. */
|
|
87
|
+
public constructor (retry?: number) {
|
|
88
|
+
super(429, undefined,
|
|
89
|
+
retry === undefined ? undefined : new Headers({ 'retry-after': String(retry) }))
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
|