@toa.io/extensions.introspection 1.0.0-alpha.262

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 (150) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/components/introspection.edges/manifest.toa.yaml +48 -0
  3. package/components/introspection.edges/operations/lib/types.d.ts +28 -0
  4. package/components/introspection.edges/operations/lib/types.js +3 -0
  5. package/components/introspection.edges/operations/lib/types.js.map +1 -0
  6. package/components/introspection.edges/operations/merge.d.ts +9 -0
  7. package/components/introspection.edges/operations/merge.js +25 -0
  8. package/components/introspection.edges/operations/merge.js.map +1 -0
  9. package/components/introspection.edges/source/lib/types.ts +33 -0
  10. package/components/introspection.edges/source/merge.ts +26 -0
  11. package/components/introspection.edges/tsconfig.json +9 -0
  12. package/components/introspection.nodes/manifest.toa.yaml +49 -0
  13. package/components/introspection.nodes/operations/lib/types.d.ts +16 -0
  14. package/components/introspection.nodes/operations/lib/types.js +3 -0
  15. package/components/introspection.nodes/operations/lib/types.js.map +1 -0
  16. package/components/introspection.nodes/operations/merge.d.ts +9 -0
  17. package/components/introspection.nodes/operations/merge.js +21 -0
  18. package/components/introspection.nodes/operations/merge.js.map +1 -0
  19. package/components/introspection.nodes/source/lib/types.ts +18 -0
  20. package/components/introspection.nodes/source/merge.ts +21 -0
  21. package/components/introspection.nodes/tsconfig.json +9 -0
  22. package/cucumber.js +8 -0
  23. package/features/calls.feature +123 -0
  24. package/features/nodes.feature +70 -0
  25. package/features/samples.feature +75 -0
  26. package/features/shutdown.feature +37 -0
  27. package/features/site/_app/immutable/asset.js +1 -0
  28. package/features/site/index.html +6 -0
  29. package/features/steps/Map.ts +151 -0
  30. package/features/steps/UI.ts +88 -0
  31. package/features/steps/components/probe.discreet/manifest.toa.yaml +12 -0
  32. package/features/steps/components/probe.discreet/operations/ping.js +7 -0
  33. package/features/steps/components/probe.quiet/manifest.toa.yaml +11 -0
  34. package/features/steps/components/probe.quiet/operations/ping.js +7 -0
  35. package/features/steps/components/probe.source/manifest.toa.yaml +27 -0
  36. package/features/steps/components/probe.source/operations/crash.js +7 -0
  37. package/features/steps/components/probe.source/operations/fail.js +12 -0
  38. package/features/steps/components/probe.source/operations/relay.js +7 -0
  39. package/features/steps/components/probe.target/manifest.toa.yaml +24 -0
  40. package/features/steps/components/probe.target/operations/compute.js +7 -0
  41. package/features/steps/components/probe.target/operations/count.js +9 -0
  42. package/features/steps/config.ts +20 -0
  43. package/features/steps/tsconfig.json +9 -0
  44. package/features/ui.feature +85 -0
  45. package/package.json +31 -0
  46. package/readme.md +141 -0
  47. package/schemas/annotation.cos.yaml +16 -0
  48. package/schemas/declaration.cos.yaml +2 -0
  49. package/source/Composition.ts +61 -0
  50. package/source/Explorer.ts +16 -0
  51. package/source/Factory.ts +119 -0
  52. package/source/Reporter.ts +210 -0
  53. package/source/Tenant.ts +47 -0
  54. package/source/UI.ts +197 -0
  55. package/source/annotation.ts +83 -0
  56. package/source/const.ts +32 -0
  57. package/source/describe.ts +68 -0
  58. package/source/extension.ts +45 -0
  59. package/source/index.ts +7 -0
  60. package/source/keys.ts +0 -0
  61. package/source/manifest.ts +19 -0
  62. package/source/model.ts +82 -0
  63. package/source/sample.ts +49 -0
  64. package/source/schemas.ts +10 -0
  65. package/transpiled/Composition.d.ts +21 -0
  66. package/transpiled/Composition.js +48 -0
  67. package/transpiled/Composition.js.map +1 -0
  68. package/transpiled/Explorer.d.ts +9 -0
  69. package/transpiled/Explorer.js +19 -0
  70. package/transpiled/Explorer.js.map +1 -0
  71. package/transpiled/Factory.d.ts +22 -0
  72. package/transpiled/Factory.js +93 -0
  73. package/transpiled/Factory.js.map +1 -0
  74. package/transpiled/Reporter.d.ts +44 -0
  75. package/transpiled/Reporter.js +187 -0
  76. package/transpiled/Reporter.js.map +1 -0
  77. package/transpiled/Tenant.d.ts +18 -0
  78. package/transpiled/Tenant.js +40 -0
  79. package/transpiled/Tenant.js.map +1 -0
  80. package/transpiled/UI.d.ts +25 -0
  81. package/transpiled/UI.js +186 -0
  82. package/transpiled/UI.js.map +1 -0
  83. package/transpiled/annotation.d.ts +40 -0
  84. package/transpiled/annotation.js +46 -0
  85. package/transpiled/annotation.js.map +1 -0
  86. package/transpiled/const.d.ts +23 -0
  87. package/transpiled/const.js +27 -0
  88. package/transpiled/const.js.map +1 -0
  89. package/transpiled/contracts.d.ts +51 -0
  90. package/transpiled/contracts.js +53 -0
  91. package/transpiled/contracts.js.map +1 -0
  92. package/transpiled/describe.d.ts +9 -0
  93. package/transpiled/describe.js +64 -0
  94. package/transpiled/describe.js.map +1 -0
  95. package/transpiled/extension.d.ts +11 -0
  96. package/transpiled/extension.js +64 -0
  97. package/transpiled/extension.js.map +1 -0
  98. package/transpiled/index.d.ts +6 -0
  99. package/transpiled/index.js +25 -0
  100. package/transpiled/index.js.map +1 -0
  101. package/transpiled/keys.d.ts +9 -0
  102. package/transpiled/keys.js +0 -0
  103. package/transpiled/keys.js.map +1 -0
  104. package/transpiled/manifest.d.ts +9 -0
  105. package/transpiled/manifest.js +43 -0
  106. package/transpiled/manifest.js.map +1 -0
  107. package/transpiled/model.d.ts +71 -0
  108. package/transpiled/model.js +3 -0
  109. package/transpiled/model.js.map +1 -0
  110. package/transpiled/sample.d.ts +7 -0
  111. package/transpiled/sample.js +44 -0
  112. package/transpiled/sample.js.map +1 -0
  113. package/transpiled/schemas.d.ts +4 -0
  114. package/transpiled/schemas.js +13 -0
  115. package/transpiled/schemas.js.map +1 -0
  116. package/tsconfig.json +12 -0
  117. package/ui/dist/_app/env.js +1 -0
  118. package/ui/dist/_app/immutable/assets/0.fg2cP6bO.css +2 -0
  119. package/ui/dist/_app/immutable/assets/2.BghF56l_.css +1 -0
  120. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  121. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  122. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  123. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  124. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  125. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  126. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  127. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  128. package/ui/dist/_app/immutable/chunks/CIvRtvVA.js +1 -0
  129. package/ui/dist/_app/immutable/chunks/D1jNriZ8.js +1 -0
  130. package/ui/dist/_app/immutable/chunks/DINmginf.js +1 -0
  131. package/ui/dist/_app/immutable/chunks/DKZCAkrF.js +1 -0
  132. package/ui/dist/_app/immutable/chunks/DLG2JvyF.js +6 -0
  133. package/ui/dist/_app/immutable/chunks/DSi-FjwI.js +2 -0
  134. package/ui/dist/_app/immutable/chunks/DdoZuyU1.js +1 -0
  135. package/ui/dist/_app/immutable/chunks/O9IjE_TH.js +1 -0
  136. package/ui/dist/_app/immutable/chunks/PUjOYRcs.js +3 -0
  137. package/ui/dist/_app/immutable/chunks/QKD4Mgck.js +5 -0
  138. package/ui/dist/_app/immutable/chunks/Ux-r8C9U.js +1 -0
  139. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  140. package/ui/dist/_app/immutable/entry/app.CTY34L35.js +2 -0
  141. package/ui/dist/_app/immutable/entry/start.Bq2Gn1Ax.js +1 -0
  142. package/ui/dist/_app/immutable/nodes/0.lOXtBRjQ.js +1 -0
  143. package/ui/dist/_app/immutable/nodes/1.r8ESZdgM.js +1 -0
  144. package/ui/dist/_app/immutable/nodes/2.DuVj0loz.js +8 -0
  145. package/ui/dist/_app/immutable/nodes/3.DhdkQ2Q1.js +1 -0
  146. package/ui/dist/_app/immutable/nodes/4.DUAEZdRr.js +1 -0
  147. package/ui/dist/_app/immutable/nodes/5.CIVn_xgq.js +1 -0
  148. package/ui/dist/_app/version.json +1 -0
  149. package/ui/dist/index.html +48 -0
  150. package/ui/dist/robots.txt +3 -0
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
+ }
@@ -0,0 +1,83 @@
1
+ import { decode } from '@toa.io/generic'
2
+ import { DEFAULT_INTERVAL, DEFAULT_THRESHOLD, DENIED, ENV } from './const'
3
+ import type { Resources } from '@toa.io/operations'
4
+
5
+ /** `context.toa.yaml` */
6
+ export type Annotation = false | {
7
+ /** Capture real payloads. Off by default: this is production data. */
8
+ samples?: boolean
9
+ /** Flush period, seconds. */
10
+ interval?: number
11
+ /** Flush as soon as this many distinct edges are buffered. */
12
+ threshold?: number
13
+ /** Publish the UI. On by default. */
14
+ ui?: boolean
15
+ resources?: Resources
16
+ }
17
+
18
+ /** `manifest.toa.yaml` */
19
+ export type Declaration = false | {
20
+ samples?: boolean
21
+ }
22
+
23
+ /** What `deployment()` encodes into the environment. */
24
+ export interface Options {
25
+ samples: boolean
26
+ interval: number
27
+ threshold: number
28
+ ui: boolean
29
+ }
30
+
31
+ /** The effective per-component decision. */
32
+ export interface Settings {
33
+ enabled: boolean
34
+ samples: boolean
35
+ }
36
+
37
+ export const DISABLED: Settings = { enabled: false, samples: false }
38
+
39
+ export function options (annotation?: Annotation): Options {
40
+ const declaration = annotation === undefined || annotation === false ? {} : annotation
41
+
42
+ return {
43
+ samples: declaration.samples === true,
44
+ interval: declaration.interval ?? DEFAULT_INTERVAL,
45
+ threshold: declaration.threshold ?? DEFAULT_THRESHOLD,
46
+ ui: declaration.ui !== false
47
+ }
48
+ }
49
+
50
+ /** Reads what `deployment()` has put into the environment. */
51
+ export function environment (): Options | null {
52
+ const value = process.env[ENV]
53
+
54
+ if (value === undefined)
55
+ return null
56
+
57
+ return decode<Options>(value)
58
+ }
59
+
60
+ export function component (declaration: Declaration | null | undefined): Declaration {
61
+ if (declaration === false)
62
+ return false
63
+
64
+ // predefined extensions arrive as null for components that say nothing
65
+ if (declaration === null || declaration === undefined)
66
+ return {}
67
+
68
+ return declaration.samples === undefined ? {} : { samples: declaration.samples }
69
+ }
70
+
71
+ /**
72
+ * Both levels must agree, and either can veto: the context is the environment
73
+ * ceiling, the manifest is the component's own call. A component handling
74
+ * personal data opts out for good, and no context flag overrides that.
75
+ */
76
+ export function settings (namespace: string, declaration: Declaration, opts: Options | null): Settings {
77
+ if (opts === null || declaration === false)
78
+ return DISABLED
79
+
80
+ const samples = opts.samples && declaration.samples !== false && !DENIED.has(namespace)
81
+
82
+ return { enabled: true, samples }
83
+ }
@@ -0,0 +1,32 @@
1
+ export const ID = 'introspection'
2
+ export const NAMESPACE = 'introspection'
3
+
4
+ export const ENV = 'TOA_INTROSPECTION'
5
+
6
+ export const NODES = 'nodes'
7
+ export const EDGES = 'edges'
8
+
9
+ /** Where the UI is mounted; `/introspection/*` belongs to the components' own API. */
10
+ export const UI_PATH = '/.introspection'
11
+ export const UI_PORT = 8002
12
+
13
+ export const DEFAULT_INTERVAL = 15
14
+ export const DEFAULT_THRESHOLD = 256
15
+
16
+ /** How often a component re-announces its description, so that removed components fade out. */
17
+ export const ANNOUNCE_INTERVAL = 1_800_000
18
+
19
+ /**
20
+ * `source` arrives over the wire, so the number of distinct edges a process
21
+ * can hold must be bounded regardless of what peers send.
22
+ */
23
+ export const MAX_EDGES = 4096
24
+
25
+ /** Never capture samples for these namespaces, whatever the annotation says. */
26
+ export const DENIED = new Set(['identity', 'introspection'])
27
+
28
+ /** Keys never stored in a sample, even when sampling is on. */
29
+ export const REDACTED = /^(password|secret|token|credentials?|key|authorization|cookie)$/i
30
+
31
+ /** Serialized size cap of a single sample. */
32
+ export const SAMPLE_LIMIT = 4096
@@ -0,0 +1,68 @@
1
+ import type { Node, Operation, Event, Receiver } from './model'
2
+ import type { Manifest } from '@toa.io/norm'
3
+
4
+ /**
5
+ * Turns a normalized component manifest into the node of the map.
6
+ *
7
+ * Everything here is already expanded by norm: concise schemas are resolved and
8
+ * the prototype chain is collapsed, so this is the shape the runtime actually runs.
9
+ */
10
+ export function describe (manifest: Manifest): Node {
11
+ return {
12
+ namespace: manifest.namespace,
13
+ component: manifest.name,
14
+ version: manifest.version,
15
+ entity: entity(manifest),
16
+ operations: operations(manifest),
17
+ events: events(manifest),
18
+ receivers: receivers(manifest),
19
+
20
+ // names only: extension declarations carry configuration and secrets
21
+ extensions: Object.keys(manifest.extensions ?? {})
22
+ }
23
+ }
24
+
25
+ function entity (manifest: Manifest): Node['entity'] {
26
+ if (manifest.entity === undefined)
27
+ return null
28
+
29
+ return {
30
+ schema: manifest.entity.schema,
31
+ storage: manifest.entity.storage,
32
+ associated: manifest.entity.associated === true
33
+ }
34
+ }
35
+
36
+ function operations (manifest: Manifest): Operation[] {
37
+ return Object.entries(manifest.operations ?? {})
38
+ .map(([endpoint, definition]: [string, any]) => ({
39
+ endpoint,
40
+ type: definition.type,
41
+ scope: definition.scope,
42
+ query: definition.query,
43
+ input: definition.input ?? null,
44
+ output: definition.output ?? null,
45
+ errors: definition.errors ?? []
46
+ }))
47
+ }
48
+
49
+ function events (manifest: Manifest): Event[] {
50
+ return Object.entries(manifest.events ?? {})
51
+ .map(([label, definition]: [string, any]) => ({ label, binding: definition.binding }))
52
+ }
53
+
54
+ /*
55
+ * Receiver labels contain dots (`identity.bans.created`), which is why nodes keep
56
+ * these as arrays rather than maps — a dot in a document key is a hazard in Mongo.
57
+ */
58
+ function receivers (manifest: Manifest): Receiver[] {
59
+ return Object.entries(manifest.receivers ?? {})
60
+ .map(([label, definition]: [string, any]) => ({
61
+ label,
62
+ source: definition.source ?? label.split('.').slice(0, 2).join('.'),
63
+ event: label.split('.').pop()!,
64
+ operation: definition.operation,
65
+ conditioned: definition.conditioned === true,
66
+ adaptive: definition.adaptive === true
67
+ }))
68
+ }
@@ -0,0 +1,45 @@
1
+ import { encode } from '@toa.io/generic'
2
+ import { options } from './annotation'
3
+ import { components } from './Composition'
4
+ import { ENV, UI_PATH, UI_PORT } from './const'
5
+ import * as schemas from './schemas'
6
+ import type { Annotation } from './annotation'
7
+ import type { Dependency, Instances, Service } from '@toa.io/operations'
8
+
9
+ export const standalone = true
10
+
11
+ /**
12
+ * The explorer hosts the introspection components, exactly as the exposition
13
+ * gateway hosts the identity ones. Collection is on unless the context says
14
+ * `introspection: false`, and the environment variable is emitted together
15
+ * with the service — never on its own, or tasks would pile up in a queue
16
+ * nothing consumes.
17
+ */
18
+ export function deployment (_: Instances<unknown>, annotation?: Annotation): Dependency {
19
+ if (annotation === false)
20
+ return {}
21
+
22
+ if (annotation !== undefined)
23
+ schemas.annotation.validate(annotation)
24
+
25
+ const opts = options(annotation)
26
+
27
+ const service: Service = {
28
+ group: 'introspection',
29
+ name: 'explorer',
30
+ version: require('../package.json').version,
31
+ components: components().labels,
32
+ resources: annotation?.resources,
33
+ variables: []
34
+ }
35
+
36
+ if (opts.ui) {
37
+ service.port = UI_PORT
38
+ service.ingress = { path: UI_PATH }
39
+ }
40
+
41
+ return {
42
+ services: [service],
43
+ variables: { global: [{ name: ENV, value: encode(opts) }] }
44
+ }
45
+ }
@@ -0,0 +1,7 @@
1
+ export { Factory } from './Factory'
2
+ export { manifest } from './manifest'
3
+ export { components } from './Composition'
4
+ export * from './extension'
5
+
6
+ export type { Annotation, Declaration, Options, Settings } from './annotation'
7
+ export type * from './model'
package/source/keys.ts ADDED
Binary file
@@ -0,0 +1,19 @@
1
+ import { component } from './annotation'
2
+ import * as schemas from './schemas'
3
+ import type { Declaration } from './annotation'
4
+
5
+ /**
6
+ * The component level of the annotation.
7
+ *
8
+ * The extension is predefined, so most components say nothing and the
9
+ * declaration arrives as `null` — which still has to produce a value,
10
+ * or norm rejects the extension.
11
+ */
12
+ export function manifest (declaration: Declaration | null | undefined): Declaration {
13
+ const normalized = component(declaration)
14
+
15
+ if (normalized !== false)
16
+ schemas.declaration.validate(normalized)
17
+
18
+ return normalized
19
+ }
@@ -0,0 +1,82 @@
1
+ import type { Source } from '@toa.io/core'
2
+
3
+ export type { Source }
4
+
5
+ /**
6
+ * The caller side of an edge: the operation that made the call, the event that
7
+ * caused it, or the service the call came from.
8
+ */
9
+ export type Origin = Source
10
+
11
+ /** The callee side of an edge. */
12
+ export interface Target {
13
+ namespace: string
14
+ component: string
15
+ operation: string
16
+ }
17
+
18
+ export interface Operation {
19
+ endpoint: string
20
+ type: string
21
+ scope?: string
22
+ query?: boolean
23
+ input?: object | null
24
+ output?: object | null
25
+ errors?: string[]
26
+ }
27
+
28
+ export interface Event {
29
+ label: string
30
+ binding?: string
31
+ }
32
+
33
+ export interface Receiver {
34
+ label: string
35
+ source: string
36
+ event: string
37
+ operation: string
38
+ conditioned: boolean
39
+ adaptive: boolean
40
+ }
41
+
42
+ export interface Entity {
43
+ schema: object
44
+ storage?: string
45
+ associated: boolean
46
+ }
47
+
48
+ /**
49
+ * The static description of a component, derived from its manifest.
50
+ * Sent to `introspection.nodes`.
51
+ */
52
+ export interface Node {
53
+ namespace: string
54
+ component: string
55
+ version: string
56
+ entity: Entity | null
57
+ operations: Operation[]
58
+ events: Event[]
59
+ receivers: Receiver[]
60
+ extensions: string[]
61
+ }
62
+
63
+ export interface Sample {
64
+ at: number
65
+ input?: unknown
66
+ outcome: Outcome
67
+ }
68
+
69
+ export type Outcome = 'ok' | 'error' | 'exception'
70
+
71
+ /**
72
+ * A call observed in a process between flushes. What the application declares —
73
+ * which events exist, and which receiver takes which — is a node's business;
74
+ * an edge is what actually happened.
75
+ *
76
+ * Sent to `introspection.edges`.
77
+ */
78
+ export interface Edge {
79
+ src: Origin
80
+ dst: Target
81
+ sample?: Sample
82
+ }
@@ -0,0 +1,49 @@
1
+ import { Readable } from 'node:stream'
2
+ import { REDACTED, SAMPLE_LIMIT } from './const'
3
+ import type { Outcome, Sample } from './model'
4
+
5
+ /**
6
+ * Captures the payload of a call. Only reached when both the context and the
7
+ * component have opted in, and never for a denied namespace.
8
+ */
9
+ export function capture (input: unknown, outcome: Outcome): Sample {
10
+ return { at: Date.now(), input: redact(input), outcome }
11
+ }
12
+
13
+ export function samplable (input: unknown): boolean {
14
+ return !(input instanceof Readable)
15
+ }
16
+
17
+ function redact (value: unknown): unknown {
18
+ if (value === null || value === undefined || typeof value !== 'object')
19
+ return truncate(value)
20
+
21
+ if (Array.isArray(value))
22
+ return truncate(value.map(redact))
23
+
24
+ const result: Record<string, unknown> = {}
25
+
26
+ for (const [key, property] of Object.entries(value))
27
+ result[key] = REDACTED.test(key) ? '***' : redact(property)
28
+
29
+ return truncate(result)
30
+ }
31
+
32
+ /**
33
+ * The map holds one sample per edge indefinitely, so a single oversized
34
+ * payload must not become a permanent tenant of the collection.
35
+ */
36
+ function truncate (value: unknown): unknown {
37
+ let serialized: string
38
+
39
+ try {
40
+ serialized = JSON.stringify(value) ?? ''
41
+ } catch {
42
+ return '[unserializable]'
43
+ }
44
+
45
+ if (serialized.length <= SAMPLE_LIMIT)
46
+ return value
47
+
48
+ return '[truncated]'
49
+ }
@@ -0,0 +1,10 @@
1
+ import { resolve } from 'node:path'
2
+ import schemas from '@toa.io/schemas'
3
+ import type { Schema } from '@toa.io/schemas'
4
+ import type { Annotation, Declaration } from './annotation'
5
+
6
+ const path = resolve(__dirname, '../schemas')
7
+ const namespace = schemas.namespace(path)
8
+
9
+ export const annotation: Schema<Exclude<Annotation, false>> = namespace.schema('annotation')
10
+ export const declaration: Schema<Exclude<Declaration, false>> = namespace.schema('declaration')
@@ -0,0 +1,21 @@
1
+ import { Connector } from '@toa.io/core';
2
+ import { type Bootloader } from './Factory';
3
+ import type { Annotation } from './annotation';
4
+ /** Hosts the introspection components in the explorer process. */
5
+ export declare class Composition extends Connector {
6
+ private readonly boot;
7
+ constructor(boot: Bootloader);
8
+ protected open(): Promise<void>;
9
+ }
10
+ export declare function find(): string[];
11
+ /**
12
+ * The extension is predefined, so an application that turns introspection off
13
+ * must not end up with the explorer components — nor with the exposition
14
+ * dependency they bring in.
15
+ */
16
+ export declare function components(annotation?: Annotation): Components;
17
+ interface Components {
18
+ labels: string[];
19
+ paths: string[];
20
+ }
21
+ export {};
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.components = exports.find = exports.Composition = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const core_1 = require("@toa.io/core");
7
+ /** Hosts the introspection components in the explorer process. */
8
+ class Composition extends core_1.Connector {
9
+ boot;
10
+ constructor(boot) {
11
+ super();
12
+ this.boot = boot;
13
+ }
14
+ async open() {
15
+ const paths = find();
16
+ const composition = await this.boot.composition(paths);
17
+ await composition.connect();
18
+ this.depends(composition);
19
+ }
20
+ }
21
+ exports.Composition = Composition;
22
+ function find() {
23
+ return entries().map((entry) => (0, node_path_1.resolve)(ROOT, entry.name));
24
+ }
25
+ exports.find = find;
26
+ function entries() {
27
+ const entries = (0, node_fs_1.readdirSync)(ROOT, { withFileTypes: true });
28
+ return entries.filter((entry) => entry.isDirectory());
29
+ }
30
+ /**
31
+ * The extension is predefined, so an application that turns introspection off
32
+ * must not end up with the explorer components — nor with the exposition
33
+ * dependency they bring in.
34
+ */
35
+ function components(annotation) {
36
+ if (annotation === false)
37
+ return { labels: [], paths: [] };
38
+ const labels = [];
39
+ const paths = [];
40
+ for (const entry of entries()) {
41
+ labels.push(entry.name.replace('.', '-'));
42
+ paths.push((0, node_path_1.resolve)(ROOT, entry.name));
43
+ }
44
+ return { labels, paths };
45
+ }
46
+ exports.components = components;
47
+ const ROOT = (0, node_path_1.resolve)(__dirname, '../components/');
48
+ //# sourceMappingURL=Composition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Composition.js","sourceRoot":"","sources":["../source/Composition.ts"],"names":[],"mappings":";;;AAAA,qCAAkD;AAClD,yCAAmC;AACnC,uCAAwC;AAIxC,kEAAkE;AAClE,MAAa,WAAY,SAAQ,gBAAS;IACvB,IAAI,CAAY;IAEjC,YAAoB,IAAgB;QAClC,KAAK,EAAE,CAAA;QACP,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;IAClB,CAAC;IAEkB,KAAK,CAAC,IAAI;QAC3B,MAAM,KAAK,GAAG,IAAI,EAAE,CAAA;QACpB,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAA;QAEtD,MAAM,WAAW,CAAC,OAAO,EAAE,CAAA;QAE3B,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC3B,CAAC;CACF;AAhBD,kCAgBC;AAED,SAAgB,IAAI;IAClB,OAAO,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAA,mBAAO,EAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAA;AAC5D,CAAC;AAFD,oBAEC;AAED,SAAS,OAAO;IACd,MAAM,OAAO,GAAG,IAAA,qBAAW,EAAC,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAA;IAE1D,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAA;AACvD,CAAC;AAED;;;;GAIG;AACH,SAAgB,UAAU,CAAE,UAAuB;IACjD,IAAI,UAAU,KAAK,KAAK;QACtB,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;IAElC,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,KAAK,GAAa,EAAE,CAAA;IAE1B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,EAAE,CAAC;QAC9B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;QACzC,KAAK,CAAC,IAAI,CAAC,IAAA,mBAAO,EAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAA;IACvC,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;AAC1B,CAAC;AAbD,gCAaC;AAOD,MAAM,IAAI,GAAG,IAAA,mBAAO,EAAC,SAAS,EAAE,gBAAgB,CAAC,CAAA"}
@@ -0,0 +1,9 @@
1
+ import { Connector } from '@toa.io/core';
2
+ /**
3
+ * The explorer process. It hosts the introspection components — the map is read
4
+ * through their own operations — and serves the UI.
5
+ */
6
+ export declare class Explorer extends Connector {
7
+ protected open(): Promise<void>;
8
+ protected dispose(): void;
9
+ }