@toa.io/extensions.exposition 1.0.0-alpha.274 → 1.0.0-alpha.276

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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  3. package/documentation/protocol.md +24 -0
  4. package/features/octets.cloudinary.feature +8 -2
  5. package/features/octets.download.feature +3 -1
  6. package/features/probes.feature +8 -4
  7. package/features/steps/Common.ts +24 -1
  8. package/features/steps/Gateway.ts +9 -1
  9. package/features/steps/Parameters.ts +4 -0
  10. package/features/steps/Probe.ts +34 -0
  11. package/package.json +3 -2
  12. package/readme.md +7 -13
  13. package/schemas/annotation.cos.yaml +11 -0
  14. package/source/Annotation.ts +11 -0
  15. package/source/HTTP/Context.ts +1 -6
  16. package/source/HTTP/Probe.ts +112 -0
  17. package/source/HTTP/Server.ts +123 -74
  18. package/source/HTTP/Timing.ts +1 -1
  19. package/source/HTTP/index.ts +2 -0
  20. package/source/HTTP/messages.test.ts +2 -5
  21. package/source/HTTP/messages.ts +38 -42
  22. package/source/HTTP/types.ts +39 -0
  23. package/source/deployment.ts +12 -4
  24. package/source/directives/octets/Get.ts +3 -3
  25. package/transpiled/Annotation.d.ts +10 -0
  26. package/transpiled/HTTP/Context.d.ts +1 -6
  27. package/transpiled/HTTP/Context.js.map +1 -1
  28. package/transpiled/HTTP/Probe.d.ts +36 -0
  29. package/transpiled/HTTP/Probe.js +117 -0
  30. package/transpiled/HTTP/Probe.js.map +1 -0
  31. package/transpiled/HTTP/Server.d.ts +11 -7
  32. package/transpiled/HTTP/Server.js +92 -58
  33. package/transpiled/HTTP/Server.js.map +1 -1
  34. package/transpiled/HTTP/Timing.d.ts +1 -2
  35. package/transpiled/HTTP/index.d.ts +2 -0
  36. package/transpiled/HTTP/index.js +2 -0
  37. package/transpiled/HTTP/index.js.map +1 -1
  38. package/transpiled/HTTP/messages.d.ts +8 -3
  39. package/transpiled/HTTP/messages.js +31 -37
  40. package/transpiled/HTTP/messages.js.map +1 -1
  41. package/transpiled/HTTP/types.d.ts +40 -0
  42. package/transpiled/HTTP/types.js +3 -0
  43. package/transpiled/HTTP/types.js.map +1 -0
  44. package/transpiled/deployment.js +8 -2
  45. package/transpiled/deployment.js.map +1 -1
  46. package/transpiled/directives/octets/Get.js +3 -3
  47. package/transpiled/directives/octets/Get.js.map +1 -1
  48. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -69,6 +69,30 @@ See also:
