├── .jshintrc ├── src ├── blazor │ ├── Pages │ │ ├── _Imports.razor │ │ └── Root.razor │ ├── Shared │ │ ├── MainLayout.razor │ │ ├── NewChatMessage.razor │ │ └── Chat.razor │ ├── Core │ │ ├── ChatMessage.cs │ │ ├── ChatMessageRepository.cs │ │ └── TimeUtils.cs │ ├── _Imports.razor │ ├── App.razor │ ├── BlazorChatApp.csproj │ └── Program.cs ├── common │ ├── index.ts │ ├── ChatMessage.ts │ ├── ChatMessageRepository.d.ts │ └── ChatMessageRepository.ts └── react │ ├── index.tsx │ ├── components │ ├── ChatMessage.tsx │ ├── Root.tsx │ ├── Chat.tsx │ └── NewChatMessage.tsx │ └── core │ └── TimeUtils.ts ├── .gitignore ├── tsconfig.json ├── README.md ├── webpack.config.js ├── blazor-output.js ├── serve.js └── package.json /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6 3 | } -------------------------------------------------------------------------------- /src/blazor/Pages/_Imports.razor: -------------------------------------------------------------------------------- 1 | @layout MainLayout -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import "./ChatMessageRepository"; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.user 2 | 3 | .vs 4 | node_modules 5 | bin 6 | obj 7 | 8 | /build/apps -------------------------------------------------------------------------------- /src/blazor/Pages/Root.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | 3 |

Blazor chat

