@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.
- package/CHANGELOG.md +12 -0
- package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
- package/documentation/protocol.md +24 -0
- package/features/octets.cloudinary.feature +8 -2
- package/features/octets.download.feature +3 -1
- package/features/probes.feature +8 -4
- package/features/steps/Common.ts +24 -1
- package/features/steps/Gateway.ts +9 -1
- package/features/steps/Parameters.ts +4 -0
- package/features/steps/Probe.ts +34 -0
- package/package.json +3 -2
- package/readme.md +7 -13
- package/schemas/annotation.cos.yaml +11 -0
- package/source/Annotation.ts +11 -0
- package/source/HTTP/Context.ts +1 -6
- package/source/HTTP/Probe.ts +112 -0
- package/source/HTTP/Server.ts +123 -74
- package/source/HTTP/Timing.ts +1 -1
- package/source/HTTP/index.ts +2 -0
- package/source/HTTP/messages.test.ts +2 -5
- package/source/HTTP/messages.ts +38 -42
- package/source/HTTP/types.ts +39 -0
- package/source/deployment.ts +12 -4
- package/source/directives/octets/Get.ts +3 -3
- package/transpiled/Annotation.d.ts +10 -0
- package/transpiled/HTTP/Context.d.ts +1 -6
- package/transpiled/HTTP/Context.js.map +1 -1
- package/transpiled/HTTP/Probe.d.ts +36 -0
- package/transpiled/HTTP/Probe.js +117 -0
- package/transpiled/HTTP/Probe.js.map +1 -0
- package/transpiled/HTTP/Server.d.ts +11 -7
- package/transpiled/HTTP/Server.js +92 -58
- package/transpiled/HTTP/Server.js.map +1 -1
- package/transpiled/HTTP/Timing.d.ts +1 -2
- package/transpiled/HTTP/index.d.ts +2 -0
- package/transpiled/HTTP/index.js +2 -0
- package/transpiled/HTTP/index.js.map +1 -1
- package/transpiled/HTTP/messages.d.ts +8 -3
- package/transpiled/HTTP/messages.js +31 -37
- package/transpiled/HTTP/messages.js.map +1 -1
- package/transpiled/HTTP/types.d.ts +40 -0
- package/transpiled/HTTP/types.js +3 -0
- package/transpiled/HTTP/types.js.map +1 -0
- package/transpiled/deployment.js +8 -2
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/directives/octets/Get.js +3 -3
- package/transpiled/directives/octets/Get.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
package/source/HTTP/Server.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) =>
|
|
36
|
+
this.server.on('request', (req, res) =>
|
|
37
|
+
this.listener(req as unknown as IncomingMessage, res as unknown as ServerResponse))
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
69
|
+
await this.probe.close()
|
|
63
70
|
|
|
64
71
|
this.server.close()
|
|
65
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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:
|
|
145
|
-
response:
|
|
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
|
|
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:
|
|
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:
|
|
223
|
+
private fail (context: Context, response: ServerResponse) {
|
|
189
224
|
return async (exception: Error) => {
|
|
190
225
|
try {
|
|
191
|
-
|
|
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:
|
|
292
|
+
function parse (request: IncomingMessage, authority: string): URL | Error {
|
|
237
293
|
try {
|
|
238
|
-
return new URL(request.url
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
})
|
package/source/HTTP/Timing.ts
CHANGED
package/source/HTTP/index.ts
CHANGED
|
@@ -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
|
|
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',
|
package/source/HTTP/messages.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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:
|
|
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'
|
package/source/deployment.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
}
|