@toa.io/extensions.configuration 1.0.0-alpha.28 → 1.0.0-alpha.282

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 (152) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/components/configuration.values/manifest.toa.yaml +158 -0
  3. package/components/configuration.values/operations/create.d.ts +16 -0
  4. package/components/configuration.values/operations/create.js +31 -0
  5. package/components/configuration.values/operations/create.js.map +1 -0
  6. package/components/configuration.values/operations/fetch.d.ts +11 -0
  7. package/components/configuration.values/operations/fetch.js +13 -0
  8. package/components/configuration.values/operations/fetch.js.map +1 -0
  9. package/components/configuration.values/operations/get.d.ts +17 -0
  10. package/components/configuration.values/operations/get.js +19 -0
  11. package/components/configuration.values/operations/get.js.map +1 -0
  12. package/components/configuration.values/operations/lib/map.d.ts +11 -0
  13. package/components/configuration.values/operations/lib/map.js +21 -0
  14. package/components/configuration.values/operations/lib/map.js.map +1 -0
  15. package/components/configuration.values/operations/lib/map.test.d.ts +1 -0
  16. package/components/configuration.values/operations/lib/map.test.js +26 -0
  17. package/components/configuration.values/operations/lib/map.test.js.map +1 -0
  18. package/components/configuration.values/operations/lib/resolve.d.ts +27 -0
  19. package/components/configuration.values/operations/lib/resolve.js +24 -0
  20. package/components/configuration.values/operations/lib/resolve.js.map +1 -0
  21. package/components/configuration.values/operations/list.d.ts +10 -0
  22. package/components/configuration.values/operations/list.js +11 -0
  23. package/components/configuration.values/operations/list.js.map +1 -0
  24. package/components/configuration.values/source/create.ts +56 -0
  25. package/components/configuration.values/source/fetch.ts +24 -0
  26. package/components/configuration.values/source/get.ts +33 -0
  27. package/components/configuration.values/source/lib/map.test.ts +37 -0
  28. package/components/configuration.values/source/lib/map.ts +34 -0
  29. package/components/configuration.values/source/lib/resolve.ts +54 -0
  30. package/components/configuration.values/source/list.ts +19 -0
  31. package/components/configuration.values/tsconfig.json +10 -0
  32. package/components/configuration.values/tsconfig.tsbuildinfo +1 -0
  33. package/cucumber.mjs +5 -0
  34. package/features/site/_app/immutable/asset.js +1 -0
  35. package/features/site/favicon.ico +0 -0
  36. package/features/site/index.html +6 -0
  37. package/features/steps/UI.ts +91 -0
  38. package/features/steps/tsconfig.json +9 -0
  39. package/features/ui.feature +93 -0
  40. package/package.json +11 -10
  41. package/readme.md +160 -47
  42. package/schemas/annotation.cos.yaml +29 -1
  43. package/schemas/manifest.cos.yaml +9 -2
  44. package/source/Aspect.test.ts +89 -9
  45. package/source/Aspect.ts +64 -5
  46. package/source/Client.test.ts +108 -0
  47. package/source/Client.ts +258 -0
  48. package/source/Composition.ts +57 -0
  49. package/source/Factory.ts +28 -6
  50. package/source/Secret.test.ts +32 -0
  51. package/source/Secret.ts +29 -0
  52. package/source/UI.ts +212 -0
  53. package/source/configuration.test.ts +96 -40
  54. package/source/configuration.ts +30 -11
  55. package/source/const.ts +17 -0
  56. package/source/deployment.test.ts +78 -12
  57. package/source/deployment.ts +75 -27
  58. package/source/epoch.test.ts +29 -0
  59. package/source/epoch.ts +21 -0
  60. package/source/index.ts +4 -3
  61. package/source/manifest.test.ts +8 -5
  62. package/source/manifest.ts +2 -4
  63. package/source/schemas.ts +1 -1
  64. package/transpiled/Aspect.d.ts +18 -4
  65. package/transpiled/Aspect.js +50 -9
  66. package/transpiled/Aspect.js.map +1 -1
  67. package/transpiled/Aspect.test.js +63 -12
  68. package/transpiled/Aspect.test.js.map +1 -1
  69. package/transpiled/Client.d.ts +61 -0
  70. package/transpiled/Client.js +169 -0
  71. package/transpiled/Client.js.map +1 -0
  72. package/transpiled/Client.test.d.ts +1 -0
  73. package/transpiled/Client.test.js +78 -0
  74. package/transpiled/Client.test.js.map +1 -0
  75. package/transpiled/Composition.d.ts +14 -0
  76. package/transpiled/Composition.js +38 -0
  77. package/transpiled/Composition.js.map +1 -0
  78. package/transpiled/Factory.d.ts +8 -2
  79. package/transpiled/Factory.js +20 -9
  80. package/transpiled/Factory.js.map +1 -1
  81. package/transpiled/Secret.d.ts +12 -0
  82. package/transpiled/Secret.js +22 -0
  83. package/transpiled/Secret.js.map +1 -0
  84. package/transpiled/Secret.test.d.ts +1 -0
  85. package/transpiled/Secret.test.js +26 -0
  86. package/transpiled/Secret.test.js.map +1 -0
  87. package/transpiled/UI.d.ts +25 -0
  88. package/transpiled/UI.js +172 -0
  89. package/transpiled/UI.js.map +1 -0
  90. package/transpiled/configuration.d.ts +7 -2
  91. package/transpiled/configuration.js +30 -41
  92. package/transpiled/configuration.js.map +1 -1
  93. package/transpiled/configuration.test.js +90 -43
  94. package/transpiled/configuration.test.js.map +1 -1
  95. package/transpiled/const.d.ts +11 -0
  96. package/transpiled/const.js +11 -0
  97. package/transpiled/const.js.map +1 -0
  98. package/transpiled/deployment.d.ts +9 -3
  99. package/transpiled/deployment.js +62 -57
  100. package/transpiled/deployment.js.map +1 -1
  101. package/transpiled/deployment.test.js +61 -13
  102. package/transpiled/deployment.test.js.map +1 -1
  103. package/transpiled/epoch.d.ts +2 -0
  104. package/transpiled/epoch.js +17 -0
  105. package/transpiled/epoch.js.map +1 -0
  106. package/transpiled/epoch.test.d.ts +1 -0
  107. package/transpiled/epoch.test.js +22 -0
  108. package/transpiled/epoch.test.js.map +1 -0
  109. package/transpiled/index.d.ts +4 -3
  110. package/transpiled/index.js +4 -9
  111. package/transpiled/index.js.map +1 -1
  112. package/transpiled/manifest.d.ts +1 -1
  113. package/transpiled/manifest.js +2 -31
  114. package/transpiled/manifest.js.map +1 -1
  115. package/transpiled/manifest.test.js +9 -9
  116. package/transpiled/manifest.test.js.map +1 -1
  117. package/transpiled/schemas.js +5 -31
  118. package/transpiled/schemas.js.map +1 -1
  119. package/tsconfig.json +2 -1
  120. package/tsconfig.tsbuildinfo +1 -0
  121. package/ui/dist/_app/env.js +1 -0
  122. package/ui/dist/_app/immutable/assets/0.CNS8Cqhc.css +2 -0
  123. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  124. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  125. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  126. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  127. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  128. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  129. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  130. package/ui/dist/_app/immutable/assets/ui.BghF56l_.css +1 -0
  131. package/ui/dist/_app/immutable/chunks/BI4iTnyC.js +1 -0
  132. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  133. package/ui/dist/_app/immutable/chunks/CcD_ISxM.js +3 -0
  134. package/ui/dist/_app/immutable/chunks/CcWYApmX.js +6 -0
  135. package/ui/dist/_app/immutable/chunks/DNNahQnI.js +8 -0
  136. package/ui/dist/_app/immutable/chunks/XPGIBZyO.js +1 -0
  137. package/ui/dist/_app/immutable/chunks/_OCUOSf_.js +50 -0
  138. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  139. package/ui/dist/_app/immutable/entry/app.Dm0NVzuV.js +2 -0
  140. package/ui/dist/_app/immutable/entry/start.DWjxVbPv.js +1 -0
  141. package/ui/dist/_app/immutable/nodes/0.C1zAG1m5.js +1 -0
  142. package/ui/dist/_app/immutable/nodes/1.QblBNjka.js +1 -0
  143. package/ui/dist/_app/immutable/nodes/2.CyVYZW9A.js +1 -0
  144. package/ui/dist/_app/immutable/nodes/3.DJ58VY-o.js +1 -0
  145. package/ui/dist/_app/immutable/nodes/4.CZA2PMJQ.js +1 -0
  146. package/ui/dist/_app/version.json +1 -0
  147. package/ui/dist/apple-touch-icon.png +0 -0
  148. package/ui/dist/favicon-96x96.png +0 -0
  149. package/ui/dist/favicon.ico +0 -0
  150. package/ui/dist/index.html +52 -0
  151. package/ui/dist/robots.txt +3 -0
  152. package/transpiled/tsconfig.tsbuildinfo +0 -1
