@toa.io/extensions.exposition 1.0.0-alpha.152 → 1.0.0-alpha.153

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 (114) hide show
  1. package/components/identity.bans/operations/tsconfig.tsbuildinfo +1 -1
  2. package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
  3. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  4. package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
  5. package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
  6. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  7. package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
  8. package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
  9. package/documentation/io.md +42 -2
  10. package/documentation/notes/desync.jpg +0 -0
  11. package/documentation/notes/peers.md +59 -0
  12. package/documentation/notes/throttling.md +82 -0
  13. package/features/io.throttle.feature +40 -0
  14. package/package.json +3 -3
  15. package/schemas/io/throttle.cos.yaml +36 -0
  16. package/source/Directive.test.ts +4 -2
  17. package/source/Directive.ts +16 -1
  18. package/source/Gateway.ts +2 -0
  19. package/source/HTTP/Server.ts +47 -21
  20. package/source/HTTP/exceptions.ts +21 -15
  21. package/source/RTD/Directives.ts +4 -0
  22. package/source/RTD/Tree.ts +4 -0
  23. package/source/directives/io/Directive.ts +5 -2
  24. package/source/directives/io/IO.ts +17 -5
  25. package/source/directives/io/Input.ts +1 -1
  26. package/source/directives/io/Output.ts +1 -1
  27. package/source/directives/io/Throttle.ts +32 -0
  28. package/source/directives/io/lib/throttle/Configuration.test.ts +40 -0
  29. package/source/directives/io/lib/throttle/Configuration.ts +58 -0
  30. package/source/directives/io/lib/throttle/Interval.ts +31 -0
  31. package/source/directives/io/lib/throttle/Keys.ts +40 -0
  32. package/source/directives/io/lib/throttle/Quota.ts +22 -0
  33. package/source/directives/io/lib/throttle/Quotas.test.ts +132 -0
  34. package/source/directives/io/lib/throttle/Quotas.ts +83 -0
  35. package/source/directives/io/lib/throttle/components/Component.ts +5 -0
  36. package/source/directives/io/lib/throttle/components/IP.ts +40 -0
  37. package/source/directives/io/lib/throttle/components/Path.ts +8 -0
  38. package/source/directives/io/lib/throttle/components/index.ts +13 -0
  39. package/source/directives/io/lib/throttle/conditions/Condition.ts +5 -0
  40. package/source/directives/io/lib/throttle/conditions/Status.ts +17 -0
  41. package/source/directives/io/lib/throttle/conditions/index.ts +11 -0
  42. package/source/directives/io/lib/throttle/index.ts +2 -0
  43. package/source/directives/io/schemas.test.ts +9 -0
  44. package/source/directives/io/schemas.ts +3 -0
  45. package/transpiled/Directive.d.ts +3 -0
  46. package/transpiled/Directive.js +12 -1
  47. package/transpiled/Directive.js.map +1 -1
  48. package/transpiled/Gateway.js +1 -0
  49. package/transpiled/Gateway.js.map +1 -1
  50. package/transpiled/HTTP/Server.js +41 -16
  51. package/transpiled/HTTP/Server.js.map +1 -1
  52. package/transpiled/HTTP/exceptions.d.ts +11 -8
  53. package/transpiled/HTTP/exceptions.js +23 -17
  54. package/transpiled/HTTP/exceptions.js.map +1 -1
  55. package/transpiled/RTD/Directives.d.ts +3 -0
  56. package/transpiled/RTD/Tree.d.ts +1 -0
  57. package/transpiled/RTD/Tree.js +3 -0
  58. package/transpiled/RTD/Tree.js.map +1 -1
  59. package/transpiled/directives/io/Directive.d.ts +5 -2
  60. package/transpiled/directives/io/IO.d.ts +4 -2
  61. package/transpiled/directives/io/IO.js +13 -3
  62. package/transpiled/directives/io/IO.js.map +1 -1
  63. package/transpiled/directives/io/Input.d.ts +1 -1
  64. package/transpiled/directives/io/Input.js +1 -1
  65. package/transpiled/directives/io/Input.js.map +1 -1
  66. package/transpiled/directives/io/Output.d.ts +1 -1
  67. package/transpiled/directives/io/Output.js +1 -1
  68. package/transpiled/directives/io/Output.js.map +1 -1
  69. package/transpiled/directives/io/Throttle.d.ts +11 -0
  70. package/transpiled/directives/io/Throttle.js +51 -0
  71. package/transpiled/directives/io/Throttle.js.map +1 -0
  72. package/transpiled/directives/io/lib/throttle/Configuration.d.ts +23 -0
  73. package/transpiled/directives/io/lib/throttle/Configuration.js +27 -0
  74. package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -0
  75. package/transpiled/directives/io/lib/throttle/Interval.d.ts +9 -0
  76. package/transpiled/directives/io/lib/throttle/Interval.js +31 -0
  77. package/transpiled/directives/io/lib/throttle/Interval.js.map +1 -0
  78. package/transpiled/directives/io/lib/throttle/Keys.d.ts +12 -0
  79. package/transpiled/directives/io/lib/throttle/Keys.js +34 -0
  80. package/transpiled/directives/io/lib/throttle/Keys.js.map +1 -0
  81. package/transpiled/directives/io/lib/throttle/Quota.d.ts +8 -0
  82. package/transpiled/directives/io/lib/throttle/Quota.js +22 -0
  83. package/transpiled/directives/io/lib/throttle/Quota.js.map +1 -0
  84. package/transpiled/directives/io/lib/throttle/Quotas.d.ts +26 -0
  85. package/transpiled/directives/io/lib/throttle/Quotas.js +61 -0
  86. package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -0
  87. package/transpiled/directives/io/lib/throttle/components/Component.d.ts +4 -0
  88. package/transpiled/directives/io/lib/throttle/components/Component.js +3 -0
  89. package/transpiled/directives/io/lib/throttle/components/Component.js.map +1 -0
  90. package/transpiled/directives/io/lib/throttle/components/IP.d.ts +6 -0
  91. package/transpiled/directives/io/lib/throttle/components/IP.js +33 -0
  92. package/transpiled/directives/io/lib/throttle/components/IP.js.map +1 -0
  93. package/transpiled/directives/io/lib/throttle/components/Path.d.ts +5 -0
  94. package/transpiled/directives/io/lib/throttle/components/Path.js +10 -0
  95. package/transpiled/directives/io/lib/throttle/components/Path.js.map +1 -0
  96. package/transpiled/directives/io/lib/throttle/components/index.d.ts +5 -0
  97. package/transpiled/directives/io/lib/throttle/components/index.js +10 -0
  98. package/transpiled/directives/io/lib/throttle/components/index.js.map +1 -0
  99. package/transpiled/directives/io/lib/throttle/conditions/Condition.d.ts +4 -0
  100. package/transpiled/directives/io/lib/throttle/conditions/Condition.js +3 -0
  101. package/transpiled/directives/io/lib/throttle/conditions/Condition.js.map +1 -0
  102. package/transpiled/directives/io/lib/throttle/conditions/Status.d.ts +7 -0
  103. package/transpiled/directives/io/lib/throttle/conditions/Status.js +19 -0
  104. package/transpiled/directives/io/lib/throttle/conditions/Status.js.map +1 -0
  105. package/transpiled/directives/io/lib/throttle/conditions/index.d.ts +5 -0
  106. package/transpiled/directives/io/lib/throttle/conditions/index.js +8 -0
  107. package/transpiled/directives/io/lib/throttle/conditions/index.js.map +1 -0
  108. package/transpiled/directives/io/lib/throttle/index.d.ts +2 -0
  109. package/transpiled/directives/io/lib/throttle/index.js +8 -0
  110. package/transpiled/directives/io/lib/throttle/index.js.map +1 -0
  111. package/transpiled/directives/io/schemas.d.ts +2 -0
  112. package/transpiled/directives/io/schemas.js +2 -1
  113. package/transpiled/directives/io/schemas.js.map +1 -1
  114. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -36,12 +36,18 @@ export class Directives implements RTD.Directives {
36
36
  if (set.family.settle !== undefined)
37
37
  await set.family.settle(set.directives, context, response)
38
38
  }
