Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions blazor/hello_world/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# ICP - cache only, keep .icp/data/ for canister IDs
.icp/cache/

# .NET build output
bin/
obj/

# Node
node_modules/

# webpack generated output (regenerated on build)
frontend/BlazorFrontend/wwwroot/icpAgent.js

# OS
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*
165 changes: 165 additions & 0 deletions blazor/hello_world/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# ICP Blazor Hello World

A hello world template combining **Blazor WebAssembly (.NET 10)** on the frontend and **Motoko** on the backend, deployed fully on-chain on the **Internet Computer (ICP)**.

> The first working template for Blazor WASM + icp-cli + Motoko.

## Stack

| Layer | Technology |
|----------|-------------------------------------|
| Frontend | Blazor WebAssembly (.NET 10) |
| Backend | Motoko canister |
| Bridge | @dfinity/agent (webpack bundle) |
| Platform | Internet Computer (ICP) |
| CLI | icp-cli |

## Project Structure

```
icp-blazor-hello/
├── icp.yaml # icp-cli project config
├── .gitignore
├── backend/
│ ├── canister.yaml # backend canister config
│ ├── backend.did # Candid interface (semicolons required!)
│ └── src/
│ └── main.mo # Motoko canister
└── frontend/
├── canister.yaml # frontend canister config
└── BlazorFrontend/
├── BlazorFrontend.csproj # .NET 10 Blazor WASM
├── Program.cs
├── App.razor
├── _Imports.razor # required — Blazor namespace imports
├── package.json # webpack + @dfinity/agent
├── webpack.config.js # bundles icpAgent.ts → wwwroot/icpAgent.js
├── tsconfig.json
├── src/
│ └── icpAgent.ts # TypeScript ICP agent bridge
├── Layout/
│ └── MainLayout.razor
├── Pages/
│ └── Home.razor # main UI with canister calls
├── Services/
│ └── IcpAgentService.cs # C# → JS interop service
└── wwwroot/
├── index.html
└── app.css
```

## How it works

```
Home.razor (C#)
→ IcpAgentService.cs (IJSRuntime)
→ window.IcpAgent.* (webpack bundle, defer loaded)
→ @dfinity/agent
→ Motoko backend canister on ICP
```

The key insight: use **webpack** to bundle `@dfinity/agent` into a plain JS file
loaded with `defer`, not as an ES module. This avoids the race condition between
the module loader and Blazor's JS interop system.

## Prerequisites

```bash
# icp-cli
npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm

# Motoko toolchain
npm install -g ic-mops
```

## Prerequisites

### icp-cli
```bash
npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm
npm install -g ic-mops
```

### .NET 10 SDK

**Method 1: Ubuntu 24.04 LTS or newer (APT)**
```bash
sudo apt update
sudo apt install -y dotnet-sdk-10.0
```

> **Ubuntu 22.04 LTS?** Register the backports PPA first:
> ```bash
> sudo add-apt-repository ppa:dotnet/backports
> sudo apt update
> sudo apt install -y dotnet-sdk-10.0
> ```

**Method 2: Official Microsoft script (recommended for non-Ubuntu or if APT fails)**
```bash
wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 10.0
```

After installing, add .NET to your PATH:
```bash
echo 'export PATH="$HOME/.dotnet:$PATH"' >> ~/.bashrc
source ~/.bashrc
```

### Blazor WASM workload (required)
```bash
dotnet workload install wasm-tools
```

### Remove conflicting `icp` binary (Ubuntu/Debian)
Ubuntu ships a package called `renameutils` that installs its own unrelated `icp` binary.
Remove it before installing icp-cli:
```bash
sudo apt remove renameutils -y
```

Verify the correct `icp` is active:
```bash
icp --version # should show icp-cli, not renameutils
```

## Run locally

```bash
# 1. Start the local ICP network (make sure port 8000 is free)
icp network start -d

# 2. Deploy both canisters
icp deploy

# 3. Open the URL printed by icp deploy
# Format: http://<frontend-canister-id>.localhost:8000
```

## Deploy to mainnet

```bash
icp deploy --network ic
```

## Known gotchas

### `_Imports.razor` is required
Without `_Imports.razor`, Blazor component events silently do nothing.
Always include `@using Microsoft.JSInterop` in it.

### Port 8000 conflict
If `icp network start` exits with status 101, check the log:
```bash
cat .icp/cache/networks/local/network-launcher/stderr.log
```
Common cause: Docker container mapped to port 8000.
```bash
docker ps | grep 8000
docker stop <container>
```
## License

