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.mountUiHandleShuttle.MountUiHandle
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