@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/components/exposition.stash/manifest.toa.yaml +13 -4
  3. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  4. package/documentation/io.md +26 -23
  5. package/documentation/notes/throttling.md +39 -5
  6. package/features/etag.feature +55 -5
  7. package/features/io.throttle.feature +3 -5
  8. package/package.json +5 -5
  9. package/schemas/io/throttle.cos.yaml +2 -3
  10. package/source/Directive.test.ts +3 -0
  11. package/source/Directive.ts +25 -4
  12. package/source/Endpoint.ts +7 -11
  13. package/source/Factory.ts +16 -1
  14. package/source/HTTP/Context.ts +39 -8
  15. package/source/HTTP/Server.ts +16 -11
  16. package/source/HTTP/exceptions.ts +9 -3
  17. package/source/HTTP/messages.ts +31 -0
  18. package/source/RTD/Directives.ts +6 -0
  19. package/source/directives/auth/Authorization.ts +29 -3
  20. package/source/directives/io/Directive.ts +5 -4
  21. package/source/directives/io/IO.ts +15 -10
  22. package/source/directives/io/Input.ts +3 -3
  23. package/source/directives/io/Output.ts +12 -4
  24. package/source/directives/io/Throttle.ts +9 -12
  25. package/source/directives/io/lib/throttle/Configuration.test.ts +1 -2
  26. package/source/directives/io/lib/throttle/Configuration.ts +2 -4
  27. package/source/directives/io/lib/throttle/Quotas.test.ts +233 -148
  28. package/source/directives/io/lib/throttle/Quotas.ts +129 -53
  29. package/source/directives/io/lib/throttle/Sync.test.ts +153 -0
  30. package/source/directives/io/lib/throttle/Sync.ts +99 -0
  31. package/source/directives/io/lib/throttle/index.ts +1 -0
  32. package/source/directives/io/schemas.test.ts +7 -1
  33. package/transpiled/Directive.d.ts +2 -0
  34. package/transpiled/Directive.js +16 -4
  35. package/transpiled/Directive.js.map +1 -1
  36. package/transpiled/Endpoint.js +7 -8
  37. package/transpiled/Endpoint.js.map +1 -1
  38. package/transpiled/Factory.js +14 -1
  39. package/transpiled/Factory.js.map +1 -1
  40. package/transpiled/HTTP/Context.d.ts +1 -1
  41. package/transpiled/HTTP/Context.js +30 -8
  42. package/transpiled/HTTP/Context.js.map +1 -1
  43. package/transpiled/HTTP/Server.js +14 -10
  44. package/transpiled/HTTP/Server.js.map +1 -1
  45. package/transpiled/HTTP/exceptions.d.ts +5 -2
  46. package/transpiled/HTTP/exceptions.js +7 -3
  47. package/transpiled/HTTP/exceptions.js.map +1 -1
  48. package/transpiled/HTTP/messages.d.ts +2 -0
  49. package/transpiled/HTTP/messages.js +21 -0
  50. package/transpiled/HTTP/messages.js.map +1 -1
  51. package/transpiled/RTD/Directives.d.ts +5 -0
  52. package/transpiled/directives/auth/Authorization.d.ts +1 -0
  53. package/transpiled/directives/auth/Authorization.js +21 -2
  54. package/transpiled/directives/auth/Authorization.js.map +1 -1
  55. package/transpiled/directives/io/Directive.d.ts +4 -4
  56. package/transpiled/directives/io/IO.d.ts +9 -4
  57. package/transpiled/directives/io/IO.js +14 -9
  58. package/transpiled/directives/io/IO.js.map +1 -1
  59. package/transpiled/directives/io/Input.d.ts +1 -1
  60. package/transpiled/directives/io/Input.js +3 -3
  61. package/transpiled/directives/io/Input.js.map +1 -1
  62. package/transpiled/directives/io/Output.d.ts +2 -0
  63. package/transpiled/directives/io/Output.js +9 -3
  64. package/transpiled/directives/io/Output.js.map +1 -1
  65. package/transpiled/directives/io/Throttle.d.ts +3 -5
  66. package/transpiled/directives/io/Throttle.js +8 -10
  67. package/transpiled/directives/io/Throttle.js.map +1 -1
  68. package/transpiled/directives/io/lib/throttle/Configuration.d.ts +0 -1
  69. package/transpiled/directives/io/lib/throttle/Configuration.js +2 -3
  70. package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
  71. package/transpiled/directives/io/lib/throttle/Quotas.d.ts +46 -22
  72. package/transpiled/directives/io/lib/throttle/Quotas.js +107 -46
  73. package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
  74. package/transpiled/directives/io/lib/throttle/Sync.d.ts +30 -0
  75. package/transpiled/directives/io/lib/throttle/Sync.js +76 -0
  76. package/transpiled/directives/io/lib/throttle/Sync.js.map +1 -0
  77. package/transpiled/directives/io/lib/throttle/index.d.ts +1 -0
  78. package/transpiled/directives/io/lib/throttle/index.js +3 -1
  79. package/transpiled/directives/io/lib/throttle/index.js.map +1 -1
  80. package/transpiled/tsconfig.tsbuildinfo +1 -1
  81. package/components/exposition.stash/operations/count.js +0 -7