MIT
5 changes: 5 additions & 0 deletions blazor/hello_world/backend/backend.did
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
service : {
getGreeting : () -> (text) query;
setGreeting : (text) -> (text);
hello : (text) -> (text) query;
}
7 changes: 7 additions & 0 deletions blazor/hello_world/backend/canister.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/canister-yaml-schema.json
name: backend
recipe:
type: "@dfinity/motoko@v4.1.0"
configuration:
main: src/main.mo
candid: backend.did
2 changes: 2 additions & 0 deletions blazor/hello_world/backend/mops.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[toolchain]
moc = "1.3.0"
18 changes: 18 additions & 0 deletions blazor/hello_world/backend/src/main.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
persistent actor {

stable var greeting : Text = "Hello from Motoko!";

public query func getGreeting() : async Text {
return greeting;
};

public func setGreeting(name : Text) : async Text {
greeting := "Hello, " # name # "! Welcome to ICP + Blazor!";
return greeting;
};

public query func hello(name : Text) : async Text {
return "Hello, " # name # "!";
};

};
12 changes: 12 additions & 0 deletions blazor/hello_world/frontend/BlazorFrontend/App.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@using Microsoft.AspNetCore.Components.Routing
@using BlazorFrontend.Layout

<Router AppAssembly="typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
<NotFound>
<p>Page not found.</p>
</NotFound>
</Router>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<!-- Release: full publish with trimming -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishTrimmed>true</PublishTrimmed>
<CompressionEnabled>true</CompressionEnabled>
</PropertyGroup>

<!-- Debug: fast builds, no trimming -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<PublishTrimmed>false</PublishTrimmed>
<RunAOTCompilation>false</RunAOTCompilation>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@inherits LayoutComponentBase

<main>
@Body
</main>
97 changes: 97 additions & 0 deletions blazor/hello_world/frontend/BlazorFrontend/Pages/Home.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
@page "/"
@inject IcpAgentService IcpAgent

<div class="container">
<h1>ICP + Blazor Hello World</h1>
<p class="subtitle">Blazor WebAssembly (.NET 10) · Motoko · Internet Computer</p>

<!-- Get Greeting -->
<section>
<h2>Current Greeting</h2>
<button @onclick="FetchGreeting" disabled="@_loading">
@(_loading ? "Loading..." : "Get Greeting")
</button>
@if (!string.IsNullOrEmpty(_greeting))
{
<p class="result">@_greeting</p>
}
</section>

<!-- Set Greeting -->
<section>
<h2>Set Custom Greeting</h2>
<input @bind="_customName" placeholder="Enter a name" />
<button @onclick="SetGreeting" disabled="@_loading">Set Greeting</button>
@if (!string.IsNullOrEmpty(_setResponse))
{
<p class="result">@_setResponse</p>
}
</section>

<!-- Say Hello -->
<section>
<h2>Say Hello</h2>
<input @bind="_name" placeholder="Enter your name" />
<button @onclick="SayHello" disabled="@_loading">Say Hello</button>
@if (!string.IsNullOrEmpty(_helloResponse))
{
<p class="result">@_helloResponse</p>
}
</section>

@if (!string.IsNullOrEmpty(_error))
{
<p class="error">Error: @_error</p>
}
</div>

@code {
private string _greeting = "";
private string _helloResponse = "";
private string _setResponse = "";
private string _name = "";
private string _customName = "";
private string _error = "";
private bool _loading = false;

private async Task FetchGreeting()
{
await Call(async () => _greeting = await IcpAgent.GetGreetingAsync());
}

private async Task SayHello()
{
if (string.IsNullOrWhiteSpace(_name)) return;
await Call(async () => _helloResponse = await IcpAgent.HelloAsync(_name));
}

private async Task SetGreeting()
{
if (string.IsNullOrWhiteSpace(_customName)) return;
await Call(async () => _setResponse = await IcpAgent.SetGreetingAsync(_customName));
}

private async Task Call(Func<Task> action)
{
_loading = true;
_error = "";
StateHasChanged();
try
{
await action();
}
catch (JSException ex)
{
_error = $"JS Error: {ex.Message}";
}
catch (Exception ex)
{
_error = $"Error: {ex.Message}";
}
finally
{
_loading = false;
StateHasChanged();
}
}
}
9 changes: 9 additions & 0 deletions blazor/hello_world/frontend/BlazorFrontend/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using BlazorFrontend.Services;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<BlazorFrontend.App>("#app");

builder.Services.AddScoped<IcpAgentService>();

await builder.Build().RunAsync();
Loading
Loading