Hubs.tt will save your life

categories: [ Visual Studio ] tags: [ GitHub Projects ] [ SignalR ] [ TypeScript ]
created: 24 Apr 2014 @ 15:58 modified: 24 Apr 2017 @ 13:57

Updates have been made, see the end of the post Smile

Recently I started playing with SignalR using TypeScript, one of the things that very quickly made it's way into my project is the Hubs.tt T4 template file

Hubs.tt is a "T4 template that creates Typescript type definitions for all your Signalr hubs. If you have C# interface named "I<hubName>Client", a TS interface will be generated for the hub's client too. If you turn on XML documentation in your build, XMLDoc comments will be picked up. Licensed with http://www.apache.org/licenses/LICENSE-2.0". You can find a copy of it on GitHub using the link https://gist.github.com/htuomola/7565357. I have also placed a modified version below that updates for SignalR.Core.2.0.3.

<#@ template debug="true" hostspecific="true" language="C#" #>
<#@ output extension=".d.ts" #>
<# /* Update this line to match your version of SignalR */ #>
<#@ assembly name="$(SolutionDir)\packages\Microsoft.AspNet.SignalR.Core.2.0.3\lib\net45\Microsoft.AspNet.SignalR.Core.dll" #>
<# /* Load the current project's DLL to make sure the DefaultHubManager can find things */ #>
<#@ assembly name="$(TargetPath)" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Web" #>
<#@ assembly name="System.Xml, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" #>
<#@ assembly name="System.Xml.Linq, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="System.Threading.Tasks" #>
<#@ import namespace="Microsoft.AspNet.SignalR" #>
<#@ import namespace="Microsoft.AspNet.SignalR.Hubs" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Reflection" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Xml.Linq" #>
<#
var hubmanager = new DefaultHubManager(new DefaultDependencyResolver());
#>
// Get signalr.d.ts.ts from https://github.com/borisyankov/DefinitelyTyped (or delete the reference)
/// <reference path="signalr/signalr.d.ts" />
/// <reference path="jquery/jquery.d.ts" />

////////////////////
// available hubs //
////////////////////
//#region available hubs

interface SignalR {
<#
foreach (var hub in hubmanager.GetHubs())
{
#>

/**
* The hub implemented by <#=hub.HubType.FullName#>
*/
<#= FirstCharLowered(hub.Name) #> : <#= hub.HubType.Name #>;
<#
}
#>
}
//#endregion available hubs

///////////////////////
// Service Contracts //
///////////////////////
//#region service contracts
<#
foreach (var hub in hubmanager.GetHubs())
{
var hubType = hub.HubType;
string clientContractName = hubType.Namespace + ".I" + hubType.Name + "Client";
var clientType = hubType.Assembly.GetType(clientContractName);
#>

//#region <#= hub.Name#> hub

interface <#= hubType.Name #> {

/**
* This property lets you send messages to the <#= hub.Name#> hub.
*/
server : <#= hubType.Name #>Server;

/**
* The functions on this property should be replaced if you want to receive messages from the <#= hub.Name#> hub.
*/
client : <#= clientType != null?(hubType.Name+"Client"):"any"#>;
}

<#
/* Server type definition */
#>
interface <#= hubType.Name #>Server {
<#
foreach (var method in hubmanager.GetHubMethods(hub.Name ))
{
var ps = method.Parameters.Select(x => x.Name+ " : "+GetTypeContractName(x.ParameterType));
var docs = GetXmlDocForMethod(hubType.GetMethod(method.Name));

#>

/**
* Sends a "<#= FirstCharLowered(method.Name) #>" message to the <#= hub.Name#> hub.
* Contract Documentation: <#= docs.Summary #>
<#
foreach (var p in method.Parameters)
{
#>
* @param <#=p.Name#> {<#=GetTypeContractName(p.ParameterType)#>} <#=docs.ParameterSummary(p.Name)#>
<#
}
#>
* @return {JQueryPromise of <#= GetTypeContractName(method.ReturnType)#>}
*/
<#= FirstCharLowered(method.Name) #>(<#=string.Join(", ", ps)#>) : JQueryPromise<<#= GetTypeContractName(method.ReturnType)#>>;
<#
}
#>
}

<#
/* Client type definition */
#>
<#
if (clientType != null)
{
#>
interface <#= hubType.Name #>Client
{
<#
foreach (var method in clientType.GetMethods())
{
var ps = method.GetParameters().Select(x => x.Name+ " : "+GetTypeContractName(x.ParameterType));
var docs = GetXmlDocForMethod(method);

#>

/**
* Set this function with a "function(<#=string.Join(", ", ps)#>){}" to receive the "<#= FirstCharLowered(method.Name) #>" message from the <#= hub.Name#> hub.
* Contract Documentation: <#= docs.Summary #>
<#
foreach (var p in method.GetParameters())
{
#>
* @param <#=p.Name#> {<#=GetTypeContractName(p.ParameterType)#>} <#=docs.ParameterSummary(p.Name)#>
<#
}
#>
* @return {void}
*/
<#= FirstCharLowered(method.Name) #> : (<#=string.Join(", ", ps)#>) => void;
<#
}
#>
}

<#
}
#>
//#endregion <#= hub.Name#> hub