@@ -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 {
@@ -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 { minimatch } from 'minimatch'
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
- minimatch(context.request.url, pattern)
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 { Remote } from '@toa.io/core'
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
- settle?: (context: Context, response: http.OutgoingMessage) => Promise<void> | void
9
- dispose?: () => void
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, counter: Promise<Remote>, route: string): Directive
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 type { Remote } from '@toa.io/core'
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 counts through a component, because only a component has a stash aspect. */
15
- private counter: Promise<Remote> | null = null
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.counter ??= remotes.discover('exposition', 'stash')
28
+ this.sync ??= new Sync(remotes.discover('exposition', 'stash'))
29
29
 
30
- return new Directive(value, this.counter!, route)
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 async settle (directives: Directive[], context: http.Context, output: http.OutgoingMessage): Promise<void> {
49
+ public settle (directives: Directive[], context: http.Context,
50
+ output: http.OutgoingMessage): void {
50
51
  for (const directive of directives)
51
- await directive.settle?.(context, output)
52
+ directive.settle?.(context, output)
52
53
  }
53
54
 
54
- public dispose (directives: Directive[]): void {
55
- for (const directive of directives)
56
- directive.dispose?.()
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 permissions: Permissions
8
+ private readonly allowed: Set<string>
9
9
 
10
10
  public constructor (permissions: Permissions) {
11
- this.permissions = permissions
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.permissions.includes(key))
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
- private fit (message: Message): Message | undefined {
63
- const entries = Object.entries(message)
64
- .filter(([key]) => this.permissions.includes(key))
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 Object.fromEntries(entries)
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, counter: Promise<Remote>, route: string) {
13
- const configuration = parse(declaration)
11
+ public constructor (declaration: Declaration, sync: Sync, route: string) {
12
+ this.quotas = Quotas.create(parse(declaration), route)
14
13
 
15
- this.quotas = Quotas.create(configuration, counter, route)
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
- if (!this.quotas.ok(context, parameters))
24
- throw new TooManyRequests()
25
- }
22
+ const retry = this.quotas.check(context, parameters)
26
23
 
27
- public async settle (context: http.Context, output: http.OutgoingMessage): Promise<void> {
28
- await this.quotas.use(context, output)
24
+ if (retry > 0)
25
+ throw new TooManyRequests(retry)
29
26
  }
30
27
 
31
- public dispose (): void {
32
- this.quotas.dispose()
28
+ public settle (context: http.Context, output: http.OutgoingMessage): void {
29
+ this.quotas.use(context, output)
33
30
  }
34
31
  }
@@ -2,8 +2,7 @@ import { parse, type Configuration } from './Configuration'
2
2
 
3
3
  const rest: Omit<Configuration, 'key' | 'condition'> = {
4
4
  interval: 1,
5
- requests: 1,
6
- cooldown: 1
5
+ requests: 1
7
6
  }
8
7
 
9
8
  it('should convert key', () => {
@@ -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, cooldown } = declaration
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