@bespokeagentics/microdots-host 0.1.0

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,468 @@
1
+ import { Array, Option, Schema as S } from 'effect'
2
+
3
+ import { setMicroDotAttribute } from './loader.ts'
4
+ import type { Wire, WireEnv, WireTransform, WireValueType } from './wire.ts'
5
+
6
+ /**
7
+ * The wire ENGINE — the runtime half of `./wire`'s record.
8
+ *
9
+ * Split in two on purpose. `planWires` is pure: an event in, attribute writes
10
+ * out, and its output TYPE is the design constraint — `{toTag, input, value}`
11
+ * can only ever become an attribute write, so a wire calling a method on a
12
+ * MicroDot is unrepresentable, not merely forbidden. `attachWireEngine` is
13
+ * the DOM adapter that feeds `planWires` from `document` events and performs
14
+ * the writes; a different transport (the cross-origin bridge's `postMessage`
15
+ * envelope, say) feeds the same planner and needs nothing from this file's
16
+ * DOM half — beyond naming the tag that emitted the event, which every
17
+ * transport must carry now that `from` is part of the match. See
18
+ * `wiki/framework/composition/wire-execution-and-transforms.md` for the four properties
19
+ * this keeps.
20
+ */
21
+
22
+ /**
23
+ * The event as the planner sees it: a name, the tag that EMITTED it, and an
24
+ * untrusted payload.
25
+ *
26
+ * `sourceTag` is an `Option` because identifying the emitter is the
27
+ * transport's job and a transport may fail at it — a DOM event whose `target`
28
+ * is not an element, a bridge envelope that names no sender. `None` means "no
29
+ * emitter could be confirmed", which is not the same as "some emitter", and
30
+ * `planWires` treats the difference as load-bearing: see the match site below.
31
+ */
32
+ export type WireEventInput = {
33
+ readonly name: string
34
+ readonly sourceTag: Option.Option<string>
35
+ readonly payload: unknown
36
+ }
37
+
38
+ /** The only thing a wire can produce: one attribute write. */
39
+ export type WireWrite = {
40
+ readonly toTag: string
41
+ readonly input: string
42
+ readonly value: string
43
+ }
44
+
45
+ /**
46
+ * Why a matched wire produced no write. `source` comes first and is the
47
+ * match gate: the event's name matched but the transport could not say which
48
+ * tag emitted it, so no wire's `from` can be confirmed. `env` and `disabled`
49
+ * are the two gates checked before the payload is even looked at; `no-field`
50
+ * covers a payload that is not a record, lacks the wire's field, or carries a
51
+ * value that fails the declared `fieldType` decode.
52
+ */
53
+ export type WireSkipReason = 'source' | 'env' | 'disabled' | 'no-field'
54
+
55
+ /**
56
+ * One matched wire's fate, for tracing. `planned` carries the write;
57
+ * `held` is a condition that did not hold and names the value it wanted;
58
+ * `skipped` names its gate. Distinct tags rather than a nullable write,
59
+ * so a trace formatter cannot conflate "held" with "skipped".
60
+ */
61
+ export type WireOutcome =
62
+ | {
63
+ readonly _tag: 'planned'
64
+ readonly wireId: string
65
+ readonly write: WireWrite
66
+ }
67
+ | { readonly _tag: 'held'; readonly wireId: string; readonly wanted: string }
68
+ | {
69
+ readonly _tag: 'skipped'
70
+ readonly wireId: string
71
+ readonly reason: WireSkipReason
72
+ }
73
+
74
+ /** Everything one event produced: the writes to perform, and why. */
75
+ export type WirePlan = {
76
+ readonly writes: ReadonlyArray<WireWrite>
77
+ readonly outcomes: ReadonlyArray<WireOutcome>
78
+ }
79
+
80
+ // Decoders hoisted once — planWires runs on every brokered event.
81
+ const decodeRecord = S.decodeUnknownOption(S.Record(S.String, S.Unknown))
82
+ const decodeString = S.decodeUnknownOption(S.String)
83
+ const decodeNumber = S.decodeUnknownOption(S.Number)
84
+ const decodeBoolean = S.decodeUnknownOption(S.Boolean)
85
+
86
+ /**
87
+ * `JSON.stringify` has two failure shapes and both mean "this value has no
88
+ * JSON encoding": it RETURNS undefined for undefined/function/symbol, and it
89
+ * THROWS for circular structures and BigInt. The payload is untrusted (a
90
+ * `CustomEvent` detail, a `postMessage` structured clone — which legally
91
+ * carries cycles), and a throw here would escape `planWires` into the DOM
92
+ * listener and kill every other wire's trace on the same event, so both
93
+ * shapes collapse to `Option.none`.
94
+ */
95
+ const safeStringify = (raw: unknown): Option.Option<string> => {
96
+ try {
97
+ return Option.fromNullishOr(JSON.stringify(raw))
98
+ } catch {
99
+ return Option.none()
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Engine-owned encoding — the `String(version)` ruling. The type CHECK
105
+ * compares declared types (`deriveWireState`); once a value passes its
106
+ * declared `fieldType` decode, encoding it onto the attribute string is the
107
+ * engine's job: `number → String(n)`, `boolean → String(b)`, `string`/`enum`
108
+ * pass through, `json → JSON.stringify`. `Option.none` means the value did
109
+ * not match its declaration and nothing is written.
110
+ */
111
+ const encodeFieldValue = (
112
+ fieldType: WireValueType,
113
+ raw: unknown,
114
+ ): Option.Option<string> => {
115
+ switch (fieldType) {
116
+ case 'string':
117
+ case 'enum':
118
+ return decodeString(raw)
119
+ case 'number':
120
+ return Option.map(decodeNumber(raw), value => String(value))
121
+ case 'boolean':
122
+ return Option.map(decodeBoolean(raw), value => String(value))
123
+ case 'json':
124
+ return safeStringify(raw)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Encodes a value whose type the wire does NOT declare — a condition's gate
130
+ * field. The condition compares encoded strings, so any primitive is admitted
131
+ * through the same encodings `encodeFieldValue` would apply.
132
+ */
133
+ const encodeUnknown = (raw: unknown): Option.Option<string> =>
134
+ Option.orElse(decodeString(raw), () =>
135
+ Option.orElse(
136
+ Option.map(decodeNumber(raw), value => String(value)),
137
+ () => Option.map(decodeBoolean(raw), value => String(value)),
138
+ ),
139
+ )
140
+
141
+ /**
142
+ * Applies a wire's transform to an already-encoded source value. Exported so
143
+ * a host's immediate-apply path (the demo host's link toggle re-applying `w1`
144
+ * between quotes) runs THIS code against the recorded transform instead of
145
+ * re-implementing the rows/fallback semantics and drifting from the engine.
146
+ */
147
+ export const applyTransform = (
148
+ transform: WireTransform,
149
+ encoded: string,
150
+ ): string => {
151
+ switch (transform._tag) {
152
+ case 'direct':
153
+ case 'condition':
154
+ return encoded
155
+ case 'lookup': {
156
+ // `Object.hasOwn` before indexing: the key is an untrusted payload
157
+ // value, and a bare `rows[encoded]` with a key like 'constructor'
158
+ // reads an Object.prototype member instead of missing — leaking a
159
+ // Function into the write where the fallback belongs.
160
+ if (!Object.hasOwn(transform.rows, encoded)) {
161
+ return transform.fallback
162
+ }
163
+ return Option.getOrElse(
164
+ Option.fromNullishOr(transform.rows[encoded]),
165
+ () => transform.fallback,
166
+ )
167
+ }
168
+ }
169
+ }
170
+
171
+ const skipped = (wireId: string, reason: WireSkipReason): WireOutcome => ({
172
+ _tag: 'skipped',
173
+ wireId,
174
+ reason,
175
+ })
176
+
177
+ const outcomeFor = (
178
+ wire: Wire,
179
+ payload: unknown,
180
+ context: {
181
+ readonly env: WireEnv
182
+ readonly isEnabled: (wireId: string) => boolean
183
+ },
184
+ ): WireOutcome => {
185
+ if (!Array.contains(wire.envs, context.env)) {
186
+ return skipped(wire.id, 'env')
187
+ }
188
+ if (!context.isEnabled(wire.id)) {
189
+ return skipped(wire.id, 'disabled')
190
+ }
191
+ return Option.match(decodeRecord(payload), {
192
+ onNone: () => skipped(wire.id, 'no-field'),
193
+ onSome: (record): WireOutcome => {
194
+ if (wire.transform._tag === 'condition') {
195
+ const wanted = wire.transform.value
196
+ const gate = wire.transform.field
197
+ const actual = Object.hasOwn(record, gate)
198
+ ? encodeUnknown(record[gate])
199
+ : Option.none()
200
+ const holds = Option.match(actual, {
201
+ onNone: () => false,
202
+ onSome: encoded => encoded === wanted,
203
+ })
204
+ if (!holds) {
205
+ return { _tag: 'held', wireId: wire.id, wanted }
206
+ }
207
+ }
208
+ if (!Object.hasOwn(record, wire.field)) {
209
+ return skipped(wire.id, 'no-field')
210
+ }
211
+ return Option.match(
212
+ encodeFieldValue(wire.fieldType, record[wire.field]),
213
+ {
214
+ onNone: () => skipped(wire.id, 'no-field'),
215
+ onSome: (encoded): WireOutcome => ({
216
+ _tag: 'planned',
217
+ wireId: wire.id,
218
+ write: {
219
+ toTag: wire.to,
220
+ input: wire.input,
221
+ value: applyTransform(wire.transform, encoded),
222
+ },
223
+ }),
224
+ },
225
+ )
226
+ },
227
+ })
228
+ }
229
+
230
+ /**
231
+ * PURE. Matches a wire on BOTH halves of its source: the event NAME and the
232
+ * `from` tag that emitted it. No DOM, no transport: whatever delivered
233
+ * `{name, sourceTag, payload}` — a `document` event's `target` + `detail`, a
234
+ * bridge envelope off `postMessage` — the plan is the same.
235
+ *
236
+ * Phase 4 matched by name alone, on the reasoning that the hand-written
237
+ * listeners it replaced never checked the source element either and a wire's
238
+ * `from` was a claim `deriveWireState` verifies rather than a runtime filter.
239
+ * That held only while every event name had exactly one emitter. Phase 5 ended
240
+ * it: `host-changed` is emitted by BOTH `wiring-canvas` and `pages-rail` with
241
+ * the same `{hostId}` payload, so a name-only match let the Pages rail's host
242
+ * switch drive the hidden Wiring screen (pw12) and the Wiring canvas's drive
243
+ * the Pages panes (pg6/pg7) — panes resolving a host their own picker was not
244
+ * showing, with all four wires deriving `live`. `from` is load-bearing; see
245
+ * the Decision in `wiki/framework/composition/wire-execution-and-transforms.md`.
246
+ *
247
+ * A wire whose source cannot be CONFIRMED must not fire — an unidentifiable
248
+ * emitter is exactly the ambiguity this match exists to remove, so `None`
249
+ * skips (`'source'`) rather than falling back to the old name-only behaviour.
250
+ * It still produces an outcome, because a name-matched wire going quiet is
251
+ * the failure a trace has to be able to show. A wire whose source is
252
+ * confirmed to be a DIFFERENT tag is not matched at all and traces nothing:
253
+ * that event belongs to another dot.
254
+ */
255
+ export const planWires = (
256
+ wires: ReadonlyArray<Wire>,
257
+ event: WireEventInput,
258
+ context: {
259
+ readonly env: WireEnv
260
+ readonly isEnabled: (wireId: string) => boolean
261
+ },
262
+ ): WirePlan => {
263
+ const outcomes = Array.getSomes(
264
+ wires.map((wire): Option.Option<WireOutcome> => {
265
+ if (wire.event !== event.name) {
266
+ return Option.none()
267
+ }
268
+ return Option.match(event.sourceTag, {
269
+ onNone: () => Option.some(skipped(wire.id, 'source')),
270
+ onSome: tag =>
271
+ tag === wire.from
272
+ ? Option.some(outcomeFor(wire, event.payload, context))
273
+ : Option.none(),
274
+ })
275
+ }),
276
+ )
277
+ const writes = Array.getSomes(
278
+ outcomes.map(outcome =>
279
+ outcome._tag === 'planned' ? Option.some(outcome.write) : Option.none(),
280
+ ),
281
+ )
282
+ return { writes, outcomes }
283
+ }
284
+
285
+ /* ============================================================
286
+ The DOM adapter.
287
+ ============================================================ */
288
+
289
+ /**
290
+ * A planned write's fate once the DOM is consulted. `target-unmounted` is a
291
+ * SKIPPED write, deliberately: a brokered nudge is an optimisation and the
292
+ * poll is the floor, so an absent element converges on its own when it next
293
+ * mounts — the trace records that the nudge went nowhere. `written` means
294
+ * EVERY mounted instance of the target tag received the write — a topology
295
+ * may place one tag in two slots, and "the first one in document order" is
296
+ * not what the record says. `write-failed` is the DOM refusing the write
297
+ * itself — a wire `input` the schema admits but `setAttribute` rejects —
298
+ * traced distinctly so a hand-edited record surfaces instead of vanishing.
299
+ */
300
+ export type WireDelivery =
301
+ | { readonly _tag: 'written'; readonly write: WireWrite }
302
+ | { readonly _tag: 'target-unmounted'; readonly write: WireWrite }
303
+ | { readonly _tag: 'write-failed'; readonly write: WireWrite }
304
+ | { readonly _tag: 'held'; readonly wanted: string }
305
+ | { readonly _tag: 'skipped'; readonly reason: WireSkipReason }
306
+
307
+ /**
308
+ * What `onTrace` surfaces: every wire firing with its delivery, and every
309
+ * `watch[]` tap with the raw payload. The host's log formatter consumes the
310
+ * taps; the Wiring screen's Watch mode will consume both.
311
+ */
312
+ export type WireTrace =
313
+ | {
314
+ readonly _tag: 'wire'
315
+ readonly event: string
316
+ readonly wireId: string
317
+ readonly delivery: WireDelivery
318
+ }
319
+ | {
320
+ readonly _tag: 'watch'
321
+ readonly event: string
322
+ readonly payload: unknown
323
+ }
324
+
325
+ export type WireEngineOptions = {
326
+ /** The wires and watch taps to run — a whole `HostTopology` is assignable. */
327
+ readonly topology: {
328
+ readonly wires: ReadonlyArray<Wire>
329
+ readonly watch: ReadonlyArray<{ readonly event: string }>
330
+ }
331
+ readonly env: WireEnv
332
+ readonly onTrace?: (trace: WireTrace) => void
333
+ }
334
+
335
+ export type WireEngine = {
336
+ /** All wires start enabled; this is the runtime kill switch per wire. */
337
+ readonly setEnabled: (wireId: string, enabled: boolean) => void
338
+ readonly detach: () => void
339
+ }
340
+
341
+ /**
342
+ * Listens on `document` for every distinct wire event name AND every watch
343
+ * tap, plans each event with `planWires`, and performs the planned writes —
344
+ * ONLY through `setMicroDotAttribute`, whose no-op guard is what keeps a
345
+ * recurring event from resetting a MicroDot on every tick. No second write
346
+ * path exists, and this adapter does not add one.
347
+ */
348
+ export const attachWireEngine = (options: WireEngineOptions): WireEngine => {
349
+ const { wires, watch } = options.topology
350
+ const onTrace = options.onTrace ?? (() => undefined)
351
+
352
+ // Target resolution is pinned: by tag name, among mounted elements, EVERY
353
+ // instance — not an injection point. `wire.to` is record data the schema
354
+ // cannot prove is a valid selector ('' or '1-foo' would make querySelectorAll
355
+ // throw), and a throw here would abort trace emission for every remaining
356
+ // wire on the same event, so an unresolvable tag resolves to nothing.
357
+ const resolveTargets = (tag: string): ReadonlyArray<Element> => {
358
+ try {
359
+ return Array.fromIterable(document.querySelectorAll(tag))
360
+ } catch {
361
+ return []
362
+ }
363
+ }
364
+
365
+ // Which tag emitted this event. `defineMicroDot` dispatches on the custom
366
+ // element itself with `bubbles: true`
367
+ // (packages/microdots-element/src/defineMicroDot.ts:281-283), so a listener
368
+ // on `document` sees the emitting element as `event.target` and its
369
+ // lowercased `tagName` IS the `from` a wire names.
370
+ //
371
+ // Anything else is `None` — an event dispatched straight at `document` or
372
+ // `window`, a retargeted node that is not an element, a null target on an
373
+ // already-dispatched event. `planWires` will not fire a wire it cannot
374
+ // confirm the source of; guessing here would reintroduce exactly the
375
+ // name-only match this replaced.
376
+ const sourceTagOf = (domEvent: Event): Option.Option<string> => {
377
+ const target = domEvent.target
378
+ return target instanceof Element
379
+ ? Option.some(target.tagName.toLowerCase())
380
+ : Option.none()
381
+ }
382
+
383
+ // Absent means enabled: every wire is live until someone flips it off.
384
+ const disabled = new Set<string>()
385
+ const isEnabled = (wireId: string): boolean => !disabled.has(wireId)
386
+
387
+ const watched = new Set(watch.map(tap => tap.event))
388
+ const eventNames = Array.dedupe([
389
+ ...wires.map(wire => wire.event),
390
+ ...watched,
391
+ ])
392
+
393
+ const deliver = (outcome: WireOutcome): WireDelivery => {
394
+ switch (outcome._tag) {
395
+ case 'planned': {
396
+ const targets = resolveTargets(outcome.write.toTag)
397
+ if (targets.length === 0) {
398
+ return { _tag: 'target-unmounted', write: outcome.write }
399
+ }
400
+ // `wire.input` is record data too: a name `setAttribute` rejects
401
+ // ('' or 'refresh token') throws InvalidCharacterError, and an
402
+ // uncaught throw here would abort trace emission for every remaining
403
+ // wire on the same event — the same reason `resolveTargets` guards
404
+ // its selector.
405
+ try {
406
+ targets.forEach(target => {
407
+ setMicroDotAttribute(
408
+ target,
409
+ outcome.write.input,
410
+ outcome.write.value,
411
+ )
412
+ })
413
+ return { _tag: 'written', write: outcome.write }
414
+ } catch {
415
+ return { _tag: 'write-failed', write: outcome.write }
416
+ }
417
+ }
418
+ case 'held':
419
+ return { _tag: 'held', wanted: outcome.wanted }
420
+ case 'skipped':
421
+ return { _tag: 'skipped', reason: outcome.reason }
422
+ }
423
+ }
424
+
425
+ const listeners = eventNames.map(name => {
426
+ const listener = (domEvent: Event): void => {
427
+ const payload: unknown =
428
+ domEvent instanceof CustomEvent ? domEvent.detail : undefined
429
+ // The tap first: today's hosts log before they act, and a wire's write
430
+ // must never be a precondition of the log line. A watch tap is the
431
+ // HOST'S LOG, not a wire — it fires for every emitter of the name, and
432
+ // an unidentifiable source does not silence it.
433
+ if (watched.has(name)) {
434
+ onTrace({ _tag: 'watch', event: name, payload })
435
+ }
436
+ const plan = planWires(
437
+ wires,
438
+ { name, sourceTag: sourceTagOf(domEvent), payload },
439
+ { env: options.env, isEnabled },
440
+ )
441
+ plan.outcomes.forEach(outcome => {
442
+ onTrace({
443
+ _tag: 'wire',
444
+ event: name,
445
+ wireId: outcome.wireId,
446
+ delivery: deliver(outcome),
447
+ })
448
+ })
449
+ }
450
+ document.addEventListener(name, listener)
451
+ return { name, listener }
452
+ })
453
+
454
+ return {
455
+ setEnabled: (wireId, enabled) => {
456
+ if (enabled) {
457
+ disabled.delete(wireId)
458
+ } else {
459
+ disabled.add(wireId)
460
+ }
461
+ },
462
+ detach: () => {
463
+ listeners.forEach(({ name, listener }) => {
464
+ document.removeEventListener(name, listener)
465
+ })
466
+ },
467
+ }
468
+ }