<#
}
#>
//#endregion service contracts



////////////////////
// Data Contracts //
////////////////////
//#region data contracts
<#
while(viewTypes.Count!=0)
{
var type = viewTypes.Pop();
#>


/**
* Data contract for <#= type.FullName#>
*/
interface <#= GenericSpecificName(type) #> {
<#
foreach (var property in type.GetProperties(BindingFlags.Instance|BindingFlags.Public|BindingFlags.DeclaredOnly))
{
#>
<#= property.Name#> : <#= GetTypeContractName(property.PropertyType)#>;
<#
}
#>
}
<#
}
#>

//#endregion data contracts

<#+

private Stack<Type> viewTypes = new Stack<Type>();
private HashSet<Type> doneTypes = new HashSet<Type>();

private string GetTypeContractName(Type type)
{
if (type == typeof (Task))
{
return "void /*task*/";
}

if (type.IsArray)
{
return GetTypeContractName(type.GetElementType())+"[]";
}

if (type.IsGenericType && typeof(Task<>).IsAssignableFrom(type.GetGenericTypeDefinition()))
{
return GetTypeContractName(type.GetGenericArguments()[0]);
}

if (type.IsGenericType && typeof(Nullable<>).IsAssignableFrom(type.GetGenericTypeDefinition()))
{
return GetTypeContractName(type.GetGenericArguments()[0]);
}

if (type.IsGenericType && typeof(List<>).IsAssignableFrom(type.GetGenericTypeDefinition()))
{
return GetTypeContractName(type.GetGenericArguments()[0])+"[]";
}



switch (type.Name.ToLowerInvariant())
{

case "datetime":
return "string";
case "int16":
case "int32":
case "int64":
case "single":
case "double":
return "number";
case "boolean":
return "bool";
case "void":
case "string":
return type.Name.ToLowerInvariant();
}

if (!doneTypes.Contains(type))
{
doneTypes.Add(type);
viewTypes.Push(type);
}
return GenericSpecificName(type);
}

private string GenericSpecificName(Type type)
{
//todo: update for Typescript's generic syntax once invented
string name = type.Name;
int index = name.IndexOf('`');
name = index == -1 ? name : name.Substring(0, index);
if (type.IsGenericType)
{
name += "Of"+string.Join("And", type.GenericTypeArguments.Select(GenericSpecificName));
}
return name;
}

private string FirstCharLowered(string s)
{
return Regex.Replace(s, "^.", x => x.Value.ToLowerInvariant());
}

Dictionary<Assembly, XDocument> xmlDocs = new Dictionary<Assembly, XDocument>();

private XDocument XmlDocForAssembly(Assembly a)
{
XDocument value;
if (!xmlDocs.TryGetValue(a, out value))
{
var path = new Uri(a.CodeBase.Replace(".dll", ".xml")).LocalPath;
xmlDocs[a] = value = File.Exists(path) ? XDocument.Load(path) : null;
}
return value;
}

private MethodDocs GetXmlDocForMethod(MethodInfo method)
{
var xmlDocForHub = XmlDocForAssembly(method.DeclaringType.Assembly);
if (xmlDocForHub == null)
{
return new MethodDocs();
}

var methodName = string.Format("M:{0}.{1}({2})", method.DeclaringType.FullName, method.Name, string.Join(",", method.GetParameters().Select(x => x.ParameterType.FullName)));
var xElement = xmlDocForHub.Descendants("member").SingleOrDefault(x => (string) x.Attribute("name") == methodName);
return xElement==null?new MethodDocs():new MethodDocs(xElement);
}

private class MethodDocs
{
public MethodDocs()
{
Summary = "---";
Parameters = new Dictionary<string, string>();
}

public MethodDocs(XElement xElement)
{
Summary = ((string) xElement.Element("summary") ?? "").Trim();
Parameters = xElement.Elements("param").ToDictionary(x => (string) x.Attribute("name"), x=>x.Value);
}

public string Summary { get; set; }
public Dictionary<string, string> Parameters { get; set; }

public string ParameterSummary(string name)
{
if (Parameters.ContainsKey(name))
{
return Parameters[name];
}
return "";
}
}

#>

The way to use this file is to simple copy it to ~/Scripts/typings/Hubs.tt and watch the magic happen Smile. Currently I have a simple hub like below

