Type conversions
Typed pins of different types refuse to connect — unless you register a conversion. NumberSource (out: number) won’t wire into TextSink (in: text) until `types.registerConversion("number", "text", String)` is called. Toggle the cast live; the existing edge drops when it disappears.
// Vanilla mount for Type Conversions (G2). DOM toggle + log readout in the editor overlay root.
import { XenolithEditor } from '@xenolithengine/graph-editor'
import { buildTypeConversions } from '@xenolithengine/demo/type-conversions'
export async function mount(target: HTMLElement): Promise<() => void> {
const editor = await XenolithEditor.init(target, { resizeToWindow: false, minimap: false })
const scene = buildTypeConversions(editor)
const panel = document.createElement('div')
panel.setAttribute('data-xeno-panel', '')
panel.style.cssText = 'position:absolute;pointer-events:auto;top:12px;left:12px;display:flex;flex-direction:column;gap:6px;padding:8px;min-width:280px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;'
const btn = document.createElement('button')
const paintBtn = (): void => {
const on = scene.conversionEnabled()
btn.textContent = on ? '✓ Conversion enabled' : 'Enable number → text cast'
btn.style.cssText = `padding:8px 12px;font-size:12px;border-radius:6px;cursor:pointer;border:1px solid ${on ? 'var(--xeno-accent,#FCB400)' : 'var(--xeno-border,#333)'};background:${on ? 'var(--xeno-accent,#FCB400)' : 'transparent'};color:${on ? 'var(--xeno-canvas,#111)' : 'var(--xeno-text,#cfcfcf)'};`
}
btn.addEventListener('click', () => { scene.toggleConversion(); paintBtn() })
paintBtn()
const logEl = document.createElement('div')
logEl.style.cssText = 'font:11px/1.4 Menlo,monospace;max-height:120px;overflow:auto;padding:6px;background:rgba(0,0,0,0.3);border-radius:4px;'
const paintLog = (): void => {
logEl.innerHTML = ''
for (const line of scene.log().slice(-6)) {
const row = document.createElement('div')
row.textContent = line
row.style.color = line.includes('✗') ? '#f88' : line.includes('✓') ? '#9f9' : '#cfcfcf'
logEl.appendChild(row)
}
}
paintLog()
const unsub = scene.onLogChange(paintLog)
panel.append(btn, logEl)
editor.overlayRoot.appendChild(panel)
return () => { unsub(); panel.remove(); editor.destroy() }
} // Type Conversions showcase (G2 — Baklava parity). Two nodes with mismatched typed pins:
// NumberSource (out: number) → TextSink (in: text)
// Without a registered conversion the connection REFUSES to form (typed Blueprint behaviour).
// Call `toggleConversion()` and `editor.types.registerConversion('number', 'text', String)` lifts
// the wall — the connection is accepted AND `setPinLiveValueProvider` displays the converted
// value live on the consumer pin. Toggle off → existing edges are disconnected with a log line
// so the demo shows the round-trip, not just the one-way activation.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { DisconnectEdge, type Edge, type NodeId } from '@xenolithengine/graph-core'
export interface TypeConversionsScene {
/** Whether the `number → text` conversion is currently registered. */
conversionEnabled: () => boolean
/** Flip the conversion on/off; on OFF we also drop any live edge that depended on the cast so
* the user immediately sees the type-mismatch refusal. Returns the new state. */
toggleConversion: () => boolean
/** Append-only log of recent events (connect attempts, refusals, conversion changes). */
log: () => readonly string[]
onLogChange: (cb: () => void) => () => void
}
/** Result of toggling the conversion: tells the host what to append to its log. */
export interface ConversionToggleResult {
/** New enabled state. */
enabled: boolean
/** Number of stale edges that were disconnected (only on disable). */
droppedEdges: number
}
/** Idempotent setup: register the two custom types, install the pin-live-value provider, and
* load the source/sink graph. The `log` of connect events lives in React state on the host. */
export function setupTypeConversions(editor: XenolithEditor): void {
editor.types.register({ id: 'number', color: '#FCB400', shape: 'circle' })
editor.types.register({ id: 'text', color: '#9F69FF', shape: 'circle' })
editor.setPinLiveValueProvider((nodeId, pinKey) => {
if (String(nodeId) !== 'sink' || pinKey !== 'in') return undefined
const sink = editor.graph.getNode('sink' as NodeId)
if (!sink) return undefined
const sinkInPin = sink.pins.find((p) => p.label === 'in' || String(p.id) === 'sink_in')
if (!sinkInPin) return undefined
const incoming = [...editor.graph.edges()].find((e: Edge) => String(e.to.pin) === String(sinkInPin.id))
if (!incoming) return undefined
const src = editor.graph.getNode(incoming.from.node)
if (!src) return undefined
const raw = (src.state as Record<string, unknown>)['value']
try { return editor.types.convert(raw, 'number', 'text') } catch { return raw }
})
editor.setIsValidConnection(() => true)
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id: 'source', type: 'NumberSource', position: { x: 60, y: 80 }, size: { x: 200, y: 120 },
state: { value: 42 },
render: { title: 'NumberSource', category: 'data' },
pins: [{ id: 'source_out', kind: 'data', direction: 'out', type: 'number', multiple: true, label: 'out' }],
widgets: [{ id: 'value', type: 'slider', key: 'value', label: '', pinKey: 'out', min: 0, max: 100, step: 0.5, visibility: 'always' }],
},
{
id: 'sink', type: 'TextSink', position: { x: 420, y: 80 }, size: { x: 220, y: 130 },
state: {},
render: { title: 'TextSink', category: 'utility' },
pins: [{ id: 'sink_in', kind: 'data', direction: 'in', type: 'text', multiple: false, label: 'in' }],
widgets: [{ id: 'shown', type: 'text', key: 'shown', label: '', pinKey: 'in', visibility: 'always' }],
},
],
edges: [],
})
editor.fitView({ padding: 80, maxZoom: 1 })
}
/** Toggle the number→text cast. On enable, registers it. On disable, removes it AND drops any
* live edges that depended on the cast (their type contract is now invalid). */
export function setConversionEnabled(editor: XenolithEditor, enabled: boolean): ConversionToggleResult {
if (enabled) {
editor.types.registerConversion('number', 'text', (v) => String(v))
return { enabled: true, droppedEdges: 0 }
}
editor.types.unregisterConversion('number', 'text')
let dropped = 0
for (const e of [...editor.graph.edges()]) {
const src = editor.graph.getNode(e.from.node)
const dst = editor.graph.getNode(e.to.node)
if (!src || !dst) continue
const srcPin = src.pins.find((p) => String(p.id) === String(e.from.pin))
const dstPin = dst.pins.find((p) => String(p.id) === String(e.to.pin))
if (srcPin?.type === 'number' && dstPin?.type === 'text') {
editor.commandBus.apply(new DisconnectEdge(e.id)); dropped++
}
}
return { enabled: false, droppedEdges: dropped }
}
/** @deprecated Prefer `setupTypeConversions` + `setConversionEnabled`. Kept for vanilla examples. */
export function buildTypeConversions(editor: XenolithEditor): TypeConversionsScene {
// Two custom types — the colours feed pin fill / wire colour automatically through TypeRegistry.
editor.types.register({ id: 'number', color: '#FCB400', shape: 'circle' })
editor.types.register({ id: 'text', color: '#9F69FF', shape: 'circle' })
const lines: string[] = []
const listeners = new Set<() => void>()
const append = (msg: string): void => {
const stamp = new Date().toISOString().slice(11, 19)
lines.push(`[${stamp}] ${msg}`)
if (lines.length > 40) lines.splice(0, lines.length - 40)
for (const cb of listeners) cb()
}
// Pin-live-value provider: for the TextSink's IN pin, find the upstream OUT pin (NumberSource)
// and convert its current widget value through the type registry. The TextSink renders a
// display widget on the IN pin (`visibility: 'always'`) which picks this value up automatically.
editor.setPinLiveValueProvider((nodeId, pinKey) => {
if (String(nodeId) !== 'sink' || pinKey !== 'in') return undefined
// Find the edge feeding the sink's IN pin.
const sink = editor.graph.getNode('sink' as NodeId)
if (!sink) return undefined
const sinkInPin = sink.pins.find((p) => p.label === 'in' || String(p.id) === 'sink_in')
if (!sinkInPin) return undefined
const incoming = [...editor.graph.edges()].find((e: Edge) => String(e.to.pin) === String(sinkInPin.id))
if (!incoming) return undefined
const src = editor.graph.getNode(incoming.from.node)
if (!src) return undefined
// Slider value lives on src.state under the widget's `key`. NumberSource exposes 'value'.
const raw = (src.state as Record<string, unknown>)['value']
try { return editor.types.convert(raw, 'number', 'text') } catch { return raw }
})
// Log every connect attempt — refused (because of type mismatch) and accepted alike.
editor.on('edge:connected', (e) => append(`✓ connected ${String(e.edge.id).slice(0, 6)} (number → text via cast)`))
// No per-widget polling needed: the editor's #propagateToDisplayConsumers walks downstream
// display widgets on every setWidgetValue (=upstream slider move) and the renderer reads
// pinLiveValue on the next render — built-in `text` widget included. The provider above is
// all the wiring this demo owns.
// The editor doesn't fire a "refused" event — but `setIsValidConnection` runs on every attempt,
// so we can log refusals through that. It returns true to defer to the type system.
editor.setIsValidConnection(() => true)
// Load the scene as data — same shape as every other demo.
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id: 'source', type: 'NumberSource', position: { x: 60, y: 80 }, size: { x: 200, y: 120 },
state: { value: 42 },
render: { title: 'NumberSource', category: 'data' },
pins: [{ id: 'source_out', kind: 'data', direction: 'out', type: 'number', multiple: true, label: 'out' }],
widgets: [{ id: 'value', type: 'slider', key: 'value', label: '', pinKey: 'out', min: 0, max: 100, step: 0.5, visibility: 'always' }],
},
{
id: 'sink', type: 'TextSink', position: { x: 420, y: 80 }, size: { x: 220, y: 130 },
state: {},
render: { title: 'TextSink', category: 'utility' },
pins: [{ id: 'sink_in', kind: 'data', direction: 'in', type: 'text', multiple: false, label: 'in' }],
widgets: [{ id: 'shown', type: 'text', key: 'shown', label: '', pinKey: 'in', visibility: 'always' }],
},
],
edges: [],
})
editor.fitView({ padding: 80, maxZoom: 1 })
let enabled = false
const setEnabled = (next: boolean): void => {
if (next === enabled) return
enabled = next
if (enabled) {
editor.types.registerConversion('number', 'text', (v) => String(v))
append('✓ conversion number → text registered — try connecting the pins now')
} else {
editor.types.unregisterConversion('number', 'text')
// Drop any extant edge that depended on the cast — without the conversion the wire is
// semantically invalid; leaving it would be lying about the type contract.
let dropped = 0
for (const e of [...editor.graph.edges()]) {
const src = editor.graph.getNode(e.from.node)
const dst = editor.graph.getNode(e.to.node)
if (!src || !dst) continue
const srcPin = src.pins.find((p) => String(p.id) === String(e.from.pin))
const dstPin = dst.pins.find((p) => String(p.id) === String(e.to.pin))
if (srcPin?.type === 'number' && dstPin?.type === 'text') {
editor.commandBus.apply(new DisconnectEdge(e.id)); dropped++
}
}
append(`✗ conversion removed${dropped > 0 ? ` (dropped ${dropped} stale edge${dropped === 1 ? '' : 's'})` : ''} — try connecting again, it refuses`)
}
}
append('No conversion registered. Try dragging from NumberSource.out to TextSink.in — refused.')
return {
conversionEnabled: () => enabled,
toggleConversion: () => { setEnabled(!enabled); return enabled },
log: () => lines,
onLogChange: (cb) => { listeners.add(cb); return () => { listeners.delete(cb) } },
}
} import { useState } from 'react'
import { XenolithGraph, XenolithPanel, useEditor, useEditorEvent } from '@xenolithengine/graph-react'
import { setupTypeConversions, setConversionEnabled } from '@xenolithengine/demo/type-conversions'
import { DemoStage } from '../Layout.js'
// Canon: toggle + log live in the panel. `setupTypeConversions` runs in `onReady` (loads nodes,
// registers types, installs the pin-live-value provider). The panel owns:
// • the boolean toggle state,
// • the rolling event log (subscribed to `edge:connected` on the editor),
// • the side-effecting call to `setConversionEnabled` that prepends a status line.
const STAMP = (): string => new Date().toISOString().slice(11, 19)
function TypeConversionsPanel() {
const editor = useEditor()
const [enabled, setEnabled] = useState(false)
const [log, setLog] = useState<string[]>([
`[${STAMP()}] No conversion registered. Try dragging from NumberSource.out to TextSink.in — refused.`,
])
const append = (line: string): void => setLog((prev) => [...prev.slice(-39), `[${STAMP()}] ${line}`])
useEditorEvent('edge:connected', (e) => {
append(`✓ connected ${String(e.edge.id).slice(0, 6)} (number → text via cast)`)
})
const toggle = (): void => {
const next = !enabled
const result = setConversionEnabled(editor, next)
setEnabled(result.enabled)
if (result.enabled) {
append('✓ conversion number → text registered — try connecting the pins now')
} else {
const tail = result.droppedEdges > 0
? ` (dropped ${result.droppedEdges} stale edge${result.droppedEdges === 1 ? '' : 's'})`
: ''
append(`✗ conversion removed${tail} — try connecting again, it refuses`)
}
}
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 8, minWidth: 280 }}>
<button onClick={toggle} style={btn(enabled)}>
{enabled ? '✓ Conversion enabled' : 'Enable number → text cast'}
</button>
<div style={{ font: '11px/1.4 Menlo,monospace', maxHeight: 120, overflow: 'auto', padding: 6, background: 'rgba(0,0,0,0.3)', borderRadius: 4 }}>
{log.slice(-6).map((l, i) => (
<div key={i} style={{ color: l.includes('✗') ? '#f88' : l.includes('✓') ? '#9f9' : '#cfcfcf' }}>{l}</div>
))}
</div>
</XenolithPanel>
)
}
/** Island: Type Conversions (G2 — Baklava parity). */
export function TypeConversionsDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={setupTypeConversions}>
<TypeConversionsPanel />
</XenolithGraph>
</DemoStage>
)
}
const btn = (on: boolean): React.CSSProperties => ({
padding: '8px 12px',
fontSize: 12,
borderRadius: 6,
border: `1px solid ${on ? 'var(--xeno-accent, #FCB400)' : 'var(--xeno-border, #333)'}`,
background: on ? 'var(--xeno-accent, #FCB400)' : 'var(--xeno-panel, #1d1d1d)',
color: on ? 'var(--xeno-canvas, #111)' : 'var(--xeno-text, #cfcfcf)',
cursor: 'pointer',
}) // Type Conversions showcase (G2 — Baklava parity). Two nodes with mismatched typed pins:
// NumberSource (out: number) → TextSink (in: text)
// Without a registered conversion the connection REFUSES to form (typed Blueprint behaviour).
// Call `toggleConversion()` and `editor.types.registerConversion('number', 'text', String)` lifts
// the wall — the connection is accepted AND `setPinLiveValueProvider` displays the converted
// value live on the consumer pin. Toggle off → existing edges are disconnected with a log line
// so the demo shows the round-trip, not just the one-way activation.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { DisconnectEdge, type Edge, type NodeId } from '@xenolithengine/graph-core'
export interface TypeConversionsScene {
/** Whether the `number → text` conversion is currently registered. */
conversionEnabled: () => boolean
/** Flip the conversion on/off; on OFF we also drop any live edge that depended on the cast so
* the user immediately sees the type-mismatch refusal. Returns the new state. */
toggleConversion: () => boolean
/** Append-only log of recent events (connect attempts, refusals, conversion changes). */
log: () => readonly string[]
onLogChange: (cb: () => void) => () => void
}
/** Result of toggling the conversion: tells the host what to append to its log. */
export interface ConversionToggleResult {
/** New enabled state. */
enabled: boolean
/** Number of stale edges that were disconnected (only on disable). */
droppedEdges: number
}
/** Idempotent setup: register the two custom types, install the pin-live-value provider, and
* load the source/sink graph. The `log` of connect events lives in React state on the host. */
export function setupTypeConversions(editor: XenolithEditor): void {
editor.types.register({ id: 'number', color: '#FCB400', shape: 'circle' })
editor.types.register({ id: 'text', color: '#9F69FF', shape: 'circle' })
editor.setPinLiveValueProvider((nodeId, pinKey) => {
if (String(nodeId) !== 'sink' || pinKey !== 'in') return undefined
const sink = editor.graph.getNode('sink' as NodeId)
if (!sink) return undefined
const sinkInPin = sink.pins.find((p) => p.label === 'in' || String(p.id) === 'sink_in')
if (!sinkInPin) return undefined
const incoming = [...editor.graph.edges()].find((e: Edge) => String(e.to.pin) === String(sinkInPin.id))
if (!incoming) return undefined
const src = editor.graph.getNode(incoming.from.node)
if (!src) return undefined
const raw = (src.state as Record<string, unknown>)['value']
try { return editor.types.convert(raw, 'number', 'text') } catch { return raw }
})
editor.setIsValidConnection(() => true)
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id: 'source', type: 'NumberSource', position: { x: 60, y: 80 }, size: { x: 200, y: 120 },
state: { value: 42 },
render: { title: 'NumberSource', category: 'data' },
pins: [{ id: 'source_out', kind: 'data', direction: 'out', type: 'number', multiple: true, label: 'out' }],
widgets: [{ id: 'value', type: 'slider', key: 'value', label: '', pinKey: 'out', min: 0, max: 100, step: 0.5, visibility: 'always' }],
},
{
id: 'sink', type: 'TextSink', position: { x: 420, y: 80 }, size: { x: 220, y: 130 },
state: {},
render: { title: 'TextSink', category: 'utility' },
pins: [{ id: 'sink_in', kind: 'data', direction: 'in', type: 'text', multiple: false, label: 'in' }],
widgets: [{ id: 'shown', type: 'text', key: 'shown', label: '', pinKey: 'in', visibility: 'always' }],
},
],
edges: [],
})
editor.fitView({ padding: 80, maxZoom: 1 })
}
/** Toggle the number→text cast. On enable, registers it. On disable, removes it AND drops any
* live edges that depended on the cast (their type contract is now invalid). */
export function setConversionEnabled(editor: XenolithEditor, enabled: boolean): ConversionToggleResult {
if (enabled) {
editor.types.registerConversion('number', 'text', (v) => String(v))
return { enabled: true, droppedEdges: 0 }
}
editor.types.unregisterConversion('number', 'text')
let dropped = 0
for (const e of [...editor.graph.edges()]) {
const src = editor.graph.getNode(e.from.node)
const dst = editor.graph.getNode(e.to.node)
if (!src || !dst) continue
const srcPin = src.pins.find((p) => String(p.id) === String(e.from.pin))
const dstPin = dst.pins.find((p) => String(p.id) === String(e.to.pin))
if (srcPin?.type === 'number' && dstPin?.type === 'text') {
editor.commandBus.apply(new DisconnectEdge(e.id)); dropped++
}
}
return { enabled: false, droppedEdges: dropped }
}
/** @deprecated Prefer `setupTypeConversions` + `setConversionEnabled`. Kept for vanilla examples. */
export function buildTypeConversions(editor: XenolithEditor): TypeConversionsScene {
// Two custom types — the colours feed pin fill / wire colour automatically through TypeRegistry.
editor.types.register({ id: 'number', color: '#FCB400', shape: 'circle' })
editor.types.register({ id: 'text', color: '#9F69FF', shape: 'circle' })
const lines: string[] = []
const listeners = new Set<() => void>()
const append = (msg: string): void => {
const stamp = new Date().toISOString().slice(11, 19)
lines.push(`[${stamp}] ${msg}`)
if (lines.length > 40) lines.splice(0, lines.length - 40)
for (const cb of listeners) cb()
}
// Pin-live-value provider: for the TextSink's IN pin, find the upstream OUT pin (NumberSource)
// and convert its current widget value through the type registry. The TextSink renders a
// display widget on the IN pin (`visibility: 'always'`) which picks this value up automatically.
editor.setPinLiveValueProvider((nodeId, pinKey) => {
if (String(nodeId) !== 'sink' || pinKey !== 'in') return undefined
// Find the edge feeding the sink's IN pin.
const sink = editor.graph.getNode('sink' as NodeId)
if (!sink) return undefined
const sinkInPin = sink.pins.find((p) => p.label === 'in' || String(p.id) === 'sink_in')
if (!sinkInPin) return undefined
const incoming = [...editor.graph.edges()].find((e: Edge) => String(e.to.pin) === String(sinkInPin.id))
if (!incoming) return undefined
const src = editor.graph.getNode(incoming.from.node)
if (!src) return undefined
// Slider value lives on src.state under the widget's `key`. NumberSource exposes 'value'.
const raw = (src.state as Record<string, unknown>)['value']
try { return editor.types.convert(raw, 'number', 'text') } catch { return raw }
})
// Log every connect attempt — refused (because of type mismatch) and accepted alike.
editor.on('edge:connected', (e) => append(`✓ connected ${String(e.edge.id).slice(0, 6)} (number → text via cast)`))
// No per-widget polling needed: the editor's #propagateToDisplayConsumers walks downstream
// display widgets on every setWidgetValue (=upstream slider move) and the renderer reads
// pinLiveValue on the next render — built-in `text` widget included. The provider above is
// all the wiring this demo owns.
// The editor doesn't fire a "refused" event — but `setIsValidConnection` runs on every attempt,
// so we can log refusals through that. It returns true to defer to the type system.
editor.setIsValidConnection(() => true)
// Load the scene as data — same shape as every other demo.
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id: 'source', type: 'NumberSource', position: { x: 60, y: 80 }, size: { x: 200, y: 120 },
state: { value: 42 },
render: { title: 'NumberSource', category: 'data' },
pins: [{ id: 'source_out', kind: 'data', direction: 'out', type: 'number', multiple: true, label: 'out' }],
widgets: [{ id: 'value', type: 'slider', key: 'value', label: '', pinKey: 'out', min: 0, max: 100, step: 0.5, visibility: 'always' }],
},
{
id: 'sink', type: 'TextSink', position: { x: 420, y: 80 }, size: { x: 220, y: 130 },
state: {},
render: { title: 'TextSink', category: 'utility' },
pins: [{ id: 'sink_in', kind: 'data', direction: 'in', type: 'text', multiple: false, label: 'in' }],
widgets: [{ id: 'shown', type: 'text', key: 'shown', label: '', pinKey: 'in', visibility: 'always' }],
},
],
edges: [],
})
editor.fitView({ padding: 80, maxZoom: 1 })
let enabled = false
const setEnabled = (next: boolean): void => {
if (next === enabled) return
enabled = next
if (enabled) {
editor.types.registerConversion('number', 'text', (v) => String(v))
append('✓ conversion number → text registered — try connecting the pins now')
} else {
editor.types.unregisterConversion('number', 'text')
// Drop any extant edge that depended on the cast — without the conversion the wire is
// semantically invalid; leaving it would be lying about the type contract.
let dropped = 0
for (const e of [...editor.graph.edges()]) {
const src = editor.graph.getNode(e.from.node)
const dst = editor.graph.getNode(e.to.node)
if (!src || !dst) continue
const srcPin = src.pins.find((p) => String(p.id) === String(e.from.pin))
const dstPin = dst.pins.find((p) => String(p.id) === String(e.to.pin))
if (srcPin?.type === 'number' && dstPin?.type === 'text') {
editor.commandBus.apply(new DisconnectEdge(e.id)); dropped++
}
}
append(`✗ conversion removed${dropped > 0 ? ` (dropped ${dropped} stale edge${dropped === 1 ? '' : 's'})` : ''} — try connecting again, it refuses`)
}
}
append('No conversion registered. Try dragging from NumberSource.out to TextSink.in — refused.')
return {
conversionEnabled: () => enabled,
toggleConversion: () => { setEnabled(!enabled); return enabled },
log: () => lines,
onLogChange: (cb) => { listeners.add(cb); return () => { listeners.delete(cb) } },
}
}