@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.
- package/CHANGELOG.md +347 -0
- package/components/configuration.values/manifest.toa.yaml +158 -0
- package/components/configuration.values/operations/create.d.ts +16 -0
- package/components/configuration.values/operations/create.js +31 -0
- package/components/configuration.values/operations/create.js.map +1 -0
- package/components/configuration.values/operations/fetch.d.ts +11 -0
- package/components/configuration.values/operations/fetch.js +13 -0
- package/components/configuration.values/operations/fetch.js.map +1 -0
- package/components/configuration.values/operations/get.d.ts +17 -0
- package/components/configuration.values/operations/get.js +19 -0
- package/components/configuration.values/operations/get.js.map +1 -0
- package/components/configuration.values/operations/lib/map.d.ts +11 -0
- package/components/configuration.values/operations/lib/map.js +21 -0
- package/components/configuration.values/operations/lib/map.js.map +1 -0
- package/components/configuration.values/operations/lib/map.test.d.ts +1 -0
- package/components/configuration.values/operations/lib/map.test.js +26 -0
- package/components/configuration.values/operations/lib/map.test.js.map +1 -0
- package/components/configuration.values/operations/lib/resolve.d.ts +27 -0
- package/components/configuration.values/operations/lib/resolve.js +24 -0
- package/components/configuration.values/operations/lib/resolve.js.map +1 -0
- package/components/configuration.values/operations/list.d.ts +10 -0
- package/components/configuration.values/operations/list.js +11 -0
- package/components/configuration.values/operations/list.js.map +1 -0
- package/components/configuration.values/source/create.ts +56 -0
- package/components/configuration.values/source/fetch.ts +24 -0
- package/components/configuration.values/source/get.ts +33 -0
- package/components/configuration.values/source/lib/map.test.ts +37 -0
- package/components/configuration.values/source/lib/map.ts +34 -0
- package/components/configuration.values/source/lib/resolve.ts +54 -0
- package/components/configuration.values/source/list.ts +19 -0
- package/components/configuration.values/tsconfig.json +10 -0
- package/components/configuration.values/tsconfig.tsbuildinfo +1 -0
- package/cucumber.mjs +5 -0
- package/features/site/_app/immutable/asset.js +1 -0
- package/features/site/favicon.ico +0 -0
- package/features/site/index.html +6 -0
- package/features/steps/UI.ts +91 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/ui.feature +93 -0
- package/package.json +11 -10
- package/readme.md +160 -47
- package/schemas/annotation.cos.yaml +29 -1
- package/schemas/manifest.cos.yaml +9 -2
- package/source/Aspect.test.ts +89 -9
- package/source/Aspect.ts +64 -5
- package/source/Client.test.ts +108 -0
- package/source/Client.ts +258 -0
- package/source/Composition.ts +57 -0
- package/source/Factory.ts +28 -6
- package/source/Secret.test.ts +32 -0
- package/source/Secret.ts +29 -0
- package/source/UI.ts +212 -0
- package/source/configuration.test.ts +96 -40
- package/source/configuration.ts +30 -11
- package/source/const.ts +17 -0
- package/source/deployment.test.ts +78 -12
- package/source/deployment.ts +75 -27
- package/source/epoch.test.ts +29 -0
- package/source/epoch.ts +21 -0
- package/source/index.ts +4 -3
- package/source/manifest.test.ts +8 -5
- package/source/manifest.ts +2 -4
- package/source/schemas.ts +1 -1
- package/transpiled/Aspect.d.ts +18 -4
- package/transpiled/Aspect.js +50 -9
- package/transpiled/Aspect.js.map +1 -1
- package/transpiled/Aspect.test.js +63 -12
- package/transpiled/Aspect.test.js.map +1 -1
- package/transpiled/Client.d.ts +61 -0
- package/transpiled/Client.js +169 -0
- package/transpiled/Client.js.map +1 -0
- package/transpiled/Client.test.d.ts +1 -0
- package/transpiled/Client.test.js +78 -0
- package/transpiled/Client.test.js.map +1 -0
- package/transpiled/Composition.d.ts +14 -0
- package/transpiled/Composition.js +38 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Factory.d.ts +8 -2
- package/transpiled/Factory.js +20 -9
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Secret.d.ts +12 -0
- package/transpiled/Secret.js +22 -0
- package/transpiled/Secret.js.map +1 -0
- package/transpiled/Secret.test.d.ts +1 -0
- package/transpiled/Secret.test.js +26 -0
- package/transpiled/Secret.test.js.map +1 -0
- package/transpiled/UI.d.ts +25 -0
- package/transpiled/UI.js +172 -0
- package/transpiled/UI.js.map +1 -0
- package/transpiled/configuration.d.ts +7 -2
- package/transpiled/configuration.js +30 -41
- package/transpiled/configuration.js.map +1 -1
- package/transpiled/configuration.test.js +90 -43
- package/transpiled/configuration.test.js.map +1 -1
- package/transpiled/const.d.ts +11 -0
- package/transpiled/const.js +11 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/deployment.d.ts +9 -3
- package/transpiled/deployment.js +62 -57
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/deployment.test.js +61 -13
- package/transpiled/deployment.test.js.map +1 -1
- package/transpiled/epoch.d.ts +2 -0
- package/transpiled/epoch.js +17 -0
- package/transpiled/epoch.js.map +1 -0
- package/transpiled/epoch.test.d.ts +1 -0
- package/transpiled/epoch.test.js +22 -0
- package/transpiled/epoch.test.js.map +1 -0
- package/transpiled/index.d.ts +4 -3
- package/transpiled/index.js +4 -9
- package/transpiled/index.js.map +1 -1
- package/transpiled/manifest.d.ts +1 -1
- package/transpiled/manifest.js +2 -31
- package/transpiled/manifest.js.map +1 -1
- package/transpiled/manifest.test.js +9 -9
- package/transpiled/manifest.test.js.map +1 -1
- package/transpiled/schemas.js +5 -31
- package/transpiled/schemas.js.map +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/dist/_app/env.js +1 -0
- package/ui/dist/_app/immutable/assets/0.CNS8Cqhc.css +2 -0
- package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/ui.BghF56l_.css +1 -0
- package/ui/dist/_app/immutable/chunks/BI4iTnyC.js +1 -0
- package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/ui/dist/_app/immutable/chunks/CcD_ISxM.js +3 -0
- package/ui/dist/_app/immutable/chunks/CcWYApmX.js +6 -0
- package/ui/dist/_app/immutable/chunks/DNNahQnI.js +8 -0
- package/ui/dist/_app/immutable/chunks/XPGIBZyO.js +1 -0
- package/ui/dist/_app/immutable/chunks/_OCUOSf_.js +50 -0
- package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/ui/dist/_app/immutable/entry/app.Dm0NVzuV.js +2 -0
- package/ui/dist/_app/immutable/entry/start.DWjxVbPv.js +1 -0
- package/ui/dist/_app/immutable/nodes/0.C1zAG1m5.js +1 -0
- package/ui/dist/_app/immutable/nodes/1.QblBNjka.js +1 -0
- package/ui/dist/_app/immutable/nodes/2.CyVYZW9A.js +1 -0
- package/ui/dist/_app/immutable/nodes/3.DJ58VY-o.js +1 -0
- package/ui/dist/_app/immutable/nodes/4.CZA2PMJQ.js +1 -0
- package/ui/dist/_app/version.json +1 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/favicon-96x96.png +0 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/index.html +52 -0
- package/ui/dist/robots.txt +3 -0
- package/transpiled/tsconfig.tsbuildinfo +0 -1
package/source/Aspect.ts
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
+
})
|
package/source/Client.ts
ADDED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
|
17
|
+
const client = overridden(locator) ? null : this.shared()
|
|
18
|
+
|
|
19
|
+
return new Aspect(locator, manifest, client)
|
|
20
|
+
}
|
|
9
21
|
|
|
10
|
-
|
|
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
|
+
})
|
package/source/Secret.ts
ADDED
|
@@ -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>'
|