using Microsoft.AspNet.SignalR;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SignalR_TypeScript_BasicChat.hubs
{
public class ChatHub : Hub
{
private static List<ConnectedClients> connections = new List<ConnectedClients>();

public void Connect(string displayName)
{
if (!connections.Exists(o => o.ConnectionId == Context.ConnectionId))
{
connections.Add(new ConnectedClients { ConnectionId = Context.ConnectionId, DisplayName = string.IsNullOrEmpty(displayName) ? Context.ConnectionId : displayName });
}
if (!string.IsNullOrEmpty(displayName))
{
connections.First(o => o.ConnectionId == Context.ConnectionId).DisplayName = displayName;
}
connections.First(o => o.ConnectionId == Context.ConnectionId).LastPingTime = DateTime.Now;
}

public void Disconnect()
{
if (connections.Exists(o => o.ConnectionId == Context.ConnectionId))
{
connections.Remove(connections.First(o => o.ConnectionId == Context.ConnectionId));
}
}

public ConnectedClients[] GetConnectedClients()
{
Connect(null);
return connections.Where(o => DateTime.Now.Subtract(o.LastPingTime).TotalSeconds < 15 && o.ConnectionId != Context.ConnectionId).ToArray();
}

public void SendAll(ChatMessage message)
{
Connect(message.Name);
// Call the addNewMessageToPage method to update clients.
Clients.All.addNewMessageToPage(message);
}

public void SendTo(ChatMessage message)
{
if (string.IsNullOrEmpty(message.ConnectionId) || message.ConnectionId == "everyone" || message.ConnectionId == "null")
{
SendAll(message);
}
else
{
Connect(message.Name);
// Call the addNewMessageToPage method to update clients.
Clients.Caller.addNewMessageToPage(message);
Clients.Client(message.ConnectionId).addNewMessageToPage(message);
}
}
}

public class ConnectedClients
{
public string ConnectionId { get; internal set; }
public string DisplayName { get; internal set; }
public DateTime LastPingTime { get; internal set; }
}

public interface IChatHubClient
{
void addNewMessageToPage(ChatMessage msg);
}

public class ChatMessage
{
public string Name { get; set; }
public string Message { get; set; }
public string ConnectionId { get; set; }
}
}

Having the Hubs.tt file stopped me from having to type all the code below to allow for TypeScript to build and also give me the correct schema of the hub.


// Get signalr.d.ts.ts from https://github.com/borisyankov/DefinitelyTyped (or delete the reference)
/// <reference path="signalr/signalr.d.ts" />
/// <reference path="jquery/jquery.d.ts" />

////////////////////
// available hubs //
////////////////////
//#region available hubs

interface SignalR {


/**
* The hub implemented by SignalR_TypeScript_BasicChat.hubs.ChatHub
*/
chatHub : ChatHub;

}
//#endregion available hubs

///////////////////////
// Service Contracts //
///////////////////////
//#region service contracts


//#region ChatHub hub

interface ChatHub {

/**
* This property lets you send messages to the ChatHub hub.
*/
server : ChatHubServer;

/**
* The functions on this property should be replaced if you want to receive messages from the ChatHub hub.
*/
client : ChatHubClient;
}


interface ChatHubServer {


/**
* Sends a "connect" message to the ChatHub hub.
* Contract Documentation: ---

* @param displayName {string}

* @return {JQueryPromise of void}
*/
connect(displayName : string) : JQueryPromise<void>;


/**
* Sends a "disconnect" message to the ChatHub hub.
* Contract Documentation: ---

* @return {JQueryPromise of void}
*/
disconnect() : JQueryPromise<void>;


/**
* Sends a "getConnectedClients" message to the ChatHub hub.
* Contract Documentation: ---

* @return {JQueryPromise of ConnectedClients[]}
*/
getConnectedClients() : JQueryPromise<ConnectedClients[]>;


/**
* Sends a "sendAll" message to the ChatHub hub.
* Contract Documentation: ---

* @param message {ChatMessage}

* @return {JQueryPromise of void}
*/
sendAll(message : ChatMessage) : JQueryPromise<void>;


/**
* Sends a "sendTo" message to the ChatHub hub.
* Contract Documentation: ---

* @param message {ChatMessage}

* @return {JQueryPromise of void}
*/
sendTo(message : ChatMessage) : JQueryPromise<void>;

}



interface ChatHubClient
{


/**
* Set this function with a "function(msg : ChatMessage){}" to receive the "addNewMessageToPage" message from the ChatHub hub.
* Contract Documentation: ---

* @param msg {ChatMessage}

* @return {void}
*/
addNewMessageToPage : (msg : ChatMessage) => void;

}


//#endregion ChatHub hub


//#endregion service contracts



////////////////////
// Data Contracts //
////////////////////
//#region data contracts



/**
* Data contract for SignalR_TypeScript_BasicChat.hubs.ChatMessage
*/
interface ChatMessage {

Name : string;

Message : string;

ConnectionId : string;

}



/**
* Data contract for SignalR_TypeScript_BasicChat.hubs.ConnectedClients
*/
interface ConnectedClients {

ConnectionId : string;

DisplayName : string;

LastPingTime : string;

}


//#endregion data contracts


As you can see this can be a huge time saver, especially if you changing things a lot or just want to play and not worry about the "boring" stuff like making sure you typing's match your C# code Open-mouthed smile.

UPDATE 22-Apr-2014: Also available on GitHub using the gist link https://gist.github.com/Gordon-Beeming/11166590 

Update 24-Apr-2014: This has been added to Web Essentials now as well and will be available in the next release and is currently available in the Web Essentials Nightly Build, https://github.com/madskristensen/WebEssentials2013/pull/926 Smile

image