39
+
40
+ public dispose (): void {
41
+ for (const set of this.sets)
42
+ set.family.dispose?.(set.directives)
43
+ }
39
44
  }
40
45
 
41
46
  export class DirectivesFactory implements RTD.DirectiveFactory {
42
47
  private readonly remotes: Remotes
43
48
  private readonly families: Record<string, RTD.DirectiveFamily> = {}
44
49
  private readonly mandatory: string[] = []
50
+ private readonly instances: Directives[] = []
45
51
 
46
52
  public constructor (families: RTD.DirectiveFamily[], remotes: Remotes) {
47
53
  for (const family of families) {
@@ -88,7 +94,16 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
88
94
  directives
89
95
  })
90
96
 
91
- return new Directives(sets)
97
+ const directives = new Directives(sets)
98
+
99
+ this.instances.push(directives)
100
+
101
+ return directives
102
+ }
103
+
104
+ public dispose (): void {
105
+ for (const directives of this.instances)
106
+ directives.dispose()
92
107
  }
93
108
  }
94
109
 
package/source/Gateway.ts CHANGED
@@ -65,6 +65,8 @@ export class Gateway extends Connector {
65
65
  }
66
66
 
67
67
  protected override dispose (): void {
68
+ this.tree.dispose()
69
+
68
70
  console.info('Gateway is closed')
69
71
  }