package/source/Aspect.ts CHANGED
@@ -1,17 +1,37 @@
1
- import { Connector, type extensions } from '@toa.io/core'
1
+ import { console } from 'openspan'
2
+ import { Connector, type Locator, type extensions } from '@toa.io/core'
3
+ import { fit, local, type Node } from './configuration.js'
4
+ import { epoch } from './epoch.js'
5
+ import type { Client, Value } from './Client.js'
6
+ import type { Manifest } from './manifest.js'
2
7
 
3
8
  export class Aspect extends Connector implements extensions.Aspect {
4
9
  public readonly name = 'configuration'
5
10
 
6
- private readonly value: object
11
+ private readonly locator: Locator
12
+ private readonly manifest: Manifest
13
+ private readonly client: Client | null
14
+ private readonly epoch: string
15
+ private value: Node = {}
16
+ private created = 0
7
17
 
8
- public constructor (value: object) {
18
+ /**
19
+ * Without a client the value is local: the variable, the defaults and the schema.
20
+ * With one, the value is what the service holds, and it follows the service.
21
+ */
22
+ public constructor (locator: Locator, manifest: Manifest, client: Client | null) {
9
23
  super()
10
24
 
11
- this.value = value
25
+ this.locator = locator
26
+ this.manifest = manifest
27
+ this.client = client
28
+ this.epoch = epoch(manifest.schema)
29
+
30
+ if (client !== null)
31
+ this.depends(client)
12
32
  }
13
33
 
14
- public invoke (path: string[]): any {
34
+ public invoke (path?: string[]): any {
15
35
  let cursor: any = this.value
16
36
 
17
37
  if (path !== undefined)
@@ -20,4 +40,43 @@ export class Aspect extends Connector implements extensions.Aspect {
20
40
 
21
41
  return cursor
22
42
  }
43
+
44
+ protected override async open (): Promise<void> {
45
+ if (this.client === null) {
46
+ this.value = local(this.locator, this.manifest)
47
+
48
+ return
49
+ }
50
+
51
+ const { configuration, created } = await this.client.fetch(this.locator.id, this.epoch)
52
+
53
+ this.value = fit(configuration, this.manifest)
54
+ this.created = created
55
+ this.client.subscribe(this.locator.id, this.epoch, this.listener)
56
+
57
+ console.info('Configuration resolved',
58
+ { component: this.locator.id, epoch: this.epoch, created })
59
+ }
60
+
61
+ protected override async close (): Promise<void> {
62
+ this.client?.unsubscribe(this.locator.id, this.epoch, this.listener)
63
+ }
64
+
65
+ private readonly listener = ({ configuration, created }: Value): void => {
66
+ // deliveries may repeat or cross: only what is newer than the held value replaces it
67
+ if (created <= this.created)
68
+ return
69
+
70
+ try {
71
+ this.value = fit(configuration, this.manifest)
72
+ this.created = created
73
+
74
+ console.info('Configuration updated',
75
+ { component: this.locator.id, epoch: this.epoch, created })
76
+ } catch (error) {
77
+ // the service validated it against the schema of its epoch, so the two schemas differ
78
+ console.error('Configuration value does not match the schema',
79
+ { component: this.locator.id, epoch: this.epoch, error })
80
+ }
81
+ }
23
82
  }
@@ -0,0 +1,108 @@
1
+ import { it, beforeEach, afterEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isDeepStrictEqual } from 'node:util'
4
+
5
+ import { Connector, type Locator, type Message, type Receiver } from '@toa.io/core'
6
+ import { timeout } from '@toa.io/generic'
7
+ import { Client, type Fetched } from './Client.js'
8
+ import { EVENT } from './const.js'
9
+ import type { Host } from './Factory.js'
10
+
11
+ class Remote extends Connector {
12
+ public readonly invoke = mock.fn(async (_endpoint: string, request: { input: Array<{ component: string, epoch: string }> }) =>
13
+ request.input.map((pair): Fetched => ({
14
+ ...pair,
15
+ configuration: this.values[pair.component] ?? null,
16
+ created: this.values[pair.component] === undefined ? 0 : 7
17
+ })))
18
+
19
+ public values: Record<string, object | null> = {}
20
+ }
21
+
22
+ let remote: Remote
23
+ let receiver: Receiver | null
24
+ let host: Host
25
+ let client: Client
26
+
27
+ beforeEach(() => {
28
+ remote = new Remote()
29
+ receiver = null
30
+
31
+ host = {
32
+ remote: mock.fn(async (locator: Locator) => {
33
+ assert.deepStrictEqual(locator.id, 'configuration.values')
34
+
35
+ return remote
36
+ }),
37
+ receive: mock.fn(async (label: string, consumer: Receiver) => {
38
+ assert.deepStrictEqual(label, EVENT)
39
+
40
+ receiver = consumer
41
+
42
+ return new Connector()
43
+ })
44
+ } as unknown as Host
45
+
46
+ client = new Client(host, { base: 10, max: 20, warn: 2 })
47
+ })
48
+
49
+ afterEach(async () => {
50
+ await client.disconnect()
51
+ })
52
+
53
+ it('should send the requests of one tick as one call', async () => {
54
+ remote.values = { 'a.one': { foo: 1 }, 'a.two': { foo: 2 } }
55
+
56
+ await client.connect()
57
+
58
+ const [one, two] = await Promise.all([client.fetch('a.one', 'e1'), client.fetch('a.two', 'e2')])
59
+
60
+ assert.deepStrictEqual(one, { configuration: { foo: 1 }, created: 7 })
61
+ assert.deepStrictEqual(two, { configuration: { foo: 2 }, created: 7 })
62
+ assert.strictEqual(remote.invoke.mock.callCount(), 1)
63
+ assert.deepStrictEqual(remote.invoke.mock.calls[0].arguments[1], {
64
+ input: [{ component: 'a.one', epoch: 'e1' }, { component: 'a.two', epoch: 'e2' }]
65
+ })
66
+ })
67
+
68
+ it('should keep asking until served', async () => {
69
+ await client.connect()
70
+
71
+ const fetching = client.fetch('a.one', 'e1')
72
+ const deadline = Date.now() + 1000
73
+
74
+ while (remote.invoke.mock.calls.length < 2 && Date.now() < deadline)
75
+ await timeout(5)
76
+
77
+ assert.ok(remote.invoke.mock.calls.length >= 2)
78
+
79
+ remote.values = { 'a.one': { foo: 1 } }
80
+
81
+ assert.deepStrictEqual((await fetching).configuration, { foo: 1 })
82
+ })
83
+
84
+ it('should hand a created object to its subscribers', async () => {
85
+ await client.connect()
86
+
87
+ const listener = mock.fn()
88
+ const other = mock.fn()
89
+
90
+ client.subscribe('a.one', 'e1', listener)
91
+ client.subscribe('a.one', 'e0', other)
92
+
93
+ await receiver!.receive({
94
+ payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 2 }, _created: 12 }
95
+ } satisfies Message)
96
+
97
+ assert.ok(listener.mock.calls.some((call: any) => call.arguments.length === 1 && isDeepStrictEqual(call.arguments[0], { configuration: { foo: 2 }, created: 12 })))
98
+ assert.strictEqual(other.mock.callCount(), 0)
99
+ assert.strictEqual(remote.invoke.mock.callCount(), 0)
100
+
101
+ client.unsubscribe('a.one', 'e1', listener)
102
+
103
+ await receiver!.receive({
104
+ payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 3 }, _created: 13 }
105
+ } satisfies Message)
106
+
107
+ assert.strictEqual(listener.mock.callCount(), 1)
108
+ })
@@ -0,0 +1,258 @@
1
+ import { console } from 'openspan'
2
+ import { Connector, Locator } from '@toa.io/core'
3
+ import { EVENT, SOURCE } from './const.js'
4
+ import type { Message, Remote } from '@toa.io/core'
5
+ import type { Host } from './Factory.js'
6
+
7
+ /**
8
+ * One per process: one remote to the values service and one subscription to its events,
9
+ * shared by every Aspect. What the Aspects ask for is collected and sent as one call;
10
+ * what the service creates afterwards is handed to whoever subscribed.
11
+ */
12
+ export class Client extends Connector {
13
+ /** Disconnected once, a connector keeps what it depended on, so a gone client is not reused. */
14
+ public disposed = false
15
+
16
+ private readonly host: Host
17
+ private readonly options: Options
18
+ private readonly pending = new Map<string, Pending>()
19
+ private readonly listeners = new Map<string, Set<Listener>>()
20
+ private remote: Remote | null = null
21
+ private timer: NodeJS.Timeout | null = null
22
+ private flushing = false
23
+ private fresh = false
24
+ private round = 0
25
+
26
+ public constructor (host: Host, options: Partial<Options> = {}) {
27
+ super()
28
+
29
+ this.host = host
30
+ this.options = { ...DEFAULTS, ...options }
31
+ }
32
+
33
+ /** The configuration of a component for an epoch, once the service has one. */
34
+ public async fetch (component: string, epoch: string): Promise<Value> {
35
+ const key = id(component, epoch)
36
+
37
+ let entry = this.pending.get(key)
38
+
39
+ if (entry === undefined) {
40
+ entry = { component, epoch, waiters: [] }
41
+ this.pending.set(key, entry)
42
+ }
43
+
44
+ const promise = new Promise<Value>((resolve) => {
45
+ entry!.waiters.push(resolve)
46
+ })
47
+
48
+ if (this.flushing)
49
+ this.fresh = true
50
+ else
51
+ this.schedule(0)
52
+
53
+ return await promise
54
+ }
55
+
56
+ public subscribe (component: string, epoch: string, listener: Listener): void {
57
+ const key = id(component, epoch)
58
+
59
+ if (!this.listeners.has(key))
60
+ this.listeners.set(key, new Set())
61
+
62
+ this.listeners.get(key)!.add(listener)
63
+ }
64
+
65
+ public unsubscribe (component: string, epoch: string, listener: Listener): void {
66
+ this.listeners.get(id(component, epoch))?.delete(listener)
67
+ }
68
+
69
+ protected override async open (): Promise<void> {
70
+ this.remote = await this.host.remote(LOCATOR, SOURCE)
71
+
72
+ this.depends(this.remote)
73
+ await this.remote.connect()
74
+
75
+ const subscription = new Subscription(this.deliver.bind(this))
76
+ const consumer = await this.host.receive(EVENT, subscription)
77
+
78
+ this.depends(consumer)
79
+ await consumer.connect()
80
+ }
81
+
82
+ protected override async close (): Promise<void> {
83
+ if (this.timer !== null) {
84
+ clearTimeout(this.timer)
85
+ this.timer = null
86
+ }
87
+ }
88
+
89
+ protected override async dispose (): Promise<void> {
90
+ this.disposed = true
91
+ }
92
+
93
+ private schedule (delay: number): void {
94
+ if (this.flushing)
95
+ return
96
+
97
+ if (this.timer !== null) {
98
+ // a request that has just arrived does not wait for the round already planned
99
+ if (delay > 0)
100
+ return
101
+
102
+ clearTimeout(this.timer)
103
+ }
104
+
105
+ this.timer = setTimeout(() => {
106
+ void this.flush()
107
+ }, delay)
108
+ }
109
+
110
+ private async flush (): Promise<void> {
111
+ this.timer = null
112
+ this.flushing = true
113
+ this.fresh = false
114
+
115
+ const batch = [...this.pending.values()]
116
+ const input = batch.map(({ component, epoch }) => ({ component, epoch }))
117
+
118
+ try {
119
+ const output = await this.remote!.invoke<Fetched[] | Error>('fetch', { input })
120
+
121
+ if (output instanceof Error)
122
+ throw output
123
+
124
+ this.settle(output)
125
+ } catch (error) {
126
+ console.warn('Configuration fetch failed', { error })
127
+ } finally {
128
+ this.flushing = false
129
+ }
130
+
131
+ this.report(batch)
132
+
133
+ if (this.pending.size === 0)
134
+ return
135
+
136
+ if (this.fresh)
137
+ this.schedule(0)
138
+ else
139
+ this.schedule(this.backoff())
140
+ }
141
+
142
+ /** Those the service has served are told; the rest stay for the next round. */
143
+ private settle (output: Fetched[]): void {
144
+ for (const { component, epoch, configuration, created } of output) {
145
+ if (configuration === null)
146
+ continue
147
+
148
+ const key = id(component, epoch)
149
+ const entry = this.pending.get(key)
150
+
151
+ if (entry === undefined)
152
+ continue
153
+
154
+ this.pending.delete(key)
155
+
156
+ for (const resolve of entry.waiters)
157
+ resolve({ configuration, created })
158
+ }
159
+ }
160
+
161
+ private report (batch: Pending[]): void {
162
+ const waiting = batch
163
+ .filter(({ component, epoch }) => this.pending.has(id(component, epoch)))
164
+ .map(({ component }) => component)
165
+
166
+ if (waiting.length === 0) {
167
+ this.round = 0
168
+
169
+ return
170
+ }
171
+
172
+ this.round++
173
+
174
+ if (this.round % this.options.warn === 1)
175
+ console.warn('Waiting for configuration', { components: waiting, round: this.round })
176
+ }
177
+
178
+ private backoff (): number {
179
+ return Math.min(this.options.base * Math.pow(FACTOR, this.round), this.options.max)
180
+ }
181
+
182
+ /** A created object goes to the subscribers of its component and epoch, as it is. */
183
+ private deliver (created: Created): void {
184
+ const listeners = this.listeners.get(id(created.component, created.epoch))
185
+
186
+ if (listeners === undefined)
187
+ return
188
+
189
+ const value: Value = { configuration: created.configuration, created: created._created }
190
+
191
+ for (const listener of listeners)
192
+ listener(value)
193
+ }
194
+ }
195
+
196
+ /** What the event consumer hands deliveries to. */
197
+ class Subscription extends Connector {
198
+ private readonly handler: (created: Created) => void
199
+
200
+ public constructor (handler: (created: Created) => void) {
201
+ super()
202
+
203
+ this.handler = handler
204
+ }
205
+
206
+ public async receive (message: Message<Created>): Promise<void> {
207
+ this.handler(message.payload)
208
+ }
209
+ }
210
+
211
+ function id (component: string, epoch: string): string {
212
+ return component + '\0' + epoch
213
+ }
214
+
215
+ const LOCATOR = new Locator('values', 'configuration')
216
+ const FACTOR = 1.5
217
+
218
+ const DEFAULTS: Options = {
219
+ base: 1000,
220
+ max: 10000,
221
+ warn: 5
222
+ }
223
+
224
+ export interface Options {
225
+ base: number
226
+ max: number
227
+ /** Every n-th round of waiting is reported. */
228
+ warn: number
229
+ }
230
+
231
+ /** A configuration and when it was created; `0` for the deployed defaults. */
232
+ export interface Value {
233
+ configuration: object
234
+ created: number
235
+ }
236
+
237
+ export interface Fetched {
238
+ component: string
239
+ epoch: string
240
+ configuration: object | null
241
+ created: number
242
+ }
243
+
244
+ /** The `configuration.values.created` payload: the object as stored. */
245
+ export interface Created {
246
+ component: string
247
+ epoch: string
248
+ configuration: object
249
+ _created: number
250
+ }
251
+
252
+ export type Listener = (value: Value) => void
253
+
254
+ interface Pending {
255
+ component: string
256
+ epoch: string
257
+ waiters: Array<(value: Value) => void>
258
+ }
@@ -0,0 +1,57 @@
1
+ import { readdirSync, type Dirent } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { Connector } from '@toa.io/core'
4
+ import { UI_PORT } from './const.js'
5
+ import { UI } from './UI.js'
6
+ import type { Host } from './Factory.js'
7
+
8
+ /** Hosts the values component in the service process. */
9
+ export class Composition extends Connector {
10
+ private readonly host: Host
11
+
12
+ public constructor (host: Host) {
13
+ super()
14
+ this.host = host
15
+ }
16
+
17
+ protected override async open (): Promise<void> {
18
+ const composition = await this.host.composition(components().paths)
19
+
20
+ await composition.connect()
21
+
22
+ this.depends(composition)
23
+
24
+ // connected here rather than declared as a dependency: dependencies are connected
25
+ // before `open` runs, so one added from inside it would never be
26
+ const ui = new UI(UI_PORT)
27
+
28
+ await ui.connect()
29
+
30
+ this.depends(ui)
31
+ }
32
+ }
33
+
34
+ export function components (): Components {
35
+ const labels: string[] = []
36
+ const paths: string[] = []
37
+
38
+ for (const entry of entries()) {
39
+ labels.push(entry.name.replace('.', '-'))
40
+ paths.push(resolve(ROOT, entry.name))
41
+ }
42
+
43
+ return { labels, paths }
44
+ }
45
+
46
+ function entries (): Dirent[] {
47
+ const entries = readdirSync(ROOT, { withFileTypes: true })
48
+
49
+ return entries.filter((entry) => entry.isDirectory())
50
+ }
51
+
52
+ interface Components {
53
+ labels: string[]
54
+ paths: string[]
55
+ }
56
+
57
+ const ROOT = resolve(import.meta.dirname, '../components/')
package/source/Factory.ts CHANGED
@@ -1,12 +1,34 @@
1
- import { type Locator, type extensions } from '@toa.io/core'
2
- import { Aspect } from './Aspect'
3
- import { type Manifest } from './manifest'
4
- import { get } from './configuration'
1
+ import { type Connector, type Locator, type extensions } from '@toa.io/core'
2
+ import { Aspect } from './Aspect.js'
3
+ import { Client } from './Client.js'
4
+ import { Composition } from './Composition.js'
5
+ import { overridden } from './configuration.js'
6
+ import type { Manifest } from './manifest.js'
5
7
 