69
69
  - [Content-Type: multipart](https://learn.microsoft.com/en-us/previous-versions/office/developer/exchange-server-2010/aa493937(v=exchg.140))
70
70
  at Microsoft
71
71
 
72
+ ## HTTP version
73
+
74
+ The gateway serves HTTP/1.1 by default. `protocol: h2c` serves cleartext HTTP/2 instead.
75
+
76
+ ```yaml
77
+ exposition:
78
+ protocol: h2c
79
+ service:
80
+ annotations:
81
+ projectcontour.io/upstream-protocol.h2c: "8000"
82
+ ```
83
+
84
+ `h2c` requires an ingress controller that proxies cleartext HTTP/2 upstream, and the
85
+ annotation that controller reads to do so. Contour, Traefik and Envoy-based controllers
86
+ proxy it; ingress-nginx does not. A cleartext HTTP/2 server answers nothing else on the
87
+ same port: there is no ALPN to negotiate with, so a controller that speaks HTTP/1.1 upstream
88
+ reaches nothing.
89
+
90
+ Under `h2c` a request refused before its body is read is answered and then cancelled with
91
+ `RST_STREAM(NO_ERROR)`. The reply arrives; the upload stops where it is. A client streaming
92
+ a request body sees that body cancelled — in Node, `ERR_STREAM_PREMATURE_CLOSE` on the stream
93
+ it passed, which throws if nothing handles it. A real failure shows in the reply, or in the
94
+ request rejecting because no reply came. A buffered body is unaffected.
95
+
72
96
  ## CORS
73
97
 
74
98
  [CORS](https://www.w3.org/TR/2020/SPSD-cors-20200602/) is supported,
@@ -144,7 +144,10 @@ Feature: Octets with Cloudinary storage
144
144
  """
145
145
  200 OK
146
146
  content-type: video/mp4
147
- transfer-encoding: chunked
147
+ """
148
+ And the reply does not contain:
149
+ """
150
+ content-length
148
151
  """
149
152
 
150
153
  # after a while, Cloudinary returns a content-length response
@@ -169,7 +172,10 @@ Feature: Octets with Cloudinary storage
169
172
  """
170
173
  200 OK
171
174
  content-type: video/mp4
172
- transfer-encoding: chunked
175
+ """
176
+ And the reply does not contain:
177
+ """
178
+ content-length
173
179
  """
174
180
 
175
181
  When the following request is received:
@@ -18,6 +18,8 @@ Feature: Download external resources
18
18
  meta: true
19
19
  """
20
20
 
21
+ Given the length of `https://avatars.githubusercontent.com/u/92763022?s=48&v=4` is captured as `length`
22
+
21
23
  When the following request is received:
22
24
  """
23
25
  POST / HTTP/1.1
@@ -43,7 +45,7 @@ Feature: Download external resources
43
45
  Then the following reply is sent:
44
46
  """
45
47
  200 OK
46
- content-length: 1288
48
+ content-length: ${{ length }}
47
49
  content-type: image/png
48
50
  etag: "${{ checksum }}"
49
51
  """
@@ -1,14 +1,18 @@
1
1
  Feature: Probes
2
2
 
3
- Scenario: Startup probe
3
+ # The probe is on a port of its own: kubelet speaks HTTP/1.1, and the gateway may serve h2c.
4
+ Scenario: The gateway answers its readiness probe
5
+ Given the Gateway is running
6
+ When the ready probe is requested
7
+ Then the ready probe answers 200
8
+
9
+ Scenario: The traffic port does not serve the probe
4
10
  Given the Gateway is running
5
- And after 15 seconds
6
11
  When the following request is received:
7
12
  """
8
13
  GET /.ready HTTP/1.1
9
14
  """
10
15
  Then the following reply is sent:
11
16
  """
12
- 200 OK
13
- cache-control: no-store
17
+ 404 Not Found
14
18
  """
@@ -1,8 +1,16 @@
1
+ import * as assert from 'node:assert'
1
2
  import { binding, given } from 'cucumber-tsflow'
2
3
  import { timeout } from '@toa.io/generic'
4
+ import { Captures } from './Captures'
3
5
 
4
- @binding()
6
+ @binding([Captures])
5
7
  export class Common {
8
+ private readonly captures: Captures
9
+
10
+ public constructor (captures: Captures) {
11
+ this.captures = captures
12
+ }
13
+
6
14
  @given('after {float} second(s)')
7
15
  public async timeout (interval: number): Promise<void> {
8
16
  await timeout(interval * 1000)
@@ -11,4 +19,19 @@ export class Common {
11
19
  @given('the process is running')
12
20
  public async noop (): Promise<void> {
13
21
  }
22
+
23
+ /**
24
+ * A remote resource is whatever it is today. A scenario downloading one compares the reply
25
+ * against what the origin actually serves, not against a literal that goes stale when it changes.
26
+ */
27
+ @given('the length of `{}` is captured as `{word}`')
28
+ public async length (url: string, name: string): Promise<void> {
29
+ const response = await fetch(url)
30
+
31
+ assert.equal(response.status, 200, `${url} answered ${response.status}`)
32
+
33
+ const body = await response.arrayBuffer()
34
+
35
+ this.captures.set(name, String(body.byteLength))
36
+ }
14
37
  }
@@ -83,6 +83,9 @@ export class Gateway {
83
83
  const patch = parse(yaml) as object
84
84
  const configuration = Object.assign({}, def, patch)
85
85
 
86
+ // scenario-scoped, as the secrets it may refer to are: a configuration left behind
87
+ // outlives the secrets it points at, and the next scenario cannot resolve them
88
+ this.written.push(key)
86
89
  process.env[key] = JSON.stringify(configuration)
87
90
 
88
91
  await Gateway.stop()
@@ -191,12 +194,17 @@ const DEFAULT_TREE = JSON.stringify({
191
194
  const DEFAULT_PROPERTIES: Partial<http.Options> = {
192
195
  authorities: {
193
196
  nex: 'nex.toa.io'
194
- }
197
+ },
198
+ // `npm run features:h2c` runs the whole suite over cleartext HTTP/2
199
+ protocol: process.env.TOA_EXPOSITION_PROTOCOL === 'h2c' ? 'h2c' : 'h1'
195
200
  }
196
201
 
197
202
  // the identity components boot inside the gateway, and without a variable each would wait
198
203
  // for the values service, which these features do not run
199
204
  const DEFAULT_CONFIGURATION: Record<string, object> = {
205
+ // a component declaring configuration waits for `configuration.values`, which these
206
+ // features do not run; the variable is the local override that stands in for it
207
+ 'realtime.streams': {},
200
208
  'identity.basic': {},
201
209
  'identity.federation': {},
202
210
  'identity.otp': {},
@@ -16,6 +16,10 @@ setDefaultTimeout(60 * 1000)
16
16
 
17
17
  process.env.TOA_DEV = '1'
18
18
 
19
+ // the gateway answers for itself, as it does in a deployment: telemetry's probe tracks the
20
+ // nested composition, which connects before route discovery settles
21
+ process.env.TOA_TELEMETRY_READY ??= JSON.stringify(false)
22
+
19
23
  // export traces to the local Tempo (`docker compose up tempo grafana`),
20
24
  // unavailability of the endpoint is harmless
21
25
  process.env.TOA_TELEMETRY_TRACES ??= JSON.stringify({
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert'
2
+ import { Agent, request } from 'undici'
3
+ import { after, binding, then, when } from 'cucumber-tsflow'
4
+ import { PATH, PROBE } from '../../source/HTTP'
5
+
6
+ @binding()
7
+ export class Probe {
8
+ private readonly agent = new Agent()
9
+ private status: number | null = null
10
+ private headers: Record<string, string> = {}
11
+
12
+ @when('the ready probe is requested')
13
+ public async request (): Promise<void> {
14
+ const response = await request(`http://127.0.0.1:${PROBE}${PATH}`, { dispatcher: this.agent })
15
+
16
+ this.status = response.statusCode
17
+ this.headers = response.headers as Record<string, string>
18
+
19
+ await response.body.dump()
20
+ }
21
+
22
+ @then('the ready probe answers {int}')
23
+ public answers (status: number): void {
24
+ assert.equal(this.status, status)
25
+
26
+ if (status === 200)
27
+ assert.equal(this.headers['cache-control'], 'no-store')
28
+ }
29
+
30
+ @after()
31
+ public async close (): Promise<void> {
32
+ await this.agent.close()
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.274",
3
+ "version": "1.0.0-alpha.276",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -46,6 +46,7 @@
46
46
  "transpile:passkeys": "tsc -p ./components/identity.passkeys",
47
47
  "transpile:otp": "tsc -p ./components/identity.otp",
48
48
  "features": "cucumber-js",
49
+ "features:h2c": "TOA_EXPOSITION_PROTOCOL=h2c TOA_AGENT_PROTOCOL=h2c cucumber-js",
49
50
  "features:security": "cucumber-js --tags @security",
50
51
  "features:octets": "cucumber-js features/octets.*"
51
52
  },
@@ -61,5 +62,5 @@
61
62
  },
62
63
  "testEnvironment": "node"
63
64
  },
64
- "gitHead": "7df32eb2872155c45681c299036b1efbeefd6518"
65
+ "gitHead": "6f81af829c7bf8a2db5a409f3d60ea40d15583ef"
65
66
  }
package/readme.md CHANGED
@@ -188,21 +188,15 @@ exposition:
188
188
 
189
189
  ## Ready probe
190
190
 
191
- The gateway serves `GET /.ready` on port `8000` for Kubernetes startup/readiness probes.
191
+ The gateway serves `GET /.ready` on port `8004`, not on the port it serves traffic on, and
192
+ telemetry's probe is disabled in its process.
192
193
 
193
- It returns `200` only after:
194
+ It answers `200` only after the in-process identity composition has connected, initial route
195
+ discovery has settled, and the HTTP server is listening. Before that it answers `503` with a
196
+ `retry-after`. When ready, the process also sends `process.send('ready')` for PM2 `wait_ready`.
194
197
 
195
- 1. The in-process identity composition has connected
196
- 2. Initial route discovery has settled
197
- 3. The HTTP server is listening
198
-
199
- Before listen, probes get connection refused; after listen but before ready, `503`.
200
- When ready, the process also sends `process.send('ready')` (for PM2 `wait_ready`).
201
-
202
- The nested identity composition shares this process. Deployment disables telemetry's port-`8001`
203
- probe (`TOA_TELEMETRY_READY=false`) so only the gateway `/.ready` is used.
204
-
205
- See also [telemetry ready probe](../telemetry/readme.md#ready-probe).
198
+ See also [telemetry ready probe](../telemetry/readme.md#ready-probe) and
199
+ [reserved ports](../../documentation/ports.md).
206
200
 
207
201
  ## See Also
208
202
 
@@ -24,8 +24,19 @@ properties:
24
24
  patternProperties:
25
25
  ^.+$:
26
26
  type: string
27
+ service:
28
+ type: object
29
+ properties:
30
+ annotations:
31
+ type: object
32
+ patternProperties:
33
+ ^.+$:
34
+ type: string
27
35
  debug:
28
36
  type: boolean
37
+ protocol:
38
+ type: string
39
+ enum: [h1, h2c]
29
40
  /: {}
30
41
  authorities:
31
42
  type: object
@@ -1,10 +1,21 @@
1
1
  import type { Resources } from '@toa.io/operations'
2
+ import type { Protocol } from './HTTP'
2
3
 
3
4
  export interface Annotation {
4
5
  authorities: Record<string, string>
6
+
7
+ /**
8
+ * `h2c` requires an ingress controller that proxies cleartext HTTP/2 upstream.
9
+ * See `documentation/protocol.md`.
10
+ */
11
+ protocol?: Protocol
5
12
  class?: string
6
13
  resources?: Resources
7
14
  annotations?: Record<string, string>
15
+
16
+ /** The Service, as opposed to the Ingress that `annotations` above describes. */
17
+ service?: { annotations?: Record<string, string> }
18
+
8
19
  debug?: boolean
9
20
  '/'?: object // parsed and validated by RTD.syntax.parse
10
21
  }
@@ -6,7 +6,7 @@ import { Timing } from './Timing'
6
6
  import { type Format, formats, types } from './formats'
7
7
  import { read } from './messages'
8
8
  import type { OutgoingMessage } from './messages'
9
- import type * as http from 'node:http'
9
+ import type { IncomingMessage } from './types'
10
10
 
11
11
  export class Context {
12
12
  public readonly id: string
@@ -87,11 +87,6 @@ export class Context {
87
87
  }
88
88
  }
89
89
 
90
- export interface IncomingMessage extends http.IncomingMessage {
91
- url: string
92
- method: string
93
- }
94
-
95
90
  interface Pipelines {
96
91
  body: Array<(input: unknown) => unknown>
97
92
  response: Array<(output: OutgoingMessage) => void | Promise<void>>
@@ -0,0 +1,112 @@
1
+ import * as http from 'node:http'
2
+ import { console } from 'openspan'
3
+
4
+ /**
5
+ * The readiness probe, on a port of its own.
6
+ *
7
+ * It cannot share the port the gateway serves traffic on: kubelet speaks HTTP/1.1 only, and a
8
+ * cleartext HTTP/2 server answers nothing else on its port — there is no ALPN to negotiate with.
9
+ *
10
+ * It is the gateway's own readiness, not the process's. The identity composition nested in this
11
+ * process connects before route discovery has settled, so a probe tied to that composition would
12
+ * report ready while the gateway still had no routes and no open port.
13
+ */
14
+ export class Probe {
15
+ private readonly server = http.createServer()
16
+ private readonly port: number
17
+ private readonly path: string
18
+
19
+ private ready = false
20
+ private startedAt = 0
21
+ private listening = false
22
+ private skipped = false
23
+
24
+ public constructor (port: number, path: string = PATH) {
25
+ this.port = port
26
+ this.path = path
27
+
28
+ this.server.on('request', (request, response) => this.listener(request, response))
29
+ }
30
+
31
+ public async listen (): Promise<void> {
32
+ this.startedAt = Date.now()
33
+
34
+ try {
35
+ await new Promise<void>((resolve, reject) => {
36
+ this.server.once('error', reject)
37
+ this.server.listen(this.port, () => {
38
+ this.server.removeListener('error', reject)
39
+ resolve()
40
+ })
41
+ })
42
+ } catch (error: any) {
43
+ // processes sharing a host share the port; in a pod nothing else claims it
44
+ if (error?.code === 'EADDRINUSE') {
45
+ this.skipped = true
46
+
47
+ console.warn('Ready probe port already in use, skipping', { port: this.port })
48
+
49
+ return
50
+ }
51
+
52
+ throw error
53
+ }
54
+
55
+ this.listening = true
56
+
57
+ // a probe answers while the process runs; it must never be the reason it keeps running
58
+ this.server.unref()
59
+ }
60
+
61
+ /** The gateway is listening and has routes: answer `200`. */
62
+ public complete (): void {
63
+ this.ready = true
64
+
65
+ // the IPC signal is not tied to the probe: a process that gave up the shared port is
66
+ // still ready, and pm2 `wait_ready` would otherwise block until `listen_timeout`
67
+ process.send?.('ready')
68
+ }
69
+
70
+ public async close (): Promise<void> {
71
+ this.ready = false
72
+
73
+ if (!this.listening || this.skipped)
74
+ return
75
+
76
+ this.listening = false
77
+
78
+ // keep-alive connections would otherwise hold the server handle, delaying the exit
79
+ this.server.closeAllConnections()
80
+
81
+ await new Promise<void>((resolve) => this.server.close(() => resolve()))
82
+ }
83
+
84
+ private listener (request: http.IncomingMessage, response: http.ServerResponse): void {
85
+ if (request.url !== this.path) {
86
+ response.writeHead(404).end()
87
+
88
+ return
89
+ }
90
+
91
+ if (this.ready)
92
+ response.writeHead(200, { 'cache-control': 'no-store' }).end()
93
+ else {
94
+ const remaining = Math.ceil((Date.now() - this.startedAt) / 1000).toString()
95
+
96
+ response.writeHead(503, { 'retry-after': remaining }).end()
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Reserved for this probe. `8001` is the Telemetry readiness probe's, and `toa export` refuses a
103
+ * port claimed twice — `toa mono` and a local run put every service in one process.
104
+ */
105
+ export const PROBE = 8004
106
+ export const PATH = '/.ready'
107
+
108
+ /**
109
+ * The initial delay of the readiness probe. The server does not sleep for it: whoever
110
+ * probes is the one that waits, and doing it here as well only delayed the process twice.
111
+ */
112
+ export const DELAY = 3 // seconds