🧩

Power Apps Code Apps 上でC# × WASMを動かしてみる

に公開

はじめに

以前の記事で、Power Apps Code Appsを使用し、Power AppsをReact + Typescriptで実装するプロコード開発について取り上げました。
その記事作成のさなか、生粋のWASMer(?)な私ですから、「power Apps Code AppsもWebアプリなら、WASMが動かせるのでは?」などと短絡的に考え、本記事の作成に至ります。

Power Apps Code Apps とは

詳細は以前の記事をご参照いただければ幸いです。

冒頭でも記載しましたが、
Power Apps をReact + Typescrptのプロコードで開発する機能の認識です。

WebAssembly(WASM)とは

以前の別記事と内容は同じですが、、定義は大事なので、再度記載します。

https://webassembly.org/

WebAssembly(略称Wasm)は、スタックベースの仮想マシンのバイナリ命令形式です。Wasmは、プログラミング言語のポータブルコンパイルターゲットとして設計されており、クライアントおよびサーバーアプリケーション用のWeb上での展開を可能にします。

https://lethediana.sakura.ne.jp/tech/archives/summary-ja/2093/


Lethediana Tech様よりイメージ図を引用

要するに、「Webブラウザなどで高速に動く、コンパクトなバイナリ形式の実行コード」という理解です。
Power AppsもWebブラウザ上で動くわけなので、WASMも動くのでは?というところです。

やりたいこと

  • Power Apps Code Apps でWASMを動かしたい。

前提

以前の記事にて実施の、Code Appsのベースアプリは作成完了していることが前提です。

環境

  • Visual Studio Code
  • 拡張機能

    • Power Platform Tools
      Power Platform Tools は、Visual Studio Code 上で Microsoft Power Platform の開発を支援する拡張機能で、CLI 操作やアプリのソース管理を簡単に行えます。Power Apps や Power Automate の開発を効率化するためのツールです。本拡張機能をインストールすることで、pacコマンドなどをVS Code上で使用可能となります。
  • node.js(LTS )

  • .NET SDK 9

構築

いざいざWASMを呼び出すCode Apps を構築します。大きく以下を実施します。

  1. .NET SDKでWASM化するC#プロジェクトを作成
  2. .NET SDKでC#プロジェクトをWASMにビルド
  3. Code Apps 側からWASM呼び出し処理実装

1. .NET SDKでWASM化するC#プロジェクトを作成

C# プロジェクトの作成

以下のdotnetコマンドでWASM用のC#プロジェクトを準備します。

