diff --git a/samples/Whiteboard/Controllers/BackgroundController.cs b/samples/Whiteboard/Controllers/BackgroundController.cs new file mode 100644 index 00000000..54c6522c --- /dev/null +++ b/samples/Whiteboard/Controllers/BackgroundController.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Microsoft.Azure.SignalR.Samples.Whiteboard; + +[Route("/background")] +public class BackgroundController(IHubContext context, Diagram diagram) : Controller +{ + private readonly IHubContext hubContext = context; + private readonly Diagram diagram = diagram; + + [HttpPost("upload")] + public async Task Upload(IFormFile file) + { + diagram.BackgroundId = Guid.NewGuid().ToString().Substring(0, 8); + diagram.Background = new byte[file.Length]; + diagram.BackgroundContentType = file.ContentType; + using (var stream = new MemoryStream(diagram.Background)) + { + await file.CopyToAsync(stream); + } + + await hubContext.Clients.All.SendAsync("BackgroundUpdated", diagram.BackgroundId); + + return Ok(); + } + + [HttpGet("{id}")] + public IActionResult Download(string id) + { + if (diagram.BackgroundId != id) return NotFound(); + return File(diagram.Background, diagram.BackgroundContentType); + } +} \ No newline at end of file diff --git a/samples/Whiteboard/Diagram.cs b/samples/Whiteboard/Diagram.cs new file mode 100644 index 00000000..fc733c16 --- /dev/null +++ b/samples/Whiteboard/Diagram.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Azure.SignalR.Samples.Whiteboard; + +public class Point +{ + public int X { get; set; } + public int Y { get; set; } +} + +public abstract class Shape +{ + public string Color { get; set; } + + public int Width { get; set; } +} + +public class Polyline : Shape +{ + public List Points { get; set; } +} + +public class Line : Shape +{ + public Point Start { get; set; } + + public Point End { get; set; } +} + +public class Circle : Shape +{ + public Point Center { get; set; } + + public int Radius { get; set; } +} + +public class Rect : Shape +{ + public Point TopLeft { get; set; } + + public Point BottomRight { get; set; } +} + +public class Ellipse : Shape +{ + public Point TopLeft { get; set; } + + public Point BottomRight { get; set; } +} + +public class Diagram +{ + private int totalUsers = 0; + + public byte[] Background { get; set; } + + public string BackgroundContentType { get; set; } + + public string BackgroundId { get; set; } + + public ConcurrentDictionary Shapes { get; } = new ConcurrentDictionary(); + + public int UserEnter() + { + return Interlocked.Increment(ref totalUsers); + } + + public int UserLeave() + { + return Interlocked.Decrement(ref totalUsers); + } +} \ No newline at end of file diff --git a/samples/Whiteboard/Hub/DrawHub.cs b/samples/Whiteboard/Hub/DrawHub.cs new file mode 100644 index 00000000..ab7de76d --- /dev/null +++ b/samples/Whiteboard/Hub/DrawHub.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.SignalR; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; +using System; + +namespace Microsoft.Azure.SignalR.Samples.Whiteboard; + +public class DrawHub(Diagram diagram) : Hub +{ + private readonly Diagram diagram = diagram; + + private async Task UpdateShape(string id, Shape shape) + { + diagram.Shapes[id] = shape; + await Clients.Others.SendAsync("ShapeUpdated", id, shape.GetType().Name, shape); + } + + public override Task OnConnectedAsync() + { + var t = Task.WhenAll(diagram.Shapes.AsEnumerable().Select(l => Clients.Client(Context.ConnectionId).SendAsync("ShapeUpdated", l.Key, l.Value.GetType().Name, l.Value))); + if (diagram.Background != null) t = t.ContinueWith(_ => Clients.Client(Context.ConnectionId).SendAsync("BackgroundUpdated", diagram.BackgroundId)); + return t.ContinueWith(_ => Clients.All.SendAsync("UserUpdated", diagram.UserEnter())); + } + + public override Task OnDisconnectedAsync(Exception exception) + { + return Clients.All.SendAsync("UserUpdated", diagram.UserLeave()); + } + + public async Task RemoveShape(string id) + { + diagram.Shapes.Remove(id, out _); + await Clients.Others.SendAsync("ShapeRemoved", id); + } + + public async Task AddOrUpdatePolyline(string id, Polyline polyline) + { + await this.UpdateShape(id, polyline); + } + + public async Task PatchPolyline(string id, Polyline polyline) + { + if (diagram.Shapes[id] is not Polyline p) throw new InvalidOperationException($"Shape {id} does not exist or is not a polyline."); + if (polyline.Color != null) p.Color = polyline.Color; + if (polyline.Width != 0) p.Width = polyline.Width; + p.Points.AddRange(polyline.Points); + await Clients.Others.SendAsync("ShapePatched", id, polyline); + } + + public async Task AddOrUpdateLine(string id, Line line) + { + await this.UpdateShape(id, line); + } + + public async Task AddOrUpdateCircle(string id, Circle circle) + { + await this.UpdateShape(id, circle); + } + + public async Task AddOrUpdateRect(string id, Rect rect) + { + await this.UpdateShape(id, rect); + } + + public async Task AddOrUpdateEllipse(string id, Ellipse ellipse) + { + await this.UpdateShape(id, ellipse); + } + + public async Task Clear() + { + diagram.Shapes.Clear(); + diagram.Background = null; + await Clients.Others.SendAsync("Clear"); + } + + public async Task SendMessage(string name, string message) + { + await Clients.Others.SendAsync("NewMessage", name, message); + } +} \ No newline at end of file diff --git a/samples/Whiteboard/MCPServer/index.js b/samples/Whiteboard/MCPServer/index.js new file mode 100644 index 00000000..37643a95 --- /dev/null +++ b/samples/Whiteboard/MCPServer/index.js @@ -0,0 +1,131 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { HubConnectionBuilder } from '@microsoft/signalr'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const logger = new class { + log = (level, message) => level > 1 && console.error(`[${level}] ${message}`); +}; + +const connection = new HubConnectionBuilder().withUrl(`${process.env['WHITEBOARD_ENDPOINT'] || 'http://localhost:5000'}/draw`).withAutomaticReconnect().configureLogging(logger).build(); + +const server = new McpServer({ + name: 'Whiteboard', + version: '1.0.0' +}); + +let color = z.string().describe('color of the shape, valid values are: black, grey, darkred, red, orange, yellow, green, deepskyblue, indigo, purple'); +let width = z.number().describe('width of the shape, valid values are: 1, 2, 4, 8'); +let point = z.object({ + x: z.number().describe('x coordinate of the point, 0 denotes the left edge of the whiteboard'), + y: z.number().describe('y coordinate of the point, 0 denotes the top edge of the whiteboard') +}); +let id = z.string().describe('unique identifier of the shape, if it does not exist, it will be created, if it exists, it will be updated'); + +server.tool('send_message', 'post a message on whiteboard', { name: z.string(), message: z.string() }, async ({ name, message }) => { + await connection.send('sendMessage', name, message); + return { content: [{ type: 'text', text: 'Message sent' }] } +}); + +server.tool( + 'add_or_update_polyline', 'add or update a polyline on whiteboard', + { + id, polyline: z.object({ + color, width, + points: z.array(point).describe('array of points that define the polyline') + }) + }, + async ({ id, polyline }) => { + await connection.send('addOrUpdatePolyline', id, polyline); + return { content: [{ type: 'text', text: 'Polyline added or updated' }] }; + }); + +server.tool( + 'add_or_update_line', 'add or update a line on whiteboard', + { + id, line: z.object({ + color, width, + start: point.describe('start point of the line'), + end: point.describe('end point of the line') + }) + }, + async ({ id, line }) => { + await connection.send('addOrUpdateLine', id, line); + return { content: [{ type: 'text', text: 'Line added or updated' }] }; + }); + +server.tool( + 'add_or_update_circle', 'add or update a circle on whiteboard', + { + id, circle: z.object({ + color, width, + center: point.describe('center point of the circle'), + radius: z.number().describe('radius of the circle') + }) + }, + async ({ id, circle }) => { + await connection.send('addOrUpdateCircle', id, circle); + return { content: [{ type: 'text', text: 'Circle added or updated' }] }; + }); + +server.tool( + 'add_or_update_rect', 'add or update a rectangle on whiteboard', + { + id, rect: z.object({ + color, width, + topLeft: point.describe('top left corner of the rectangle'), + bottomRight: point.describe('bottom right of the rectangle') + }) + }, + async ({ id, rect }) => { + await connection.send('addOrUpdateRect', id, rect); + return { content: [{ type: 'text', text: 'Rectangle added or updated' }] }; + }); + +server.tool( + 'add_or_update_ellipse', 'add or update an ellipse on whiteboard', + { + id, ellipse: z.object({ + color, width, + topLeft: point.describe('top left corner of the bounding rectangle of the ellipse'), + bottomRight: point.describe('bottom right of the bounding rectangle of the ellipse') + }) + }, + async ({ id, ellipse }) => { + await connection.send('addOrUpdateEllipse', id, ellipse); + return { content: [{ type: 'text', text: 'Ellipse added or updated' }] }; + }); + +server.tool( + 'remove_shape', 'remove a shape from whiteboard', + { id }, + async ({ id }) => { + await connection.send('removeShape', id); + return { content: [{ type: 'text', text: 'Shape removed' }] }; + }); + +server.tool( + 'clear', 'clear the whiteboard', + {}, + async () => { + await connection.send('clear'); + return { content: [{ type: 'text', text: 'Whiteboard cleared' }] }; + }); + +const transport = new StdioServerTransport(); + +await server.connect(transport); + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +for (;;) { + try { + await connection.start(); + break; + } catch (e) { + console.error('Failed to start SignalR connection: ' + e.message); + await sleep(5000); + } +} \ No newline at end of file diff --git a/samples/Whiteboard/MCPServer/package-lock.json b/samples/Whiteboard/MCPServer/package-lock.json new file mode 100644 index 00000000..dca6fda4 --- /dev/null +++ b/samples/Whiteboard/MCPServer/package-lock.json @@ -0,0 +1,1190 @@ +{ + "name": "signalrmcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "signalrmcp", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "@modelcontextprotocol/sdk": "^1.9.0", + "dotenv": "^16.4.7" + } + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.9.0.tgz", + "integrity": "sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/samples/Whiteboard/MCPServer/package.json b/samples/Whiteboard/MCPServer/package.json new file mode 100644 index 00000000..988bbfb8 --- /dev/null +++ b/samples/Whiteboard/MCPServer/package.json @@ -0,0 +1,18 @@ +{ + "name": "signalrmcp", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "type": "module", + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "@modelcontextprotocol/sdk": "^1.9.0", + "dotenv": "^16.4.7" + } +} \ No newline at end of file diff --git a/samples/Whiteboard/Program.cs b/samples/Whiteboard/Program.cs new file mode 100644 index 00000000..0a54118f --- /dev/null +++ b/samples/Whiteboard/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Azure.SignalR.Samples.Whiteboard; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); +builder.Services.AddMvc(); +builder.Services.AddSignalR().AddAzureSignalR(); + +var app = builder.Build(); +app.UseRouting(); +app.UseFileServer(); +app.MapControllers(); +app.MapHub("/draw"); + +app.Run(); \ No newline at end of file diff --git a/samples/Whiteboard/Properties/launchSettings.json b/samples/Whiteboard/Properties/launchSettings.json new file mode 100644 index 00000000..3f820e4e --- /dev/null +++ b/samples/Whiteboard/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5000/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Whiteboard": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5000/" + } + } +} \ No newline at end of file diff --git a/samples/Whiteboard/README.md b/samples/Whiteboard/README.md new file mode 100644 index 00000000..a56bd7af --- /dev/null +++ b/samples/Whiteboard/README.md @@ -0,0 +1,107 @@ +# Whiteboard: Real Time Collaboration using Azure SignalR Service + +This is a sample project to demonstrate how to build a web application for real time collaboration using Azure, ASP.NET Core and other related technologies. This sample application includes the following features: + +* A whiteboard that anyone can paint on it and others can see you paint in real time +* Painting features: + 1. Basic paint tools (freehand, line, rectangle, circle, ellipse) with customizable color and stroke thickness + 2. Upload a background image + 3. Pan and zoom canvas + 4. Undo and redo + 5. Touch support for mobile devices +* Real time chat + +This application is based on the following technologies: + +* For frontend: HTML5/javascript, bootstrap and vue.js +* For backend: ASP.NET Core +* For realtime communication: SignalR and Azure SignalR Service + +## Build and run locally + +1. Create an Azure SignalR Service instance +2. Go to Access control (IAM) tab on portal, add "SignalR App Server" role to your own Azure account +3. Set connection string + ``` + set Azure__SignalR__ConnectionString=Endpoint=https://.service.signalr.net;Type=azure; + ``` +4. Make sure you log into Azure using Azure CLI +5. Build and run the application locally + + ``` + dotnet build + dotnet run + ``` + +> Alternatively you can also use access key based connection string to authenticate with Azure (which may be simpler but less secure than using Entra ID): +> 1. Go to portal and copy connection string from Connection strings tab +> 2. Save it to .NET secret store +> ``` +> dotnet user-secrets set Azure:SignalR:ConnectionString "" +> ``` + +Open multiple windows on http://localhost:5000/, when you paint in one window, others will see the update immediately. + +## Deploy to Azure + +1. To deploy the application to Azure Web App, first package it into a zip file: + + ``` + dotnet build + dotnet publish -c Release + ``` + Then package all files under `bin/Release/net9.0/publish` to a zip file. + +2. Then use the following command to deploy it to Azure Web App: + + ``` + az webapp deployment source config-zip --src -n -g + ``` + +3. Set Azure SignalR Service connection string in the application settings. You can do it through portal or using Azure CLI: + ``` + az webapp config appsettings set --resource-group --name \ + --setting Azure__SignalR__ConnectionString="Endpoint=https://.service.signalr.net;Type=azure;" + ``` + And add "SignalR App Server" role to your web app instance + + > You can also use access key based connection string but it's highly unrecommended + +Now your whiteboard is running in Azure at `https://.azurewebsites.net`. Enjoy! + +## Interact with Large Language Model + +[Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) is an open protocol that enables seamless integration between LLM applications and external data sources and tools. With MCP we can expose the painting capability of whiteboard to LLM so it can draw the picture for you! + +![mcp](./mcp.gif) + +[MCPServer](MCPServer/) is a MCP server implementation that exposes hub methods from the whiteboard SignalR hub so LLM can directly operate on the whiteboard. + +To install the MCP server: + +1. Install dependencies + + ``` + npm install + ``` + +2. The MCP server will by default connect to local server (http://localhost:5000). If your whiteboard is not running locally, set the endpoint in `WHITEBOARD_ENDPOINT` environment variable or `.env` file + +3. Configure the MCP server in your LLM app (like Claude Desktop or GitHub Copilot in VS Code): + + ```json + "mcpServers": { + "Whiteboard": { + "command": "node", + "args": [ + "/index.js" + ] + } + } + ``` + + > Change `mcpServers` to `mcp` if you're using VS Code + +4. Start the server if it's not automatically started (like in VS Code) + +5. Now open your favorite LLM app and ask it to paint something on whiteboard, you'll see it paint in real time. \ No newline at end of file diff --git a/samples/Whiteboard/Whiteboard.csproj b/samples/Whiteboard/Whiteboard.csproj new file mode 100644 index 00000000..cb731ee7 --- /dev/null +++ b/samples/Whiteboard/Whiteboard.csproj @@ -0,0 +1,15 @@ + + + net9.0 + whiteboard + Microsoft.Azure.SignalR.Samples.Whiteboard + + + + + + + + + + diff --git a/samples/Whiteboard/Whiteboard.sln b/samples/Whiteboard/Whiteboard.sln new file mode 100644 index 00000000..85a8562a --- /dev/null +++ b/samples/Whiteboard/Whiteboard.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Whiteboard", "Whiteboard.csproj", "{E4CDAD0A-E491-036F-B3BE-BDF81466BF12}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E4CDAD0A-E491-036F-B3BE-BDF81466BF12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4CDAD0A-E491-036F-B3BE-BDF81466BF12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4CDAD0A-E491-036F-B3BE-BDF81466BF12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4CDAD0A-E491-036F-B3BE-BDF81466BF12}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1E3E8FF2-65C4-40E1-A734-AA964A438553} + EndGlobalSection +EndGlobal diff --git a/samples/Whiteboard/appsettings.json b/samples/Whiteboard/appsettings.json new file mode 100644 index 00000000..26bb0ac7 --- /dev/null +++ b/samples/Whiteboard/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "IncludeScopes": false, + "Debug": { + "LogLevel": { + "Default": "Warning" + } + }, + "Console": { + "LogLevel": { + "Default": "Warning" + } + } + } +} diff --git a/samples/Whiteboard/mcp.gif b/samples/Whiteboard/mcp.gif new file mode 100644 index 00000000..5f768cc0 Binary files /dev/null and b/samples/Whiteboard/mcp.gif differ diff --git a/samples/Whiteboard/wwwroot/images/signalr-logo.png b/samples/Whiteboard/wwwroot/images/signalr-logo.png new file mode 100644 index 00000000..dbd60847 Binary files /dev/null and b/samples/Whiteboard/wwwroot/images/signalr-logo.png differ diff --git a/samples/Whiteboard/wwwroot/index.html b/samples/Whiteboard/wwwroot/index.html new file mode 100644 index 00000000..14356792 --- /dev/null +++ b/samples/Whiteboard/wwwroot/index.html @@ -0,0 +1,197 @@ + + + + + + + + + + + + Whiteboard + + + + +
+
+ +
+
+

{{ m.name + ': ' + m.message }}

+
+
+ +
+ + +
+
+ + + + + + + + \ No newline at end of file diff --git a/samples/Whiteboard/wwwroot/script.js b/samples/Whiteboard/wwwroot/script.js new file mode 100644 index 00000000..f4969ec0 --- /dev/null +++ b/samples/Whiteboard/wwwroot/script.js @@ -0,0 +1,518 @@ +function connect(url, connected, disconnected) { + let connectWithRetry = c => c.start().then(() => connected()).catch(error => { + console.log('Failed to start SignalR connection: ' + error.message); + setTimeout(() => connectWithRetry(c), 5000); + }); + + // create connection + let c = new signalR.HubConnectionBuilder().withUrl(url).withAutomaticReconnect().build(); + + c.onreconnecting(() => disconnected()); + c.onreconnected(() => connected()); + + connectWithRetry(c); + return c; +} + +async function resizeImage(data, maxSize) { + return new Promise(resolve => { + if (!maxSize) { + resolve(); + return; + } + let dataURLToBlob = dataURL => { + let BASE64_MARKER = ';base64,'; + if (dataURL.indexOf(BASE64_MARKER) == -1) { + let parts = dataURL.split(','); + let contentType = parts[0].split(':')[1]; + let raw = parts[1]; + + return new Blob([raw], { type: contentType }); + } + + let parts = dataURL.split(BASE64_MARKER); + let contentType = parts[0].split(':')[1]; + let raw = window.atob(parts[1]); + let rawLength = raw.length; + + let uInt8Array = new Uint8Array(rawLength); + + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.charCodeAt(i); + } + + return new Blob([uInt8Array], { type: contentType }); + } + + let reader = new FileReader(); + reader.onload = readerEvent => { + let image = new Image(); + image.onload = function () { + let canvas = document.createElement('canvas'); + let ratio = Math.max(image.width / maxSize, image.height / maxSize); + if (ratio < 1) { + resolve(); + return; + } + canvas.width = image.width / ratio; + canvas.height = image.height / ratio; + canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height); + resolve(dataURLToBlob(canvas.toDataURL('image/jpeg'))); + } + image.src = readerEvent.target.result; + } + reader.readAsDataURL(data); + }); +} + +let Diagram = function (element, tools) { + let id; + let shapes = {}; + let past = [], future = []; + let timestamp = 0; + let buffer = []; + let background; + let scale = 1; + let offset = [0, 0]; + + let shapeUpdateCallback = shapePatchCallback = shapeRemoveCallback = clearCallback = historyChangeCallback = () => { }; + + function generateId() { + return Math.floor((1 + Math.random()) * 0x100000000).toString(16).substring(1); + } + + function tryNotify(c) { + let t = new Date().getTime(); + if (t - timestamp < 250) return; + c(); + timestamp = t; + } + + function historyChange() { + historyChangeCallback(past.length > 0, future.length > 0); + } + + function applyStyle(e, c, w) { + return e.fill('none').stroke({ color: c, width: w, linecap: 'round' }); + } + + function translate(x, y) { + return [offset[0] + x / scale, offset[1] + y / scale]; + } + + function serialize(m) { + let t = tools[m.kind]; + return { + color: m.color, + width: m.width, + ...t.serialize(m.data) + }; + } + + function startShape(k, c, w, x, y) { + if (id) return; + id = generateId(); + [x, y] = translate(x, y); + let m = { kind: k, color: c, width: w, data: tools[k].start(x, y) }; + shapes[id] = { view: applyStyle(tools[k].draw(element, m.data), c, w), model: m }; + future = []; + past.push(id); + historyChange(); + shapeUpdateCallback(id, k, serialize(m)); + } + + function drawShape(x, y) { + if (!id) return; + [x, y] = translate(x, y); + let s = shapes[id]; + let t = tools[s.model.kind]; + let d = t.move(x, y, s.model.data); + t.update(s.view, s.model.data); + if (d) { + buffer = buffer.concat(d); + tryNotify(() => { + shapePatchCallback(id, s.model.kind, t.serialize(buffer)); + buffer = []; + }); + } else tryNotify(() => shapeUpdateCallback(id, s.model.kind, serialize(s.model))); + } + + function endShape() { + if (!id) return; + let s = shapes[id]; + let t = tools[s.model.kind]; + if (buffer.length > 0) { + shapePatchCallback(id, s.model.kind, t.serialize(buffer)); + buffer = []; + } else shapeUpdateCallback(id, shapes[id].model.kind, serialize(shapes[id].model)); + id = null; + } + + function updateShapeInternal(i, m) { + if (shapes[i]) { + shapes[i].model = m; + tools[m.kind].update(shapes[i].view, m.data); + applyStyle(shapes[i].view, m.color, m.width); + } else shapes[i] = { view: applyStyle(tools[m.kind].draw(element, m.data), m.color, m.width), model: m }; + } + + function updateShape(i, k, d) { + let t = tools[k]; + let m = { color: d.color, width: d.width, kind: k, data: t.deserialize(d) }; + updateShapeInternal(i, m); + } + + function patchShape(i, d) { + if (shapes[i]) { + let m = shapes[i].model; + let t = tools[m.kind]; + if (d.color) m.color = d.color; + if (d.width) m.width = d.width; + m.data = m.data.concat(t.deserialize(d)); + t.update(shapes[i].view, m.data); + applyStyle(shapes[i].view, m.color, m.width); + } + } + + function removeShape(i) { + if (!shapes[i]) return; + shapes[i].view.remove(); + delete shapes[i]; + } + + function clear() { + removeAll(); + clearCallback(); + } + + function removeAll() { + id = null; + shapes = {}; + past = [], future = []; + timestamp = 0; + buffer = []; + background = null; + element.clear(); + historyChange(); + } + + function updateBackground(file) { + if (background) background.remove(); + background = element.image(file).back(); + } + + function resizeViewbox(w, h) { + let v = element.viewbox(); + element.viewbox(v.x, v.y, w / scale, h / scale); + } + + function pan(dx, dy) { + let v = element.viewbox(); + offset = [v.x + dx / scale, v.y + dy / scale]; + element.viewbox(offset[0], offset[1], v.width, v.height); + } + + function zoom(r) { + scale *= r; + let v = element.viewbox(); + element.viewbox(v.x, v.y, v.width / r, v.height / r); + } + + function undo() { + let i = past.pop(); + if (!i) return; + future.push(shapes[i].model); + removeShape(i); + shapeRemoveCallback(i); + historyChange(); + } + + function redo() { + let m = future.pop(); + if (!m) return; + let i = generateId(); + updateShapeInternal(i, m); + shapeUpdateCallback(i, m.kind, serialize(m)); + past.push(i); + historyChange(); + } + + return { + startShape: startShape, + drawShape: drawShape, + endShape: endShape, + updateShape: updateShape, + patchShape: patchShape, + removeShape: removeShape, + clear: clear, + removeAll: removeAll, + updateBackground: updateBackground, + resizeViewbox: resizeViewbox, + pan: pan, + zoom: zoom, + undo: undo, + redo: redo, + onShapeUpdate: c => shapeUpdateCallback = c, + onShapeRemove: c => shapeRemoveCallback = c, + onShapePatch: c => shapePatchCallback = c, + onClear: c => clearCallback = c, + onHistoryChange: c => historyChangeCallback = c + }; +}; + +let modes = { + panAndZoom: { + startOne: p => 0, + moveOne: (p, pp) => diagram.pan(pp[0] - p[0], pp[1] - p[1]), + startTwo: (p1, p2) => 0, + moveTwo: (p1, p2, pp1, pp2) => { + let r = Math.sqrt(((p2[0] - p1[0]) * (p2[0] - p1[0]) + (p2[1] - p1[1]) * (p2[1] - p1[1])) + / ((pp2[0] - pp1[0]) * (pp2[0] - pp1[0]) + (pp2[1] - pp1[1]) * (pp2[1] - pp1[1]))); + diagram.pan(pp1[0] - p1[0] / r, pp1[1] - p1[1] / r); + diagram.zoom(r); + }, + end: () => 0 + }, + draw: { + startOne: p => { if (appData.connected.value) diagram.startShape(appData.tool, appData.color, appData.width, p[0], p[1]); }, + moveOne: (p, pp) => { if (appData.connected.value) diagram.drawShape(p[0], p[1]); }, + startTwo: () => 0, + moveTwo: () => 0, + end: () => { if (appData.connected.value) diagram.endShape(); } + } +}; + +let tools = { + 'Polyline': { + start: (x, y) => [x, y], + move: (x, y, d) => { d.push(x, y); return [x, y]; }, + draw: (b, d) => b.polyline(d), + update: (e, d) => e.plot(d), + serialize: d => ({ + points: d.reduce((a, c, i) => { + if (i % 2 === 0) a.push({ x: c, y: d[i + 1] }); + return a; + }, []) + }), + deserialize: d => d.points.reduce((a, c) => a.concat(c.x, c.y), []) + }, + 'Line': { + start: (x, y) => [x, y, x, y], + move: (x, y, d) => { d[2] = x; d[3] = y; }, + draw: (b, d) => b.line(d), + update: (e, d) => e.plot(d), + serialize: d => ({ + start: { x: d[0], y: d[1] }, + end: { x: d[2], y: d[3] } + }), + deserialize: d => [d.start.x, d.start.y, d.end.x, d.end.y] + }, + 'Rect': { + start: (x, y) => [x, y, x, y], + move: (x, y, d) => { d[2] = x; d[3] = y; }, + draw: (b, d) => b.rect(Math.abs(d[2] - d[0]), Math.abs(d[3] - d[1])).move(Math.min(d[0], d[2]), Math.min(d[1], d[3])), + update: (e, d) => e.x(Math.min(d[2], d[0])).y(Math.min(d[1], d[3])).size(Math.abs(d[2] - d[0]), Math.abs(d[3] - d[1])), + serialize: d => ({ + topLeft: { x: d[0], y: d[1] }, + bottomRight: { x: d[2], y: d[3] } + }), + deserialize: d => [d.topLeft.x, d.topLeft.y, d.bottomRight.x, d.bottomRight.y] + }, + 'Circle': { + start: (x, y) => [x, y, 0], + move: (x, y, d) => { d[2] = Math.floor(Math.sqrt((d[0] - x) * (d[0] - x) + (d[1] - y) * (d[1] - y))) }, + draw: (b, d) => b.circle(d[2] * 2).cx(d[0]).cy(d[1]), + update: (e, d) => e.cx(d[0]).cy(d[1]).radius(d[2]), + serialize: d => ({ + center: { x: d[0], y: d[1] }, + radius: d[2] + }), + deserialize: d => [d.center.x, d.center.y, d.radius] + }, + 'Ellipse': { + start: (x, y) => [x, y, x, y], + move: (x, y, d) => { d[2] = x; d[3] = y; }, + draw: (b, d) => b.ellipse(Math.abs(d[2] - d[0]), Math.abs(d[3] - d[1])).cx((d[0] + d[2]) / 2).cy((d[1] + d[3]) / 2), + update: (e, d) => e.cx((d[0] + d[2]) / 2).cy((d[1] + d[3]) / 2).radius(Math.abs(d[2] - d[0]) / 2, Math.abs(d[3] - d[1]) / 2), + serialize: d => ({ + topLeft: { x: d[0], y: d[1] }, + bottomRight: { x: d[2], y: d[3] } + }), + deserialize: d => [d.topLeft.x, d.topLeft.y, d.bottomRight.x, d.bottomRight.y] + } +}; + +let connection = connect('/draw', () => { + appData.connected.value = true; + diagram.removeAll(); +}, () => appData.connected.value = false); + +let diagram = new Diagram(SVG('whiteboard'), tools); +diagram.onShapeUpdate((i, k, m) => connection.send(`addOrUpdate${k}`, i, m)); +diagram.onShapePatch((i, k, m) => connection.send(`patch${k}`, i, m)); +diagram.onShapeRemove(i => connection.send('removeShape', i)); +diagram.onClear(() => connection.send('clear')); +diagram.onHistoryChange((p, f) => [appData.hasUndo.value, appData.hasRedo.value] = [p, f]); +connection.on('clear', diagram.removeAll); +connection.on('shapeUpdated', diagram.updateShape); +connection.on('shapePatched', diagram.patchShape); +connection.on('shapeRemoved', diagram.removeShape); +connection.on('backgroundUpdated', i => diagram.updateBackground('/background/' + i)); +connection.on('newMessage', (n, m) => appData.messages.push({ name: n, message: m })); +connection.on('userUpdated', n => appData.totalUsers.value = n); + +const { createApp, reactive, ref } = Vue; + +let appData = { + diagram, + connected: ref(false), + totalUsers: ref(1), + hasUndo: ref(false), + hasRedo: ref(false), + tool: 'Polyline', + color: 'black', + width: 1, + tools: Object.keys(tools), + colors: ['black', 'grey', 'darkred', 'red', 'orange', 'yellow', 'green', 'deepskyblue', 'indigo', 'purple'], + widths: [1, 2, 4, 8], + messages: reactive([]), + messageColor: 'black', + name: '', + draft: '', + showLog: true, + maxImageSize: 1920 +}; + +let app = createApp({ + data: () => appData, + methods: { + upload: async function (e) { + let f = document.querySelector('#uploadForm'); + let formData = new FormData(f); + let b = await resizeImage(e.target.files[0], this.maxImageSize); + if (b) { + formData.delete('file'); + formData.append('file', b); + } + await fetch('/background/upload', { + method: 'POST', + body: formData, + cache: 'no-cache' + }); + + f.reset(); + }, + zoomIn: () => diagram.zoom(1.25), + zoomOut: () => diagram.zoom(0.8), + sendMessage: function () { + if (!this.draft) return; + this.messages.push({ name: this.name, message: this.draft }); + connection.send('sendMessage', this.name, this.draft); + this.draft = ''; + }, + setName: function () { if (this.name) inputName.hide(); }, + toggleLog: function () { this.showLog = !this.showLog; }, + showSettings: () => new bootstrap.Modal(document.querySelector("#settings"), { backdrop: 'static', keyboard: false }).show() + } +}); + +app.mount('#app'); + +let inputNameElement = document.querySelector('#inputName'); +// disable keyboard events for username dialog +let inputName = new bootstrap.Modal(inputNameElement, { + backdrop: 'static', + keyboard: false +}); + +// UI initialization +(function () { + // hook mouse and touch events for whiteboard + let mode; + let prev; + let started; + let start = p => { + if (!mode) return; + prev = p; + }; + let move = p => { + if (!mode) return; + if (prev.length !== p.length) return; + // do not start if the move is too small + if (!started && p.length === 1 && Math.abs(p[0][0] - prev[0][0]) < 5 && Math.abs(p[0][1] - prev[0][1]) < 5) return; + else { + started = true; + if (p.length === 1) modes[mode].startOne(prev[0]); + else if (p.length === 2) modes[mode].startTwo(prev[0], prev[1]); + } + if (p.length === 1) modes[mode].moveOne(p[0], prev[0]); + else if (p.length === 2) modes[mode].moveTwo(p[0], p[1], prev[0], prev[1]); + prev = p; + }; + let end = p => { + if (!mode) return; + if (started) modes[mode].end(); + prev = started = null; + }; + let flatten = (ts, f) => { + let ps = []; + for (let i = 0; i < ts.length; i++) ps.push(f(ts[i])); + return ps; + }; + + const whiteboard = document.querySelector('#whiteboard'); + whiteboard.addEventListener('mousedown', e => { + mode = e.ctrlKey ? 'panAndZoom' : 'draw'; + start([[e.offsetX, e.offsetY]]); + }); + + whiteboard.addEventListener('mousemove', e => { + move([[e.offsetX, e.offsetY]]); + }); + + whiteboard.addEventListener('mouseup', e => { + end(); + mode = null; + }); + + whiteboard.addEventListener('touchstart', e => { + if (e.touches.length > 2) return; + if (prev) end(); + mode = e.touches.length === 1 ? 'draw' : 'panAndZoom'; + start(flatten(e.touches, t => [t.pageX, t.pageY - 66])); + e.preventDefault(); + }); + + whiteboard.addEventListener('touchmove', e => { + move(flatten(e.touches, t => [t.pageX, t.pageY - 66])); + e.preventDefault(); + }); + + whiteboard.addEventListener('touchend', e => { + end(); + mode = null; + e.preventDefault(); + }); + + whiteboard.addEventListener('touchcancel', e => { + end(); + mode = null; + e.preventDefault(); + }); + + inputNameElement.addEventListener('shown.bs.modal', () => { + document.querySelector('#username').focus(); + }); + inputName.show(); + + // update zoom level for small devices + let w = window.innerWidth; + diagram.zoom(w < 576 ? 1 / 3 : + w < 768 ? 1 / 2 : + w < 992 ? 2 / 3 : + w < 1200 ? 5 / 6 : + 1); + + // hook window resize event to set correct viewbox size + window.onresize = () => diagram.resizeViewbox(document.querySelector('#whiteboard').clientWidth, document.querySelector('#whiteboard').clientHeight); +})(); \ No newline at end of file diff --git a/samples/Whiteboard/wwwroot/style.css b/samples/Whiteboard/wwwroot/style.css new file mode 100644 index 00000000..25cb4c7f --- /dev/null +++ b/samples/Whiteboard/wwwroot/style.css @@ -0,0 +1,72 @@ +.navbar-brand { + padding: 0; + margin: 0 10px 0 0; +} + +.nav-logo-img { + width: 50px; +} + +.disconnected { + -webkit-filter: grayscale(100%); + /* Safari 6.0 - 9.0 */ + filter: grayscale(100%); +} + +html { + height: 100%; +} + +body { + height: calc(100% - 120px); +} + +#whiteboard { + width: 100%; + height: 100%; + min-height: 100%; + display: block; + margin-top: 66px; +} + +svg { + display: block; +} + +.toolbox { + width: 7rem; + height: 1.5rem; + display: inline-block; + vertical-align: middle; + border-radius: 0.3rem; + border: 1px solid black; +} + +.selected { + width: 4rem; + border: 1px solid white; +} + +.penbox { + background-color: white; +} + +.message-log { + margin-bottom: 50px; + pointer-events: none; + font-weight: bold; + text-shadow: 1px 1px 2px white; +} + +.top-btn-group { + margin-right: 10px; +} + +.hidden { + display: none; +} + +.popover-body { + width: 240px; + height: 240px; +} \ No newline at end of file