@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.
- package/LICENSE +202 -0
- package/package.json +25 -0
- package/src/index.ts +144 -0
- package/src/loader.ts +119 -0
- package/src/mounting.test.ts +214 -0
- package/src/mounting.ts +90 -0
- package/src/placementChecks.test.ts +593 -0
- package/src/placementChecks.ts +428 -0
- package/src/registry.ts +63 -0
- package/src/routes.ts +156 -0
- package/src/rules.test.ts +598 -0
- package/src/rules.ts +448 -0
- package/src/slots.test.ts +46 -0
- package/src/slots.ts +58 -0
- package/src/wire.test.ts +631 -0
- package/src/wire.ts +560 -0
- package/src/wireEngine.test.ts +810 -0
- package/src/wireEngine.ts +468 -0
|
@@ -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
|
+
}
|