dotnet new console `
  -n MyWasmApp `
  --framework net9.0 `
  --use-program-main true

wasmへのビルドは最低限exeがないとダメなようなので、consoleアプリとしています。


dotnet new コマンド実行後、無事C#のconsoleプロジェクトが作成されました。

呼び出されるC#側処理を作成

TypeScriptから呼び出される処理を記述します。実際の実装では本項目部分を作りこむことになるかと思います。

ポイントは以下です。

  • TypeScript側から呼び出すため、JSExport属性を付与します。
  • TypeScript側から呼び出すため、静的クラス、静的メソッドとします。
  • JSExport属性使用の関係上、partialクラスとします。
  • JSExport属性はunsafeブロックとなるため、ビルド時、またはcsprojでAllowUnsafeBlocks=trueを指定します。今回はビルド時に指定します。

今回は、Class1.csを追加し、サンプル的なメソッドとして、以下を実装しています。

  • GetGreeting()
    : 名前に基づいて挨拶メッセージを生成します。

Class1.cs
using System.Threading.Tasks;
using System.Runtime.InteropServices.JavaScript;

namespace MyWasmApp;
// JSExportを使用する関係上、partialクラスとします。
public static partial class Class1
{
    // TypeScript/JavaScriptから呼び出せる静的メソッドの例(JSExport属性を使用)
    // JSからの呼び出し方法はランタイムやバンドル方法によって異なります。ここでは属性をJSExportに置き換えた例を示します。
    
    /// <summary>
    /// 名前に基づいて挨拶メッセージを生成します。
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    [JSExport]
    public static string GetGreeting(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            return "Hello from WASM!";
        return $"Hello, {name}, from WASM!";
    }
}

デバッグ等はC# Dev kit拡張機能でも構いませんし、別途Visual Studio を開いてデバッグをしても良いかと思います。

VS Code 用 C# 開発キット拡張機能の使用について

2. .NET SDKでC#プロジェクトをWASMにビルド

wasm-toolsをインストール

C#プロジェクトをWASMにビルドするためのwasm-toolsをインストールします。すでにインストール済みの場合はスキップいただいて構いません。Visual Studio インストーラーの変更オプションでもインストール可能です。

dotnet workload install wasm-tools

WASMにビルド

以下のdotnetコマンドでC#プロジェクトをWASMで発行します。
発行先のフォルダは、本拡張機能直下のmediaフォルダを想定としています。
ビルドも同時に行うので、この時点でビルドエラーがある場合はターミナルに表示されるかと思います。
本記事のフォルダ構成では、発行先フォルダは、code Appsの静的アセットを示す、.\my-code-app\public内にmediaフォルダとして発行するとします。

dotnet publish .\MyWasmApp\MyWasmApp.csproj `
  -c Release `
  -r browser-wasm `
  --self-contained true `
  /p:RunAOTCompilation=true `
  /p:InvariantGlobalization=true `
  /p:StripSymbols=true `
  /p:AllowUnsafeBlocks=true `
  -o .\my-code-app\public\media

publishのパラメータとして以下を指定しています。csprojで指定しても構いません。

  • -c Release
    : Build Configuration を Release モードに指定(最適化有効)
  • -r browser-wasm
    : 実行ターゲットを WebAssembly(ブラウザ)ランタイム に指定
  • --self-contained true
    : .NET ランタイムを同梱した自己完結型ビルドにする(ユーザー環境に .NET がなくても動作)
  • RunAOTCompilation=true
    : Ahead-of-Time コンパイルを有効化(事前にネイティブコード化しパフォーマンス改善)
  • InvariantGlobalization=true
    : グローバル化サポートを簡略化(カルチャ依存機能を無効化)してサイズ縮小
  • StripSymbols=true
    : デバッグシンボルを除去してファイルサイズ削減
  • AllowUnsafeBlocks=true
    : unsafe コードブロックのコンパイルを許可


無事、発行が完了し、mediaフォルダ配下にwasm一式のファイルが作成されました。

3. Code Apps 側からWASM呼び出し処理実装

要となるCode Apps 側からWASM呼び出し処理を実装します。
.NET WASM内の処理を呼び出すには、dotnet.js読み込み、必要なdllなどのリソースを指定後、ランタイムを起動することで、TypescriptにてJSExport属性の処理を呼び出せる認識です。

図示すると以下のイメージです。

mermaid図コード
```mermaid
flowchart LR
  subgraph Users["ユーザー"]
    U["ユーザー"]
  end

  subgraph PowerApps["Power Apps 本番環境"]
    P["Power Apps Player (apps.powerapps.com)"]
    H["アプリ静的ホスティング (CDN/FrontDoor)"]
  end

  subgraph App["アプリ実体(配信物)"]
    I["index.html / App.tsx / wasm.ts"]
    DJS["media/dotnet.js"]
    R[".NET ランタイム (withConfig / withResourceLoader / create)"]
    subgraph Media["public/media/* (静的リソース)"]
      JR["dotnet.runtime.js"]
      JN["dotnet.native.js"]
      W["dotnet.native.wasm"]
      C0["System.Private.CoreLib.dll"]
      C1["System.Runtime.InteropServices.JavaScript.dll"]
      A0["MyWasmApp.dll"]
    end
  end

  U -->|"アプリを操作(起動・ボタン押下)" | P
  P -->|"アプリを起動(iframe/同一プレイヤー配信)" | H
  H -->|"GET \index.html\ 他" | I

  I -->|"動的 import(base + 'media/dotnet.js')" | DJS
  DJS --> R
  R -->|"name → base + 'media/' + name を解決" | JR
  R --> JN
  R --> W
  R --> C0
  R --> C1
  R --> A0

  R -->|"getAssemblyExports('MyWasmApp')" | I
  I -->|"Class1.GetGreeting(name)" | A0
  I -->|"戻り値文字列を UI 表示" | P
  P -->|"画面表示" | U

  classDef user fill:#E3F2FD,stroke:#2196F3,color:#0D47A1,stroke-width:1.5px;
  classDef player fill:#F3E5F5,stroke:#9C27B0,color:#4A148C,stroke-width:1.5px;
  classDef hosting fill:#FCE4EC,stroke:#E91E63,color:#880E4F,stroke-width:1.5px;
  classDef app fill:#E8F5E9,stroke:#4CAF50,color:#1B5E20,stroke-width:1.5px;
  classDef runtime fill:#FFF3E0,stroke:#FF9800,color:#E65100,stroke-width:1.5px;
  classDef media fill:#FFFDE7,stroke:#FBC02D,color:#7F6000,stroke-width:1.5px;

  class U user;
  class P player;
  class H hosting;
  class I app;
  class DJS runtime;
  class R runtime;
  class JR,JN,W,C0,C1,A0 media;
```

UI作成

App.tsxを編集し、WASM呼び出しと結果表示のUIを作成します。
今回は以下のようにApp.tsxを編集しています。

App.tsx
// 画面は「WASM 呼び出し」ボタンと「クリア」ボタン、結果表示のラベルのみのシンプル構成です。
// - 「WASM 呼び出し」: MyWasmApp.Class1.GetGreeting("") の実行結果をラベルに表示
// - 「クリア」: ラベルの内容を空に戻します
import { useState } from 'react'
import './App.css'
import { callGetGreeting } from './wasm'

function App() {
  const [message, setMessage] = useState<string>('')
  const [loading, setLoading] = useState<boolean>(false)

  // 「WASM 呼び出し」押下ボタンの処理
  const onCallWasm = async () => {
    setLoading(true)
    try {
      // WASM 呼び出しは src/wasm.ts のヘルパーを経由して実行します。
      const text = await callGetGreeting('CodeApps!')
      setMessage(text)
    } catch (err) {
      console.error(err)
      setMessage('WASM 呼び出しでエラーが発生しました')
    } finally {
      setLoading(false)
    }
  }

  // 「クリア」ボタン押下の処理
  const onClear = () => {
    setMessage('')
  }
  // UIのHTMLを返却
  return (
    <div className="wasm-container">
      <h2>WASM 呼び出しサンプル</h2>
      <div className="wasm-row">
        <button onClick={onCallWasm} disabled={loading}>
          {loading ? '実行中…' : 'WASM 呼び出し'}
        </button>
        <button onClick={onClear}>クリア</button>
      </div>
      <div>
        <label>結果:</label>
        <div className="wasm-output">{message}</div>
      </div>
    </div>
  )
}

export default App

CSSはサンプルUI表示用に追加。

App.css
/* 既存cssは省略 */

/* 以下、サンプルUIボタンなど表示用に追加 */
.wasm-container {
  max-width: 520px;
  margin: 2rem auto;
  padding: 1rem;
}

.wasm-row {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

.wasm-output {
  min-height: 24px;
  margin-top: 6px;
  padding: 8px 12px;
  background: #f7f7f9;
  border: 1px solid #e1e1e8;
  border-radius: 4px;
  white-space: pre-wrap;
}


以下は作成したUIです。

なんの面白みもないUIですが、WASM呼び出しとしての目的は達成できそうなので、良しとしましょう。

呼び出し処理実装

Typescriptでの.NET WASMの呼び出しは以下のポイントを含めた実装が必要です。

  • WASM を配置している実ファイルパスを解決する。
  • dotnet.js を 動的import する
  • ビルダー API を準備する。ランタイムを設定・起動するためのオブジェクトを取り出す。
  • config(構成情報)を用意する。メインのアセンブリ名、読み込む DLL 群、ICU 設定などを指定。
  • リソースローダーを定義する。ランタイムが要求する DLL や WASM をどのパスから取るかを教える。
  • dotnet.create() でランタイムを起動する
  • getAssemblyExports で DLL 内の関数をJSから直接呼べるように取得する。
  • 起動したランタイムgetAssemblyExports('MyWasmApp')から呼び出し。

以下のようなwasm.tsファイルを./my-code-app/srcフォルダ内に作成し、WASM呼び出しを実装します。

wasm.ts
// .NET WASM 呼び出し(最小サンプル)
// 目的: MyWasmApp.Class1.GetGreeting(string) を Code Apps から実行する
// 手順(コード内で順に実行):
//  1) ベースURL決定(本番は document.baseURI、デバッグ時のみ _localAppUrl を優先)
//  2) dotnet.js を動的 import(ビルド時に束ねず、実行時の場所から取得)
//  3) withConfig で最小構成(resources に必要な js/wasm/DLL を列挙)
//  4) withResourceLoader で name → base + 'media/' + name に絶対解決
//  5) create() でランタイム起動完了を await
//  6) getAssemblyExports("MyWasmApp") で [JSExport] の静的メソッドを取得
//  7) Class1.GetGreeting(name) を呼び出して結果文字列を返す

// 最小限の型(概念上の形のみ)。実体は実行時に /media/dotnet.js が提供します。
interface DotnetRuntimeAPI { getAssemblyExports(name: string): Promise<unknown> }
interface DotnetBuilderLike {
  withDiagnosticTracing(enabled: boolean): DotnetBuilderLike;
  withConfig(config: Record<string, unknown>): DotnetBuilderLike;
  withResourceLoader(cb: (...args: unknown[]) => unknown): DotnetBuilderLike;
  create(): Promise<DotnetRuntimeAPI>;
}

type MyExports = { MyWasmApp: { Class1: { GetGreeting: (name: string) => string } } }

// 初期化の多重起動を避けるため、エクスポート取得を保持用の変数
let exportsPromise: Promise<MyExports> | null = null;

export async function callGetGreeting(name: string): Promise<string> {
  // 初回のみ初期化を実行(以降は再利用)
  if (!exportsPromise) {
    exportsPromise = (async () => {
      // 1) ベースURL決定(常に「ディレクトリ」になるよう正規化)
      //    例: document.baseURI が .../index.html の場合でも、new URL('.', baseURI) で配信ディレクトリに揃える
      let base: string;
      try {
        const current = new URL(window.location.href);
        let localAppUrl = current.searchParams.get('_localAppUrl');
        if (!localAppUrl && document.referrer) {
          try { localAppUrl = new URL(document.referrer).searchParams.get('_localAppUrl'); } catch { /* ignore parse error */ }
        }
        const baseCandidate = localAppUrl ?? document.baseURI;
        // ディレクトリ正規化(末尾がファイル名でも . を指定してディレクトリURLに)
        base = new URL('.', baseCandidate).toString();
      } catch {
        base = new URL('.', document.baseURI).toString();
      }

      // 2) dotnet.js を動的 import(@vite-ignore でビルド時解決を回避)
      const runtimeUrl = new URL('media/dotnet.js', base).toString();
      const mod = await import(/* @vite-ignore */ runtimeUrl);
      const dotnet = (mod as unknown as { dotnet: DotnetBuilderLike }).dotnet;

      // 3,4,5) ランタイム起動
      const api = await dotnet
        .withDiagnosticTracing(false)
        .withConfig({
          mainAssemblyName: 'MyWasmApp',
          globalizationMode: 'invariant',
          resources: {
            jsModuleRuntime: { 'dotnet.runtime.js': '' },
            jsModuleNative: { 'dotnet.native.js': '' },
            jsModuleGlobalization: { 'dotnet.globalization.js': '' },
            wasmNative: { 'dotnet.native.wasm': '' },
            coreAssembly: { 'System.Private.CoreLib.dll': '' },
            assembly: {
              'MyWasmApp.dll': '',
              'System.Console.dll': '',
              'System.Runtime.InteropServices.JavaScript.dll': '',
            },
          },
        })
        .withResourceLoader((...args: unknown[]) => {
          // ローカルデバッグ、本番環境とリソースを取り先が異なるため、以下コードで対応。
          // .NET ランタイムからのコールバック引数(type, name, defaultUri)
          const type = String(args[0] ?? '');
          const nameArg = String(args[1] ?? '');
          const defaultUri = String(args[2] ?? '');

          // ベース名のみを取り出す("path/to/file" → "file")
          const basename = (p: string) => (p.includes('/') ? p.substring(p.lastIndexOf('/') + 1) : p);

          // まず name を基準にし、
          // 拡張子が無い場合は defaultUri の拡張子を参照して補完、
          // それでも特定できない場合は type から推測(assembly → .dll / wasm → .wasm / js → .js)
          let file = basename(nameArg);
          const hasExt = /\.[A-Za-z0-9]+$/.test(file);
          if (!hasExt) {
            const dBase = basename(defaultUri);
            if (/\.[A-Za-z0-9]+$/.test(dBase)) {
              file = dBase; // defaultUri がファイル名を持っている場合はそれを採用
            } else {
              const t = type.toLowerCase();
              if (t.includes('assembly')) file += '.dll';
              else if (t.includes('wasm')) file += '.wasm';
              else if (t.includes('js')) file += '.js';
            }
          }

          const resolved = new URL(`media/${file}`, base).toString();
          return resolved;
        })
        .create();

      // 6) エクスポート取得
      const ex = (await api.getAssemblyExports('MyWasmApp')) as MyExports;
      return ex;
    })();
  }
  const ex = await exportsPromise;
  // 7) 呼び出し
  return ex.MyWasmApp.Class1.GetGreeting(name ?? '<unknown>');
}

ローカルの開発環境とデプロイ後のPower Platform環境のどちらでも動作させたかったため、ベースURLの決定やwithResourceLoaderのリソース指定が、微妙といわざる得ない実装となってしまいましたが、、良しとしましょう。余力があれば別記事などで整理したいですね。。

動作確認いよいよ動作確認です。

Power Apps環境での動作を確認したいので、以下コマンドでPower Apps環境にデプロイし、動作確認を進めます。

pac code push

デプロイ結果に表示されるURLにアクセスし、Power Platform環境にデプロイされたCode Appsに接続します。

以下は実行結果です。

「WAMS呼び出し」ボタンを押下することで、WASMでの実行結果「Hello, CodeApps!, from WASM!」文字列がラベルUIに表示されました。普通にうれしい。

まとめ

というわけで、Code AppsでWASMを実行してみました。
Code Apps上でも、dotnet.jsを読み取り、ランタイムがCreateできれば、問題なく動作することが確認できました。
過去の資産をWASM化して利利用できればと思う今日この頃です。

本記事が少しでも何かのお役に立てれば幸いです。

参考

  • Microsoft Learn Power Apps のコード アプリ (プレビュー) ドキュメント
  • GitHub Power Apps code apps (preview)

Discussion