@bespokeagentics/microdots-host 0.1.0 → 0.1.2

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.
@@ -0,0 +1,137 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { TopologyUnavailable, acquireTopology } from './topologySource.ts'
4
+
5
+ /**
6
+ * The runtime acquisition Phase A introduced, and specifically its FAILURE
7
+ * shapes — which is the whole reason this module exists as more than a `fetch`.
8
+ *
9
+ * Both shells used to decode at module scope, where a malformed topology threw
10
+ * before a line of the shell ran. Moving that to runtime traded a loud import
11
+ * error for a rejected promise, so every way this can go wrong now has to be
12
+ * distinguishable by a caller that wants to RENDER it. A version of this module
13
+ * that returned `undefined`, or an empty topology, on any of the four paths
14
+ * below would produce a blank page with a clean console — this repo's signature
15
+ * failure mode — and every assertion here exists to prevent that regression.
16
+ */
17
+
18
+ const VALID = {
19
+ host: { id: 'test-host', label: 'Test host', ownedInputs: [] },
20
+ routes: [
21
+ {
22
+ path: '/readout',
23
+ label: 'Readout',
24
+ title: 'Readout',
25
+ sectionIds: ['section-readout'],
26
+ mounts: [{ tag: 'readout-view', slotId: 'readout-slot' }],
27
+ },
28
+ ],
29
+ wires: [],
30
+ watch: [],
31
+ }
32
+
33
+ /**
34
+ * Only the CALLABLE half of `fetch`, deliberately: Bun's lib adds
35
+ * `fetch.preconnect`, so `typeof globalThis.fetch` demands a property no stub
36
+ * here needs and none of these tests exercise.
37
+ */
38
+ type FetchStub = (input?: unknown, init?: RequestInit) => Promise<Response>
39
+
40
+ const stubFetch = (impl: FetchStub): void => {
41
+ vi.stubGlobal('fetch', impl)
42
+ }
43
+
44
+ const jsonResponse = (body: unknown): Response =>
45
+ new Response(JSON.stringify(body), {
46
+ status: 200,
47
+ headers: { 'content-type': 'application/json' },
48
+ })
49
+
50
+ afterEach(() => {
51
+ vi.unstubAllGlobals()
52
+ })
53
+
54
+ describe('acquireTopology', () => {
55
+ test('decodes a well-formed document', async () => {
56
+ stubFetch(() => Promise.resolve(jsonResponse(VALID)))
57
+
58
+ const topology = await acquireTopology('/host-topology.json')
59
+
60
+ expect(topology.host.id).toBe('test-host')
61
+ expect(topology.routes[0].path).toBe('/readout')
62
+ })
63
+
64
+ test('never caches — a shell that re-reads a cached copy reports the placement it booted with forever', async () => {
65
+ const seen: Array<RequestInit | undefined> = []
66
+ stubFetch((_input, init) => {
67
+ seen.push(init)
68
+ return Promise.resolve(jsonResponse(VALID))
69
+ })
70
+
71
+ await acquireTopology('/host-topology.json')
72
+
73
+ expect(seen[0]?.cache).toBe('no-store')
74
+ })
75
+
76
+ test('a non-2xx response is unreachable, not an empty topology', async () => {
77
+ stubFetch(() =>
78
+ Promise.resolve(
79
+ new Response('nope', { status: 404, statusText: 'Not Found' }),
80
+ ),
81
+ )
82
+
83
+ const error = await acquireTopology('/gone.json').catch(e => e)
84
+
85
+ expect(error).toBeInstanceOf(TopologyUnavailable)
86
+ expect(error.reason).toBe('unreachable')
87
+ expect(error.url).toBe('/gone.json')
88
+ expect(error.message).toContain('404')
89
+ })
90
+
91
+ test('a fetch that throws is unreachable', async () => {
92
+ stubFetch(() => Promise.reject(new Error('offline')))
93
+
94
+ const error = await acquireTopology('/host-topology.json').catch(e => e)
95
+
96
+ expect(error).toBeInstanceOf(TopologyUnavailable)
97
+ expect(error.reason).toBe('unreachable')
98
+ expect(error.message).toContain('offline')
99
+ })
100
+
101
+ test('a body that is not JSON is undecodable', async () => {
102
+ stubFetch(() =>
103
+ Promise.resolve(
104
+ new Response('<!doctype html><title>a login page</title>', {
105
+ status: 200,
106
+ headers: { 'content-type': 'text/html' },
107
+ }),
108
+ ),
109
+ )
110
+
111
+ const error = await acquireTopology('/host-topology.json').catch(e => e)
112
+
113
+ expect(error).toBeInstanceOf(TopologyUnavailable)
114
+ expect(error.reason).toBe('undecodable')
115
+ })
116
+
117
+ test('valid JSON that is not a topology is undecodable — the case module-scope decoding used to catch at import', async () => {
118
+ stubFetch(() =>
119
+ Promise.resolve(jsonResponse({ definitely: 'not a topology' })),
120
+ )
121
+
122
+ const error = await acquireTopology('/host-topology.json').catch(e => e)
123
+
124
+ expect(error).toBeInstanceOf(TopologyUnavailable)
125
+ expect(error.reason).toBe('undecodable')
126
+ expect(error.message).toContain('not a valid host topology')
127
+ })
128
+
129
+ test('an EMPTY routes array is refused — `routes` is NonEmptyArray so a fallback always exists', async () => {
130
+ stubFetch(() => Promise.resolve(jsonResponse({ ...VALID, routes: [] })))
131
+
132
+ const error = await acquireTopology('/host-topology.json').catch(e => e)
133
+
134
+ expect(error).toBeInstanceOf(TopologyUnavailable)
135
+ expect(error.reason).toBe('undecodable')
136
+ })
137
+ })
@@ -0,0 +1,97 @@
1
+ import { Schema as S } from 'effect'
2
+
3
+ import { HostTopology } from './wire.ts'
4
+
5
+ /**
6
+ * Acquiring a host's topology at RUNTIME instead of importing it at build.
7
+ *
8
+ * Both shells used to open with
9
+ * `const topology = S.decodeUnknownSync(HostTopology)(topologyJson)` at module
10
+ * scope, and that decode was deliberately loud: a malformed topology failed the
11
+ * page at startup rather than routing to a blank section at click time. A
12
+ * fetched document cannot fail that way — the module imports fine and the
13
+ * failure arrives later, on a promise — so the loudness has to be re-paid by
14
+ * the caller as a RENDERED state. That is why this module throws a typed error
15
+ * carrying a `reason` instead of returning `undefined` or an empty topology:
16
+ * an empty success is the shape that produces a blank page with a clean
17
+ * console, which is this repo's signature failure mode.
18
+ *
19
+ * See `wiki/plans/active/wiring-live-placement-on-a-remote-host.md` §The spine,
20
+ * Phase A. Nothing here polls; a caller re-reads by calling again.
21
+ */
22
+
23
+ /** Why a topology could not be produced. The two cases read differently to a human. */
24
+ export type TopologyFailureReason =
25
+ /** The document never arrived: offline, wrong URL, 404, CORS, 500. */
26
+ | 'unreachable'
27
+ /** The document arrived and is not a topology: bad JSON, or it failed the schema. */
28
+ | 'undecodable'
29
+
30
+ /**
31
+ * The declared failure. An `Error` subclass rather than a tagged union because
32
+ * every caller here is plain async/await — `loadMicroDot` and `mountMicroDot`
33
+ * throw too — and a shell's boot path already catches to render.
34
+ */
35
+ export class TopologyUnavailable extends Error {
36
+ readonly reason: TopologyFailureReason
37
+ readonly url: string
38
+ readonly detail: string
39
+
40
+ constructor(reason: TopologyFailureReason, url: string, detail: string) {
41
+ super(
42
+ reason === 'unreachable'
43
+ ? `The topology at ${url} could not be loaded: ${detail}`
44
+ : `The topology at ${url} is not a valid host topology: ${detail}`,
45
+ )
46
+ this.name = 'TopologyUnavailable'
47
+ this.reason = reason
48
+ this.url = url
49
+ this.detail = detail
50
+ }
51
+ }
52
+
53
+ /** Hoisted once — the v4 idiom this repo uses for every decoder. */
54
+ const decodeTopology = S.decodeUnknownSync(HostTopology)
55
+
56
+ const describe = (error: unknown): string =>
57
+ error instanceof Error ? error.message : String(error)
58
+
59
+ /**
60
+ * Fetch one topology document and decode it, or throw {@link TopologyUnavailable}.
61
+ *
62
+ * `cache: 'no-store'` because the whole point of moving this to runtime is that
63
+ * the answer changes: a shell that re-reads a cached copy would report the
64
+ * placement it booted with forever, which looks exactly like the feature not
65
+ * working.
66
+ */
67
+ export const acquireTopology = async (
68
+ url: string,
69
+ ): Promise<typeof HostTopology.Type> => {
70
+ let response: Response
71
+ try {
72
+ response = await fetch(url, { cache: 'no-store' })
73
+ } catch (error) {
74
+ throw new TopologyUnavailable('unreachable', url, describe(error))
75
+ }
76
+
77
+ if (!response.ok) {
78
+ throw new TopologyUnavailable(
79
+ 'unreachable',
80
+ url,
81
+ `${String(response.status)} ${response.statusText}`,
82
+ )
83
+ }
84
+
85
+ let raw: unknown
86
+ try {
87
+ raw = await response.json()
88
+ } catch (error) {
89
+ throw new TopologyUnavailable('undecodable', url, describe(error))
90
+ }
91
+
92
+ try {
93
+ return decodeTopology(raw)
94
+ } catch (error) {
95
+ throw new TopologyUnavailable('undecodable', url, describe(error))
96
+ }
97
+ }
@@ -0,0 +1,95 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { postWireTraces, startTracePublisher } from './tracePublisher.ts'
4
+ import type { WireTrace } from './wireEngine.ts'
5
+
6
+ type FetchStub = (input?: unknown, init?: RequestInit) => Promise<Response>
7
+
8
+ const stubFetch = (impl: FetchStub): void => {
9
+ vi.stubGlobal('fetch', impl)
10
+ }
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals()
14
+ })
15
+
16
+ const written: WireTrace = {
17
+ _tag: 'wire',
18
+ event: 'quote-changed',
19
+ wireId: 'w2',
20
+ delivery: {
21
+ _tag: 'written',
22
+ write: { toTag: 'roster-list', input: 'region', value: 'us' },
23
+ },
24
+ }
25
+
26
+ describe('postWireTraces', () => {
27
+ test('no-ops when url, key, or hostId is empty — fail closed, no fetch', async () => {
28
+ const seen: unknown[] = []
29
+ stubFetch(input => {
30
+ seen.push(input)
31
+ return Promise.resolve(new Response(null, { status: 200 }))
32
+ })
33
+ await postWireTraces({
34
+ url: '',
35
+ hostId: 'demo-host',
36
+ key: 'secret',
37
+ traces: [{ at: 1, heard: written }],
38
+ })
39
+ await postWireTraces({
40
+ url: '/traces',
41
+ hostId: 'demo-host',
42
+ key: '',
43
+ traces: [{ at: 1, heard: written }],
44
+ })
45
+ expect(seen).toHaveLength(0)
46
+ })
47
+
48
+ test('POSTs JSON with no-store', async () => {
49
+ const seen: Array<{ input: unknown; init: RequestInit | undefined }> = []
50
+ stubFetch((input, init) => {
51
+ seen.push({ input, init })
52
+ return Promise.resolve(new Response(null, { status: 200 }))
53
+ })
54
+ await postWireTraces({
55
+ url: 'http://localhost:3107/traces',
56
+ hostId: 'demo-host',
57
+ key: 'secret',
58
+ traces: [{ at: 1, heard: written }],
59
+ })
60
+ expect(seen).toHaveLength(1)
61
+ expect(seen[0]?.init?.method).toBe('POST')
62
+ expect(seen[0]?.init?.cache).toBe('no-store')
63
+ expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
64
+ hostId: 'demo-host',
65
+ key: 'secret',
66
+ traces: [{ at: 1, heard: written }],
67
+ })
68
+ })
69
+ })
70
+
71
+ describe('startTracePublisher', () => {
72
+ test('batches publishes onto one POST', async () => {
73
+ vi.useFakeTimers()
74
+ const seen: unknown[] = []
75
+ stubFetch((_input, init) => {
76
+ seen.push(init?.body)
77
+ return Promise.resolve(new Response(null, { status: 200 }))
78
+ })
79
+ const publisher = startTracePublisher({
80
+ url: 'http://localhost:3107/traces',
81
+ hostId: 'demo-host',
82
+ key: 'secret',
83
+ intervalMs: 50,
84
+ })
85
+ publisher.publish(written)
86
+ publisher.publish(written)
87
+ expect(seen).toHaveLength(0)
88
+ await vi.advanceTimersByTimeAsync(50)
89
+ expect(seen).toHaveLength(1)
90
+ const body: unknown = JSON.parse(String(seen[0]))
91
+ expect(body).toMatchObject({ hostId: 'demo-host', traces: [{}, {}] })
92
+ publisher.stop()
93
+ vi.useRealTimers()
94
+ })
95
+ })
@@ -0,0 +1,117 @@
1
+ import type { WireTrace } from './wireEngine.ts'
2
+
3
+ /**
4
+ * Posting this page's wire-engine traces to a host-document URL
5
+ * (`POST /traces`), so a Wiring screen on another origin can Watch them.
6
+ *
7
+ * Lives here, not in a MicroDot: the host imports no MicroDot code. The body
8
+ * is plain JSON matching wiring's `RecordTracesInput`. Empty `url` or `key`
9
+ * is a silent no-op — fail closed, never a public dump, never a thrown boot.
10
+ */
11
+
12
+ export type PostedWireTrace = {
13
+ readonly at: number
14
+ readonly heard: WireTrace
15
+ }
16
+
17
+ export type PostWireTracesOptions = {
18
+ readonly url: string
19
+ readonly hostId: string
20
+ readonly key: string
21
+ readonly traces: ReadonlyArray<PostedWireTrace>
22
+ }
23
+
24
+ const serialisable = (
25
+ traces: ReadonlyArray<PostedWireTrace>,
26
+ ): ReadonlyArray<PostedWireTrace> =>
27
+ traces.filter(trace => {
28
+ try {
29
+ JSON.stringify(trace.heard)
30
+ return true
31
+ } catch {
32
+ return false
33
+ }
34
+ })
35
+
36
+ /**
37
+ * One POST. Callers that cannot import the wiring contract use this.
38
+ * Failures are swallowed — a down ring must not take the host down with it.
39
+ */
40
+ export const postWireTraces = async (
41
+ options: PostWireTracesOptions,
42
+ ): Promise<void> => {
43
+ if (options.url === '' || options.key === '' || options.hostId === '') return
44
+ const traces = serialisable(options.traces)
45
+ if (traces.length === 0) return
46
+ try {
47
+ await fetch(options.url, {
48
+ method: 'POST',
49
+ cache: 'no-store',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ hostId: options.hostId,
53
+ key: options.key,
54
+ traces,
55
+ }),
56
+ })
57
+ } catch {
58
+ return
59
+ }
60
+ }
61
+
62
+ export type TracePublisher = {
63
+ readonly publish: (trace: WireTrace) => void
64
+ readonly stop: () => void
65
+ }
66
+
67
+ export type TracePublisherOptions = {
68
+ readonly url: string
69
+ readonly hostId: string
70
+ readonly key: string
71
+ readonly intervalMs?: number
72
+ }
73
+
74
+ /**
75
+ * Batches `onTrace` firings and POSTs them. A live host can fire many wires
76
+ * on one event; one POST per firing would be a denial of the ring.
77
+ */
78
+ export const startTracePublisher = (
79
+ options: TracePublisherOptions,
80
+ ): TracePublisher => {
81
+ const intervalMs = options.intervalMs ?? 200
82
+ const pending: Array<PostedWireTrace> = []
83
+ let timer: ReturnType<typeof setTimeout> | undefined
84
+ let stopped = false
85
+
86
+ const flush = (): void => {
87
+ timer = undefined
88
+ if (stopped || pending.length === 0) return
89
+ const batch = pending.splice(0, pending.length)
90
+ void postWireTraces({
91
+ url: options.url,
92
+ hostId: options.hostId,
93
+ key: options.key,
94
+ traces: batch,
95
+ })
96
+ }
97
+
98
+ return {
99
+ publish: trace => {
100
+ if (stopped) return
101
+ if (options.url === '' || options.key === '' || options.hostId === '') {
102
+ return
103
+ }
104
+ pending.push({ at: Date.now(), heard: trace })
105
+ if (timer !== undefined) return
106
+ timer = setTimeout(flush, intervalMs)
107
+ },
108
+ stop: () => {
109
+ stopped = true
110
+ if (timer !== undefined) {
111
+ clearTimeout(timer)
112
+ timer = undefined
113
+ }
114
+ pending.length = 0
115
+ },
116
+ }
117
+ }
package/src/wire.test.ts CHANGED
@@ -62,7 +62,7 @@ const manifestEvent = (
62
62
  })
