diff --git a/Libraries/Esiur/Data/TruComposite.cs b/Libraries/Esiur/Data/TruComposite.cs index 8d59578..95500e0 100644 --- a/Libraries/Esiur/Data/TruComposite.cs +++ b/Libraries/Esiur/Data/TruComposite.cs @@ -7,7 +7,7 @@ using System.Text; namespace Esiur.Data { - internal class TruComposite : Tru + public class TruComposite : Tru { public Tru[] SubTypes; diff --git a/Tools/Esiur.CLI/CliApplication.cs b/Tools/Esiur.CLI/CliApplication.cs index 52e1066..b0d912c 100644 --- a/Tools/Esiur.CLI/CliApplication.cs +++ b/Tools/Esiur.CLI/CliApplication.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using Esiur.CLI.Authentication; using Esiur.CLI.Client; using Esiur.CLI.Configuration; +using Esiur.CLI.Generation; using Esiur.CLI.Rendering; namespace Esiur.CLI; @@ -76,6 +77,7 @@ public sealed class CliApplication "query" => await QueryAsync(tokens, configuration, global, cancellation.Token), "describe" => await DescribeAsync(tokens, configuration, global, cancellation.Token), "get" => await GetAsync(tokens, configuration, global, cancellation.Token), + "generate" => await GenerateAsync(tokens, configuration, global, cancellation.Token), _ => throw new CliException($"Unknown command \"{command}\".", ExitCodes.InvalidArguments), }; } @@ -302,6 +304,44 @@ public sealed class CliApplication return ExitCodes.Success; } + async Task GenerateAsync( + List tokens, CliConfiguration configuration, GlobalOptions global, + CancellationToken cancellationToken) + { + if (tokens.Count < 2) + throw new CliException("Usage: esiur generate typescript [--out ]", ExitCodes.InvalidArguments); + var language = Take(tokens).ToLowerInvariant(); + if (language != "typescript") + throw new CliException($"Unsupported target language \"{language}\". Supported: typescript.", ExitCodes.InvalidArguments); + var path = Take(tokens); + var outFile = TakeOption(tokens, "--out"); + EnsureEmpty(tokens); + var settings = ConfigurationResolver.Resolve(configuration, global); + using var timeout = CreateTimeout(settings.Timeout, cancellationToken); + try + { + await using var session = await sessions.ConnectAsync(settings, false, input, error, timeout.Token); + var resource = await resources.ResolveAsync(session, path, timeout.Token); + var source = TypeScriptStubGenerator.Generate(resource.Instance.Definition, ResourceInspectionService.NormalizePath(path)); + if (outFile is null) + { + await output.WriteAsync(source); + } + else + { + var directory = Path.GetDirectoryName(Path.GetFullPath(outFile)); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + await File.WriteAllTextAsync(outFile, source, timeout.Token); + await output.WriteLineAsync($"Wrote {outFile}"); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new CliException("The operation timed out.", ExitCodes.Timeout); + } + return ExitCodes.Success; + } + static CancellationTokenSource CreateTimeout(TimeSpan duration, CancellationToken parent) { var source = CancellationTokenSource.CreateLinkedTokenSource(parent); @@ -386,6 +426,7 @@ public sealed class CliApplication query [--recursive|--depth ] [--type ] describe [--values|--schema-only] get [property...] + generate typescript [--out ] Global options: --profile Use a saved profile diff --git a/Tools/Esiur.CLI/Client/EsiurSession.cs b/Tools/Esiur.CLI/Client/EsiurSession.cs index 2401619..b54fd03 100644 --- a/Tools/Esiur.CLI/Client/EsiurSession.cs +++ b/Tools/Esiur.CLI/Client/EsiurSession.cs @@ -60,7 +60,8 @@ public sealed class EsiurSessionFactory(ICredentialService credentials) : IEsiur using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); if (settings.Timeout > TimeSpan.Zero) timeout.CancelAfter(settings.Timeout); - var endpoint = EndpointParser.ConnectionEndpoint(settings.Endpoint); + var (endpoint, webSocketUri) = EndpointParser.Parse(settings.Endpoint); + if (webSocketUri is not null) context.WebSocketUri = webSocketUri; var connectTask = AwaitReply(warehouse.Get(endpoint, context), timeout.Token); var connection = await connectTask; if (connection is null) @@ -126,10 +127,21 @@ public sealed class EsiurSession : IAsyncDisposable public static class EndpointParser { - public static string ConnectionEndpoint(string endpoint) + /// + /// Splits an endpoint into the ep://host:port string used to identify + /// the connection to , and — for ws(s):// + /// endpoints with a path — the literal to dial as + /// instead of + /// deriving the socket URL from host/port alone. + /// + public static (string ConnectEndpoint, Uri? WebSocketUri) Parse(string endpoint) { ConfigurationResolver.ValidateEndpoint(endpoint); var uri = new Uri(endpoint); - return $"ep://{uri.Authority}"; + var connectEndpoint = $"ep://{uri.Authority}"; + var isWebSocketScheme = uri.Scheme is "ws" or "wss"; + return isWebSocketScheme ? (connectEndpoint, uri) : (connectEndpoint, null); } + + public static string ConnectionEndpoint(string endpoint) => Parse(endpoint).ConnectEndpoint; } diff --git a/Tools/Esiur.CLI/Configuration/ConfigurationResolver.cs b/Tools/Esiur.CLI/Configuration/ConfigurationResolver.cs index 79d424a..4a8a1b6 100644 --- a/Tools/Esiur.CLI/Configuration/ConfigurationResolver.cs +++ b/Tools/Esiur.CLI/Configuration/ConfigurationResolver.cs @@ -60,13 +60,22 @@ public static class ConfigurationResolver profile); } + static readonly string[] ValidSchemes = ["ep", "eps", "ws", "wss"]; + + /// + /// Accepts ep(s)://host:port (a bare Esiur endpoint, connects at the + /// WebSocket root) as well as ws(s)://host:port/path (for hosts like + /// ASP.NET Core's MapEsiur("/esiur") that mount the WebSocket route + /// somewhere other than root) — see for how + /// the two forms are dialed. + /// public static void ValidateEndpoint(string endpoint) { if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) - || !string.Equals(uri.Scheme, "ep", StringComparison.OrdinalIgnoreCase) + || !ValidSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) - throw new CliException($"Endpoint \"{endpoint}\" is not a valid ep:// endpoint.", ExitCodes.InvalidArguments); + throw new CliException($"Endpoint \"{endpoint}\" is not a valid ep:// or ws:// endpoint.", ExitCodes.InvalidArguments); } } diff --git a/Tools/Esiur.CLI/Generation/TypeScriptStubGenerator.cs b/Tools/Esiur.CLI/Generation/TypeScriptStubGenerator.cs new file mode 100644 index 0000000..ee4947b --- /dev/null +++ b/Tools/Esiur.CLI/Generation/TypeScriptStubGenerator.cs @@ -0,0 +1,142 @@ +using System.Text; +using System.Text.RegularExpressions; +using Esiur.Data; +using Esiur.Data.Types; + +namespace Esiur.CLI.Generation; + +/// +/// Emits TypeScript interfaces (and enums) for a resource's +/// and every type it transitively references, for use with esiur-ts's +/// `connection.get<T>(path)` / `warehouse.get<T>(url, ...)`. Since +/// esiur-ts resolves resources dynamically at runtime (TypeDefs are fetched +/// over the wire, not compiled in), the generated output is pure type-level +/// TypeScript — no runtime code is needed for the interfaces themselves. +/// +public static class TypeScriptStubGenerator +{ + public static string Generate(TypeDef root, string sourcePath) + { + var discovered = new Dictionary(); + var order = new List(); + Collect(root, discovered, order); + var names = AssignNames(order); + + var usesDecimal128 = order.Any(typeDef => + typeDef.Properties.Any(p => TypeScriptTypeMapper.UsesDecimal128(p.ValueType)) + || typeDef.Functions.Any(f => TypeScriptTypeMapper.UsesDecimal128(f.ReturnType) + || (f.Arguments ?? []).Any(a => TypeScriptTypeMapper.UsesDecimal128(a.Type))) + || typeDef.Events.Any(e => TypeScriptTypeMapper.UsesDecimal128(e.ArgumentType)) + || typeDef.Constants.Any(c => TypeScriptTypeMapper.UsesDecimal128(c.ValueType))); + + var sb = new StringBuilder(); + sb.AppendLine($"// Generated by `esiur generate typescript {sourcePath}` — do not edit by hand."); + sb.AppendLine($"// Regenerate with: esiur generate typescript {sourcePath} --out "); + if (usesDecimal128) sb.AppendLine("import type { Decimal128 } from \"esiur\";"); + sb.AppendLine(); + + foreach (var typeDef in order) EmitType(sb, typeDef, names); + + return sb.ToString(); + } + + static void Collect(TypeDef typeDef, Dictionary discovered, List order) + { + if (!discovered.TryAdd(typeDef.Id, typeDef)) return; + order.Add(typeDef); + + foreach (var property in typeDef.Properties) CollectFromTru(property.ValueType, discovered, order); + foreach (var function in typeDef.Functions) + { + foreach (var argument in function.Arguments ?? []) + CollectFromTru(argument.Type, discovered, order); + CollectFromTru(function.ReturnType, discovered, order); + } + foreach (var @event in typeDef.Events) CollectFromTru(@event.ArgumentType, discovered, order); + foreach (var constant in typeDef.Constants) CollectFromTru(constant.ValueType, discovered, order); + } + + static void CollectFromTru(Tru? type, Dictionary discovered, List order) + { + switch (type) + { + case TruTypeDef { TypeDef: not null } reference: + Collect(reference.TypeDef, discovered, order); + break; + case TruComposite composite: + foreach (var sub in composite.SubTypes) CollectFromTru(sub, discovered, order); + break; + } + } + + /// + /// A TypeDef's Name is often a fully-qualified CLR name (e.g. + /// MyApp.Server.Counter), not a valid TypeScript identifier. Use the + /// last segment as the interface/enum name, falling back to the full name + /// (sanitized) on collision so two distinct types never collide. + /// + static Dictionary AssignNames(List order) + { + var used = new HashSet(StringComparer.Ordinal); + var names = new Dictionary(); + foreach (var typeDef in order) + { + var shortName = Sanitize(LastSegment(typeDef.Name)); + var name = used.Add(shortName) ? shortName : Sanitize(typeDef.Name); + used.Add(name); + names[typeDef.Id] = name; + } + return names; + } + + static string LastSegment(string fullName) + { + var lastDot = fullName.LastIndexOf('.'); + return lastDot >= 0 ? fullName[(lastDot + 1)..] : fullName; + } + + static string Sanitize(string name) + { + var cleaned = Regex.Replace(name, "[^A-Za-z0-9_$]", "_"); + return cleaned.Length > 0 && char.IsDigit(cleaned[0]) ? "_" + cleaned : cleaned; + } + + static void EmitType(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary names) + { + if (typeDef.Kind == TypeDefKind.Enum) EmitEnum(sb, typeDef, names); + else EmitInterface(sb, typeDef, names); + } + + static void EmitEnum(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary names) + { + sb.AppendLine($"export enum {names[typeDef.Id]} {{"); + foreach (var constant in typeDef.Constants.OrderBy(c => c.Index)) + sb.AppendLine($" {constant.Name} = {Convert.ToInt64(constant.Value)},"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + static void EmitInterface(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary names) + { + sb.AppendLine($"export interface {names[typeDef.Id]} {{"); + + foreach (var property in typeDef.Properties.OrderBy(p => p.Index)) + { + var readonlyModifier = property.ReadOnly || property.Constant ? "readonly " : ""; + sb.AppendLine( + $" {readonlyModifier}{property.Name}: {TypeScriptTypeMapper.Map(property.ValueType, names)};"); + } + + foreach (var function in typeDef.Functions.OrderBy(f => f.Index)) + { + var args = string.Join(", ", (function.Arguments ?? []) + .OrderBy(a => a.Index) + .Select(a => $"{a.Name}{(a.Optional ? "?" : "")}: {TypeScriptTypeMapper.Map(a.Type, names)}")); + var returnType = TypeScriptTypeMapper.Map(function.ReturnType, names); + sb.AppendLine($" {function.Name}({args}): PromiseLike<{returnType}>;"); + } + + sb.AppendLine("}"); + sb.AppendLine(); + } +} diff --git a/Tools/Esiur.CLI/Generation/TypeScriptTypeMapper.cs b/Tools/Esiur.CLI/Generation/TypeScriptTypeMapper.cs new file mode 100644 index 0000000..8f0f135 --- /dev/null +++ b/Tools/Esiur.CLI/Generation/TypeScriptTypeMapper.cs @@ -0,0 +1,80 @@ +using Esiur.Data; + +namespace Esiur.CLI.Generation; + +/// +/// Maps a wire-level type representation onto the closest +/// TypeScript type that esiur-ts's runtime actually decodes it into (e.g. +/// 64-bit integers become `bigint`, not `number` — see +/// esiur-ts/src/data/DataDeserializer.ts) so generated stubs match runtime +/// values, not just wire shape. +/// +public static class TypeScriptTypeMapper +{ + /// Maps a referenced TypeDef's id to the identifier name + /// it was declared under (see ) — a + /// TypeDef's own Name is often a fully-qualified CLR name like + /// MyApp.Server.Counter, not a valid TypeScript identifier. + public static string Map(Tru? type, IReadOnlyDictionary names) + { + if (type is null) return "unknown"; + var mapped = MapCore(type, names); + return type.Nullable && mapped != "void" ? $"{mapped} | null" : mapped; + } + + static string MapCore(Tru type, IReadOnlyDictionary names) => type switch + { + TruTypeDef { TypeDef: not null } reference => + names.TryGetValue(reference.TypeDef.Id, out var name) ? name : "unknown", + TruTypeDef => "unknown", + TruComposite composite => MapComposite(composite, names), + TruPrimitive primitive => MapPrimitive(primitive.Identifier), + _ => "unknown", + }; + + static string MapComposite(TruComposite composite, IReadOnlyDictionary names) => + composite.Identifier switch + { + TruIdentifier.TypedList => $"{Map(composite.SubTypes[0], names)}[]", + TruIdentifier.TypedMap => + $"Map<{Map(composite.SubTypes[0], names)}, {Map(composite.SubTypes[1], names)}>", + TruIdentifier.Tuple2 or TruIdentifier.Tuple3 or TruIdentifier.Tuple4 + or TruIdentifier.Tuple5 or TruIdentifier.Tuple6 or TruIdentifier.Tuple7 => + $"[{string.Join(", ", composite.SubTypes.Select(sub => Map(sub, names)))}]", + _ => "unknown", + }; + + static string MapPrimitive(TruIdentifier identifier) => identifier switch + { + TruIdentifier.Void => "void", + TruIdentifier.Dynamic => "unknown", + TruIdentifier.Bool => "boolean", + TruIdentifier.Char => "string", + TruIdentifier.UInt8 or TruIdentifier.Int8 + or TruIdentifier.UInt16 or TruIdentifier.Int16 + or TruIdentifier.UInt32 or TruIdentifier.Int32 + or TruIdentifier.Float32 or TruIdentifier.Float64 => "number", + // esiur-ts decodes 64-bit integers as `bigint` to avoid precision loss. + TruIdentifier.Int64 or TruIdentifier.UInt64 => "bigint", + // esiur-ts decodes Decimal as its own Decimal128 class (exported from "esiur"). + TruIdentifier.Decimal => "Decimal128", + TruIdentifier.String => "string", + TruIdentifier.DateTime => "Date", + TruIdentifier.RawData => "Uint8Array", + TruIdentifier.Resource => "unknown", + TruIdentifier.Record => "unknown", + TruIdentifier.List => "unknown[]", + TruIdentifier.Map => "Map", + _ => "unknown", + }; + + /// True when any type reachable from maps to Decimal128, + /// so the emitter knows whether to import it. + public static bool UsesDecimal128(Tru? type) => type switch + { + null => false, + TruPrimitive primitive => primitive.Identifier == TruIdentifier.Decimal, + TruComposite composite => composite.SubTypes.Any(UsesDecimal128), + _ => false, + }; +}