4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/common/ChatMessage.ts: -------------------------------------------------------------------------------- 1 | export interface ChatMessage { 2 | time: Date; 3 | from: string; 4 | text: string; 5 | }; -------------------------------------------------------------------------------- /src/blazor/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 |
4 |
5 | @Body 6 |
7 |
-------------------------------------------------------------------------------- /src/react/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | 4 | import { Root } from "./components/Root"; 5 | 6 | ReactDOM.render( 7 | , 8 | document.getElementById("react-app") 9 | ); -------------------------------------------------------------------------------- /src/blazor/Core/ChatMessage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace BlazorChatApp.Core 4 | { 5 | public class ChatMessage 6 | { 7 | public DateTime Time { get; set; } 8 | 9 | public string From { get; set; } 10 | 11 | public string Text { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/blazor/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Linq 2 | @using System.Net.Http 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.JSInterop 7 | @using BlazorChatApp 8 | @using BlazorChatApp.Core 9 | @using BlazorChatApp.Shared -------------------------------------------------------------------------------- /src/blazor/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Sorry, there's nothing at this address.

8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /src/common/ChatMessageRepository.d.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessage } from "./ChatMessage"; 2 | 3 | declare const ChatMessageRepository: IChatMessageRepository; 4 | 5 | declare global { 6 | interface Window { 7 | ChatMessageRepository: IChatMessageRepository; 8 | } 9 | } 10 | 11 | interface IChatMessageRepository { 12 | getChatMessages: () => ChatMessage[]; 13 | addChatMessage: (chatMessage: ChatMessage) => void; 14 | } 15 | 16 | export = ChatMessageRepository; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "jsx": "react-jsx", 5 | "lib": ["DOM", "ES2022"], 6 | "moduleResolution": "node", 7 | "baseUrl": ".", 8 | "paths": { 9 | "ChatMessageRepository": [ 10 | "src/common/ChatMessageRepository.d" 11 | ] 12 | } 13 | }, 14 | "exclude": [ 15 | "node_modules" 16 | ], 17 | "include": [ 18 | "./src/react/**/*", 19 | "./src/common/**/*", 20 | ] 21 | } -------------------------------------------------------------------------------- /src/blazor/BlazorChatApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/common/ChatMessageRepository.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessage } from "./ChatMessage"; 2 | 3 | const sessionStorageKey = "chat-messages"; 4 | 5 | const getChatMessages = 6 | () => (JSON.parse(sessionStorage.getItem(sessionStorageKey) || "[]")) 7 | .map(x => { from: x.from, text: x.text, time: new Date(Date.parse(x.time)) }); 8 | 9 | const chatMessageRepository = { 10 | getChatMessages, 11 | addChatMessage: (chatMessage: ChatMessage) => 12 | sessionStorage.setItem(sessionStorageKey, JSON.stringify([...getChatMessages(), chatMessage])) 13 | } 14 | 15 | window.ChatMessageRepository = chatMessageRepository; -------------------------------------------------------------------------------- /src/blazor/Program.cs: -------------------------------------------------------------------------------- 1 | using BlazorChatApp.Core; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace BlazorChatApp 7 | { 8 | public class Program 9 | { 10 | public static async Task Main(string[] args) 11 | { 12 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 13 | builder.RootComponents.Add("#blazor-app"); 14 | builder.Services.AddSingleton(); 15 | 16 | await builder.Build().RunAsync(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/react/components/ChatMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChatMessage as ChatMessageModel } from "../../common/ChatMessage"; 3 | import { formatRelativeTime } from "../core/TimeUtils"; 4 | 5 | export interface ChatMessageProps { 6 | isLeft: boolean; 7 | chatMessage: ChatMessageModel; 8 | } 9 | 10 | export const ChatMessage = (props: ChatMessageProps) => 11 |
  • 12 |
    13 | 14 |

    {props.chatMessage.text}

    15 | {formatRelativeTime(props.chatMessage.time)} 16 |
    17 |
  • ; -------------------------------------------------------------------------------- /src/react/core/TimeUtils.ts: -------------------------------------------------------------------------------- 1 | export const formatRelativeTime = (date: Date) => { 2 | const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); 3 | let interval = Math.floor(seconds / 31536000); 4 | 5 | if (interval > 1) 6 | return `${interval} years ago`; 7 | 8 | interval = Math.floor(seconds / 2592000); 9 | if (interval > 1) 10 | return `${interval} months ago`; 11 | 12 | interval = Math.floor(seconds / 86400); 13 | if (interval > 1) 14 | return `${interval} days ago`; 15 | 16 | interval = Math.floor(seconds / 3600); 17 | if (interval > 1) 18 | return `${interval} hours ago`; 19 | 20 | interval = Math.floor(seconds / 60); 21 | if (interval >= 1) 22 | return `${interval} minute${interval === 1 ? '' : 's'} ago`; 23 | 24 | return `${(Math.floor(seconds) || 1)} seconds ago`; 25 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React vs. Blazor 2 | 3 | This demo shows how React apps can live together with Blazor apps, which are basically C# apps running in the browser with the help of WebAssembly. 4 | 5 | ## Getting started 6 | 7 | To build the blazor app you need either Docker installed or you can follow the [instructions](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) to install the appropriate .NET SDK locally. For the rest, you only need: 8 | 9 | ``` 10 | npm install 11 | ``` 12 | 13 | ### Build 14 | 15 | To build the project and run it locally run 16 | 17 | ``` 18 | npm run build 19 | npm start 20 | ``` 21 | 22 | Then you can access it at `http://localhost:8080/react-blazor/`. 23 | 24 | ## Questions & contribution 25 | 26 | You can follow me on Twitter [@boyanio](https://twitter.com/boyanio) and ask me any questions you might have. You can also open an issue here on GitHub. Pull Requests are welcome too :-) 27 | -------------------------------------------------------------------------------- /src/blazor/Shared/NewChatMessage.razor: -------------------------------------------------------------------------------- 1 | @inject ChatMessageRepository ChatMessageRepository 2 | 3 |
    4 |
    5 |
    6 | 7 |
    8 |
    9 | 10 |
    11 |
    12 |
    13 | 14 | @code { 15 | private string newMessage = null; 16 | 17 | private async Task AddChatMessageAsync(MouseEventArgs e) 18 | { 19 | await ChatMessageRepository.AddChatMessageAsync(new ChatMessage 20 | { 21 | Time = DateTime.Now, 22 | From = "Blazor", 23 | Text = newMessage 24 | }); 25 | newMessage = null; 26 | } 27 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = { 2 | mode: "production", 3 | devtool: "source-map", 4 | resolve: { 5 | extensions: [".ts", ".tsx", ".js"] 6 | }, 7 | module: { 8 | rules: [ 9 | { test: /\.tsx?$/, loader: "ts-loader" }, 10 | ] 11 | } 12 | }; 13 | 14 | const commonConfig = Object.assign({}, baseConfig, { 15 | name: "common", 16 | entry: "./src/common/index.ts", 17 | output: { 18 | filename: "common.js", 19 | path: __dirname + "/build/apps" 20 | }, 21 | }); 22 | 23 | const reactConfig = Object.assign({}, baseConfig, { 24 | name: "react", 25 | entry: "./src/react/index.tsx", 26 | output: { 27 | filename: "react-app.js", 28 | path: __dirname + "/build/apps/react" 29 | }, 30 | externals: { 31 | "react": "React", 32 | "react-dom": "ReactDOM", 33 | "ChatMessageRepository": "ChatMessageRepository" 34 | } 35 | }); 36 | 37 | module.exports = [commonConfig, reactConfig]; -------------------------------------------------------------------------------- /blazor-output.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const fs = require('fs'); 3 | const rimraf = require('rimraf'); 4 | const { ncp } = require('ncp'); 5 | 6 | const fsAsync = ['exists', 'mkdir'] 7 | .reduce((value, func) => Object.assign(value, { [func]: promisify(fs[func]) }), {}); 8 | const rimrafAsync = promisify(rimraf); 9 | const ncpAsync = promisify(ncp); 10 | 11 | const run = async (buildConfiguration) => { 12 | const buildDir = `${__dirname}/build/apps/blazor`; 13 | 14 | if (await fsAsync.exists(buildDir)) { 15 | await rimrafAsync(buildDir); 16 | } 17 | 18 | await fsAsync.mkdir(buildDir, { recursive: true }); 19 | 20 | const publishDir = `${__dirname}/src/blazor/bin/${buildConfiguration}/net5.0/publish/wwwroot`; 21 | await ncpAsync(`${publishDir}/_framework`, `${buildDir}/_framework`); 22 | }; 23 | 24 | 25 | const buildConfiguration = process.argv.length > 2 ? process.argv[2] : 'Release'; 26 | run(buildConfiguration); -------------------------------------------------------------------------------- /src/blazor/Core/ChatMessageRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.JSInterop; 2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | namespace BlazorChatApp.Core 6 | { 7 | public class ChatMessageRepository 8 | { 9 | private readonly IJSRuntime _jsRuntime; 10 | 11 | public ChatMessageRepository(IJSRuntime jsRuntime) 12 | { 13 | _jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime)); 14 | } 15 | 16 | public async Task GetChatMessagesAsync() 17 | { 18 | return await _jsRuntime.InvokeAsync( 19 | "ChatMessageRepository.getChatMessages"); 20 | 21 | } 22 | 23 | public async Task AddChatMessageAsync(ChatMessage chatMessage) 24 | { 25 | await _jsRuntime.InvokeAsync( 26 | "ChatMessageRepository.addChatMessage", 27 | chatMessage); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/react/components/Root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { NewChatMessage } from "./NewChatMessage"; 3 | import { Chat } from "./Chat"; 4 | import { ChatMessage as ChatMessageModel } from "../../common/ChatMessage"; 5 | import * as ChatMessageRepository from "ChatMessageRepository"; 6 | 7 | interface RootState { 8 | chatMessages: ChatMessageModel[]; 9 | } 10 | 11 | export class Root extends React.Component<{}, RootState> { 12 | 13 | constructor(props) { 14 | super(props); 15 | 16 | this.state = { chatMessages: [] }; 17 | } 18 | 19 | componentDidMount() { 20 | setInterval(() => { 21 | this.setState({ 22 | chatMessages: ChatMessageRepository.getChatMessages() 23 | }); 24 | }, 1000); 25 | } 26 | 27 | render() { 28 | return ( 29 |
    30 |

    React chat

    31 | 32 | 33 | 34 | 35 |
    36 | ); 37 | } 38 | } -------------------------------------------------------------------------------- /src/react/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChatMessage } from "./ChatMessage"; 3 | import { ChatMessage as ChatMessageModel } from "../../common/ChatMessage"; 4 | 5 | const byTimeDesc = (a: ChatMessageModel, b: ChatMessageModel) => 6 | b.time.getTime() - a.time.getTime(); 7 | 8 | export interface ChatProps { 9 | chatMessages: ChatMessageModel[]; 10 | } 11 | 12 | export const Chat = (props: ChatProps) => { 13 | const hasChatMessages = props.chatMessages.length > 0; 14 | 15 | const body = hasChatMessages ? 16 |
    17 |
      18 | {props.chatMessages 19 | .sort(byTimeDesc) 20 | .map((chatMessage, index) => )} 21 |
    22 |
    : 23 |

    24 | There aren't any messages yet. Why don't you write one? 25 |

    ; 26 | 27 | return ( 28 |
    29 |
    30 | {body} 31 |
    32 |
    33 | ); 34 | } -------------------------------------------------------------------------------- /serve.js: -------------------------------------------------------------------------------- 1 | const connect = require('connect'); 2 | const serveStatic = require('serve-static'); 3 | const modRewrite = require('connect-modrewrite'); 4 | 5 | const buildDir = `${__dirname}/build`; 6 | const port = 8080; 7 | const setHeaders = (res, path) => { 8 | const ext = path.split('.').pop().toLowerCase(); 9 | let contentType; 10 | switch (ext) { 11 | case 'wasm': 12 | contentType = 'application/wasm'; 13 | break; 14 | 15 | case 'js': 16 | contentType = 'text/javascript'; 17 | break; 18 | 19 | case 'dll': 20 | contentType = 'application/octet-stream'; 21 | break; 22 | 23 | case 'html': 24 | case 'css': 25 | contentType = `text/${ext}`; 26 | break; 27 | } 28 | 29 | if (contentType) { 30 | res.setHeader('Content-Type', contentType); 31 | } 32 | }; 33 | 34 | console.log(`Serving HTTP on http://localhost:${port}/react-blazor/ ...`); 35 | connect() 36 | .use(modRewrite([ 37 | '^/react-blazor/_framework/(.+) /apps/blazor/_framework/$1 [L]', 38 | '^/react-blazor/?(.+) /$1 [L]' 39 | ])) 40 | .use(serveStatic(buildDir, { 'setHeaders': setHeaders })) 41 | .use((req, res, next) => { 42 | const url = req.url; 43 | console.log(url); 44 | next(); 45 | }) 46 | .listen(port); -------------------------------------------------------------------------------- /src/react/components/NewChatMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ChatMessageRepository from "ChatMessageRepository"; 3 | 4 | interface NewChatMessageState { 5 | newMessage: string; 6 | } 7 | 8 | const createDefaultState: () => NewChatMessageState = 9 | () => ({ newMessage: "" }); 10 | 11 | export class NewChatMessage extends React.Component<{}, NewChatMessageState> { 12 | 13 | constructor(props) { 14 | super(props); 15 | 16 | this.state = createDefaultState(); 17 | this.handleSubmit = this.handleSubmit.bind(this); 18 | this.handleChange = this.handleChange.bind(this); 19 | } 20 | 21 | render() { 22 | return ( 23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 | 30 |
    31 |
    32 |
    33 | ); 34 | } 35 | 36 | handleChange(event: React.FormEvent) { 37 | this.setState({ newMessage: event.currentTarget.value }); 38 | } 39 | 40 | handleSubmit() { 41 | ChatMessageRepository.addChatMessage({ from: 'React', time: new Date(), text: this.state.newMessage }); 42 | this.setState(createDefaultState()); 43 | } 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-blazor", 3 | "version": "0.1.0", 4 | "description": "React vs. Blazor side by side", 5 | "keywords": [ 6 | "React", 7 | "Blazor", 8 | "WebAssembly" 9 | ], 10 | "scripts": { 11 | "build:react": "webpack", 12 | "build:blazor": "dotnet publish src/blazor/BlazorChatApp.csproj -c Release && node blazor-output.js Release", 13 | "build:blazor:docker": "docker run --rm -v $(pwd)/src/blazor:/app mcr.microsoft.com/dotnet/sdk:5.0 dotnet publish -c Release app/BlazorChatApp.csproj && node blazor-output.js Release", 14 | "build:blazor:docker:win": "docker run --rm -v ${pwd}/src/blazor:/app mcr.microsoft.com/dotnet/sdk:5.0 dotnet publish -c Release app/BlazorChatApp.csproj && node blazor-output.js Release", 15 | "build": "npm run build:react && npm run build:blazor", 16 | "start": "node serve.js" 17 | }, 18 | "private": true, 19 | "author": "Boyan Mihaylov", 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/boyanio/react-blazor.git" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.47", 27 | "@types/react-dom": "^18.2.18", 28 | "connect": "^3.7.0", 29 | "connect-modrewrite": "^0.10.2", 30 | "ncp": "^2.0.0", 31 | "rimraf": "^5.0.5", 32 | "serve-static": "^1.16.2", 33 | "ts-loader": "^9.5.1", 34 | "typescript": "^5.2.2", 35 | "webpack": "^5.94.0", 36 | "webpack-cli": "^5.1.4" 37 | }, 38 | "dependencies": { 39 | "react": "^18.2.0", 40 | "react-dom": "^18.2.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/blazor/Shared/Chat.razor: -------------------------------------------------------------------------------- 1 | @using System.Timers 2 | @inject ChatMessageRepository ChatMessageRepository 3 | 4 |
    5 |
    6 | @if (_chatMessages.Length > 0) 7 | { 8 |
    9 |
      10 | @foreach (var chatMessage in _chatMessages) 11 | { 12 | bool isLeft = _chatMessages[_chatMessages.Length - 1].From.Equals(chatMessage.From); 13 | 14 |
    • 15 |
      16 | 17 |

      @chatMessage.Text

      18 | @TimeUtils.FormatRelativeTime(chatMessage.Time) 19 |
      20 |
    • 21 | } 22 |
    23 |
    24 | } 25 | else 26 | { 27 |

    28 | There aren't any messages yet. Why don't you write one? 29 |

    30 | } 31 |
    32 |
    33 | 34 | @code { 35 | private ChatMessage[] _chatMessages = new ChatMessage[0]; 36 | private Timer _timer = new Timer(1000); 37 | 38 | protected override void OnInitialized() 39 | { 40 | _timer.Elapsed += async (sender, args) => await TickAsync(); 41 | _timer.Start(); 42 | } 43 | 44 | private async Task TickAsync() 45 | { 46 | _chatMessages = (await ChatMessageRepository.GetChatMessagesAsync()) 47 | .OrderByDescending(m => m.Time) 48 | .ToArray(); 49 | StateHasChanged(); 50 | } 51 | } -------------------------------------------------------------------------------- /src/blazor/Core/TimeUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace BlazorChatApp.Core 4 | { 5 | internal static class TimeUtils 6 | { 7 | internal static string FormatRelativeTime(DateTime date) 8 | { 9 | // Taken from https://stackoverflow.com/a/1248 10 | const int SECOND = 1; 11 | const int MINUTE = 60 * SECOND; 12 | const int HOUR = 60 * MINUTE; 13 | const int DAY = 24 * HOUR; 14 | const int MONTH = 30 * DAY; 15 | 16 | var ts = new TimeSpan(DateTime.UtcNow.Ticks - date.Ticks); 17 | double delta = Math.Abs(ts.TotalSeconds); 18 | 19 | if (delta < 1 * MINUTE) 20 | return ts.Seconds <= 1 ? "1 second ago" : $"{ts.Seconds} seconds ago"; 21 | 22 | if (delta < 2 * MINUTE) 23 | return "1 minute ago"; 24 | 25 | if (delta < 45 * MINUTE) 26 | return $"{ts.Minutes} minutes ago"; 27 | 28 | if (delta < 90 * MINUTE) 29 | return "1 hour ago"; 30 | 31 | if (delta < 24 * HOUR) 32 | return $"{ts.Hours} hours ago"; 33 | 34 | if (delta < 48 * HOUR) 35 | return "yesterday"; 36 | 37 | if (delta < 30 * DAY) 38 | return $"{ts.Days} days ago"; 39 | 40 | if (delta < 12 * MONTH) 41 | { 42 | int months = Convert.ToInt32(Math.Floor((double)ts.Days / 30)); 43 | return months <= 1 ? "1 month ago" : $"{months} months ago"; 44 | } 45 | else 46 | { 47 | int years = Convert.ToInt32(Math.Floor((double)ts.Days / 365)); 48 | return years <= 1 ? "1 year ago" : $"{years} years ago"; 49 | } 50 | } 51 | } 52 | } --------------------------------------------------------------------------------