63
63
 
64
64
  const sourceTag = (event: ManifestEvent): ManifestTag => ({
65
- tag: 'price-ticker',
65
+ tag: 'readout-view',
66
66
  attributes: [],
67
67
  events: [event],
68
68
  })
@@ -87,11 +87,11 @@ const tableOf = (
87
87
  ): RouteTable => ({ routes: [first, ...rest], fallback: first })
88
88
 
89
89
  /** Both ends on one route: the placement half of `live`. */
90
- const SHARED_TABLE = tableOf(route('/all', ['price-ticker', 'fleet-health']))
90
+ const SHARED_TABLE = tableOf(route('/all', ['readout-view', 'fleet-health']))
91
91
 
92
92
  /** Both ends placed, but never together. */
93
93
  const SPLIT_TABLE = tableOf(
94
- route('/price', ['price-ticker']),
94
+ route('/price', ['readout-view']),
95
95
  route('/fleet', ['fleet-health']),
96
96
  )
97
97
 
@@ -102,7 +102,7 @@ const STRING_MANIFESTS: ReadonlyArray<ManifestTag> = [
102
102
 
103
103
  const directWire: Wire = {
104
104
  id: 'w1',
105
- from: 'price-ticker',
105
+ from: 'readout-view',
106
106
  event: 'quote-changed',
107
107
  field: 'symbol',
108
108
  fieldType: 'string',
@@ -355,7 +355,7 @@ describe('deriveWireState', () => {
355
355
  topologyRouteTable
356
356
  ============================================================ */
357
357
 
358
- const PRICE_ROUTE = route('/price', ['price-ticker'])
358
+ const PRICE_ROUTE = route('/price', ['readout-view'])
359
359
  const FLEET_ROUTE = route('/fleet', ['fleet-health'])
360
360
 
361
361
  const topologyWith = (overview: HostTopology['overview']): HostTopology => ({
@@ -383,11 +383,11 @@ describe('topologyRouteTable', () => {
383
383
  expect(table.fallback.path).toBe('/')
384
384
  expect(table.fallback.sectionIds).toEqual([
385
385
  'intro-section',
386
- 'price-ticker-section',
386
+ 'readout-view-section',
387
387
  'fleet-health-section',
388
388
  ])
389
389
  expect(table.fallback.mounts.map(mount => mount.tag)).toEqual([
390
- 'price-ticker',
390
+ 'readout-view',
391
391
  'fleet-health',
392
392
  ])
393
393
  // The derived overview leads the table, so `/` wins route matching.
@@ -430,7 +430,7 @@ describe('topologyRouteTable', () => {
430
430
  // The fallback is a route too — a rule-carried tag must be findable there.
431
431
  expect(table.fallback.mounts.map(mount => mount.tag)).toEqual([
432
432
  'promo-banner',
433
- 'price-ticker',
433
+ 'readout-view',
434
434
  ])
435
435
  })
436
436
 
@@ -444,7 +444,7 @@ describe('topologyRouteTable', () => {
444
444
  label: 'Everywhere',
445
445
  pattern: '/*',
446
446
  placements: [
447
- { tag: 'price-ticker', slotId: 'price-ticker-slot' },
447
+ { tag: 'readout-view', slotId: 'readout-view-slot' },
448
448
  { tag: 'fleet-health', slotId: 'fleet-health-slot' },
449
449
  ],
450
450
  },
@@ -543,8 +543,10 @@ const topologyOnDisk = (relativePath: string): unknown =>
543
543
  // decode, would change what every host mounts without either file being
544
544
  // touched. `toEqual(Option.some(parsed))` pins the decode as the identity.
545
545
  describe('the shipped host-topology.json files', () => {
546
- test('the demo host topology still decodes, unchanged', () => {
547
- const parsed = topologyOnDisk('../../../apps/host/host-topology.json')
546
+ test('the pitch-deck host topology still decodes, unchanged', () => {
547
+ const parsed = topologyOnDisk(
548
+ '../../../apps/pitch-deck-website/host/host-topology.json',
549
+ )
548
550
  expect(decodeTopology(parsed)).toEqual(Option.some(parsed))
549
551
  })
550
552