6
8
  export class Factory implements extensions.Factory {
9
+ private readonly host: Host
10
+ private client: Client | null = null
11
+
12
+ public constructor (host: Host) {
13
+ this.host = host
14
+ }
15
+
7
16
  public aspect (locator: Locator, manifest: Manifest): extensions.Aspect {
8
- const value = get(locator, manifest)
17
+ const client = overridden(locator) ? null : this.shared()
18
+
19
+ return new Aspect(locator, manifest, client)
20
+ }
9
21
 
10
- return new Aspect(value)
22
+ public service (): Connector {
23
+ return new Composition(this.host)
24
+ }
25
+
26
+ private shared (): Client {
27
+ if (this.client === null || this.client.disposed)
28
+ this.client = new Client(this.host)
29
+
30
+ return this.client
11
31
  }
12
32
  }
33
+
34
+ export type Host = extensions.Host
@@ -0,0 +1,32 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { inspect } from 'node:util'
5
+ import { REDACTED, Secret } from './Secret.js'
6
+
7
+ const secret = new Secret('s3cret')
8
+
9
+ it('should unwrap', () => {
10
+ assert.deepStrictEqual(secret.unwrap(), 's3cret')
11
+ })
12
+
13
+ it('should not show as a string', () => {
14
+ assert.deepStrictEqual(String(secret), REDACTED)
15
+ assert.deepStrictEqual(`${secret}`, REDACTED)
16
+ assert.deepStrictEqual('' + secret, REDACTED)
17
+ })
18
+
19
+ it('should not show in JSON', () => {
20
+ assert.deepStrictEqual(JSON.stringify({ key: secret }), '{"key":"<REDACTED>"}')
21
+ })
22
+
23
+ it('should not show when inspected', () => {
24
+ assert.deepStrictEqual(inspect(secret), REDACTED)
25
+ assert.ok(inspect({ key: secret }).includes(REDACTED))
26
+ assert.ok(!(inspect({ key: secret }).includes('s3cret')))
27
+ })
28
+
29
+ it('should not expose the value as a property', () => {
30
+ assert.deepStrictEqual(Object.keys(secret), [])
31
+ assert.deepStrictEqual({ ...secret }, {})
32
+ })
@@ -0,0 +1,29 @@
1
+ import { inspect } from 'node:util'
2
+ import type { Secret as Contract } from '@toa.io/types'
3
+
4
+ /** A configuration value that must not leak: a string only to whoever asks for it. */
5
+ export class Secret implements Contract {
6
+ readonly #value: string
7
+
8
+ public constructor (value: string) {
9
+ this.#value = value
10
+ }
11
+
12
+ public unwrap (): string {
13
+ return this.#value
14
+ }
15
+
16
+ public toString (): string {
17
+ return REDACTED
18
+ }
19
+
20
+ public toJSON (): string {
21
+ return REDACTED
22
+ }
23
+
24
+ public [inspect.custom] (): string {
25
+ return REDACTED
26
+ }
27
+ }
28
+
29
+ export const REDACTED = '<REDACTED>'