Add user agent header to TS client and normalized the other clients (#14484)
This commit is contained in:
parent
5df73373b5
commit
d35b33f294
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
@ -22,22 +23,68 @@ namespace Microsoft.AspNetCore.Http.Connections.Client.Internal
|
||||||
|
|
||||||
Debug.Assert(assemblyVersion != null);
|
Debug.Assert(assemblyVersion != null);
|
||||||
|
|
||||||
var majorVersion = typeof(Constants).Assembly.GetName().Version.Major;
|
|
||||||
var minorVersion = typeof(Constants).Assembly.GetName().Version.Minor;
|
|
||||||
var os = RuntimeInformation.OSDescription;
|
|
||||||
var runtime = ".NET";
|
var runtime = ".NET";
|
||||||
var runtimeVersion = RuntimeInformation.FrameworkDescription;
|
var runtimeVersion = RuntimeInformation.FrameworkDescription;
|
||||||
|
|
||||||
// assembly version attribute should always be present
|
UserAgentHeader = ConstructUserAgent(typeof(Constants).Assembly.GetName().Version, assemblyVersion?.InformationalVersion, GetOS(), runtime, runtimeVersion);
|
||||||
// but in case it isn't then don't include version in user-agent
|
}
|
||||||
if (assemblyVersion != null)
|
|
||||||
|
private static string GetOS()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
UserAgentHeader = $"Microsoft SignalR/{majorVersion}.{minorVersion} ({assemblyVersion.InformationalVersion}; {os}; {runtime}; {runtimeVersion})";
|
return "Windows NT";
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
return "macOS";
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
return "Linux";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
UserAgentHeader = $"Microsoft SignalR/{majorVersion}.{minorVersion} ({os}; {runtime}; {runtimeVersion})";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string ConstructUserAgent(Version version, string detailedVersion, string os, string runtime, string runtimeVersion)
|
||||||
|
{
|
||||||
|
var userAgent = $"Microsoft SignalR/{version.Major}.{version.Minor} (";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(detailedVersion))
|
||||||
|
{
|
||||||
|
userAgent += $"{detailedVersion}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userAgent += "Unknown Version";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(os))
|
||||||
|
{
|
||||||
|
userAgent += $"; {os}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userAgent += "; Unknown OS";
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgent += $"; {runtime}";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(runtimeVersion))
|
||||||
|
{
|
||||||
|
userAgent += $"; {runtimeVersion}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userAgent += "; Unknown Runtime Version";
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgent += ")";
|
||||||
|
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,23 +12,40 @@ public class UserAgentHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String createUserAgentString() {
|
public static String createUserAgentString() {
|
||||||
|
return constructUserAgentString(Version.getDetailedVersion(), getOS(), "Java", getJavaVersion(), getJavaVendor());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String constructUserAgentString(String detailedVersion, String os, String runtime, String runtimeVersion, String vendor) {
|
||||||
StringBuilder agentBuilder = new StringBuilder("Microsoft SignalR/");
|
StringBuilder agentBuilder = new StringBuilder("Microsoft SignalR/");
|
||||||
|
|
||||||
// Parsing version numbers
|
|
||||||
String detailedVersion = Version.getDetailedVersion();
|
|
||||||
agentBuilder.append(getVersion(detailedVersion));
|
agentBuilder.append(getVersion(detailedVersion));
|
||||||
agentBuilder.append(" (");
|
agentBuilder.append(" (");
|
||||||
agentBuilder.append(detailedVersion);
|
agentBuilder.append(detailedVersion);
|
||||||
agentBuilder.append("; ");
|
|
||||||
|
|
||||||
// Getting the OS name
|
|
||||||
agentBuilder.append(getOS());
|
|
||||||
agentBuilder.append("; Java; ");
|
|
||||||
|
|
||||||
// Vendor and Version
|
|
||||||
agentBuilder.append(getJavaVersion());
|
|
||||||
agentBuilder.append("; ");
|
agentBuilder.append("; ");
|
||||||
agentBuilder.append(getJavaVendor());
|
if (!os.isEmpty()) {
|
||||||
|
agentBuilder.append(os);
|
||||||
|
} else {
|
||||||
|
agentBuilder.append("Unknown OS");
|
||||||
|
}
|
||||||
|
|
||||||
|
agentBuilder.append("; ");
|
||||||
|
agentBuilder.append(runtime);
|
||||||
|
|
||||||
|
agentBuilder.append("; ");
|
||||||
|
if (!runtimeVersion.isEmpty()) {
|
||||||
|
agentBuilder.append(runtimeVersion);
|
||||||
|
} else {
|
||||||
|
agentBuilder.append("Unknown Runtime Version");
|
||||||
|
}
|
||||||
|
|
||||||
|
agentBuilder.append("; ");
|
||||||
|
if (!vendor.isEmpty()) {
|
||||||
|
agentBuilder.append(vendor);
|
||||||
|
} else {
|
||||||
|
agentBuilder.append("Unknown Vendor");
|
||||||
|
}
|
||||||
|
|
||||||
agentBuilder.append(")");
|
agentBuilder.append(")");
|
||||||
|
|
||||||
return agentBuilder.toString();
|
return agentBuilder.toString();
|
||||||
|
|
@ -49,6 +66,16 @@ public class UserAgentHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
static String getOS() {
|
static String getOS() {
|
||||||
return System.getProperty("os.name");
|
String osName = System.getProperty("os.name").toLowerCase();
|
||||||
|
|
||||||
|
if (osName.indexOf("win") >= 0) {
|
||||||
|
return "Windows NT";
|
||||||
|
} else if (osName.contains("mac") || osName.contains("darwin")) {
|
||||||
|
return "macOS";
|
||||||
|
} else if (osName.contains("linux")) {
|
||||||
|
return "Linux";
|
||||||
|
} else {
|
||||||
|
return osName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,17 @@ public class UserAgentTest {
|
||||||
|
|
||||||
assertEquals(handMadeUserAgent, userAgent);
|
assertEquals(handMadeUserAgent, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("UserAgents")
|
||||||
|
public void UserAgentHeaderIsCorrect(String detailedVersion, String os, String runtime, String runtimeVersion, String vendor, String expected) {
|
||||||
|
assertEquals(expected, UserAgentHelper.constructUserAgentString(detailedVersion, os, runtime, runtimeVersion, vendor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream<Arguments> UserAgents() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("1.4.5-dev", "Windows NT", "Java", "7.0.1", "Oracle", "Microsoft SignalR/1.4 (1.4.5-dev; Windows NT; Java; 7.0.1; Oracle)"),
|
||||||
|
Arguments.of("3.1.0", "", "Java", "7.0.1", "", "Microsoft SignalR/3.1 (3.1.0; Unknown OS; Java; 7.0.1; Unknown Vendor)"),
|
||||||
|
Arguments.of("5.0.2", "macOS", "Java", "", "Android", "Microsoft SignalR/5.0 (5.0.2; macOS; Java; Unknown Runtime Version; Android)"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http.Connections;
|
using Microsoft.AspNetCore.Http.Connections;
|
||||||
using Microsoft.AspNetCore.Http.Connections.Features;
|
using Microsoft.AspNetCore.Http.Connections.Features;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
namespace FunctionalTests
|
namespace FunctionalTests
|
||||||
{
|
{
|
||||||
|
|
@ -136,6 +137,11 @@ namespace FunctionalTests
|
||||||
return Context.GetHttpContext().Request.Headers["Content-Type"];
|
return Context.GetHttpContext().Request.Headers["Content-Type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetHeader(string headerName)
|
||||||
|
{
|
||||||
|
return Context.GetHttpContext().Request.Headers[headerName];
|
||||||
|
}
|
||||||
|
|
||||||
public string GetCookie(string cookieName)
|
public string GetCookie(string cookieName)
|
||||||
{
|
{
|
||||||
var cookies = Context.GetHttpContext().Request.Cookies;
|
var cookies = Context.GetHttpContext().Request.Cookies;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import { AbortError, DefaultHttpClient, HttpClient, HttpRequest, HttpResponse, HttpTransportType, HubConnectionBuilder, IHttpConnectionOptions, JsonHubProtocol, NullLogger } from "@microsoft/signalr";
|
import { AbortError, DefaultHttpClient, HttpClient, HttpRequest, HttpResponse, HttpTransportType, HubConnectionBuilder, IHttpConnectionOptions, JsonHubProtocol, NullLogger } from "@microsoft/signalr";
|
||||||
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
||||||
|
import { getUserAgentHeader, Platform } from "@microsoft/signalr/dist/esm/Utils";
|
||||||
|
|
||||||
import { eachTransport, eachTransportAndProtocolAndHttpClient, ENDPOINT_BASE_HTTPS_URL, ENDPOINT_BASE_URL } from "./Common";
|
import { eachTransport, eachTransportAndProtocolAndHttpClient, ENDPOINT_BASE_HTTPS_URL, ENDPOINT_BASE_URL } from "./Common";
|
||||||
import "./LogBannerReporter";
|
import "./LogBannerReporter";
|
||||||
|
|
@ -1092,6 +1093,33 @@ describe("hubConnection", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eachTransport((t) => {
|
||||||
|
it("sets the user agent header", async (done) => {
|
||||||
|
const hubConnection = getConnectionBuilder(t, TESTHUBENDPOINT_URL)
|
||||||
|
.withHubProtocol(new JsonHubProtocol())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await hubConnection.start();
|
||||||
|
|
||||||
|
// Check to see that the Content-Type header is set the expected value
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
const headerValue = await hubConnection.invoke("GetHeader", name);
|
||||||
|
|
||||||
|
if ((t === HttpTransportType.ServerSentEvents || t === HttpTransportType.WebSockets) && !Platform.isNode) {
|
||||||
|
expect(headerValue).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(headerValue).toEqual(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await hubConnection.stop();
|
||||||
|
done();
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function getJwtToken(url: string): Promise<string> {
|
function getJwtToken(url: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const httpClient = new DefaultHttpClient(NullLogger.instance);
|
const httpClient = new DefaultHttpClient(NullLogger.instance);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { HttpTransportType, ITransport, TransferFormat } from "./ITransport";
|
import { HttpTransportType, ITransport, TransferFormat } from "./ITransport";
|
||||||
import { LongPollingTransport } from "./LongPollingTransport";
|
import { LongPollingTransport } from "./LongPollingTransport";
|
||||||
import { ServerSentEventsTransport } from "./ServerSentEventsTransport";
|
import { ServerSentEventsTransport } from "./ServerSentEventsTransport";
|
||||||
import { Arg, createLogger, Platform } from "./Utils";
|
import { Arg, createLogger, getUserAgentHeader, Platform } from "./Utils";
|
||||||
import { WebSocketTransport } from "./WebSocketTransport";
|
import { WebSocketTransport } from "./WebSocketTransport";
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
|
|
@ -302,16 +302,17 @@ export class HttpConnection implements IConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getNegotiationResponse(url: string): Promise<INegotiateResponse> {
|
private async getNegotiationResponse(url: string): Promise<INegotiateResponse> {
|
||||||
let headers;
|
const headers = {};
|
||||||
if (this.accessTokenFactory) {
|
if (this.accessTokenFactory) {
|
||||||
const token = await this.accessTokenFactory();
|
const token = await this.accessTokenFactory();
|
||||||
if (token) {
|
if (token) {
|
||||||
headers = {
|
headers[`Authorization`] = `Bearer ${token}`;
|
||||||
["Authorization"]: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
headers[name] = value;
|
||||||
|
|
||||||
const negotiateUrl = this.resolveNegotiateUrl(url);
|
const negotiateUrl = this.resolveNegotiateUrl(url);
|
||||||
this.logger.log(LogLevel.Debug, `Sending negotiation request: ${negotiateUrl}.`);
|
this.logger.log(LogLevel.Debug, `Sending negotiation request: ${negotiateUrl}.`);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { HttpError, TimeoutError } from "./Errors";
|
||||||
import { HttpClient, HttpRequest } from "./HttpClient";
|
import { HttpClient, HttpRequest } from "./HttpClient";
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { ITransport, TransferFormat } from "./ITransport";
|
import { ITransport, TransferFormat } from "./ITransport";
|
||||||
import { Arg, getDataDetail, sendMessage } from "./Utils";
|
import { Arg, getDataDetail, getUserAgentHeader, sendMessage } from "./Utils";
|
||||||
|
|
||||||
// Not exported from 'index', this type is internal.
|
// Not exported from 'index', this type is internal.
|
||||||
/** @private */
|
/** @private */
|
||||||
|
|
@ -58,9 +58,13 @@ export class LongPollingTransport implements ITransport {
|
||||||
throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported.");
|
throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
headers[name] = value;
|
||||||
|
|
||||||
const pollOptions: HttpRequest = {
|
const pollOptions: HttpRequest = {
|
||||||
abortSignal: this.pollAbort.signal,
|
abortSignal: this.pollAbort.signal,
|
||||||
headers: {},
|
headers,
|
||||||
timeout: 100000,
|
timeout: 100000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -194,8 +198,12 @@ export class LongPollingTransport implements ITransport {
|
||||||
// Send DELETE to clean up long polling on the server
|
// Send DELETE to clean up long polling on the server
|
||||||
this.logger.log(LogLevel.Trace, `(LongPolling transport) sending DELETE request to ${this.url}.`);
|
this.logger.log(LogLevel.Trace, `(LongPolling transport) sending DELETE request to ${this.url}.`);
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
headers[name] = value;
|
||||||
|
|
||||||
const deleteOptions: HttpRequest = {
|
const deleteOptions: HttpRequest = {
|
||||||
headers: {},
|
headers,
|
||||||
};
|
};
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
this.updateHeaderToken(deleteOptions, token);
|
this.updateHeaderToken(deleteOptions, token);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { HttpClient } from "./HttpClient";
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { ITransport, TransferFormat } from "./ITransport";
|
import { ITransport, TransferFormat } from "./ITransport";
|
||||||
import { EventSourceConstructor } from "./Polyfills";
|
import { EventSourceConstructor } from "./Polyfills";
|
||||||
import { Arg, getDataDetail, Platform, sendMessage } from "./Utils";
|
import { Arg, getDataDetail, getUserAgentHeader, Platform, sendMessage } from "./Utils";
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
export class ServerSentEventsTransport implements ITransport {
|
export class ServerSentEventsTransport implements ITransport {
|
||||||
|
|
@ -62,7 +62,13 @@ export class ServerSentEventsTransport implements ITransport {
|
||||||
} else {
|
} else {
|
||||||
// Non-browser passes cookies via the dictionary
|
// Non-browser passes cookies via the dictionary
|
||||||
const cookies = this.httpClient.getCookieString(url);
|
const cookies = this.httpClient.getCookieString(url);
|
||||||
eventSource = new this.eventSourceConstructor(url, { withCredentials: true, headers: { Cookie: cookies } } as EventSourceInit);
|
const headers = {
|
||||||
|
Cookie: cookies,
|
||||||
|
};
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
headers[name] = value;
|
||||||
|
|
||||||
|
eventSource = new this.eventSourceConstructor(url, { withCredentials: true, headers } as EventSourceInit);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import { NullLogger } from "./Loggers";
|
||||||
import { IStreamSubscriber, ISubscription } from "./Stream";
|
import { IStreamSubscriber, ISubscription } from "./Stream";
|
||||||
import { Subject } from "./Subject";
|
import { Subject } from "./Subject";
|
||||||
|
|
||||||
|
// Version token that will be replaced by the prepack command
|
||||||
|
/** The version of the SignalR client. */
|
||||||
|
export const VERSION: string = "0.0.0-DEV_BUILD";
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
export class Arg {
|
export class Arg {
|
||||||
public static isRequired(val: any, name: string): void {
|
public static isRequired(val: any, name: string): void {
|
||||||
|
|
@ -25,7 +29,6 @@ export class Arg {
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
export class Platform {
|
export class Platform {
|
||||||
|
|
||||||
public static get isBrowser(): boolean {
|
public static get isBrowser(): boolean {
|
||||||
return typeof window === "object";
|
return typeof window === "object";
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +85,7 @@ export function isArrayBuffer(val: any): val is ArrayBuffer {
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
export async function sendMessage(logger: ILogger, transportName: string, httpClient: HttpClient, url: string, accessTokenFactory: (() => string | Promise<string>) | undefined, content: string | ArrayBuffer, logMessageContent: boolean): Promise<void> {
|
export async function sendMessage(logger: ILogger, transportName: string, httpClient: HttpClient, url: string, accessTokenFactory: (() => string | Promise<string>) | undefined, content: string | ArrayBuffer, logMessageContent: boolean): Promise<void> {
|
||||||
let headers;
|
let headers = {};
|
||||||
if (accessTokenFactory) {
|
if (accessTokenFactory) {
|
||||||
const token = await accessTokenFactory();
|
const token = await accessTokenFactory();
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -92,6 +95,9 @@ export async function sendMessage(logger: ILogger, transportName: string, httpCl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
headers[name] = value;
|
||||||
|
|
||||||
logger.log(LogLevel.Trace, `(${transportName} transport) sending data. ${getDataDetail(content, logMessageContent)}.`);
|
logger.log(LogLevel.Trace, `(${transportName} transport) sending data. ${getDataDetail(content, logMessageContent)}.`);
|
||||||
|
|
||||||
const responseType = isArrayBuffer(content) ? "arraybuffer" : "text";
|
const responseType = isArrayBuffer(content) ? "arraybuffer" : "text";
|
||||||
|
|
@ -181,3 +187,71 @@ export class ConsoleLogger implements ILogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
export function getUserAgentHeader(): [string, string] {
|
||||||
|
let userAgentHeaderName = "X-SignalR-User-Agent";
|
||||||
|
if (Platform.isNode) {
|
||||||
|
userAgentHeaderName = "User-Agent";
|
||||||
|
}
|
||||||
|
return [ userAgentHeaderName, constructUserAgent(VERSION, getOsName(), getRuntime(), getRuntimeVersion()) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
export function constructUserAgent(version: string, os: string, runtime: string, runtimeVersion: string | undefined): string {
|
||||||
|
// Microsoft SignalR/[Version] ([Detailed Version]; [Operating System]; [Runtime]; [Runtime Version])
|
||||||
|
let userAgent: string = "Microsoft SignalR/";
|
||||||
|
|
||||||
|
const majorAndMinor = version.split(".");
|
||||||
|
userAgent += `${majorAndMinor[0]}.${majorAndMinor[1]}`;
|
||||||
|
userAgent += ` (${version}; `;
|
||||||
|
|
||||||
|
if (os && os !== "") {
|
||||||
|
userAgent += `${os}; `;
|
||||||
|
} else {
|
||||||
|
userAgent += "Unknown OS; ";
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgent += `${runtime}`;
|
||||||
|
|
||||||
|
if (runtimeVersion) {
|
||||||
|
userAgent += `; ${runtimeVersion}`;
|
||||||
|
} else {
|
||||||
|
userAgent += "; Unknown Runtime Version";
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgent += ")";
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOsName(): string {
|
||||||
|
if (Platform.isNode) {
|
||||||
|
switch (process.platform) {
|
||||||
|
case "win32":
|
||||||
|
return "Windows NT";
|
||||||
|
case "darwin":
|
||||||
|
return "macOS";
|
||||||
|
case "linux":
|
||||||
|
return "Linux";
|
||||||
|
default:
|
||||||
|
return process.platform;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeVersion(): string | undefined {
|
||||||
|
if (Platform.isNode) {
|
||||||
|
return process.versions.node;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntime(): string {
|
||||||
|
if (Platform.isNode) {
|
||||||
|
return "NodeJS";
|
||||||
|
} else {
|
||||||
|
return "Browser";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { HttpClient } from "./HttpClient";
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { ITransport, TransferFormat } from "./ITransport";
|
import { ITransport, TransferFormat } from "./ITransport";
|
||||||
import { WebSocketConstructor } from "./Polyfills";
|
import { WebSocketConstructor } from "./Polyfills";
|
||||||
import { Arg, getDataDetail, Platform } from "./Utils";
|
import { Arg, getDataDetail, getUserAgentHeader, Platform } from "./Utils";
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
export class WebSocketTransport implements ITransport {
|
export class WebSocketTransport implements ITransport {
|
||||||
|
|
@ -50,12 +50,18 @@ export class WebSocketTransport implements ITransport {
|
||||||
let webSocket: WebSocket | undefined;
|
let webSocket: WebSocket | undefined;
|
||||||
const cookies = this.httpClient.getCookieString(url);
|
const cookies = this.httpClient.getCookieString(url);
|
||||||
|
|
||||||
if (Platform.isNode && cookies) {
|
if (Platform.isNode) {
|
||||||
|
const headers = {};
|
||||||
|
const [name, value] = getUserAgentHeader();
|
||||||
|
headers[name] = value;
|
||||||
|
|
||||||
|
if (cookies) {
|
||||||
|
headers[`Cookie`] = `${cookies}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Only pass cookies when in non-browser environments
|
// Only pass cookies when in non-browser environments
|
||||||
webSocket = new this.webSocketConstructor(url, undefined, {
|
webSocket = new this.webSocketConstructor(url, undefined, {
|
||||||
headers: {
|
headers,
|
||||||
Cookie: `${cookies}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
// Version token that will be replaced by the prepack command
|
|
||||||
/** The version of the SignalR client. */
|
|
||||||
export const VERSION: string = "0.0.0-DEV_BUILD";
|
|
||||||
|
|
||||||
// Everything that users need to access must be exported here. Including interfaces.
|
// Everything that users need to access must be exported here. Including interfaces.
|
||||||
export { AbortSignal } from "./AbortController";
|
export { AbortSignal } from "./AbortController";
|
||||||
export { AbortError, HttpError, TimeoutError } from "./Errors";
|
export { AbortError, HttpError, TimeoutError } from "./Errors";
|
||||||
|
|
@ -22,3 +18,4 @@ export { NullLogger } from "./Loggers";
|
||||||
export { JsonHubProtocol } from "./JsonHubProtocol";
|
export { JsonHubProtocol } from "./JsonHubProtocol";
|
||||||
export { Subject } from "./Subject";
|
export { Subject } from "./Subject";
|
||||||
export { IRetryPolicy, RetryContext } from "./IRetryPolicy";
|
export { IRetryPolicy, RetryContext } from "./IRetryPolicy";
|
||||||
|
export { VERSION } from "./Utils";
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { HttpResponse } from "../src/HttpClient";
|
||||||
import { HttpConnection, INegotiateResponse, TransportSendQueue } from "../src/HttpConnection";
|
import { HttpConnection, INegotiateResponse, TransportSendQueue } from "../src/HttpConnection";
|
||||||
import { IHttpConnectionOptions } from "../src/IHttpConnectionOptions";
|
import { IHttpConnectionOptions } from "../src/IHttpConnectionOptions";
|
||||||
import { HttpTransportType, ITransport, TransferFormat } from "../src/ITransport";
|
import { HttpTransportType, ITransport, TransferFormat } from "../src/ITransport";
|
||||||
|
import { getUserAgentHeader } from "../src/Utils";
|
||||||
|
|
||||||
import { HttpError } from "../src/Errors";
|
import { HttpError } from "../src/Errors";
|
||||||
import { NullLogger } from "../src/Loggers";
|
import { NullLogger } from "../src/Loggers";
|
||||||
|
|
@ -1124,6 +1125,32 @@ describe("HttpConnection", () => {
|
||||||
"Failed to start the transport 'WebSockets': Error: There was an error with the transport.");
|
"Failed to start the transport 'WebSockets': Error: There was an error with the transport.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("user agent header set on negotiate", async () => {
|
||||||
|
await VerifyLogger.run(async (logger) => {
|
||||||
|
let userAgentValue: string = "";
|
||||||
|
const options: IHttpConnectionOptions = {
|
||||||
|
...commonOptions,
|
||||||
|
httpClient: new TestHttpClient()
|
||||||
|
.on("POST", (r) => {
|
||||||
|
userAgentValue = r.headers![`User-Agent`];
|
||||||
|
return new HttpResponse(200, "", "{\"error\":\"nope\"}");
|
||||||
|
}),
|
||||||
|
logger,
|
||||||
|
} as IHttpConnectionOptions;
|
||||||
|
|
||||||
|
const connection = new HttpConnection("http://tempuri.org", options);
|
||||||
|
try {
|
||||||
|
await connection.start(TransferFormat.Text);
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
await connection.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, value] = getUserAgentHeader();
|
||||||
|
expect(userAgentValue).toEqual(value);
|
||||||
|
}, "Failed to start the connection: Error: nope");
|
||||||
|
});
|
||||||
|
|
||||||
describe(".constructor", () => {
|
describe(".constructor", () => {
|
||||||
it("throws if no Url is provided", async () => {
|
it("throws if no Url is provided", async () => {
|
||||||
// Force TypeScript to let us call the constructor incorrectly :)
|
// Force TypeScript to let us call the constructor incorrectly :)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { HttpResponse } from "../src/HttpClient";
|
import { HttpResponse } from "../src/HttpClient";
|
||||||
import { TransferFormat } from "../src/ITransport";
|
import { TransferFormat } from "../src/ITransport";
|
||||||
import { LongPollingTransport } from "../src/LongPollingTransport";
|
import { LongPollingTransport } from "../src/LongPollingTransport";
|
||||||
|
import { getUserAgentHeader } from "../src/Utils";
|
||||||
|
|
||||||
import { VerifyLogger } from "./Common";
|
import { VerifyLogger } from "./Common";
|
||||||
import { TestHttpClient } from "./TestHttpClient";
|
import { TestHttpClient } from "./TestHttpClient";
|
||||||
|
|
@ -120,6 +121,50 @@ describe("LongPollingTransport", () => {
|
||||||
await stopPromise;
|
await stopPromise;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("user agent header set on sends and polls", async () => {
|
||||||
|
await VerifyLogger.run(async (logger) => {
|
||||||
|
let firstPoll = true;
|
||||||
|
let firstPollUserAgent = "";
|
||||||
|
let secondPollUserAgent = "";
|
||||||
|
let deleteUserAgent = "";
|
||||||
|
const pollingPromiseSource = new PromiseSource();
|
||||||
|
const httpClient = new TestHttpClient()
|
||||||
|
.on("GET", async (r) => {
|
||||||
|
if (firstPoll) {
|
||||||
|
firstPoll = false;
|
||||||
|
firstPollUserAgent = r.headers![`User-Agent`];
|
||||||
|
return new HttpResponse(200);
|
||||||
|
} else {
|
||||||
|
secondPollUserAgent = r.headers![`User-Agent`];
|
||||||
|
await pollingPromiseSource.promise;
|
||||||
|
return new HttpResponse(204);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("DELETE", async (r) => {
|
||||||
|
deleteUserAgent = r.headers![`User-Agent`];
|
||||||
|
return new HttpResponse(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new LongPollingTransport(httpClient, undefined, logger, false);
|
||||||
|
|
||||||
|
await transport.connect("http://tempuri.org", TransferFormat.Text);
|
||||||
|
|
||||||
|
// Begin stopping transport
|
||||||
|
const stopPromise = transport.stop();
|
||||||
|
|
||||||
|
// Allow polling to complete
|
||||||
|
pollingPromiseSource.resolve();
|
||||||
|
|
||||||
|
// Wait for stop to complete
|
||||||
|
await stopPromise;
|
||||||
|
|
||||||
|
const [, value] = getUserAgentHeader();
|
||||||
|
expect(firstPollUserAgent).toEqual(value);
|
||||||
|
expect(deleteUserAgent).toEqual(value);
|
||||||
|
expect(secondPollUserAgent).toEqual(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeClosedPromise(transport: LongPollingTransport): Promise<void> {
|
function makeClosedPromise(transport: LongPollingTransport): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { TransferFormat } from "../src/ITransport";
|
||||||
import { HttpClient, HttpRequest } from "../src/HttpClient";
|
import { HttpClient, HttpRequest } from "../src/HttpClient";
|
||||||
import { ILogger } from "../src/ILogger";
|
import { ILogger } from "../src/ILogger";
|
||||||
import { ServerSentEventsTransport } from "../src/ServerSentEventsTransport";
|
import { ServerSentEventsTransport } from "../src/ServerSentEventsTransport";
|
||||||
|
import { getUserAgentHeader } from "../src/Utils";
|
||||||
import { VerifyLogger } from "./Common";
|
import { VerifyLogger } from "./Common";
|
||||||
import { TestEventSource, TestMessageEvent } from "./TestEventSource";
|
import { TestEventSource, TestMessageEvent } from "./TestEventSource";
|
||||||
import { TestHttpClient } from "./TestHttpClient";
|
import { TestHttpClient } from "./TestHttpClient";
|
||||||
|
|
@ -179,6 +180,26 @@ describe("ServerSentEventsTransport", () => {
|
||||||
expect(error).toEqual(new Error("error parsing"));
|
expect(error).toEqual(new Error("error parsing"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets user agent header on connect and sends", async () => {
|
||||||
|
await VerifyLogger.run(async (logger) => {
|
||||||
|
let request: HttpRequest;
|
||||||
|
const httpClient = new TestHttpClient().on((r) => {
|
||||||
|
request = r;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const sse = await createAndStartSSE(logger, "http://example.com", undefined, httpClient);
|
||||||
|
|
||||||
|
let [, value] = getUserAgentHeader();
|
||||||
|
expect((TestEventSource.eventSource.eventSourceInitDict as any).headers[`User-Agent`]).toEqual(value);
|
||||||
|
await sse.send("");
|
||||||
|
|
||||||
|
[, value] = getUserAgentHeader();
|
||||||
|
expect(request!.headers![`User-Agent`]).toBe(value);
|
||||||
|
expect(request!.url).toBe("http://example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createAndStartSSE(logger: ILogger, url?: string, accessTokenFactory?: (() => string | Promise<string>), httpClient?: HttpClient): Promise<ServerSentEventsTransport> {
|
async function createAndStartSSE(logger: ILogger, url?: string, accessTokenFactory?: (() => string | Promise<string>), httpClient?: HttpClient): Promise<ServerSentEventsTransport> {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export class TestEventSource {
|
||||||
public onmessage!: (evt: MessageEvent) => any;
|
public onmessage!: (evt: MessageEvent) => any;
|
||||||
public readyState: number = 0;
|
public readyState: number = 0;
|
||||||
public url: string = "";
|
public url: string = "";
|
||||||
|
public eventSourceInitDict?: EventSourceInit;
|
||||||
public withCredentials: boolean = false;
|
public withCredentials: boolean = false;
|
||||||
|
|
||||||
// tslint:disable-next-line:variable-name
|
// tslint:disable-next-line:variable-name
|
||||||
|
|
@ -31,6 +32,7 @@ export class TestEventSource {
|
||||||
|
|
||||||
constructor(url: string, eventSourceInitDict?: EventSourceInit) {
|
constructor(url: string, eventSourceInitDict?: EventSourceInit) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
this.eventSourceInitDict = eventSourceInitDict;
|
||||||
|
|
||||||
TestEventSource.eventSource = this;
|
TestEventSource.eventSource = this;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export class TestWebSocket {
|
||||||
public protocol: string;
|
public protocol: string;
|
||||||
public readyState: number = 1;
|
public readyState: number = 1;
|
||||||
public url: string;
|
public url: string;
|
||||||
|
public options?: any;
|
||||||
|
|
||||||
public static webSocketSet: PromiseSource;
|
public static webSocketSet: PromiseSource;
|
||||||
public static webSocket: TestWebSocket;
|
public static webSocket: TestWebSocket;
|
||||||
|
|
@ -67,10 +68,11 @@ export class TestWebSocket {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(url: string, protocols?: string | string[]) {
|
constructor(url: string, protocols?: string | string[], options?: any) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.protocol = protocols ? (typeof protocols === "string" ? protocols : protocols[0]) : "";
|
this.protocol = protocols ? (typeof protocols === "string" ? protocols : protocols[0]) : "";
|
||||||
this.receivedData = [];
|
this.receivedData = [];
|
||||||
|
this.options = options;
|
||||||
|
|
||||||
TestWebSocket.webSocket = this;
|
TestWebSocket.webSocket = this;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
import { constructUserAgent } from "../src/Utils";
|
||||||
|
|
||||||
|
describe("User Agent", () => {
|
||||||
|
[["1.0.4-build.10", "Linux", "NodeJS", "10", "Microsoft SignalR/1.0 (1.0.4-build.10; Linux; NodeJS; 10)"],
|
||||||
|
["1.4.7-build.10", "", "Browser", "", "Microsoft SignalR/1.4 (1.4.7-build.10; Unknown OS; Browser; Unknown Runtime Version)"],
|
||||||
|
["3.1.1-build.10", "macOS", "Browser", "", "Microsoft SignalR/3.1 (3.1.1-build.10; macOS; Browser; Unknown Runtime Version)"],
|
||||||
|
["3.1.3-build.10", "", "Browser", "4", "Microsoft SignalR/3.1 (3.1.3-build.10; Unknown OS; Browser; 4)"]]
|
||||||
|
.forEach(([version, os, runtime, runtimeVersion, expected]) => {
|
||||||
|
it(`is in correct format`, async () => {
|
||||||
|
const userAgent = constructUserAgent(version, os, runtime, runtimeVersion);
|
||||||
|
expect(userAgent).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { ILogger } from "../src/ILogger";
|
import { ILogger } from "../src/ILogger";
|
||||||
import { TransferFormat } from "../src/ITransport";
|
import { TransferFormat } from "../src/ITransport";
|
||||||
|
import { getUserAgentHeader } from "../src/Utils";
|
||||||
import { WebSocketTransport } from "../src/WebSocketTransport";
|
import { WebSocketTransport } from "../src/WebSocketTransport";
|
||||||
import { VerifyLogger } from "./Common";
|
import { VerifyLogger } from "./Common";
|
||||||
import { TestMessageEvent } from "./TestEventSource";
|
import { TestMessageEvent } from "./TestEventSource";
|
||||||
|
|
@ -202,6 +203,32 @@ describe("WebSocketTransport", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets user agent header on connect", async () => {
|
||||||
|
await VerifyLogger.run(async (logger) => {
|
||||||
|
(global as any).ErrorEvent = TestEvent;
|
||||||
|
const webSocket = await createAndStartWebSocket(logger);
|
||||||
|
|
||||||
|
let closeCalled: boolean = false;
|
||||||
|
let error: Error;
|
||||||
|
webSocket.onclose = (e) => {
|
||||||
|
closeCalled = true;
|
||||||
|
error = e!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [, value] = getUserAgentHeader();
|
||||||
|
expect(TestWebSocket.webSocket.options!.headers[`User-Agent`]).toEqual(value);
|
||||||
|
|
||||||
|
await webSocket.stop();
|
||||||
|
|
||||||
|
expect(closeCalled).toBe(true);
|
||||||
|
expect(error!).toBeUndefined();
|
||||||
|
|
||||||
|
await expect(webSocket.send(""))
|
||||||
|
.rejects
|
||||||
|
.toBe("WebSocket is not in the OPEN state");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createAndStartWebSocket(logger: ILogger, url?: string, accessTokenFactory?: (() => string | Promise<string>), format?: TransferFormat): Promise<WebSocketTransport> {
|
async function createAndStartWebSocket(logger: ILogger, url?: string, accessTokenFactory?: (() => string | Promise<string>), format?: TransferFormat): Promise<WebSocketTransport> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
@ -12,23 +14,19 @@ namespace Microsoft.AspNetCore.Http.Connections.Tests
|
||||||
{
|
{
|
||||||
public class UserAgentHeaderTest
|
public class UserAgentHeaderTest
|
||||||
{
|
{
|
||||||
[Fact]
|
[Theory]
|
||||||
public void UserAgentHeaderIsAccurate()
|
[MemberData(nameof(UserAgents))]
|
||||||
|
public void UserAgentHeaderIsCorrect(Version version, string detailedVersion, string os, string runtime, string runtimeVersion, string expected)
|
||||||
{
|
{
|
||||||
var majorVersion = typeof(HttpConnection).Assembly.GetName().Version.Major;
|
Assert.Equal(expected, Constants.ConstructUserAgent(version, detailedVersion, os, runtime, runtimeVersion));
|
||||||
var minorVersion = typeof(HttpConnection).Assembly.GetName().Version.Minor;
|
}
|
||||||
var version = typeof(HttpConnection).Assembly.GetName().Version;
|
|
||||||
var os = RuntimeInformation.OSDescription;
|
|
||||||
var runtime = ".NET";
|
|
||||||
var runtimeVersion = RuntimeInformation.FrameworkDescription;
|
|
||||||
var assemblyVersion = typeof(Constants)
|
|
||||||
.Assembly
|
|
||||||
.GetCustomAttributes<AssemblyInformationalVersionAttribute>()
|
|
||||||
.FirstOrDefault();
|
|
||||||
var userAgent = Constants.UserAgentHeader;
|
|
||||||
var expectedUserAgent = $"Microsoft SignalR/{majorVersion}.{minorVersion} ({assemblyVersion.InformationalVersion}; {os}; {runtime}; {runtimeVersion})";
|
|
||||||
|
|
||||||
Assert.Equal(expectedUserAgent, userAgent);
|
public static IEnumerable<object[]> UserAgents()
|
||||||
|
{
|
||||||
|
yield return new object[] { new Version(1, 4), "1.4.3-preview9", "Windows NT", ".NET", ".NET 4.8.7", "Microsoft SignalR/1.4 (1.4.3-preview9; Windows NT; .NET; .NET 4.8.7)" };
|
||||||
|
yield return new object[] { new Version(3, 1), "3.1.0", "", ".NET", ".NET 4.8.9", "Microsoft SignalR/3.1 (3.1.0; Unknown OS; .NET; .NET 4.8.9)" };
|
||||||
|
yield return new object[] { new Version(3, 1), "3.1.0", "", ".NET", "", "Microsoft SignalR/3.1 (3.1.0; Unknown OS; .NET; Unknown Runtime Version)" };
|
||||||
|
yield return new object[] { new Version(3, 1), "", "Linux", ".NET", ".NET 4.5.1", "Microsoft SignalR/3.1 (Unknown Version; Linux; .NET; .NET 4.5.1)" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue