@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
@@ -2,36 +2,42 @@ import assert from 'node:assert'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import * as http from 'node:http'
5
+ import * as http2 from 'node:http2'
5
6
  import { once } from 'node:events'
6
7
  import { setTimeout } from 'node:timers/promises'
7
8
  import { console, current, decide, decode, run, type SpanContext } from 'openspan'
8
9
  import { Connector } from '@toa.io/core'
9
10
  import { type OutgoingMessage, write } from './messages'
10
11
  import { ClientError, Exception } from './exceptions'
11
- import { Context, type IncomingMessage } from './Context'
12
+ import { Context } from './Context'
13
+ import { PROBE, Probe } from './Probe'
14
+ import type { IncomingMessage, Protocol, ServerResponse } from './types'
12
15
 
13
16
  export class Server extends Connector {
14
- private readonly server: http.Server = http.createServer()
17
+ private readonly server: http.Server | http2.Http2Server
15
18
  private readonly properties: Properties
16
19
  private readonly authorities: Record<string, string>
20
+
21
+ /** Tracked for the drain: `Http2Server` has no `closeIdleConnections`. */
22
+ private readonly sessions = new Set<http2.ServerHttp2Session>()
23
+
24
+ private readonly probe: Probe
25
+
17
26
  private process?: Processor
18
- private ready: boolean = false
19
- private startedAt: number = 0
20
27
 
21
28
  private constructor (properties: Properties) {
22
29
  super()
23
30
 
24
31
  this.properties = properties
25
32
  this.authorities = Object.fromEntries(Object.entries(properties.authorities).map(([key, value]) => [value, key]))
33
+ this.server = instantiate(properties.protocol)
34
+ this.probe = new Probe(properties.probe)
26
35
 
27
- this.server.on('request', (req, res) => this.listener(req, res))
36
+ this.server.on('request', (req, res) =>
37
+ this.listener(req as unknown as IncomingMessage, res as unknown as ServerResponse))
28
38
 
29
- this.server.on('clientError', (error, socket) => {
30
- console.warn('Client connection error', error)
31
-
32
- if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
33
- else socket.destroy()
34
- })
39
+ if (properties.protocol === 'h1') this.h1(this.server as http.Server)
40
+ else this.h2(this.server as http2.Http2Server)
35
41
  }
36
42
 
37
43
  public static create (options: Options): Server {
@@ -45,24 +51,30 @@ export class Server extends Connector {
45
51
  }
46
52
 
47
53
  protected override async open (): Promise<void> {
48
- this.startedAt = Date.now()
54
+ // answers 503 from here; the gateway's dependencies have settled by the time `open` runs
55
+ await this.probe.listen()
56
+
49
57
  this.server.listen(this.properties.port)
50
58
 
51
59
  await once(this.server, 'listening')
52
60
 
53
61
  console.info('HTTP Server is listening')
54
62
 
55
- this.ready = true
63
+ this.probe.complete()
56
64
 
57
65
  console.info('Ready')
58
- process.send?.('ready')
59
66
  }
60
67
 
61
68
  protected override async close (): Promise<void> {
62
- this.ready = false
69
+ await this.probe.close()
63
70
 
64
71
  this.server.close()
65
- this.server.closeIdleConnections()
72
+
73
+ // GOAWAY lets in-flight streams finish; `Http2Server` has no `closeIdleConnections`
74
+ if (this.properties.protocol === 'h1')
75
+ (this.server as http.Server).closeIdleConnections()
76
+ else
77
+ for (const session of this.sessions) session.close()
66
78
 
67
79
  console.info('Stopped accepting new connections')
68
80
 
@@ -72,12 +84,41 @@ export class Server extends Connector {
72
84
  setTimeout(this.properties.drain, undefined, { ref: false })
73
85
  ])
74
86
 
75
- this.server.closeAllConnections()
87
+ if (this.properties.protocol === 'h1')
88
+ (this.server as http.Server).closeAllConnections()
89
+ else
90
+ for (const session of this.sessions) session.destroy()
76
91
 
77
92
  console.info('Stopped')
78
93
  }
79
94
 
80
- private listener (request: http.IncomingMessage, response: http.ServerResponse): void {
95
+ /** A malformed HTTP/1.1 request has no framing to answer in, so the status line is written by hand. */
96
+ private h1 (server: http.Server): void {
97
+ server.on('clientError', (error, socket) => {
98
+ console.warn('Client connection error', error)
99
+
100
+ if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
101
+ else socket.destroy()
102
+ })
103
+ }
104
+
105
+ /** HTTP/2 has no `clientError`: failures surface per session, per stream, or per frame. */
106
+ private h2 (server: http2.Http2Server): void {
107
+ server.on('session', (session) => {
108
+ this.sessions.add(session)
109
+ session.on('close', () => this.sessions.delete(session))
110
+ })
111
+
112
+ server.on('sessionError', (error) => console.warn('Session error', error))
113
+ server.on('streamError', (error) => console.warn('Stream error', error))
114
+
115
+ server.on('unknownProtocol', (socket) => {
116
+ console.warn('Unknown protocol')
117
+ socket.destroy()
118
+ })
119
+ }
120
+
121
+ private listener (request: IncomingMessage, response: ServerResponse): void {
81
122
  request.once('error', (error) => {
82
123
  console.warn('Request error', errorAttributes(request, error))
83
124
 
@@ -85,14 +126,24 @@ export class Server extends Connector {
85
126
  response.destroy()
86
127
  })
87
128
 
88
- request.socket.once('error', (error) => {
89
- console.warn('Socket error', errorAttributes(request, error))
129
+ // no listener on `request.socket`: under HTTP/2 it is the session's socket, shared by
130
+ // every concurrent stream, and removing listeners on it would strip the siblings'
90
131
 
91
- if (!response.writableEnded)
92
- response.destroy()
93
- })
132
+ const host = authorityOf(request)
133
+
134
+ if (host === undefined) {
135
+ console.warn('Request without an authority', errorAttributes(request, new Error('No authority')))
94
136
 
95
- const url = parse(request)
137
+ response.writeHead(400).end()
138
+
139
+ return
140
+ }
141
+
142
+ // directives and components read `host`; HTTP/2 sends `:authority` instead, and what a
143
+ // component sees must not depend on the protocol the request arrived over
144
+ request.headers.host ??= host
145
+
146
+ const url = parse(request, host)
96
147
 
97
148
  if (url instanceof Error) {
98
149
  console.warn('Invalid request', errorAttributes(request, url))
@@ -108,21 +159,8 @@ export class Server extends Connector {
108
159
  return
109
160
  }
110
161
 
111
- if (request.url === '/.ready') {
112
- if (this.ready)
113
- response.writeHead(200, { 'cache-control': 'no-store' }).end()
114
- else {
115
- const remaining = (Math.ceil((Date.now() - this.startedAt) / 1000)).toString()
116
-
117
- response.writeHead(503, { 'retry-after': remaining }).end()
118
- }
119
-
120
- return
121
- }
122
-
123
162
  assert(this.process !== undefined, 'Request processor is not attached')
124
163
 
125
- const host = request.headers.host!
126
164
  const authority = this.authorities[host] ?? host
127
165
 
128
166
  // if the request carries no trace context, the trace starts here
@@ -141,8 +179,8 @@ export class Server extends Connector {
141
179
  }
142
180
 
143
181
  // eslint-disable-next-line max-params
144
- private async serve (request: http.IncomingMessage,
145
- response: http.ServerResponse,
182
+ private async serve (request: IncomingMessage,
183
+ response: ServerResponse,
146
184
  authority: string,
147
185
  url: URL): Promise<void> {
148
186
  await console.span({
@@ -153,19 +191,16 @@ export class Server extends Connector {
153
191
  }, async () => {
154
192
  response.setHeader('ray', current()!.traceId)
155
193
 
156
- const context = new Context(authority, request as IncomingMessage, this.properties, url)
194
+ const context = new Context(authority, request, this.properties, url)
157
195
 
158
196
  await this.process!(context)
159
197
  .then(this.success(context, response))
160
198
  .catch(this.fail(context, response))
161
- .finally(() => {
162
- request.removeAllListeners('error')
163
- request.socket.removeAllListeners('error')
164
- })
199
+ .finally(() => request.removeAllListeners('error'))
165
200
  })
166
201
  }
167
202
 
168
- private success (context: Context, response: http.ServerResponse) {
203
+ private success (context: Context, response: ServerResponse) {
169
204
  return async (message: OutgoingMessage) => {
170
205
  let status = message.status
171
206
 
@@ -185,10 +220,12 @@ export class Server extends Connector {
185
220
  }
186
221
  }
187
222
 
188
- private fail (context: Context, response: http.ServerResponse) {
223
+ private fail (context: Context, response: ServerResponse) {
189
224
  return async (exception: Error) => {
190
225
  try {
191
- if (!context.request.complete)
226
+ // Over HTTP/2 the reply is followed by RST_STREAM(NO_ERROR), which tells the client
227
+ // to stop sending without discarding the response — so the body is never read.
228
+ if (!context.request.complete && this.properties.protocol === 'h1')
192
229
  await adam(context.request)
193
230
 
194
231
  const status = exception instanceof Exception ? exception.status : 500
@@ -232,17 +269,36 @@ export class Server extends Connector {
232
269
  }
233
270
  }
234
271
 
272
+ function instantiate (protocol: Protocol): http.Server | http2.Http2Server {
273
+ if (protocol === 'h1')
274
+ return http.createServer()
275
+
276
+ return http2.createServer({
277
+ // realtime pins one stream per subscription, and they all share a session
278
+ maxSessionMemory: SESSION_MEMORY,
279
+ settings: { initialWindowSize: WINDOW }
280
+ })
281
+ }
282
+
283
+ /**
284
+ * The authority the request is addressed to. HTTP/2 carries it in `:authority` and omits
285
+ * `host` entirely, so reading `host` alone would leave every HTTP/2 request unattributed.
286
+ */
287
+ function authorityOf (request: IncomingMessage): string | undefined {
288
+ return request.headers[':authority'] ?? request.headers.host
289
+ }
290
+
235
291
  /** Parsing the URL is how a request is validated, so the `Context` is handed the result. */
236
- function parse (request: http.IncomingMessage): URL | Error {
292
+ function parse (request: IncomingMessage, authority: string): URL | Error {
237
293
  try {
238
- return new URL(request.url!, `https://${request.headers.host}`)
294
+ return new URL(request.url, `https://${authority}`)
239
295
  } catch (error) {
240
296
  return error as Error
241
297
  }
242
298
  }
243
299
 
244
300
  // https://github.com/whatwg/fetch/issues/1254
245
- async function adam (request: http.IncomingMessage): Promise<void> {
301
+ async function adam (request: IncomingMessage): Promise<void> {
246
302
  const devnull = fs.createWriteStream(os.devNull)
247
303
 
248
304
  devnull.on('error', () => undefined)
@@ -251,10 +307,10 @@ async function adam (request: http.IncomingMessage): Promise<void> {
251
307
  await once(request, 'end')
252
308
  }
253
309
 
254
- function errorAttributes (request: http.IncomingMessage, error: Error & any): RequestErrorAttributes {
310
+ function errorAttributes (request: IncomingMessage, error: Error & any): RequestErrorAttributes {
255
311
  const attributes: RequestErrorAttributes = {
256
- path: request.url!,
257
- method: request.method!,
312
+ path: request.url,
313
+ method: request.method,
258
314
  name: error.name
259
315
  }
260
316
 
@@ -269,20 +325,21 @@ function errorAttributes (request: http.IncomingMessage, error: Error & any): Re
269
325
 
270
326
  export const PORT = 8000
271
327
 
272
- /**
273
- * The initial delay of the readiness probe. The server does not sleep for it: whoever
274
- * probes is the one that waits, and doing it here as well only delayed the process twice.
275
- */
276
- export const DELAY = 3 // seconds
277
328
  export const DRAIN = 10 // seconds
278
329
 
330
+ /** Megabytes a single HTTP/2 session may hold, over Node's default of 10. */
331
+ const SESSION_MEMORY = 128
332
+
333
+ /** Per-stream flow control window. The default 64 KiB throttles in proportion to RTT. */
334
+ const WINDOW = 1024 * 1024
335
+
279
336
  /**
280
337
  * Extracts the remote trace context from the request headers.
281
338
  *
282
339
  * The `ray` header adopts the trace by ID only and does not bypass sampling:
283
340
  * the sampling decision is made by the server.
284
341
  */
285
- function trace (headers: http.IncomingHttpHeaders): SpanContext | null {
342
+ function trace (headers: IncomingMessage['headers']): SpanContext | null {
286
343
  if (typeof headers.traceparent === 'string')
287
344
  return decode(headers.traceparent)
288
345
 
@@ -300,7 +357,9 @@ const DEFAULTS: Omit<Properties, 'authorities'> = {
300
357
  methods: new Set<string>(['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'LOCK', 'UNLOCK']),
301
358
  debug: false,
302
359
  port: PORT,
303
- drain: DRAIN * 1000
360
+ drain: DRAIN * 1000,
361
+ protocol: 'h1',
362
+ probe: PROBE
304
363
  }
305
364
 
306
365
  interface Properties {
@@ -309,6 +368,10 @@ interface Properties {
309
368
  debug: boolean
310
369
  port: number
311
370
  drain: number
371
+ protocol: Protocol
372
+
373
+ /** Port of the readiness probe, which is HTTP/1.1 whatever the gateway serves. */
374
+ probe: number
312
375
  }
313
376
 
314
377
  export type Options = { authorities: Properties['authorities'] } & {
@@ -324,17 +387,3 @@ interface RequestErrorAttributes {
324
387
  code?: string
325
388
  stack?: string
326
389
  }
327
-
328
- /**
329
- * I'm too fucking dumb to figure out how to handle this in a better way.
330
- * It can be reproduced by calling `response.destroy()` on the client side while reading
331
- * an empty stream.
332
- */
333
- process.on('uncaughtException', (err: unknown) => {
334
- const code = (err as { code?: string }).code
335
-
336
- if (code === 'ECONNRESET' || code === 'EPIPE')
337
- console.warn('Connection reset by peer', code)
338
- else
339
- throw err
340
- })
@@ -1,5 +1,5 @@
1
1
  import { performance } from 'node:perf_hooks'
2
- import type { ServerResponse } from 'node:http'
2
+ import type { ServerResponse } from './types'
3
3
 
4
4
  export class Timing {
5
5
  private readonly start = performance.now()
@@ -2,3 +2,5 @@ export * from './Server'
2
2
  export * from './messages'
3
3
  export * from './exceptions'
4
4
  export * from './Context'
5
+ export * from './types'
6
+ export * from './Probe'
@@ -1,6 +1,5 @@
1
1
  import { PassThrough, Readable } from 'node:stream'
2
2
  import * as streamConsumers from 'node:stream/consumers'
3
- import { once } from 'node:events'
4
3
  import { generate } from 'randomstring'
5
4
  import * as msgpack from 'msgpackr'
6
5
  import { multipart, read, type OutgoingMessage } from './messages'
@@ -99,11 +98,9 @@ describe('read', () => {
99
98
  const context = { encoder: formats['text/plain'] } as unknown as Context
100
99
  const message = { body: Readable.from(['Hello', 'New', 'World']) } as unknown as OutgoingMessage
101
100
 
102
- multipart(message, context, response as unknown as http.ServerResponse)
101
+ const framed = multipart(message, context, response as unknown as http.ServerResponse)
103
102
 
104
- await once(message.body, 'end')
105
-
106
- const result = await streamConsumers.text(response)
103
+ const result = await streamConsumers.text(framed)
107
104
 
108
105
  expect(result).toBe([
109
106
  '--cut',
@@ -1,11 +1,12 @@
1
1
  import { Readable } from 'node:stream'
2
+ import { pipeline } from 'node:stream/promises'
2
3
  import { createHash } from 'node:crypto'
3
4
  import * as contentType from 'content-type'
4
5
  import { console } from 'openspan'
5
- import { formats } from './formats'
6
+ import { type Format, formats } from './formats'
6
7
  import { BadRequest, NotAcceptable, UnsupportedMediaType } from './exceptions'
7
8
  import type { Context } from './Context'
8
- import type * as http from 'node:http'
9
+ import type { ServerResponse } from './types'
9
10
 
10
11
  const server = `Exposition/${require('../../package.json').version}` +
11
12
  ((process.env.TOA_CONTEXT === undefined ? '' : ` ${process.env.TOA_CONTEXT}`) +
@@ -13,7 +14,7 @@ const server = `Exposition/${require('../../package.json').version}` +
13
14
 
14
15
  const pending = new Map<string, PendingStream>()
15
16
 
16
- export async function write (context: Context, response: http.ServerResponse, message: OutgoingMessage): Promise<void> {
17
+ export async function write (context: Context, response: ServerResponse, message: OutgoingMessage): Promise<void> {
17
18
  for (const transform of context.pipelines.response)
18
19
  await transform(message)
19
20
 
@@ -65,7 +66,7 @@ export async function read (context: Context): Promise<any> {
65
66
  }
66
67
  }
67
68
 
68
- function send (message: OutgoingMessage, context: Context, response: http.ServerResponse): void {
69
+ function send (message: OutgoingMessage, context: Context, response: ServerResponse): void {
69
70
  if (message.body === undefined || message.body === null) {
70
71
  // a HEAD reply carries no body but must still report the length a GET would
71
72
  // have returned, so a length already set by a directive is left alone
@@ -85,11 +86,10 @@ function send (message: OutgoingMessage, context: Context, response: http.Server
85
86
  if (message.etag === true && conditional(context, response, buf))
86
87
  return
87
88
 
88
- response
89
- .setHeader('content-type', context.encoder.type)
90
- .setHeader('content-length', buf.length.toString())
91
- .appendHeader('vary', 'accept')
92
- .end(buf)
89
+ response.setHeader('content-type', context.encoder.type)
90
+ response.setHeader('content-length', buf.length.toString())
91
+ response.appendHeader('vary', 'accept')
92
+ response.end(buf)
93
93
  }
94
94
 
95
95
  /**
@@ -98,7 +98,7 @@ function send (message: OutgoingMessage, context: Context, response: http.Server
98
98
  * than from a serialization of its own, so it identifies the representation — which is
99
99
  * what `vary` says.
100
100
  */
101
- function conditional (context: Context, response: http.ServerResponse, buf: Buffer): boolean {
101
+ function conditional (context: Context, response: ServerResponse, buf: Buffer): boolean {
102
102
  const etag = `"${createHash('sha256').update(buf).digest('hex')}"`
103
103
 
104
104
  response.setHeader('etag', etag)
@@ -106,9 +106,8 @@ function conditional (context: Context, response: http.ServerResponse, buf: Buff
106
106
  if (context.request.headers['if-none-match'] !== etag)
107
107
  return false
108
108
 
109
- response
110
- .setHeader('content-length', '0')
111
- .appendHeader('vary', 'accept')
109
+ response.setHeader('content-length', '0')
110
+ response.appendHeader('vary', 'accept')
112
111
 
113
112
  response.statusCode = 304
114
113
  response.end()
@@ -116,24 +115,28 @@ function conditional (context: Context, response: http.ServerResponse, buf: Buff
116
115
  return true
117
116
  }
118
117
 
119
- function stream (message: OutgoingMessage, context: Context, response: http.ServerResponse): void {
118
+ function stream (message: OutgoingMessage, context: Context, response: ServerResponse): void {
120
119
  const encoded = message.headers !== undefined && message.headers.has('content-type')
120
+ const source: Readable = encoded ? message.body : multipart(message, context, response)
121
121
 
122
- if (encoded)
123
- message.body.pipe(response)
124
- else
125
- multipart(message, context, response)
126
-
127
- message.body.on('error', (exception: Error) => {
128
- console.warn('Message stream error', { path: context.url.pathname, exception })
129
- response.end()
130
- })
122
+ // not awaited: a reply that streams is written long after the request is answered, and a
123
+ // realtime subscription outlives the span the reply was produced in
124
+ //
125
+ // `pipeline` carries an error to every stage and destroys them. `pipe` leaves the stages it
126
+ // built behind, and an `error` on a stream nobody listens to is an uncaught exception.
127
+ pipeline(source, response)
128
+ .catch((exception: Error) =>
129
+ console.warn('Message stream error', { path: context.url.pathname, exception }))
131
130
 
132
131
  if (context.debug)
133
132
  debugStream(context, response)
134
133
  }
135
134
 
136
- export function multipart (message: OutgoingMessage, context: Context, response: http.ServerResponse): void {
135
+ /**
136
+ * Frames an object stream as `multipart/*`: an `ACK` part, the parts themselves, then `FIN`.
137
+ * The body is a `Readable`; `write` reached here by testing it.
138
+ */
139
+ export function multipart (message: OutgoingMessage, context: Context, response: ServerResponse): Readable {
137
140
  if (context.encoder === null)
138
141
  throw new NotAcceptable()
139
142
 
@@ -141,27 +144,20 @@ export function multipart (message: OutgoingMessage, context: Context, response:
141
144
 
142
145
  response.setHeader('content-type', `${encoder.multipart}; boundary=${BOUNDARY}`)
143
146
 
144
- response.write(Buffer.concat([
145
- CUT,
146
- CRLF,
147
- encoder.encode('ACK'),
148
- CRLF,
149
- CUT
150
- ]))
147
+ return Readable.from(frames(message.body as Readable, encoder))
148
+ }
149
+
150
+ async function * frames (body: Readable, encoder: Format): AsyncGenerator<Buffer> {
151
+ yield Buffer.concat([CUT, CRLF, encoder.encode('ACK'), CRLF, CUT])
151
152
 
152
- message.body
153
- .map((part: unknown) => Buffer.concat([
153
+ for await (const part of body)
154
+ yield Buffer.concat([
154
155
  CRLF /* indicates no boundary headers */,
155
156
  encoder.encode(part),
156
157
  CRLF,
157
- CUT]))
158
- .on('end', () => response.end(Buffer.concat([
159
- CRLF,
160
- encoder.encode('FIN'),
161
- CRLF,
162
- FINALCUT
163
- ])))
164
- .pipe(response)
158
+ CUT])
159
+
160
+ yield Buffer.concat([CRLF, encoder.encode('FIN'), CRLF, FINALCUT])
165
161
  }
166
162
 
167
163
  const BOUNDARY = 'cut'
@@ -173,7 +169,7 @@ const PENDING_DEBUG_INTERVAL = 30000
173
169
 
174
170
  let pendingInterval: NodeJS.Timeout | null = null
175
171
 
176
- function debugStream (context: Context, response: http.ServerResponse): void {
172
+ function debugStream (context: Context, response: ServerResponse): void {
177
173
  const ctx = { method: context.request.method, path: context.url.pathname }
178
174
 
179
175
  console.debug('Stream opened', ctx)
@@ -0,0 +1,39 @@
1
+ import type { Readable, Writable } from 'node:stream'
2
+ import type { OutgoingHttpHeaders } from 'node:http'
3
+ import type { IncomingHttpHeaders } from 'node:http2'
4
+
5
+ /**
6
+ * What Exposition requires of a request. Both `http.IncomingMessage` and
7
+ * `http2.Http2ServerRequest` satisfy it, so the pipeline is written once.
8
+ */
9
+ export interface IncomingMessage extends Readable {
10
+ url: string
11
+ method: string
12
+ complete: boolean
13
+ headers: IncomingHeaders
14
+ socket: { remoteAddress?: string | undefined }
15
+ }
16
+
17
+ /**
18
+ * What Exposition requires of a response.
19
+ *
20
+ * `setHeader` and `appendHeader` return `void` because `Http2ServerResponse` returns
21
+ * nothing from them, so neither can be chained.
22
+ */
23
+ export interface ServerResponse extends Writable {
24
+ statusCode: number
25
+ writableEnded: boolean
26
+ setHeader: (name: string, value: number | string | string[]) => void
27
+ appendHeader: (name: string, value: string | string[]) => void
28
+ hasHeader: (name: string) => boolean
29
+ writeHead: (status: number, headers?: OutgoingHttpHeaders) => ServerResponse
30
+ }
31
+
32
+ /**
33
+ * HTTP/2's header map: HTTP/1.1's, plus the pseudo-headers. `:authority` is typed here
34
+ * so the authority can be read without narrowing at every call site.
35
+ */
36
+ export type IncomingHeaders = IncomingHttpHeaders
37
+
38
+ /** `h2c` is cleartext HTTP/2: there is no in-process TLS to negotiate ALPN with. */
39
+ export type Protocol = 'h1' | 'h2c'
@@ -5,7 +5,7 @@ import * as schemas from './schemas'
5
5
  import { shortcuts } from './Directive'
6
6
  import { components } from './Composition'
7
7
  import { parse } from './RTD/syntax'
8
- import { DELAY, PORT } from './HTTP'
8
+ import { DELAY, PORT, PROBE } from './HTTP'
9
9
 
10
10
  export function deployment (_: unknown, annotation?: Annotation): Dependency {
11
11
  assert.ok(annotation !== undefined, 'Exposition context annotation is required')
@@ -24,7 +24,7 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
24
24
  ingress: { path: '/', hosts: [] },
25
25
  probe: {
26
26
  path: '/.ready',
27
- port: PORT,
27
+ port: PROBE,
28
28
  delay: DELAY
29
29
  }
30
30
  }
@@ -49,17 +49,25 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
49
49
  if (annotation.annotations !== undefined)
50
50
  service.ingress!.annotations = annotation.annotations
51
51
 
52
+ if (annotation.service?.annotations !== undefined)
53
+ service.annotations = annotation.service.annotations
54
+
52
55
  const properties: Properties = { authorities }
53
56
 
54
57
  if (debug === true)
55
58
  properties.debug = true
56
59
 
60
+ if (annotation.protocol !== undefined)
61
+ properties.protocol = annotation.protocol
62
+
57
63
  service.variables!.push({
58
64
  name: 'TOA_EXPOSITION_PROPERTIES',
59
65
  value: JSON.stringify(properties)
60
66
  })
61
67
 
62
- // Nested identity composition shares this process; gateway already exposes /.ready.
68
+ // The identity composition nested in this process connects before route discovery settles,
69
+ // so telemetry's probe — which tracks that composition — would report ready too early.
70
+ // The gateway answers for itself, on the same port telemetry would have used.
63
71
  service.variables!.push({
64
72
  name: 'TOA_TELEMETRY_READY',
65
73
  value: JSON.stringify(false)
@@ -68,4 +76,4 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
68
76
  return { services: [service] }
69
77
  }
70
78
 
71
- type Properties = Pick<Annotation, 'authorities' | 'debug'>
79
+ type Properties = Pick<Annotation, 'authorities' | 'debug' | 'protocol'>
@@ -64,9 +64,9 @@ export class Get extends Directive {
64
64
  if (entry.range !== undefined)
65
65
  headers.set('content-range', entry.range)
66
66
 
67
- if (entry.size === null)
68
- headers.set('transfer-encoding', 'chunked')
69
- else
67
+ // an absent content-length is what says the length is unknown; naming the encoding
68
+ // is redundant over HTTP/1.1 and forbidden over HTTP/2
69
+ if (entry.size !== null)
70
70
  headers.set('content-length', entry.size.toString())
71
71
 
72
72
  return {
@@ -1,9 +1,19 @@
1
1
  import type { Resources } from '@toa.io/operations';
2
+ import type { Protocol } from './HTTP';
2
3
  export interface Annotation {
3
4
  authorities: Record<string, string>;
5
+ /**
6
+ * `h2c` requires an ingress controller that proxies cleartext HTTP/2 upstream.
7
+ * See `documentation/protocol.md`.
8
+ */
9
+ protocol?: Protocol;
4
10
  class?: string;
5
11
  resources?: Resources;
6
12
  annotations?: Record<string, string>;
13
+ /** The Service, as opposed to the Ingress that `annotations` above describes. */
14
+ service?: {
15
+ annotations?: Record<string, string>;
16
+ };
7
17
  debug?: boolean;
8
18
  '/'?: object;
9
19
  }