Skip to main content

ftui_web/
sdk_event_model.rs

1//! Typed host-event and error model for the FrankenTermJS SDK, with a
2//! TypeScript-definition generator kept in lockstep (bd-2vr05.9.2).
3//!
4//! # Why this exists
5//!
6//! The FrankenTermJS browser SDK promises host integrators a *stable, typed*
7//! event and error surface (see [`docs/spec/frankenterm-web-api.md`] — the
8//! canonical contract). xterm.js integrators reach for `term.onData`,
9//! `term.onKey`, typed event payloads, and predictable error shapes; the
10//! FrankenTermJS replacement must offer the same with a single, versioned
11//! taxonomy.
12//!
13//! The original `apiContract()` constants from `bd-2vr05.9.1` shipped inside the
14//! `frankenterm-web` WASM-packaging crate, which is **transient** and not
15//! vendored in this checkout. This module is therefore the **durable, in-tree
16//! source of truth** for the event/error model: pure Rust enums that mirror the
17//! documented taxonomy, plus a deterministic [`typescript_definitions`]
18//! generator so the runtime payloads and the shipped `.d.ts` can never drift.
19//!
20//! ```text
21//!  HostEventClass / SdkErrorKind (Rust, single source of truth)
22//!        │
23//!        ├── as_str()/code()  ──> runtime JSONL payloads (host-observable)
24//!        └── typescript_definitions() ──> sdk/frankenterm-js-events.d.ts (golden)
25//! ```
26//!
27//! ## Determinism
28//!
29//! Everything here is pure: the taxonomy order, the generated `.d.ts`, and the
30//! buffer-policy snapshot are byte-for-byte reproducible for a fixed crate
31//! version. The conformance harness
32//! (`crates/ftui-web/tests/frankenterm_js_sdk_contract_compat.rs`) asserts the
33//! Rust model and the committed `.d.ts` stay in lockstep.
34
35use core::fmt;
36
37/// Event-schema version of the host-observable taxonomy. Mirrors
38/// `eventSchemaVersion` in the web API contract.
39pub const EVENT_SCHEMA_VERSION: &str = "1.0.0";
40
41/// Canonical, host-observable event classes (`eventSchemaVersion = 1.0.0`).
42///
43/// The wire strings and their order match the **Host Event Taxonomy** section of
44/// `docs/spec/frankenterm-web-api.md` exactly. The list is monotonically sorted
45/// by wire string, which the contract guarantees for `apiContract().eventTypes`.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum HostEventClass {
48    /// `attach.transition` — websocket attach state-machine transitions.
49    AttachTransition,
50    /// `input.accessibility` — assistive-technology originated input.
51    InputAccessibility,
52    /// `input.composition` — IME composition lifecycle.
53    InputComposition,
54    /// `input.composition_trace` — IME rewrite/diagnostic trace.
55    InputCompositionTrace,
56    /// `input.focus` — focus gained/lost.
57    InputFocus,
58    /// `input.key` — key press/release.
59    InputKey,
60    /// `input.mouse` — mouse button/motion.
61    InputMouse,
62    /// `input.paste` — bracketed paste payload.
63    InputPaste,
64    /// `input.touch` — touch input.
65    InputTouch,
66    /// `input.vt_bytes` — raw VT byte chunk forwarded to the PTY.
67    InputVtBytes,
68    /// `input.wheel` — wheel/scroll input.
69    InputWheel,
70    /// `terminal.progress` — OSC 9;4 progress signal.
71    TerminalProgress,
72    /// `terminal.reply_bytes` — terminal-generated reply bytes.
73    TerminalReplyBytes,
74    /// `ui.accessibility_announcement` — live-region announcement for the host.
75    UiAccessibilityAnnouncement,
76    /// `ui.link_click` — host-actionable hyperlink activation.
77    UiLinkClick,
78}
79
80impl HostEventClass {
81    /// All event classes in canonical (sorted) order.
82    pub const ALL: [HostEventClass; 15] = [
83        HostEventClass::AttachTransition,
84        HostEventClass::InputAccessibility,
85        HostEventClass::InputComposition,
86        HostEventClass::InputCompositionTrace,
87        HostEventClass::InputFocus,
88        HostEventClass::InputKey,
89        HostEventClass::InputMouse,
90        HostEventClass::InputPaste,
91        HostEventClass::InputTouch,
92        HostEventClass::InputVtBytes,
93        HostEventClass::InputWheel,
94        HostEventClass::TerminalProgress,
95        HostEventClass::TerminalReplyBytes,
96        HostEventClass::UiAccessibilityAnnouncement,
97        HostEventClass::UiLinkClick,
98    ];
99
100    /// Canonical dotted wire string (stable for the `1.x` schema line).
101    #[must_use]
102    pub const fn as_str(self) -> &'static str {
103        match self {
104            HostEventClass::AttachTransition => "attach.transition",
105            HostEventClass::InputAccessibility => "input.accessibility",
106            HostEventClass::InputComposition => "input.composition",
107            HostEventClass::InputCompositionTrace => "input.composition_trace",
108            HostEventClass::InputFocus => "input.focus",
109            HostEventClass::InputKey => "input.key",
110            HostEventClass::InputMouse => "input.mouse",
111            HostEventClass::InputPaste => "input.paste",
112            HostEventClass::InputTouch => "input.touch",
113            HostEventClass::InputVtBytes => "input.vt_bytes",
114            HostEventClass::InputWheel => "input.wheel",
115            HostEventClass::TerminalProgress => "terminal.progress",
116            HostEventClass::TerminalReplyBytes => "terminal.reply_bytes",
117            HostEventClass::UiAccessibilityAnnouncement => "ui.accessibility_announcement",
118            HostEventClass::UiLinkClick => "ui.link_click",
119        }
120    }
121
122    /// Namespace prefix (`attach` / `input` / `terminal` / `ui`).
123    #[must_use]
124    pub fn namespace(self) -> &'static str {
125        let s = self.as_str();
126        match s.split_once('.') {
127            Some((ns, _)) => ns,
128            None => s,
129        }
130    }
131
132    /// Parse a wire string back to its class (inverse of [`Self::as_str`]).
133    #[must_use]
134    pub fn from_wire(value: &str) -> Option<Self> {
135        Self::ALL.into_iter().find(|c| c.as_str() == value)
136    }
137}
138
139impl fmt::Display for HostEventClass {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.write_str(self.as_str())
142    }
143}
144
145/// Typed SDK error taxonomy.
146///
147/// Consolidates the contract's documented rejection/policy surfaces (malformed
148/// progress signals, blocked links, clipboard bounds, capability gating,
149/// bounded-queue drops, input parsing, and attach/protocol faults) into a
150/// stable, host-consumable set of error codes. Each variant maps to a single
151/// dotted [`Self::code`] that is stable for the `1.x` line.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
153pub enum SdkErrorKind {
154    /// `attach.protocol_error` — websocket attach/lifecycle protocol fault.
155    AttachProtocol,
156    /// `capability.unsupported` — requested feature/renderer not advertised.
157    CapabilityUnsupported,
158    /// `clipboard.disabled` — copy/paste disabled by host clipboard policy.
159    ClipboardDisabled,
160    /// `clipboard.paste_too_large` — paste payload exceeds `maxPasteBytes`.
161    ClipboardPasteTooLarge,
162    /// `input.parse` — malformed host-encoded input payload.
163    InputParse,
164    /// `queue.overflow` — a bounded host-drained queue dropped its oldest entry.
165    QueueOverflow,
166    /// `terminal.progress.malformed` — malformed OSC 9;4 progress payload.
167    TerminalProgressMalformed,
168    /// `ui.link.blocked` — hyperlink activation denied by link-open policy.
169    UiLinkBlocked,
170}
171
172impl SdkErrorKind {
173    /// All error kinds in canonical (sorted-by-code) order.
174    pub const ALL: [SdkErrorKind; 8] = [
175        SdkErrorKind::AttachProtocol,
176        SdkErrorKind::CapabilityUnsupported,
177        SdkErrorKind::ClipboardDisabled,
178        SdkErrorKind::ClipboardPasteTooLarge,
179        SdkErrorKind::InputParse,
180        SdkErrorKind::QueueOverflow,
181        SdkErrorKind::TerminalProgressMalformed,
182        SdkErrorKind::UiLinkBlocked,
183    ];
184
185    /// Stable dotted error code.
186    #[must_use]
187    pub const fn code(self) -> &'static str {
188        match self {
189            SdkErrorKind::AttachProtocol => "attach.protocol_error",
190            SdkErrorKind::CapabilityUnsupported => "capability.unsupported",
191            SdkErrorKind::ClipboardDisabled => "clipboard.disabled",
192            SdkErrorKind::ClipboardPasteTooLarge => "clipboard.paste_too_large",
193            SdkErrorKind::InputParse => "input.parse",
194            SdkErrorKind::QueueOverflow => "queue.overflow",
195            SdkErrorKind::TerminalProgressMalformed => "terminal.progress.malformed",
196            SdkErrorKind::UiLinkBlocked => "ui.link.blocked",
197        }
198    }
199
200    /// Stable, human-readable summary of the error condition.
201    #[must_use]
202    pub const fn summary(self) -> &'static str {
203        match self {
204            SdkErrorKind::AttachProtocol => "websocket attach protocol error",
205            SdkErrorKind::CapabilityUnsupported => {
206                "requested capability is not advertised by the engine"
207            }
208            SdkErrorKind::ClipboardDisabled => "clipboard access disabled by host policy",
209            SdkErrorKind::ClipboardPasteTooLarge => {
210                "paste payload exceeds the maximum allowed size"
211            }
212            SdkErrorKind::InputParse => "host-encoded input payload could not be parsed",
213            SdkErrorKind::QueueOverflow => "a bounded host queue dropped its oldest entry",
214            SdkErrorKind::TerminalProgressMalformed => "malformed OSC 9;4 progress payload",
215            SdkErrorKind::UiLinkBlocked => "hyperlink activation blocked by link-open policy",
216        }
217    }
218
219    /// Parse a wire code back to its kind (inverse of [`Self::code`]).
220    #[must_use]
221    pub fn from_code(value: &str) -> Option<Self> {
222        Self::ALL.into_iter().find(|k| k.code() == value)
223    }
224}
225
226impl fmt::Display for SdkErrorKind {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        f.write_str(self.code())
229    }
230}
231
232/// Bounded host-drained queue policy (drop-oldest), mirroring the
233/// **Bounded Buffering Contract** in `docs/spec/frankenterm-web-api.md`.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct EventBufferPolicy {
236    /// `encoded_inputs_queue_max`.
237    pub encoded_inputs_queue_max: u32,
238    /// `encoded_input_bytes_queue_max`.
239    pub encoded_input_bytes_queue_max: u32,
240    /// `ime_trace_queue_max`.
241    pub ime_trace_queue_max: u32,
242    /// `link_click_queue_max`.
243    pub link_click_queue_max: u32,
244    /// `accessibility_announcement_queue_max`.
245    pub accessibility_announcement_queue_max: u32,
246    /// `attach_transition_queue_max`.
247    pub attach_transition_queue_max: u32,
248    /// `event_subscription_queue_default_max`.
249    pub event_subscription_queue_default_max: u32,
250    /// `event_subscription_queue_configurable_max` (upper bound for the above).
251    pub event_subscription_queue_configurable_max: u32,
252    /// `event_subscription_registry_max`.
253    pub event_subscription_registry_max: u32,
254}
255
256impl EventBufferPolicy {
257    /// The documented default policy (`eventSchemaVersion = 1.0.0`).
258    pub const DEFAULT: EventBufferPolicy = EventBufferPolicy {
259        encoded_inputs_queue_max: 4096,
260        encoded_input_bytes_queue_max: 4096,
261        ime_trace_queue_max: 2048,
262        link_click_queue_max: 2048,
263        accessibility_announcement_queue_max: 64,
264        attach_transition_queue_max: 512,
265        event_subscription_queue_default_max: 512,
266        event_subscription_queue_configurable_max: 8192,
267        event_subscription_registry_max: 256,
268    };
269
270    /// Stable `(wire_name, value)` entries in declaration order, for
271    /// serialization and contract validation.
272    #[must_use]
273    pub fn entries(self) -> [(&'static str, u32); 9] {
274        [
275            ("encoded_inputs_queue_max", self.encoded_inputs_queue_max),
276            (
277                "encoded_input_bytes_queue_max",
278                self.encoded_input_bytes_queue_max,
279            ),
280            ("ime_trace_queue_max", self.ime_trace_queue_max),
281            ("link_click_queue_max", self.link_click_queue_max),
282            (
283                "accessibility_announcement_queue_max",
284                self.accessibility_announcement_queue_max,
285            ),
286            (
287                "attach_transition_queue_max",
288                self.attach_transition_queue_max,
289            ),
290            (
291                "event_subscription_queue_default_max",
292                self.event_subscription_queue_default_max,
293            ),
294            (
295                "event_subscription_queue_configurable_max",
296                self.event_subscription_queue_configurable_max,
297            ),
298            (
299                "event_subscription_registry_max",
300                self.event_subscription_registry_max,
301            ),
302        ]
303    }
304}
305
306/// Convert a snake_case wire name to lowerCamelCase for the TypeScript surface.
307fn to_lower_camel(snake: &str) -> String {
308    let mut out = String::with_capacity(snake.len());
309    let mut upper_next = false;
310    for ch in snake.chars() {
311        if ch == '_' {
312            upper_next = true;
313        } else if upper_next {
314            out.extend(ch.to_uppercase());
315            upper_next = false;
316        } else {
317            out.push(ch);
318        }
319    }
320    out
321}
322
323/// Generate the TypeScript definition file for the SDK event/error model.
324///
325/// Output is deterministic and is the single source for the committed
326/// `sdk/frankenterm-js-events.d.ts`. The conformance harness asserts the
327/// committed file matches this output exactly, so the runtime model and the
328/// shipped types can never drift.
329#[must_use]
330pub fn typescript_definitions() -> String {
331    let mut out = String::new();
332    out.push_str(
333        "// AUTO-GENERATED — do not edit by hand.\n\
334         // Source of truth: crates/ftui-web/src/sdk_event_model.rs\n\
335         // (mirrors docs/spec/frankenterm-web-api.md). Regenerate with:\n\
336         //   FTUI_SDK_DTS_BLESS=1 cargo test -p ftui-web \\\n\
337         //     --test frankenterm_js_sdk_contract_compat\n\
338         //\n\
339         // FrankenTermJS SDK event/error model (bd-2vr05.9.2).\n\n",
340    );
341
342    out.push_str(&format!(
343        "export const EVENT_SCHEMA_VERSION = \"{EVENT_SCHEMA_VERSION}\";\n\n"
344    ));
345
346    out.push_str("/** Canonical host-observable event classes. */\n");
347    out.push_str("export type HostEventClass =\n");
348    for (idx, class) in HostEventClass::ALL.iter().enumerate() {
349        let last = idx + 1 == HostEventClass::ALL.len();
350        let term = if last { ";" } else { "" };
351        out.push_str(&format!("  | \"{}\"{term}\n", class.as_str()));
352    }
353    out.push('\n');
354
355    out.push_str("/** A single host-observable event. */\n");
356    out.push_str("export interface HostEvent {\n");
357    out.push_str("  readonly type: HostEventClass;\n");
358    out.push_str("  /** Globally monotonic sequence number. */\n");
359    out.push_str("  readonly seq: number;\n");
360    out.push_str("  /** Event-class-specific payload. */\n");
361    out.push_str("  readonly payload?: unknown;\n");
362    out.push_str("}\n\n");
363
364    out.push_str("/** Stable SDK error codes. */\n");
365    out.push_str("export type SdkErrorCode =\n");
366    for (idx, kind) in SdkErrorKind::ALL.iter().enumerate() {
367        let last = idx + 1 == SdkErrorKind::ALL.len();
368        let term = if last { ";" } else { "" };
369        out.push_str(&format!("  | \"{}\"{term}\n", kind.code()));
370    }
371    out.push('\n');
372
373    out.push_str("/** A typed SDK error. */\n");
374    out.push_str("export interface SdkError {\n");
375    out.push_str("  readonly code: SdkErrorCode;\n");
376    out.push_str("  readonly message: string;\n");
377    out.push_str("}\n\n");
378
379    out.push_str("/** Bounded host-queue policy (drop-oldest). */\n");
380    out.push_str("export interface EventBufferPolicy {\n");
381    for (name, _) in EventBufferPolicy::DEFAULT.entries() {
382        out.push_str(&format!("  readonly {}: number;\n", to_lower_camel(name)));
383    }
384    out.push_str("}\n\n");
385
386    out.push_str("export const EVENT_BUFFER_POLICY: EventBufferPolicy = {\n");
387    for (name, value) in EventBufferPolicy::DEFAULT.entries() {
388        out.push_str(&format!("  {}: {value},\n", to_lower_camel(name)));
389    }
390    out.push_str("};\n");
391
392    out
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn taxonomy_is_unique_sorted_and_round_trips() {
401        let wires: Vec<&str> = HostEventClass::ALL.iter().map(|c| c.as_str()).collect();
402        let mut sorted = wires.clone();
403        sorted.sort_unstable();
404        sorted.dedup();
405        assert_eq!(wires, sorted, "taxonomy must be unique and sorted");
406        for class in HostEventClass::ALL {
407            assert_eq!(HostEventClass::from_wire(class.as_str()), Some(class));
408        }
409        assert_eq!(HostEventClass::from_wire("nope"), None);
410    }
411
412    #[test]
413    fn error_codes_are_unique_sorted_and_round_trip() {
414        let codes: Vec<&str> = SdkErrorKind::ALL.iter().map(|k| k.code()).collect();
415        let mut sorted = codes.clone();
416        sorted.sort_unstable();
417        sorted.dedup();
418        assert_eq!(codes, sorted, "error codes must be unique and sorted");
419        for kind in SdkErrorKind::ALL {
420            assert_eq!(SdkErrorKind::from_code(kind.code()), Some(kind));
421            assert!(!kind.summary().is_empty());
422        }
423    }
424
425    #[test]
426    fn typescript_definitions_are_deterministic_and_complete() {
427        let a = typescript_definitions();
428        let b = typescript_definitions();
429        assert_eq!(a, b, "generator must be deterministic");
430        for class in HostEventClass::ALL {
431            assert!(
432                a.contains(&format!("\"{}\"", class.as_str())),
433                "d.ts missing event class {}",
434                class.as_str()
435            );
436        }
437        for kind in SdkErrorKind::ALL {
438            assert!(
439                a.contains(&format!("\"{}\"", kind.code())),
440                "d.ts missing error code {}",
441                kind.code()
442            );
443        }
444    }
445
446    #[test]
447    fn lower_camel_conversion() {
448        assert_eq!(
449            to_lower_camel("encoded_inputs_queue_max"),
450            "encodedInputsQueueMax"
451        );
452        assert_eq!(to_lower_camel("seq"), "seq");
453    }
454}