Skip to main content

TypeScript ↔ .NET

mekik ships two implementations that speak the identical mekik/1 wire: TypeScript (the reference) and .NET (the port). This page is the naming map and the handful of deliberate divergences. It extends ilmek's own MODEL.md §11 conventions — TS camelCase free functions and builder methods; .NET PascalCase with an Async suffix.

The naming map​

conceptTypeScript (@mekik/core, @mekik/ws).NET (Mekik.Core, Mekik.AspNetCore)
app factorymekik(options) → MekikAppnew MekikApp(MekikOptions)
serveserveWs(app, { port, path })endpoints.MapMekik(path, app)
engineConversationEngineConversationEngine
connectionConnectionIConnection
event → frameeventToFrames / TurnMapperMapper.EventToFrames / TurnMapper
canonical JSONcanonicalizeJson.Canonicalize
parse inboundparseIncomingProtocol.ParseIncoming
authoring helpersmekik.text / ui / event / tool / approveShuttle.Text / Ui / Event / Tool / Approve
button chipsmekik.action / mekik.chooseShuttle.Action / Shuttle.Choose<T>
managed ui instancemekik.mount → UiHandleShuttle.Mount → UiHandle
typed componentmekik.component<P>(name)pass the name to Shuttle.Ui / Shuttle.Mount
built-in componentsmekik.genui.*GenUI.* (+ GenUI.Names.*)
rich messagesmekik.message / mekik.messageKind / mekik.messages.*Shuttle.Message / Messages.*
described (unemitted) messagemekik.messageSpec / messages.card.spec(…)Messages.Spec / Messages.CardSpec(…)
ilmek seamIlmekAdapterIlmekAdapter
history portHistoryStore / InMemoryHistoryStoreIHistoryStore / InMemoryHistoryStore
conversation portConversationStore / InMemoryConversationStoreIConversationStore / InMemoryConversationStore
auth portAuthenticator / StaticTokenAuthenticatorIAuthenticator / StaticTokenAuthenticator
id minterIdMinter / randomMinter()IIdMinter / RandomMinter
protocol versionPROTOCOL_VERSIONProtocol.Version
auth close codeAUTH_CLOSE_CODE (4401)Protocol.AuthCloseCode

The pattern is mechanical: a TS interface Foo becomes .NET IFoo; a TS free function foo() becomes a PascalCase method, Async-suffixed where it awaits. If you know one side, you can read the other.

The five deliberate divergences​

Where the two can't be mechanically identical, they diverge on purpose. Five cases:

1. The helper class is Shuttle, not Mekik​

A static class sharing its namespace's name (Mekik) binds ambiguously at call sites — the same reason ilmek's runtime is IlmekRuntime, not Ilmek. So .NET call sites read Shuttle.Ui(ctx, …). "Shuttle" is what mekik means — the loom part that carries the thread across — so the name still says what the layer does. TypeScript has no such clash: it folds the helpers onto the callable mekik factory, so both mekik({ graph }) and mekik.ui(ctx, …) work off one name.

// TS
mekik.approve(ctx, { title: "Refund?" });
// .NET
Shuttle.Approve(ctx, new() { ["title"] = "Refund?" });

2. Frames are dictionaries in .NET​

The TS side has structural frame types; the .NET mapper builds Dictionary<string, object?> and the wire path stays dictionary-based. This is deliberate — it makes canonical-JSON parity exact, with nothing lost to serializer attributes, property casing, or optional-field omission. Frames are just JSON either way; .NET declines to model them as typed objects precisely so the serializer can't introduce a divergence the fixtures would then have to chase.

3. Cancellation​

An abort frame cancels via an AbortController/AbortSignal in TS and a CancellationTokenSource/CancellationToken in .NET — which is exactly what ilmek's .NET run loop already takes. Same behaviour, each language's idiom.

4. Choose's answer type​

TypeScript infers it from the options themselves — a const type parameter turns ["S", "M", "L"] into "S" | "M" | "L", so a choose call site needs no generic at all. C# has no literal-type inference, so Shuttle.Choose<T> takes the answer type explicitly. Same contract, stated instead of inferred.

// TS — inferred
const size = await mekik.choose(ctx, "Pick a size", ["S", "M", "L"]); // "S" | "M" | "L"
// .NET — stated
var size = await Shuttle.Choose<string>(ctx, "Pick a size", ["S", "M", "L"]);

The same asymmetry explains why TS has a per-component factory (mekik.component<Props>(name), whose props type flows into every call) while .NET passes the name to Shuttle.Ui with a props dictionary: a typed wrapper would buy nothing over the dictionary the wire path already uses (divergence 2).

5. The interrupt rethrow rule​

This one is load-bearing. In .NET, an interrupt propagates as an InterruptSignalException, so any try/catch in the adapter or helpers that wraps node execution must rethrow when InterruptSignalException.IsInterrupt(ex) — a blanket catch (Exception) would swallow the pause and turn a human-in-the-loop into a silently-dropped run. Shuttle.Tool does this.

In TS the pause is a thrown non-Error value, so an instanceof Error catch can't accidentally swallow it — but the helper still checks isInterrupt for symmetry. If you write your own tool wrapper in .NET, this is the rule you must not forget. See ilmek MODEL.md §11.

try {
var result = await ctx.StepAsync(name, fn);
// …
} catch (Exception ex) when (!InterruptSignalException.IsInterrupt(ex)) {
// only real failures land here; the pause propagates untouched
}

What guarantees they agree​

Naming and divergences are cosmetic; the wire is not. Two mechanisms hold the two implementations to byte-identical output:

  1. Golden fixtures — recorded ilmek event streams plus the exact frames they must produce, in canonical JSON. Both eventToFrames implementations replay the same files and compare byte-for-byte. If a change makes them diverge, a fixture test goes red.
  2. Scenario suites — the multi-frame, multi-run behaviours (handshake, replay, fan-out, resume routing, locking, auth), written as ordinary tests in each language against the same observable wire.

Canonical JSON is UTF-8, object keys sorted ascending, no insignificant whitespace, numbers in shortest round-trip form. The full list of fixtures and scenarios is Conformance.

Where to go next​