@toa.io/extensions.introspection 1.0.0-alpha.259
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/LICENSE +22 -0
- package/components/introspection.edges/manifest.toa.yaml +48 -0
- package/components/introspection.edges/operations/lib/types.d.ts +28 -0
- package/components/introspection.edges/operations/lib/types.js +3 -0
- package/components/introspection.edges/operations/lib/types.js.map +1 -0
- package/components/introspection.edges/operations/merge.d.ts +9 -0
- package/components/introspection.edges/operations/merge.js +25 -0
- package/components/introspection.edges/operations/merge.js.map +1 -0
- package/components/introspection.edges/operations/tsconfig.tsbuildinfo +1 -0
- package/components/introspection.edges/source/lib/types.ts +33 -0
- package/components/introspection.edges/source/merge.ts +26 -0
- package/components/introspection.edges/tsconfig.json +9 -0
- package/components/introspection.nodes/manifest.toa.yaml +49 -0
- package/components/introspection.nodes/operations/lib/types.d.ts +16 -0
- package/components/introspection.nodes/operations/lib/types.js +3 -0
- package/components/introspection.nodes/operations/lib/types.js.map +1 -0
- package/components/introspection.nodes/operations/merge.d.ts +9 -0
- package/components/introspection.nodes/operations/merge.js +21 -0
- package/components/introspection.nodes/operations/merge.js.map +1 -0
- package/components/introspection.nodes/operations/tsconfig.tsbuildinfo +1 -0
- package/components/introspection.nodes/source/lib/types.ts +18 -0
- package/components/introspection.nodes/source/merge.ts +21 -0
- package/components/introspection.nodes/tsconfig.json +9 -0
- package/cucumber.js +8 -0
- package/features/calls.feature +123 -0
- package/features/nodes.feature +70 -0
- package/features/samples.feature +75 -0
- package/features/shutdown.feature +37 -0
- package/features/site/_app/immutable/asset.js +1 -0
- package/features/site/index.html +6 -0
- package/features/steps/Map.ts +151 -0
- package/features/steps/UI.ts +88 -0
- package/features/steps/components/probe.discreet/manifest.toa.yaml +12 -0
- package/features/steps/components/probe.discreet/operations/ping.js +7 -0
- package/features/steps/components/probe.quiet/manifest.toa.yaml +11 -0
- package/features/steps/components/probe.quiet/operations/ping.js +7 -0
- package/features/steps/components/probe.source/manifest.toa.yaml +27 -0
- package/features/steps/components/probe.source/operations/crash.js +7 -0
- package/features/steps/components/probe.source/operations/fail.js +12 -0
- package/features/steps/components/probe.source/operations/relay.js +7 -0
- package/features/steps/components/probe.target/manifest.toa.yaml +24 -0
- package/features/steps/components/probe.target/operations/compute.js +7 -0
- package/features/steps/components/probe.target/operations/count.js +9 -0
- package/features/steps/config.ts +20 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/ui.feature +85 -0
- package/package.json +32 -0
- package/readme.md +135 -0
- package/schemas/annotation.cos.yaml +21 -0
- package/schemas/declaration.cos.yaml +2 -0
- package/source/Composition.ts +61 -0
- package/source/Explorer.ts +16 -0
- package/source/Factory.ts +119 -0
- package/source/Reporter.ts +210 -0
- package/source/Tenant.ts +47 -0
- package/source/UI.ts +197 -0
- package/source/annotation.ts +83 -0
- package/source/const.ts +32 -0
- package/source/describe.ts +68 -0
- package/source/extension.ts +45 -0
- package/source/index.ts +7 -0
- package/source/keys.ts +0 -0
- package/source/manifest.ts +19 -0
- package/source/model.ts +82 -0
- package/source/sample.ts +49 -0
- package/source/schemas.ts +10 -0
- package/transpiled/Composition.d.ts +21 -0
- package/transpiled/Composition.js +48 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Explorer.d.ts +9 -0
- package/transpiled/Explorer.js +19 -0
- package/transpiled/Explorer.js.map +1 -0
- package/transpiled/Factory.d.ts +22 -0
- package/transpiled/Factory.js +93 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Reporter.d.ts +44 -0
- package/transpiled/Reporter.js +187 -0
- package/transpiled/Reporter.js.map +1 -0
- package/transpiled/Tenant.d.ts +18 -0
- package/transpiled/Tenant.js +40 -0
- package/transpiled/Tenant.js.map +1 -0
- package/transpiled/UI.d.ts +25 -0
- package/transpiled/UI.js +186 -0
- package/transpiled/UI.js.map +1 -0
- package/transpiled/annotation.d.ts +40 -0
- package/transpiled/annotation.js +46 -0
- package/transpiled/annotation.js.map +1 -0
- package/transpiled/const.d.ts +23 -0
- package/transpiled/const.js +27 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/describe.d.ts +9 -0
- package/transpiled/describe.js +64 -0
- package/transpiled/describe.js.map +1 -0
- package/transpiled/extension.d.ts +11 -0
- package/transpiled/extension.js +64 -0
- package/transpiled/extension.js.map +1 -0
- package/transpiled/index.d.ts +6 -0
- package/transpiled/index.js +25 -0
- package/transpiled/index.js.map +1 -0
- package/transpiled/keys.d.ts +9 -0
- package/transpiled/keys.js +0 -0
- package/transpiled/keys.js.map +1 -0
- package/transpiled/manifest.d.ts +9 -0
- package/transpiled/manifest.js +43 -0
- package/transpiled/manifest.js.map +1 -0
- package/transpiled/model.d.ts +71 -0
- package/transpiled/model.js +3 -0
- package/transpiled/model.js.map +1 -0
- package/transpiled/sample.d.ts +7 -0
- package/transpiled/sample.js +44 -0
- package/transpiled/sample.js.map +1 -0
- package/transpiled/schemas.d.ts +4 -0
- package/transpiled/schemas.js +13 -0
- package/transpiled/schemas.js.map +1 -0
- package/transpiled/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readdirSync, type Dirent } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { Connector } from '@toa.io/core'
|
|
4
|
+
import { type Bootloader } from './Factory'
|
|
5
|
+
import type { Annotation } from './annotation'
|
|
6
|
+
|
|
7
|
+
/** Hosts the introspection components in the explorer process. */
|
|
8
|
+
export class Composition extends Connector {
|
|
9
|
+
private readonly boot: Bootloader
|
|
10
|
+
|
|
11
|
+
public constructor (boot: Bootloader) {
|
|
12
|
+
super()
|
|
13
|
+
this.boot = boot
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
protected override async open (): Promise<void> {
|
|
17
|
+
const paths = find()
|
|
18
|
+
const composition = await this.boot.composition(paths)
|
|
19
|
+
|
|
20
|
+
await composition.connect()
|
|
21
|
+
|
|
22
|
+
this.depends(composition)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function find (): string[] {
|
|
27
|
+
return entries().map((entry) => resolve(ROOT, entry.name))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function entries (): Dirent[] {
|
|
31
|
+
const entries = readdirSync(ROOT, { withFileTypes: true })
|
|
32
|
+
|
|
33
|
+
return entries.filter((entry) => entry.isDirectory())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The extension is predefined, so an application that turns introspection off
|
|
38
|
+
* must not end up with the explorer components — nor with the exposition
|
|
39
|
+
* dependency they bring in.
|
|
40
|
+
*/
|
|
41
|
+
export function components (annotation?: Annotation): Components {
|
|
42
|
+
if (annotation === false)
|
|
43
|
+
return { labels: [], paths: [] }
|
|
44
|
+
|
|
45
|
+
const labels: string[] = []
|
|
46
|
+
const paths: string[] = []
|
|
47
|
+
|
|
48
|
+
for (const entry of entries()) {
|
|
49
|
+
labels.push(entry.name.replace('.', '-'))
|
|
50
|
+
paths.push(resolve(ROOT, entry.name))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { labels, paths }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Components {
|
|
57
|
+
labels: string[]
|
|
58
|
+
paths: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ROOT = resolve(__dirname, '../components/')
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core'
|
|
2
|
+
import { console } from 'openspan'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The explorer process. It hosts the introspection components — the map is read
|
|
6
|
+
* through their own operations — and serves the UI.
|
|
7
|
+
*/
|
|
8
|
+
export class Explorer extends Connector {
|
|
9
|
+
protected override async open (): Promise<void> {
|
|
10
|
+
console.info('Introspection explorer started')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
protected override dispose (): void {
|
|
14
|
+
console.info('Introspection explorer is closed')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core'
|
|
2
|
+
import { DISABLED, environment, component as declaration, settings } from './annotation'
|
|
3
|
+
import { NAMESPACE, UI_PORT } from './const'
|
|
4
|
+
import { describe } from './describe'
|
|
5
|
+
import { Reporter } from './Reporter'
|
|
6
|
+
import { Tenant } from './Tenant'
|
|
7
|
+
import { Composition } from './Composition'
|
|
8
|
+
import { Explorer } from './Explorer'
|
|
9
|
+
import { UI } from './UI'
|
|
10
|
+
import { capture, samplable } from './sample'
|
|
11
|
+
import type { Declaration, Options, Settings } from './annotation'
|
|
12
|
+
import type { Origin, Outcome, Target } from './model'
|
|
13
|
+
import type { Manifest } from '@toa.io/norm'
|
|
14
|
+
import type { Component, Locator, Reply, Request, extensions } from '@toa.io/core'
|
|
15
|
+
|
|
16
|
+
export class Factory implements extensions.Factory {
|
|
17
|
+
private readonly boot: Bootloader
|
|
18
|
+
private readonly options: Options | null
|
|
19
|
+
private readonly settings: Record<string, Settings> = {}
|
|
20
|
+
private reporter: Reporter | null = null
|
|
21
|
+
|
|
22
|
+
public constructor (boot: Bootloader) {
|
|
23
|
+
this.boot = boot
|
|
24
|
+
this.options = environment()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public tenant (locator: Locator, decl: Declaration | null, manifest: Manifest): Connector {
|
|
28
|
+
const resolved = settings(locator.namespace, declaration(decl), this.options)
|
|
29
|
+
|
|
30
|
+
this.settings[locator.id] = resolved
|
|
31
|
+
|
|
32
|
+
if (!resolved.enabled || locator.namespace === NAMESPACE)
|
|
33
|
+
return new Connector()
|
|
34
|
+
|
|
35
|
+
return new Tenant(this.collector(), describe(manifest))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public component (component: Component): Component {
|
|
39
|
+
const locator = component.locator
|
|
40
|
+
const resolved = this.resolve(locator)
|
|
41
|
+
|
|
42
|
+
if (!resolved.enabled)
|
|
43
|
+
return component
|
|
44
|
+
|
|
45
|
+
const reporter = this.collector()
|
|
46
|
+
const invoke = component.invoke.bind(component)
|
|
47
|
+
|
|
48
|
+
component.invoke = async (endpoint: string, request: Request): Promise<any> => {
|
|
49
|
+
let outcome: Outcome = 'ok'
|
|
50
|
+
let reply: Reply | undefined
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
reply = await invoke(endpoint, request)
|
|
54
|
+
|
|
55
|
+
if (reply?.exception !== undefined) outcome = 'exception'
|
|
56
|
+
else if (reply?.error !== undefined) outcome = 'error'
|
|
57
|
+
|
|
58
|
+
return reply
|
|
59
|
+
} catch (error) {
|
|
60
|
+
outcome = 'exception'
|
|
61
|
+
|
|
62
|
+
throw error
|
|
63
|
+
} finally {
|
|
64
|
+
// a call that failed is still a connection between two components
|
|
65
|
+
const src: Origin = request?.source ?? UNKNOWN
|
|
66
|
+
const dst: Target = { namespace: locator.namespace, component: locator.name, operation: endpoint }
|
|
67
|
+
|
|
68
|
+
const sample = resolved.samples && samplable(request?.input)
|
|
69
|
+
? capture(request?.input, outcome)
|
|
70
|
+
: undefined
|
|
71
|
+
|
|
72
|
+
reporter.observe({ src, dst, sample })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
component.depends(reporter)
|
|
77
|
+
|
|
78
|
+
return component
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public service (): Connector | null {
|
|
82
|
+
if (this.options === null)
|
|
83
|
+
return null
|
|
84
|
+
|
|
85
|
+
const composition = new Composition(this.boot)
|
|
86
|
+
const explorer = new Explorer()
|
|
87
|
+
|
|
88
|
+
explorer.depends(composition)
|
|
89
|
+
|
|
90
|
+
if (this.options.ui)
|
|
91
|
+
explorer.depends(new UI(UI_PORT))
|
|
92
|
+
|
|
93
|
+
return explorer
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `tenant()` runs before any component is created, so settings are warm.
|
|
98
|
+
* A component booted on its own (without a composition) falls back to
|
|
99
|
+
* the environment, with sampling off.
|
|
100
|
+
*/
|
|
101
|
+
private resolve (locator: Locator): Settings {
|
|
102
|
+
if (locator.namespace === NAMESPACE)
|
|
103
|
+
return DISABLED
|
|
104
|
+
|
|
105
|
+
return this.settings[locator.id] ??
|
|
106
|
+
settings(locator.namespace, {}, this.options === null ? null : { ...this.options, samples: false })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private collector (): Reporter {
|
|
110
|
+
this.reporter ??= new Reporter(this.boot, this.options!)
|
|
111
|
+
|
|
112
|
+
return this.reporter
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const UNKNOWN = { service: 'unknown' } as const
|
|
117
|
+
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
119
|
+
export type Bootloader = typeof import('@toa.io/boot')
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Connector, Locator } from '@toa.io/core'
|
|
2
|
+
import { console } from 'openspan'
|
|
3
|
+
import { EDGES, MAX_EDGES, NAMESPACE, NODES } from './const'
|
|
4
|
+
import * as keys from './keys'
|
|
5
|
+
import type { Bootloader } from './Factory'
|
|
6
|
+
import type { Options } from './annotation'
|
|
7
|
+
import type { Edge, Node } from './model'
|
|
8
|
+
import type { Remote } from '@toa.io/core'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Buffers what a process observes and flushes it into the introspection
|
|
12
|
+
* components.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here is on the critical path. Reaching the explorer is a discovery,
|
|
15
|
+
* which waits as long as it takes, so the collector never holds up a component
|
|
16
|
+
* starting, running or stopping: it buffers until the connection is there, and
|
|
17
|
+
* when it has to choose it gives up on the data rather than on the application.
|
|
18
|
+
*/
|
|
19
|
+
export class Reporter extends Connector {
|
|
20
|
+
private readonly boot: Bootloader
|
|
21
|
+
private readonly options: Options
|
|
22
|
+
private readonly nodes = new Map<string, Node>()
|
|
23
|
+
private readonly edges = new Map<string, Edge>()
|
|
24
|
+
|
|
25
|
+
/** Holds a remote only once it is connected and usable. */
|
|
26
|
+
private readonly remotes: Record<string, Remote> = {}
|
|
27
|
+
|
|
28
|
+
private timer: NodeJS.Timeout | null = null
|
|
29
|
+
private flushing: Promise<void> | null = null
|
|
30
|
+
private acquiring = false
|
|
31
|
+
private dropped = 0
|
|
32
|
+
|
|
33
|
+
public constructor (boot: Bootloader, options: Options) {
|
|
34
|
+
super()
|
|
35
|
+
|
|
36
|
+
this.boot = boot
|
|
37
|
+
this.options = options
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The static description of a component. */
|
|
41
|
+
public expose (node: Node): void {
|
|
42
|
+
this.nodes.set(keys.node(node.namespace, node.component), node)
|
|
43
|
+
|
|
44
|
+
void this.flush()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A call between two components. */
|
|
48
|
+
public observe (observed: Edge): void {
|
|
49
|
+
const id = keys.edge(observed.src, observed.dst)
|
|
50
|
+
const edge = this.edges.get(id)
|
|
51
|
+
|
|
52
|
+
if (edge === undefined) {
|
|
53
|
+
/*
|
|
54
|
+
* `source` arrives over the wire, so the number of distinct edges a process
|
|
55
|
+
* can hold has to be bounded regardless of what peers send — and of whether
|
|
56
|
+
* anyone is there to take them.
|
|
57
|
+
*/
|
|
58
|
+
if (this.edges.size >= MAX_EDGES) {
|
|
59
|
+
this.dropped++
|
|
60
|
+
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.edges.set(id, observed)
|
|
65
|
+
} else if (observed.sample !== undefined)
|
|
66
|
+
edge.sample = observed.sample
|
|
67
|
+
|
|
68
|
+
if (this.edges.size >= this.options.threshold)
|
|
69
|
+
void this.flush()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected override async open (): Promise<void> {
|
|
73
|
+
// deliberately not awaited: the explorer may not be there yet, or at all
|
|
74
|
+
this.acquire()
|
|
75
|
+
|
|
76
|
+
this.timer = setInterval(() => void this.flush(), this.options.interval * 1000)
|
|
77
|
+
this.timer.unref()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected override async close (): Promise<void> {
|
|
81
|
+
if (this.timer !== null) {
|
|
82
|
+
clearInterval(this.timer)
|
|
83
|
+
this.timer = null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await this.flushing
|
|
87
|
+
|
|
88
|
+
if (!this.ready()) {
|
|
89
|
+
this.discard('the explorer was never reached')
|
|
90
|
+
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// the remotes are still up: dependencies are disconnected after this returns
|
|
95
|
+
await this.dispatch().catch((error: Error) => {
|
|
96
|
+
console.debug('Introspection final flush failed', { message: error.message })
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private ready (): boolean {
|
|
101
|
+
return NODES in this.remotes && EDGES in this.remotes
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async flush (): Promise<void> {
|
|
105
|
+
// a dispatch in flight keeps observations buffered, they join the next batch
|
|
106
|
+
if (this.flushing !== null)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if (this.nodes.size === 0 && this.edges.size === 0)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if (!this.ready()) {
|
|
113
|
+
this.acquire()
|
|
114
|
+
|
|
115
|
+
if (this.edges.size >= MAX_EDGES)
|
|
116
|
+
this.discard('the explorer is not reachable')
|
|
117
|
+
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.flushing = this.dispatch()
|
|
122
|
+
.catch((error: Error) => {
|
|
123
|
+
console.debug('Introspection flush failed', { message: error.message })
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
this.flushing = null
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await this.flushing
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private discard (reason: string): void {
|
|
133
|
+
const nodes = this.nodes.size
|
|
134
|
+
const edges = this.edges.size + this.dropped
|
|
135
|
+
|
|
136
|
+
this.nodes.clear()
|
|
137
|
+
this.edges.clear()
|
|
138
|
+
this.dropped = 0
|
|
139
|
+
|
|
140
|
+
if (nodes === 0 && edges === 0)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
console.warn(`Introspection data discarded, ${reason}`, { nodes, edges })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async dispatch (): Promise<void> {
|
|
147
|
+
const nodes = [...this.nodes.entries()]
|
|
148
|
+
const edges = [...this.edges.entries()]
|
|
149
|
+
|
|
150
|
+
this.nodes.clear()
|
|
151
|
+
this.edges.clear()
|
|
152
|
+
|
|
153
|
+
if (this.dropped > 0) {
|
|
154
|
+
console.warn('Introspection edges dropped', { dropped: this.dropped, limit: MAX_EDGES })
|
|
155
|
+
this.dropped = 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await Promise.all([
|
|
159
|
+
this.merge(NODES, 'nodes', nodes),
|
|
160
|
+
this.merge(EDGES, 'edges', edges)
|
|
161
|
+
])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A mass transition: every affected object is acquired and committed at once,
|
|
166
|
+
* so a flush is one call per component whatever it carries.
|
|
167
|
+
*/
|
|
168
|
+
private async merge (name: string, property: string,
|
|
169
|
+
observed: Array<[string, Node | Edge]>): Promise<void> {
|
|
170
|
+
if (observed.length === 0)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
const objects: Record<string, Node | Edge> = {}
|
|
174
|
+
|
|
175
|
+
for (const [id, object] of observed)
|
|
176
|
+
objects[id] = object
|
|
177
|
+
|
|
178
|
+
await this.remotes[name].invoke('merge', {
|
|
179
|
+
query: { ids: observed.map(([id]) => id) },
|
|
180
|
+
input: { [property]: objects },
|
|
181
|
+
task: true
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Runs in the background: discovery waits for the explorer as long as it takes. */
|
|
186
|
+
private acquire (): void {
|
|
187
|
+
if (this.acquiring)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
this.acquiring = true
|
|
191
|
+
|
|
192
|
+
void this.reach().catch((error: Error) => {
|
|
193
|
+
this.acquiring = false
|
|
194
|
+
|
|
195
|
+
console.error('Introspection cannot reach its explorer', { message: error.message })
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async reach (): Promise<void> {
|
|
200
|
+
await Promise.all([NODES, EDGES].map(async (name) => {
|
|
201
|
+
const remote = await this.boot.remote(new Locator(name, NAMESPACE))
|
|
202
|
+
|
|
203
|
+
this.depends(remote)
|
|
204
|
+
|
|
205
|
+
await remote.connect()
|
|
206
|
+
|
|
207
|
+
this.remotes[name] = remote
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
}
|
package/source/Tenant.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { setTimeout } from 'node:timers/promises'
|
|
2
|
+
import { Connector } from '@toa.io/core'
|
|
3
|
+
import { ANNOUNCE_INTERVAL } from './const'
|
|
4
|
+
import type { Reporter } from './Reporter'
|
|
5
|
+
import type { Node } from './model'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Announces the static description of a component.
|
|
9
|
+
*
|
|
10
|
+
* Delivery is guaranteed, so the repeat is not about reliability — it keeps
|
|
11
|
+
* `_updated` fresh, which is how a removed component fades off the map.
|
|
12
|
+
*/
|
|
13
|
+
export class Tenant extends Connector {
|
|
14
|
+
private readonly reporter: Reporter
|
|
15
|
+
private readonly node: Node
|
|
16
|
+
private stopped = false
|
|
17
|
+
|
|
18
|
+
public constructor (reporter: Reporter, node: Node) {
|
|
19
|
+
super()
|
|
20
|
+
|
|
21
|
+
this.reporter = reporter
|
|
22
|
+
this.node = node
|
|
23
|
+
|
|
24
|
+
this.depends(reporter)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected override async open (): Promise<void> {
|
|
28
|
+
this.reporter.expose(this.node)
|
|
29
|
+
|
|
30
|
+
void this.announce()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected override dispose (): void {
|
|
34
|
+
this.stopped = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async announce (): Promise<void> {
|
|
38
|
+
while (!this.stopped) {
|
|
39
|
+
await setTimeout(ANNOUNCE_INTERVAL, undefined, { ref: false })
|
|
40
|
+
|
|
41
|
+
if (this.stopped)
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
this.reporter.expose(this.node)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/source/UI.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as http from 'node:http'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { Connector } from '@toa.io/core'
|
|
5
|
+
import { console } from 'openspan'
|
|
6
|
+
import { UI_PATH } from './const'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serves the introspection UI: the directory `ui` builds, and nothing else.
|
|
10
|
+
*
|
|
11
|
+
* The page is a single-page application whose mount path is baked into the bundle,
|
|
12
|
+
* so this server routes relative to `UI_PATH` — which is also what the ingress
|
|
13
|
+
* forwards. `/introspection/*` is left alone: that is the components' own API.
|
|
14
|
+
*/
|
|
15
|
+
export class UI extends Connector {
|
|
16
|
+
private readonly server: http.Server = http.createServer()
|
|
17
|
+
private readonly port: number
|
|
18
|
+
private readonly root: string
|
|
19
|
+
|
|
20
|
+
public constructor (port: number, root: string = SITE) {
|
|
21
|
+
super()
|
|
22
|
+
|
|
23
|
+
this.port = port
|
|
24
|
+
this.root = root
|
|
25
|
+
this.server.on('request', (request, response) => {
|
|
26
|
+
this.listen(request, response)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
protected override async open (): Promise<void> {
|
|
31
|
+
/*
|
|
32
|
+
* A taken port is a real error: uniqueness across services is settled at export
|
|
33
|
+
* time, so nothing here has to negotiate for one.
|
|
34
|
+
*/
|
|
35
|
+
await new Promise<void>((resolve, reject) => {
|
|
36
|
+
const failed = (error: Error): void => {
|
|
37
|
+
this.server.off('listening', listening)
|
|
38
|
+
reject(error)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const listening = (): void => {
|
|
42
|
+
this.server.off('error', failed)
|
|
43
|
+
resolve()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.server.once('error', failed)
|
|
47
|
+
this.server.once('listening', listening)
|
|
48
|
+
this.server.listen(this.port)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (!isFile(path.join(this.root, 'index.html')))
|
|
52
|
+
console.warn('Introspection UI is not built', { root: this.root })
|
|
53
|
+
|
|
54
|
+
console.info('Introspection UI started', { port: this.port, path: UI_PATH + '/' })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected override async close (): Promise<void> {
|
|
58
|
+
const closed = new Promise<void>((resolve) => this.server.once('close', () => {
|
|
59
|
+
resolve()
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
this.server.close()
|
|
63
|
+
this.server.closeAllConnections()
|
|
64
|
+
|
|
65
|
+
await closed
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private listen (request: http.IncomingMessage, response: http.ServerResponse): void {
|
|
69
|
+
void this.respond(request, response).catch((error: Error) => {
|
|
70
|
+
console.error('Introspection UI failure', { message: error.message })
|
|
71
|
+
|
|
72
|
+
if (!response.writableEnded)
|
|
73
|
+
response.writeHead(500).end()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async respond (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
|
|
78
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
79
|
+
response.writeHead(405, { allow: 'GET, HEAD' }).end()
|
|
80
|
+
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pathname = decode(request.url ?? '/')
|
|
85
|
+
|
|
86
|
+
if (pathname === null) {
|
|
87
|
+
response.writeHead(400).end()
|
|
88
|
+
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// the port is the explorer's alone, so the root is the way in
|
|
93
|
+
if (pathname === '/') {
|
|
94
|
+
response.writeHead(302, { location: UI_PATH + '/' }).end()
|
|
95
|
+
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const file = this.resolve(pathname)
|
|
100
|
+
|
|
101
|
+
if (file === null)
|
|
102
|
+
response.writeHead(404).end()
|
|
103
|
+
else
|
|
104
|
+
await this.send(file, request, response)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The file a request lands on, or `null` when nothing does. A path that exists is
|
|
109
|
+
* served as it is; anything else that could be a route falls back to the page,
|
|
110
|
+
* because the client router — not this server — knows what routes there are.
|
|
111
|
+
*/
|
|
112
|
+
private resolve (pathname: string): string | null {
|
|
113
|
+
if (!pathname.startsWith(UI_PATH))
|
|
114
|
+
return null
|
|
115
|
+
|
|
116
|
+
const relative = pathname.slice(UI_PATH.length)
|
|
117
|
+
|
|
118
|
+
if (relative !== '' && !relative.startsWith('/'))
|
|
119
|
+
return null
|
|
120
|
+
|
|
121
|
+
const file = path.join(this.root, relative)
|
|
122
|
+
|
|
123
|
+
if (file !== this.root && !file.startsWith(this.root + path.sep))
|
|
124
|
+
return null
|
|
125
|
+
|
|
126
|
+
if (isFile(file))
|
|
127
|
+
return file
|
|
128
|
+
|
|
129
|
+
/*
|
|
130
|
+
* A missing asset is missing, but a route can look like one: `identity.passkeys` is a
|
|
131
|
+
* component, not a file with an extension. What this server would have served is what
|
|
132
|
+
* it knows how to serve, so anything else is a route and falls back to the page — as
|
|
133
|
+
* does anything ending in a slash, which is no name for a file at all.
|
|
134
|
+
*/
|
|
135
|
+
const asset = !relative.endsWith('/') && path.extname(relative) in TYPES
|
|
136
|
+
|
|
137
|
+
return asset ? null : path.join(this.root, 'index.html')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async send (file: string, request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
|
|
141
|
+
const stats = await fs.promises.stat(file).catch(() => null)
|
|
142
|
+
|
|
143
|
+
if (stats === null) {
|
|
144
|
+
response.writeHead(503, { 'content-type': 'text/plain' })
|
|
145
|
+
.end('The introspection UI is not built. Run `npm run build:ui`.\n')
|
|
146
|
+
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
response.writeHead(200, {
|
|
151
|
+
'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream',
|
|
152
|
+
'content-length': stats.size,
|
|
153
|
+
'cache-control': file.startsWith(path.join(this.root, IMMUTABLE)) ? FOREVER : 'no-cache'
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (request.method === 'HEAD')
|
|
157
|
+
response.end()
|
|
158
|
+
else
|
|
159
|
+
fs.createReadStream(file).pipe(response)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function decode (url: string): string | null {
|
|
164
|
+
try {
|
|
165
|
+
return decodeURIComponent(url.split('?')[0])
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isFile (file: string): boolean {
|
|
172
|
+
return fs.existsSync(file) && fs.statSync(file).isFile()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Where `npm run build:ui` puts the page, from both `source` and `transpiled`. */
|
|
176
|
+
const SITE = path.resolve(__dirname, '..', 'ui', 'dist')
|
|
177
|
+
|
|
178
|
+
/** Assets under this prefix carry their build hash in the name. */
|
|
179
|
+
const IMMUTABLE = path.join('_app', 'immutable')
|
|
180
|
+
const FOREVER = 'public, max-age=31536000, immutable'
|
|
181
|
+
|
|
182
|
+
const TYPES: Record<string, string> = {
|
|
183
|
+
'.css': 'text/css; charset=utf-8',
|
|
184
|
+
'.html': 'text/html; charset=utf-8',
|
|
185
|
+
'.ico': 'image/x-icon',
|
|
186
|
+
'.jpg': 'image/jpeg',
|
|
187
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
188
|
+
'.json': 'application/json; charset=utf-8',
|
|
189
|
+
'.map': 'application/json; charset=utf-8',
|
|
190
|
+
'.png': 'image/png',
|
|
191
|
+
'.svg': 'image/svg+xml',
|
|
192
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
193
|
+
'.webmanifest': 'application/manifest+json',
|
|
194
|
+
'.webp': 'image/webp',
|
|
195
|
+
'.woff': 'font/woff',
|
|
196
|
+
'.woff2': 'font/woff2'
|
|
197
|
+
}
|