@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/source/HTTP/messages.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Readable } from 'node:stream'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import * as contentType from 'content-type'
|
|
3
4
|
import { console } from 'openspan'
|
|
4
5
|
import { formats } from './formats'
|
|
@@ -81,6 +82,9 @@ function send (message: OutgoingMessage, context: Context, response: http.Server
|
|
|
81
82
|
|
|
82
83
|
const buf = context.encoder.encode(message.body)
|
|
83
84
|
|
|
85
|
+
if (message.etag === true && conditional(context, response, buf))
|
|
86
|
+
return
|
|
87
|
+
|
|
84
88
|
response
|
|
85
89
|
.setHeader('content-type', context.encoder.type)
|
|
86
90
|
.setHeader('content-length', buf.length.toString())
|
|
@@ -88,6 +92,30 @@ function send (message: OutgoingMessage, context: Context, response: http.Server
|
|
|
88
92
|
.end(buf)
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Tags a reply that carries no version with a hash of the body being sent, and answers
|
|
97
|
+
* `304` when the client already has it. The tag is taken from the encoded body rather
|
|
98
|
+
* than from a serialization of its own, so it identifies the representation — which is
|
|
99
|
+
* what `vary` says.
|
|
100
|
+
*/
|
|
101
|
+
function conditional (context: Context, response: http.ServerResponse, buf: Buffer): boolean {
|
|
102
|
+
const etag = `"${createHash('sha256').update(buf).digest('hex')}"`
|
|
103
|
+
|
|
104
|
+
response.setHeader('etag', etag)
|
|
105
|
+
|
|
106
|
+
if (context.request.headers['if-none-match'] !== etag)
|
|
107
|
+
return false
|
|
108
|
+
|
|
109
|
+
response
|
|
110
|
+
.setHeader('content-length', '0')
|
|
111
|
+
.appendHeader('vary', 'accept')
|
|
112
|
+
|
|
113
|
+
response.statusCode = 304
|
|
114
|
+
response.end()
|
|
115
|
+
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
function stream (message: OutgoingMessage, context: Context, response: http.ServerResponse): void {
|
|
92
120
|
const encoded = message.headers !== undefined && message.headers.has('content-type')
|
|
93
121
|
|
|
@@ -173,6 +201,9 @@ export interface OutgoingMessage {
|
|
|
173
201
|
status?: number
|
|
174
202
|
headers?: Headers
|
|
175
203
|
body?: any
|
|
204
|
+
|
|
205
|
+
/** tag the response with a hash of the encoded body, see `conditional` */
|
|
206
|
+
etag?: boolean
|
|
176
207
|
}
|
|
177
208
|
|
|
178
209
|
export interface Query {
|
package/source/RTD/Directives.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface DirectiveFamily<TDirective = any, TExtension = any> {
|
|
|
28
28
|
|
|
29
29
|
create: (name: string, ...rest: any[]) => TDirective
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Puts the directives of one route in the order they must run in. Called once, when
|
|
33
|
+
* the set is built — the order cannot depend on the request.
|
|
34
|
+
*/
|
|
35
|
+
arrange?: (directives: TDirective[]) => void
|
|
36
|
+
|
|
31
37
|
preflight?: (directives: TDirective[],
|
|
32
38
|
request: Context & TExtension,
|
|
33
39
|
parameters: Parameter[]) => Output | Promise<Output>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
import { match } from 'matchacho'
|
|
3
3
|
import { console } from 'openspan'
|
|
4
|
-
import {
|
|
4
|
+
import { Minimatch } from 'minimatch'
|
|
5
5
|
import * as http from '../../HTTP'
|
|
6
6
|
import { Anonymous } from './Anonymous'
|
|
7
7
|
import { Id } from './Id'
|
|
@@ -61,11 +61,14 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
|
|
|
61
61
|
() => new Class(value))
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
public arrange (directives: Directive[]): void {
|
|
65
|
+
directives.sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1))
|
|
66
|
+
}
|
|
67
|
+
|
|
64
68
|
public async preflight (directives: Directive[],
|
|
65
69
|
context: Context,
|
|
66
70
|
parameters: Parameter[]): Promise<Output> {
|
|
67
71
|
context.identity = await this.resolve(context.authority, context.request.headers.authorization)
|
|
68
|
-
directives.sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1))
|
|
69
72
|
|
|
70
73
|
for (const directive of directives) {
|
|
71
74
|
const allow = await directive.authorize(context.identity, context, parameters)
|
|
@@ -162,7 +165,7 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
|
|
|
162
165
|
|
|
163
166
|
return Object.entries(permissions).some(([pattern, methods]) => {
|
|
164
167
|
return methods.some((method) => method === '*' || method === context.request.method) &&
|
|
165
|
-
|
|
168
|
+
glob(pattern).match(context.request.url)
|
|
166
169
|
})
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -175,6 +178,29 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
|
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
/**
|
|
182
|
+
* `minimatch(str, pattern)` compiles the pattern on every call, and a permission is
|
|
183
|
+
* matched on every request the identity makes. Patterns arrive with an identity, hence
|
|
184
|
+
* the bound.
|
|
185
|
+
*/
|
|
186
|
+
function glob (pattern: string): Minimatch {
|
|
187
|
+
let compiled = GLOBS.get(pattern)
|
|
188
|
+
|
|
189
|
+
if (compiled === undefined) {
|
|
190
|
+
if (GLOBS.size >= GLOBS_LIMIT)
|
|
191
|
+
GLOBS.clear()
|
|
192
|
+
|
|
193
|
+
compiled = new Minimatch(pattern)
|
|
194
|
+
|
|
195
|
+
GLOBS.set(pattern, compiled)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return compiled
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const GLOBS = new Map<string, Minimatch>()
|
|
202
|
+
const GLOBS_LIMIT = 1024
|
|
203
|
+
|
|
178
204
|
const constructors: Record<string, new (value: any, argument?: any) => Directive> = {
|
|
179
205
|
anonymous: Anonymous,
|
|
180
206
|
anyone: Anyone,
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Sync } from './lib/throttle'
|
|
2
2
|
import type { Parameter } from '../../RTD'
|
|
3
3
|
import type { Input as Context } from '../../io'
|
|
4
4
|
import type * as http from '../../HTTP'
|
|
5
5
|
|
|
6
6
|
export interface Directive {
|
|
7
7
|
preflight: (context: Context, parameters: Parameter[]) => void
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
/** Synchronous by contract: settling holds the response, and none of it needs I/O. */
|
|
10
|
+
settle?: (context: Context, response: http.OutgoingMessage) => void
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface Constructor {
|
|
13
14
|
validate: (value: unknown) => void
|
|
14
15
|
|
|
15
|
-
new (value: any,
|
|
16
|
+
new (value: any, sync: Sync, route: string): Directive
|
|
16
17
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Output } from './Output'
|
|
2
2
|
import { Input } from './Input'
|
|
3
3
|
import { Throttle } from './Throttle'
|
|
4
|
-
import
|
|
4
|
+
import { Sync } from './lib/throttle'
|
|
5
5
|
import type * as http from '../../HTTP'
|
|
6
6
|
import type { Parameter, DirectiveFamily } from '../../RTD'
|
|
7
7
|
import type { Remotes } from '../../Remotes'
|
|
@@ -11,8 +11,8 @@ export class IO implements DirectiveFamily<Directive> {
|
|
|
11
11
|
public readonly name = 'io'
|
|
12
12
|
public readonly mandatory = true
|
|
13
13
|
|
|
14
|
-
/** Throttling
|
|
15
|
-
private
|
|
14
|
+
/** Throttling reconciles through a component, because only a component has a stash aspect. */
|
|
15
|
+
private sync: Sync | null = null
|
|
16
16
|
|
|
17
17
|
// eslint-disable-next-line max-params
|
|
18
18
|
public create (name: string, value: unknown, remotes: Remotes, route: string): Directive {
|
|
@@ -25,9 +25,9 @@ export class IO implements DirectiveFamily<Directive> {
|
|
|
25
25
|
|
|
26
26
|
// discovering boots the component, so nothing is discovered until something throttles
|
|
27
27
|
if (name === 'throttle')
|
|
28
|
-
this.
|
|
28
|
+
this.sync ??= new Sync(remotes.discover('exposition', 'stash'))
|
|
29
29
|
|
|
30
|
-
return new Directive(value, this.
|
|
30
|
+
return new Directive(value, this.sync!, route)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
public preflight (directives: Directive[], context: http.Context,
|
|
@@ -46,14 +46,19 @@ export class IO implements DirectiveFamily<Directive> {
|
|
|
46
46
|
return null
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
public
|
|
49
|
+
public settle (directives: Directive[], context: http.Context,
|
|
50
|
+
output: http.OutgoingMessage): void {
|
|
50
51
|
for (const directive of directives)
|
|
51
|
-
|
|
52
|
+
directive.settle?.(context, output)
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The ticker belongs to the family rather than to any route's directives, and the
|
|
57
|
+
* factory disposes every route it made — so this runs once per route at shutdown,
|
|
58
|
+
* and disposing an already stopped ticker is what makes that harmless.
|
|
59
|
+
*/
|
|
60
|
+
public dispose (): void {
|
|
61
|
+
this.sync?.dispose()
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -5,10 +5,10 @@ import type { Directive } from './Directive'
|
|
|
5
5
|
import type { Input as Context } from '../../io'
|
|
6
6
|
|
|
7
7
|
export class Input implements Directive {
|
|
8
|
-
private readonly
|
|
8
|
+
private readonly allowed: Set<string>
|
|
9
9
|
|
|
10
10
|
public constructor (permissions: Permissions) {
|
|
11
|
-
this.
|
|
11
|
+
this.allowed = new Set(permissions)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
public static validate (permissions: unknown): asserts permissions is Permissions {
|
|
@@ -43,7 +43,7 @@ export class Input implements Directive {
|
|
|
43
43
|
|
|
44
44
|
private violation (value: Message | Message[]): string | undefined {
|
|
45
45
|
if (!Array.isArray(value))
|
|
46
|
-
return Object.keys(value).find((key) => !this.
|
|
46
|
+
return Object.keys(value).find((key) => !this.allowed.has(key))
|
|
47
47
|
|
|
48
48
|
for (const item of value) {
|
|
49
49
|
const property = this.violation(item)
|
|
@@ -10,6 +10,7 @@ export class Output implements Directive {
|
|
|
10
10
|
private readonly disabled: boolean = false
|
|
11
11
|
private readonly omitted: boolean = true
|
|
12
12
|
private readonly permissions: string[] = []
|
|
13
|
+
private readonly allowed: Set<string>
|
|
13
14
|
|
|
14
15
|
public constructor (permissions: Permissions) {
|
|
15
16
|
if (typeof permissions === 'boolean')
|
|
@@ -20,6 +21,8 @@ export class Output implements Directive {
|
|
|
20
21
|
|
|
21
22
|
else
|
|
22
23
|
this.permissions = permissions
|
|
24
|
+
|
|
25
|
+
this.allowed = new Set(this.permissions)
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
public static validate (permissions: unknown): asserts permissions is Permissions {
|
|
@@ -59,11 +62,16 @@ export class Output implements Directive {
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
/** Runs per entity of a collection, hence the set and the absence of intermediates. */
|
|
66
|
+
private fit (message: Message): Message {
|
|
67
|
+
const output: Message = {}
|
|
68
|
+
|
|
69
|
+
// the keys of the entity, so that the response keeps the order it was built in
|
|
70
|
+
for (const key of Object.keys(message))
|
|
71
|
+
if (this.allowed.has(key))
|
|
72
|
+
output[key] = message[key]
|
|
65
73
|
|
|
66
|
-
return
|
|
74
|
+
return output
|
|
67
75
|
}
|
|
68
76
|
}
|
|
69
77
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { TooManyRequests } from '../../HTTP'
|
|
2
2
|
import * as schemas from './schemas'
|
|
3
|
-
import { parse, Quotas, type Declaration } from './lib/throttle'
|
|
4
|
-
import type { Remote } from '@toa.io/core'
|
|
3
|
+
import { parse, Quotas, type Declaration, type Sync } from './lib/throttle'
|
|
5
4
|
import type * as http from '../../HTTP'
|
|
6
5
|
import type { Parameter } from '../../RTD'
|
|
7
6
|
import type { Directive } from './Directive'
|
|
@@ -9,10 +8,10 @@ import type { Directive } from './Directive'
|
|
|
9
8
|
export class Throttle implements Directive {
|
|
10
9
|
private readonly quotas: Quotas
|
|
11
10
|
|
|
12
|
-
public constructor (declaration: Declaration,
|
|
13
|
-
|
|
11
|
+
public constructor (declaration: Declaration, sync: Sync, route: string) {
|
|
12
|
+
this.quotas = Quotas.create(parse(declaration), route)
|
|
14
13
|
|
|
15
|
-
this.quotas
|
|
14
|
+
sync.register(this.quotas)
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
public static validate (declaration: unknown): asserts declaration is Declaration {
|
|
@@ -20,15 +19,13 @@ export class Throttle implements Directive {
|
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
public preflight (context: http.Context, parameters: Parameter[]): void {
|
|
23
|
-
|
|
24
|
-
throw new TooManyRequests()
|
|
25
|
-
}
|
|
22
|
+
const retry = this.quotas.check(context, parameters)
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
if (retry > 0)
|
|
25
|
+
throw new TooManyRequests(retry)
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
public
|
|
32
|
-
this.quotas.
|
|
28
|
+
public settle (context: http.Context, output: http.OutgoingMessage): void {
|
|
29
|
+
this.quotas.use(context, output)
|
|
33
30
|
}
|
|
34
31
|
}
|
|
@@ -3,7 +3,6 @@ export interface Configuration {
|
|
|
3
3
|
condition?: KeyCondition[]
|
|
4
4
|
requests: number
|
|
5
5
|
interval: number
|
|
6
|
-
cooldown: number
|
|
7
6
|
}
|
|
8
7
|
|
|
9
8
|
interface Rule<T, K = unknown> {
|
|
@@ -31,14 +30,13 @@ export interface Declaration extends Omit<Configuration, 'key' | 'condition'> {
|
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
export function parse (declaration: Declaration): Configuration {
|
|
34
|
-
const { key, condition, requests, interval
|
|
33
|
+
const { key, condition, requests, interval } = declaration
|
|
35
34
|
|
|
36
35
|
return {
|
|
37
36
|
key: mapKey(key),
|
|
38
37
|
condition: mapCondition(condition),
|
|
39
38
|
requests,
|
|
40
|
-
interval: interval * 1000
|
|
41
|
-
cooldown: cooldown * 1000
|
|
39
|
+
interval: interval * 1000
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
42
|
|