70
72
 
@@ -29,7 +29,9 @@ export class Server extends Connector {
29
29
 
30
30
  this.server.on('clientError', (error, socket) => {
31
31
  console.warn('Client connection error', error)
32
- socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
32
+
33
+ if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
34
+ else socket.destroy()
33
35
  })
34
36
  }
35
37
 
@@ -70,6 +72,20 @@ export class Server extends Connector {
70
72
  }
71
73
 
72
74
  private listener (request: http.IncomingMessage, response: http.ServerResponse): void {
75
+ request.once('error', (error) => {
76
+ console.warn('Request error', errorAttributes(request, error))
77
+
78
+ if (!response.writableEnded)
79
+ response.destroy()
80
+ })
81
+
82
+ request.socket.once('error', (error) => {
83
+ console.warn('Socket error', errorAttributes(request, error))
84
+
85
+ if (!response.writableEnded)
86
+ response.destroy()
87
+ })
88
+
73
89
  const invalid = validate(request)
74
90
 
75
91
  if (invalid !== null) {
@@ -80,9 +96,6 @@ export class Server extends Connector {
80
96
  return
81
97
  }
82
98
 
83
- request.once('error', (error) => console.warn('Request error', errorAttributes(request, error)))
84
- request.socket.once('error', (error) => console.warn('Socket error', errorAttributes(request, error)))
85
-
86
99
  if (request.method === undefined || !this.properties.methods.has(request.method)) {
87
100
  response.writeHead(501).end()
88
101
 
@@ -113,7 +126,6 @@ export class Server extends Connector {
113
126
  .finally(() => {
114
127
  request.removeAllListeners('error')
115
128
  request.socket.removeAllListeners('error')
116
- response.removeAllListeners('error')
117
129
  })
118
130
  }
119
131
 
@@ -139,22 +151,36 @@ export class Server extends Connector {
139
151
 
140
152
  private fail (context: Context, response: http.ServerResponse) {
141
153
  return async (exception: Error) => {
142
- if (!context.request.complete)
143
- await adam(context.request)
144
-
145
- response.statusCode = exception instanceof Exception ? exception.status : 500
146
-
147
- const message: OutgoingMessage = { status: response.statusCode }
148
-
149
- if (context.encoder === null)
150
- message.body = undefined
151
- else if (exception instanceof ClientError || this.properties.debug)
152
- message.body =
153
- exception instanceof Exception
154
- ? exception.body
155
- : exception.stack ?? exception.message
156
-
157
- await write(context, response, message)
154
+ try {
155
+ if (!context.request.complete)
156
+ await adam(context.request)
157
+
158
+ if (!response.writableEnded) {
159
+ response.statusCode = exception instanceof Exception ? exception.status : 500
160
+
161
+ const message: OutgoingMessage = { status: response.statusCode }
162
+
163
+ // eslint-disable-next-line max-depth
164
+ if (context.encoder === null)
165
+ message.body = undefined
166
+ else if (exception instanceof ClientError || this.properties.debug)
167
+ message.body =
168
+ exception instanceof Exception
169
+ ? exception.body
170
+ : exception.stack ?? exception.message
171
+
172
+ await write(context, response, message)
173
+ }
174
+ } catch (final) {
175
+ console.error('Error in error handler', final)
176
+
177
+ if (!response.writableEnded)
178
+ try {
179
+ response.writeHead(500).end()
180
+ } catch (e) {
181
+ // Nothing more we can do
182
+ }
183
+ }
158
184
  }
159
185
  }
160
186
  }
@@ -36,18 +36,6 @@ export class NotFound extends ClientError {
36
36
  }
37
37
  }
38
38
 
39
- export class Conflict extends ClientError {
40
- public constructor (body?: any) {
41
- super(409, body)
42
- }
43
- }
44
-
45
- export class UnprocessableEntity extends ClientError {
46
- public constructor (body?: any) {
47
- super(422, body)
48
- }
49
- }
50
-
51
39
  export class MethodNotAllowed extends ClientError {
52
40
  public constructor () {
53
41
  super(405)
@@ -60,9 +48,9 @@ export class NotAcceptable extends ClientError {
60
48
  }
61
49
  }
62
50
 
63
- export class UnsupportedMediaType extends ClientError {
64
- public constructor () {
65
- super(415)
51
+ export class Conflict extends ClientError {
52
+ public constructor (body?: any) {
53
+ super(409, body)
66
54
  }
67
55
  }
68
56
 
@@ -77,3 +65,21 @@ export class RequestEntityTooLarge extends ClientError {
77
65
  super(413, body)
78
66
  }
79
67
  }
68
+
69
+ export class UnsupportedMediaType extends ClientError {
70
+ public constructor () {
71
+ super(415)
72
+ }
73
+ }
74
+
75
+ export class UnprocessableEntity extends ClientError {
76
+ public constructor (body?: any) {
77
+ super(422, body)
78
+ }
79
+ }
80
+
81
+ export class TooManyRequests extends ClientError {
82
+ public constructor () {
83
+ super(429)
84
+ }
85
+ }
@@ -6,10 +6,12 @@ import type { Output } from '../io'
6
6
  export interface Directives {
7
7
  preflight: (context: Context, parameters: Parameter[]) => Promise<Output>
8
8
  settle: (context: Context, response: OutgoingMessage) => Promise<void>
9
+ dispose: () => void
9
10
  }
10
11
 
11
12
  export interface DirectiveFactory {
12
13
  create: (directives: syntax.Directive[]) => Directives
14
+ dispose: () => void
13
15
  }
14
16
 
15
17
  export interface DirectiveSet {
@@ -30,4 +32,6 @@ export interface DirectiveFamily<TDirective = any, TExtension = any> {
30
32
  settle?: (directives: TDirective[],
31
33
  request: Context & TExtension,
32
34
  response: OutgoingMessage) => void | Promise<void>
35
+
36
+ dispose?: (directives: TDirective[]) => void
33
37
  }
@@ -39,6 +39,10 @@ export class Tree {
39
39
  this.trunk.merge(branch)
40
40
  }
41
41
 
42
+ public dispose (): void {
43
+ this.directives.dispose()
44
+ }
45
+
42
46
  private createNode
43
47
  (node: syntax.Node, protect: boolean, extension?: unknown): Node {
44
48
  const context: Context = {
@@ -1,7 +1,10 @@
1
- import type { Input } from '../../io'
1
+ import type { Input as Context } from '../../io'
2
+ import type * as http from '../../HTTP'
2
3
 
3
4
  export interface Directive {
4
- attach: (context: Input) => void
5
+ preflight: (context: Context) => void
6
+ settle?: (context: Context, response: http.OutgoingMessage) => Promise<void> | void
7
+ dispose?: () => void
5
8
  }
6
9
 
7
10
  export interface Constructor {
@@ -1,7 +1,8 @@
1
1
  import { Output } from './Output'
2
2
  import { Input } from './Input'
3
+ import { Throttle } from './Throttle'
4
+ import type * as http from '../../HTTP'
3
5
  import type { Constructor, Directive } from './Directive'
4
- import type { Input as Context } from '../../io'
5
6
  import type { DirectiveFamily } from '../../RTD'
6
7
 
7
8
  export class IO implements DirectiveFamily<Directive> {
@@ -19,25 +20,36 @@ export class IO implements DirectiveFamily<Directive> {
19
20
  return new Directive(value)
20
21
  }
21
22
 
22
- public preflight (directives: Directive[], context: Context): null {
23
+ public preflight (directives: Directive[], context: http.Context): null {
23
24
  let restricted = false
24
25
 
25
26
  for (const directive of directives) {
26
27
  restricted ||= directive instanceof Output
27
28
 
28
- directive.attach(context)
29
+ directive.preflight(context)
29
30
  }
30
31
 
31
32
  if (!restricted)
32
- DENIAL.attach(context)
33
+ DENIAL.preflight(context)
33
34
 
34
35
  return null
35
36
  }
37
+
38
+ public async settle (directives: Directive[], context: http.Context, output: http.OutgoingMessage): Promise<void> {
39
+ for (const directive of directives)
40
+ await directive.settle?.(context, output)
41
+ }
42
+
43
+ public dispose (directives: Directive[]): void {
44
+ for (const directive of directives)
45
+ directive.dispose?.()
46
+ }
36
47
  }
37
48
 
38
49
  const constructors: Record<string, Constructor> = {
50
+ input: Input,
39
51
  output: Output,
40
- input: Input
52
+ throttle: Throttle
41
53
  }
42
54
 
43
55
  const DENIAL: Output = new Output([])
@@ -15,7 +15,7 @@ export class Input implements Directive {
15
15
  schemas.input.validate<Permissions>(permissions, 'Incorrect \'io:input\' format')
16
16
  }
17
17
 
18
- public attach (context: Context): void {
18
+ public preflight (context: Context): void {
19
19
  context.pipelines.body.push((body) => this.check(body))
20
20
  }
21
21
 
@@ -26,7 +26,7 @@ export class Output implements Directive {
26
26
  schemas.output.validate(permissions, 'Incorrect \'io:output\' format')
27
27
  }
28
28
 
29
- public attach (context: Context): void {
29
+ public preflight (context: Context): void {
30
30
  context.pipelines.response.push(this.restriction(context))
31
31
  }
32
32
 
@@ -0,0 +1,32 @@
1
+ import { TooManyRequests } from '../../HTTP'
2
+ import * as schemas from './schemas'
3
+ import { parse, Quotas, type Declaration } from './lib/throttle'
4
+ import type * as http from '../../HTTP'
5
+ import type { Directive } from './Directive'
6
+
7
+ export class Throttle implements Directive {
8
+ private readonly quotas: Quotas
9
+
10
+ public constructor (declaration: Declaration) {
11
+ const configuration = parse(declaration)
12
+
13
+ this.quotas = Quotas.create(configuration)
14
+ }
15
+
16
+ public static validate (declaration: unknown): asserts declaration is Declaration {
17
+ schemas.throttle.validate(declaration, 'Incorrect \'io:throttle\' format')
18
+ }
19
+
20
+ public preflight (context: http.Context): void {
21
+ if (!this.quotas.ok(context))
22
+ throw new TooManyRequests()
23
+ }
24
+
25
+ public settle (context: http.Context, output: http.OutgoingMessage): void {
26
+ this.quotas.use(context, output)
27
+ }
28
+
29
+ public dispose (): void {
30
+ this.quotas.dispose()
31
+ }
32
+ }
@@ -0,0 +1,40 @@
1
+ import { parse, type Configuration } from './Configuration'
2
+
3
+ const rest: Omit<Configuration, 'key' | 'condition'> = {
4
+ interval: 1,
5
+ requests: 1,
6
+ cooldown: 1
7
+ }
8
+
9
+ it('should convert key', () => {
10
+ const result: Partial<Configuration> = {
11
+ key: [
12
+ {
13
+ method: 'ip'
14
+ }
15
+ ]
16
+ }
17
+
18
+ expect(parse({ key: 'ip', ...rest })).toMatchObject(result)
19
+ expect(parse({ key: ['ip'], ...rest })).toMatchObject(result)
20
+ })
21
+
22
+ it('should convert condition', () => {
23
+ expect(parse({ key: ['ip', 'path'], condition: { status: '404' }, ...rest })).toMatchObject({
24
+ condition: [
25
+ {
26
+ method: 'status',
27
+ options: '404'
28
+ }
29
+ ]
30
+ })
31
+
32
+ expect(parse({ key: 'ip', condition: { status: '404' }, ...rest })).toMatchObject({
33
+ condition: [
34
+ {
35
+ method: 'status',
36
+ options: '404'
37
+ }
38
+ ]
39
+ })
40
+ })
@@ -0,0 +1,58 @@
1
+ export interface Configuration {
2
+ key: KeyComponent[]
3
+ condition?: KeyCondition[]
4
+ requests: number
5
+ interval: number
6
+ cooldown: number
7
+ }
8
+
9
+ interface Rule<T, K = unknown> {
10
+ method: T
11
+ options?: K
12
+ }
13
+
14
+ export type KeyComponentMethod = 'ip' | 'path'
15
+ export type KeyConditionMethod = 'status'
16
+
17
+ export type KeyComponent = Rule<KeyComponentMethod>
18
+ export type KeyCondition = Rule<KeyConditionMethod>
19
+
20
+ type KeyDeclaration = KeyComponentMethod | KeyComponentMethod[]
21
+
22
+ type ConditionDeclaration =
23
+ Record<KeyConditionMethod, unknown>
24
+ | Array<Record<KeyConditionMethod, unknown>>
25
+
26
+ export interface Declaration extends Omit<Configuration, 'key' | 'condition'> {
27
+ key: KeyDeclaration
28
+ condition?: ConditionDeclaration
29
+ }
30
+
31
+ export function parse (declaration: Declaration): Configuration {
32
+ const { key, condition, requests, interval, cooldown } = declaration
33
+
34
+ return {
35
+ key: mapKey(key),
36
+ condition: mapCondition(condition),
37
+ requests,
38
+ interval: interval * 1000,
39
+ cooldown: cooldown * 1000
40
+ }
41
+ }
42
+
43
+ function mapKey (declaration: KeyDeclaration): KeyComponent[] {
44
+ const methods = Array.isArray(declaration) ? declaration : [declaration]
45
+
46
+ return methods.map((method) => ({ method }))
47
+ }
48
+
49
+ function mapCondition (declaration?: ConditionDeclaration): KeyCondition[] | undefined {
50
+ if (declaration === undefined)
51
+ return
52
+
53
+ // reduce to a single object, then map entries to rules
54
+ const conditions = Array.isArray(declaration) ? declaration : [declaration]
55
+ const single = conditions.reduce((acc, condition) => ({ ...acc, ...condition }), {})
56
+
57
+ return Object.entries(single).map(([method, options]) => ({ method, options })) as KeyCondition[]
58
+ }
@@ -0,0 +1,31 @@
1
+ import assert from 'node:assert'
2
+ import { EventEmitter } from 'node:events'
3
+
4
+ export class Interval extends EventEmitter {
5
+ public number: number = 0
6
+ private interval: ReturnType<typeof setInterval> | null = null
7
+
8
+ public constructor (interval: number) {
9
+ super()
10
+
11
+ const number = Math.ceil(Date.now() / interval)
12
+ const shift = number * interval - Date.now()
13
+
14
+ assert.ok(shift >= 0, 'shift must be positive')
15
+
16
+ setTimeout(() => this.start(interval), shift)
17
+ }
18
+
19
+ public start (interval: number): void {
20
+ this.interval = setInterval(() => this.emit('tick'), interval)
21
+ }
22
+
23
+ public dispose (): void {
24
+ if (this.interval !== null) {
25
+ clearInterval(this.interval)
26
+ this.interval = null
27
+ }
28
+
29
+ this.removeAllListeners()
30
+ }
31
+ }
@@ -0,0 +1,40 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { Components, type Component } from './components'
3
+ import { Conditions, type Condition } from './conditions'
4
+ import type { KeyComponent, KeyCondition } from './Configuration'
5
+ import type { Input as Context, Output } from '../../../../io'
6
+
7
+ export class Keys {
8
+ private readonly components: Component[]
9
+ private readonly conditions?: Condition[]
10
+
11
+ public constructor (components: Component[], conditions?: Condition[]) {
12
+ this.components = components
13
+ this.conditions = conditions
14
+ }
15
+
16
+ public static create (componentRules: KeyComponent[], conditionRules?: KeyCondition[]): Keys {
17
+ const components = componentRules.map((rule) => new Components[rule.method](rule.options))
18
+ const conditions = conditionRules?.map((rule) => new Conditions[rule.method](rule.options))
19
+
20
+ return new this(components, conditions)
21
+ }
22
+
23
+ public get (context: Context): string {
24
+ const hash = createHash('sha256')
25
+
26
+ for (const component of this.components)
27
+ hash.update(component.get(context))
28
+
29
+ return hash.digest('hex')
30
+ }
31
+
32
+ public match (input: Context, output: Output): string | null {
33
+ const miss = this.conditions?.some((condition) => !condition.match(input, output))
34
+
35
+ if (miss === true)
36
+ return null
37
+ else
38
+ return this.get(input)
39
+ }
40
+ }
@@ -0,0 +1,22 @@
1
+ export class Quota {
2
+ private readonly limit: number = 0
3
+ private used: number = 0
4
+
5
+ public constructor (limit: number) {
6
+ this.limit = limit
7
+ }
8
+
9
+ public get idle (): boolean {
10
+ return this.used === 0
11
+ }
12
+
13
+ public use (count = 1): boolean {
14
+ this.used += count
15
+
16
+ return this.used < this.limit
17
+ }
18
+
19
+ public reset (): void {
20
+ this.used = 0
21
+ }
22
+ }
@@ -0,0 +1,132 @@
1
+ import { setTimeout } from 'node:timers/promises'
2
+ import { Quotas } from './Quotas'
3
+ import type { Configuration } from './Configuration'
4
+ import type { Input as Context, Output } from '../../../../io'
5
+
6
+ let quotas: Quotas
7
+ let configuration: Configuration
8
+ let context: Context
9
+ let output: Output
10
+
11
+ beforeEach(() => {
12
+ output = { status: 200 }
13
+ })
14
+
15
+ describe('common', () => {
16
+ beforeEach(() => {
17
+ context = createContext()
18
+ configuration = createConfiguration()
19
+ quotas = Quotas.create(configuration)
20
+ })
21
+
22
+ it('should be ok', () => {
23
+ quotas.ok(context)
24
+ quotas.use(context, output)
25
+ })
26
+
27
+ it('should throttle', () => {
28
+ expect(quotas.ok(context)).toBe(true)
29
+
30
+ quotas.use(context, output)
31
+ quotas.use(context, output)
32
+
33
+ expect(quotas.ok(context)).toBe(false)
34
+ })
35
+
36
+ it('should unblock after cooldown', async () => {
37
+ quotas.use(context, output)
38
+ quotas.use(context, output)
39
+
40
+ expect(quotas.ok(context)).toBe(false)
41
+
42
+ await setTimeout(configuration.cooldown)
43
+
44
+ expect(quotas.ok(context)).toBe(true)
45
+ })
46
+
47
+ it('should reset after interval', async () => {
48
+ quotas.use(context, output)
49
+
50
+ // pseudo-synchronous intervals start somewhere between 0 and interval
51
+ await setTimeout(configuration.interval * 2)
52
+
53
+ quotas.use(context, output)
54
+
55
+ expect(quotas.ok(context)).toBe(true)
56
+ })
57
+ })
58
+
59
+ describe('path', () => {
60
+ beforeEach(() => {
61
+ configuration = createConfiguration()
62
+ quotas = Quotas.create(configuration)
63
+ })
64
+
65
+ it('should have separate quotas', () => {
66
+ const one = createContext({ url: new URL('http://localhost/one/') })
67
+ const two = createContext({ url: new URL('http://localhost/two/') })
68
+
69
+ quotas.use(one, output)
70
+ quotas.use(one, output)
71
+
72
+ expect(quotas.ok(one)).toBe(false)
73
+ expect(quotas.ok(two)).toBe(true)
74
+
75
+ quotas.use(two, output)
76
+ quotas.use(two, output)
77
+
78
+ expect(quotas.ok(two)).toBe(false)
79
+ })
80
+ })
81
+
82
+ describe('ip', () => {
83
+ beforeEach(() => {
84
+ configuration = createConfiguration({ key: [{ method: 'ip' }] })
85
+ quotas = Quotas.create(configuration)
86
+ })
87
+
88
+ it('should have separate quotas', () => {
89
+ const one = createContext({ request: { headers: { 'x-forwarded-for': '1.1.1.1' } } })
90
+ const two = createContext({ request: { headers: { 'x-forwarded-for': '2.2.2.2' } } })
91
+
92
+ quotas.use(one, output)
93
+ quotas.use(one, output)
94
+
95
+ expect(quotas.ok(one)).toBe(false)
96
+ expect(quotas.ok(two)).toBe(true)
97
+
98
+ quotas.use(two, output)
99
+ quotas.use(two, output)
100
+
101
+ expect(quotas.ok(two)).toBe(false)
102
+ })
103
+ })
104
+
105
+ describe('status', () => {
106
+ beforeEach(() => {
107
+ context = createContext()
108
+ configuration = createConfiguration({ condition: [{ method: 'status', options: 404 }] })
109
+ quotas = Quotas.create(configuration)
110
+ })
111
+
112
+ it('should not throttle on 200', () => {
113
+ quotas.use(context, output)
114
+
115
+ expect(quotas.ok(context)).toBe(true)
116
+ })
117
+
118
+ it('should throttle on 404', () => {
119
+ quotas.use(context, { status: 404 })
120
+ quotas.use(context, { status: 404 })
121
+
122
+ expect(quotas.ok(context)).toBe(false)
123
+ })
124
+ })
125
+
126
+ function createConfiguration (properties?: Partial<Configuration>): Configuration {
127
+ return Object.assign({ key: [{ method: 'path' }], requests: 2, interval: 10, cooldown: 10 }, properties)
128
+ }
129
+
130
+ function createContext (properties?: any): Context {
131
+ return Object.assign({ url: new URL('http://localhost/') }, properties) as unknown as Context
132
+ }