@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.
Files changed (116) hide show
  1. package/LICENSE +22 -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/operations/tsconfig.tsbuildinfo +1 -0
  10. package/components/introspection.edges/source/lib/types.ts +33 -0
  11. package/components/introspection.edges/source/merge.ts +26 -0
  12. package/components/introspection.edges/tsconfig.json +9 -0
  13. package/components/introspection.nodes/manifest.toa.yaml +49 -0
  14. package/components/introspection.nodes/operations/lib/types.d.ts +16 -0
  15. package/components/introspection.nodes/operations/lib/types.js +3 -0
  16. package/components/introspection.nodes/operations/lib/types.js.map +1 -0
  17. package/components/introspection.nodes/operations/merge.d.ts +9 -0
  18. package/components/introspection.nodes/operations/merge.js +21 -0
  19. package/components/introspection.nodes/operations/merge.js.map +1 -0
  20. package/components/introspection.nodes/operations/tsconfig.tsbuildinfo +1 -0
  21. package/components/introspection.nodes/source/lib/types.ts +18 -0
  22. package/components/introspection.nodes/source/merge.ts +21 -0
  23. package/components/introspection.nodes/tsconfig.json +9 -0
  24. package/cucumber.js +8 -0
  25. package/features/calls.feature +123 -0
  26. package/features/nodes.feature +70 -0
  27. package/features/samples.feature +75 -0
  28. package/features/shutdown.feature +37 -0
  29. package/features/site/_app/immutable/asset.js +1 -0
  30. package/features/site/index.html +6 -0
  31. package/features/steps/Map.ts +151 -0
  32. package/features/steps/UI.ts +88 -0
  33. package/features/steps/components/probe.discreet/manifest.toa.yaml +12 -0
  34. package/features/steps/components/probe.discreet/operations/ping.js +7 -0
  35. package/features/steps/components/probe.quiet/manifest.toa.yaml +11 -0
  36. package/features/steps/components/probe.quiet/operations/ping.js +7 -0
  37. package/features/steps/components/probe.source/manifest.toa.yaml +27 -0
  38. package/features/steps/components/probe.source/operations/crash.js +7 -0
  39. package/features/steps/components/probe.source/operations/fail.js +12 -0
  40. package/features/steps/components/probe.source/operations/relay.js +7 -0
  41. package/features/steps/components/probe.target/manifest.toa.yaml +24 -0
  42. package/features/steps/components/probe.target/operations/compute.js +7 -0
  43. package/features/steps/components/probe.target/operations/count.js +9 -0
  44. package/features/steps/config.ts +20 -0
  45. package/features/steps/tsconfig.json +9 -0
  46. package/features/ui.feature +85 -0
  47. package/package.json +32 -0
  48. package/readme.md +135 -0
  49. package/schemas/annotation.cos.yaml +21 -0
  50. package/schemas/declaration.cos.yaml +2 -0
  51. package/source/Composition.ts +61 -0
  52. package/source/Explorer.ts +16 -0
  53. package/source/Factory.ts +119 -0
  54. package/source/Reporter.ts +210 -0
  55. package/source/Tenant.ts +47 -0
  56. package/source/UI.ts +197 -0
  57. package/source/annotation.ts +83 -0
  58. package/source/const.ts +32 -0
  59. package/source/describe.ts +68 -0
  60. package/source/extension.ts +45 -0
  61. package/source/index.ts +7 -0
  62. package/source/keys.ts +0 -0
  63. package/source/manifest.ts +19 -0
  64. package/source/model.ts +82 -0
  65. package/source/sample.ts +49 -0
  66. package/source/schemas.ts +10 -0
  67. package/transpiled/Composition.d.ts +21 -0
  68. package/transpiled/Composition.js +48 -0
  69. package/transpiled/Composition.js.map +1 -0
  70. package/transpiled/Explorer.d.ts +9 -0
  71. package/transpiled/Explorer.js +19 -0
  72. package/transpiled/Explorer.js.map +1 -0
  73. package/transpiled/Factory.d.ts +22 -0
  74. package/transpiled/Factory.js +93 -0
  75. package/transpiled/Factory.js.map +1 -0
  76. package/transpiled/Reporter.d.ts +44 -0
  77. package/transpiled/Reporter.js +187 -0
  78. package/transpiled/Reporter.js.map +1 -0
  79. package/transpiled/Tenant.d.ts +18 -0
  80. package/transpiled/Tenant.js +40 -0
  81. package/transpiled/Tenant.js.map +1 -0
  82. package/transpiled/UI.d.ts +25 -0
  83. package/transpiled/UI.js +186 -0
  84. package/transpiled/UI.js.map +1 -0
  85. package/transpiled/annotation.d.ts +40 -0
  86. package/transpiled/annotation.js +46 -0
  87. package/transpiled/annotation.js.map +1 -0
  88. package/transpiled/const.d.ts +23 -0
  89. package/transpiled/const.js +27 -0
  90. package/transpiled/const.js.map +1 -0
  91. package/transpiled/describe.d.ts +9 -0
  92. package/transpiled/describe.js +64 -0
  93. package/transpiled/describe.js.map +1 -0
  94. package/transpiled/extension.d.ts +11 -0
  95. package/transpiled/extension.js +64 -0
  96. package/transpiled/extension.js.map +1 -0
  97. package/transpiled/index.d.ts +6 -0
  98. package/transpiled/index.js +25 -0
  99. package/transpiled/index.js.map +1 -0
  100. package/transpiled/keys.d.ts +9 -0
  101. package/transpiled/keys.js +0 -0
  102. package/transpiled/keys.js.map +1 -0
  103. package/transpiled/manifest.d.ts +9 -0
  104. package/transpiled/manifest.js +43 -0
  105. package/transpiled/manifest.js.map +1 -0
  106. package/transpiled/model.d.ts +71 -0
  107. package/transpiled/model.js +3 -0
  108. package/transpiled/model.js.map +1 -0
  109. package/transpiled/sample.d.ts +7 -0
  110. package/transpiled/sample.js +44 -0
  111. package/transpiled/sample.js.map +1 -0
  112. package/transpiled/schemas.d.ts +4 -0
  113. package/transpiled/schemas.js +13 -0
  114. package/transpiled/schemas.js.map +1 -0
  115. package/transpiled/tsconfig.tsbuildinfo +1 -0
  116. package/tsconfig.json +12 -0
@@ -0,0 +1,2 @@
1
+ # manifest.toa.yaml introspection declaration
2
+ samples: boolean
@@ -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
+ }
@@ -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
+ }